# Adoption paths (/docs/adoption-paths) There are two ways to adopt Pleach. One is to leave your agent loop where it is and add the [`@pleach/observe`](/docs/observe) SDK around it. The other is to take [`@pleach/core`](/docs/core) as the substrate and migrate the loop onto it. Both land you at the same audit row; the cost shape and the feature ceiling are different. This page is the decision. The per-stack walkthroughs ([Vercel AI SDK](/docs/migrating-from-ai-sdk), [LangChain](/docs/migrating-from-langchain), [Anthropic Enterprise](/docs/migrating-from-anthropic-enterprise), [OpenAI Enterprise](/docs/migrating-from-openai-enterprise)) sit downstream of it. ## The two paths [#the-two-paths] | | Brownfield (SDK) | Greenfield (runtime) | | ------------------------------------------------ | --------------------------------------------------- | ------------------------------------------------------------ | | Package | [`@pleach/observe`](/docs/observe) | [`@pleach/core`](/docs/core) | | Adoption shape | \~15 lines around your existing LLM calls. | New `SessionRuntime` per process; rewrite the loop. | | Audit row | `ObserveRow` — strict subset of `AuditableCall` v7. | Full `AuditableCall` v7. | | Destinations | Postgres / Supabase / OTel / Memory / custom. | Postgres / Supabase / Memory / any `ProviderDecisionLedger`. | | Family-locked routing | No. | Yes. | | Replay determinism | No. | Yes. | | Channels + interrupts | No. | Yes. | | Checkpoint / restore | No. | Yes. | | Time-travel via [`@pleach/replay`](/docs/replay) | No. | Yes. | | Provider swap without breaking dialect | Partial — observability only. | Yes — substrate-level. | | Migration cost | \~15 LoC per loop. | Loop rewrite; runtime config; tests. | The rows aren't a hierarchy. The greenfield path is bigger because it gives more; brownfield is smaller because it asks less. Picking the smaller one when you don't need the bigger one is the right call. Every choice you make on the SDK destination travels with you when you adopt the full runtime. The two surfaces are designed to compose: * The runtime detects an `@pleach/observe` `init` config at startup. Runtime-side audit writes route through the SDK's transport rather than opening a parallel sink. * `recordCall()` is a no-op when `init()` hasn't run, and inside a runtime-managed turn the runtime's turn id wins — SDK call sites can drop their `if (observe)` guards while the runtime writes through the same destination. * `ObserveRow` is a strict subset of `AuditableCall` v7, so rows the SDK wrote yesterday remain valid v7 rows the runtime can read tomorrow. The `tenantId`, `turnId`, and `(model, family, callClass)` join keys don't change. * Fingerprint compute is shared. Both surfaces import from `@pleach/core/fingerprint`; no two implementations to drift. A buyer who adopts the SDK first and the runtime second keeps the audit history. The migration is monotonic by construction, not by a `legacy_*` column. ## Pick brownfield when [#pick-brownfield-when] * You already run an agent loop on the Vercel AI SDK, LangChain, or a thin wrapper over a provider SDK, and you don't want to rewrite it this quarter. * A regulator, a finance team, or a customer is asking for per-tenant cost attribution — `(tenantId, turnId, toolName, model, tokens, costUSD)` joinable to your billing schema — and the row is what's missing, not the loop. * You already have an OTel collector (Honeycomb, Datadog, Grafana Tempo, an OTLP gateway). The [OTel destination](/docs/observe#otel) emits each row as a span on a backend you already operate. * You want to start small. The brownfield path lets you wire one loop, watch the row land, and decide whether to widen. The brownfield shape: ```typescript import { init, recordCall } from "@pleach/observe"; import { postgres } from "@pleach/observe/destinations"; init({ destination: postgres({ connectionString: process.env.PG_URL! }) }); const startedAt = Date.now(); const reply = await yourExistingLLMCall(messages); recordCall({ turnId: sessionId, providerId: "anthropic", family: "anthropic", callClass: "synthesize", model: "claude-sonnet-4-6", inputTokens: reply.usage.inputTokens, outputTokens: reply.usage.outputTokens, costUSD: reply.usage.costUSD, startedAt, completedAt: Date.now(), tags: { tenantId }, }); ``` That's the whole brownfield surface for the common case. The loop you had before stays the loop you have now. ## Pick greenfield when [#pick-greenfield-when] * You need **replay determinism** — recording a turn today and replaying it against a fresh runtime tomorrow, byte-for-byte, for regression tests or an [`@pleach/eval`](/docs/eval) suite. The SDK writes a row; replay needs the event log. * You need **family-locked routing** — the cascade-on-503 walk that stays inside one provider family and refuses to silently widen across families. That's a [`GatewayClient`](/docs/gateway) decision, made before the wire call; the SDK observes calls, it doesn't make them. * You need **reactive channels** — fan-out, back-pressure, and interrupt handling tied to the same session. Those live in [`@pleach/core/channels`](/docs/channels). * You need **checkpoint / restore** for long-running sessions that survive process restarts, or **time-travel forking** via [`@pleach/replay`](/docs/replay) for what-if analysis. Both consume the canonical event log. * You're starting fresh, the loop doesn't exist yet, and the cost of building on the runtime substrate is the same as the cost of building any other way. The greenfield shape leads with the runtime: ```typescript import { SessionRuntime, AiSdkProvider } from "@pleach/core"; import { anthropic } from "@ai-sdk/anthropic"; const runtime = new SessionRuntime({ provider: new AiSdkProvider({ model: anthropic("claude-sonnet-4-6"), maxSteps: 5, }), userId: req.user.id, }); const session = await runtime.createSession({ tools: { enabled: Object.keys(tools) }, }); for await (const event of runtime.executeMessage(session.id, prompt)) { // stream events, write audit rows, walk the family cascade — // all of it is the runtime's job, not yours. } ``` The migration walkthroughs for the four common starting points — [AI SDK](/docs/migrating-from-ai-sdk), [LangChain](/docs/migrating-from-langchain), [Anthropic Enterprise](/docs/migrating-from-anthropic-enterprise), [OpenAI Enterprise](/docs/migrating-from-openai-enterprise) — each show the loop rewrite step by step. ## Pick neither when [#pick-neither-when] The two paths cover most starting points, but not all of them. Stay where you are when: * You're shipping a single-shot RAG bot with no tools, no sub-agents, and no per-tenant attribution requirement. The row is overhead you won't recoup. * Your audit need is satisfied by the provider's own dashboard (an Anthropic Workspace, an OpenAI Project, a Bedrock invocation log). Adding a second ledger to maintain isn't free. * You're prototyping an agent shape and the loop is going to change three more times this week. Wire the SDK once the shape stabilizes. The voice on those constraints is information, not gatekeeping — if you do need the row later, the brownfield path is \~15 lines away. ## Where to go next [#where-to-go-next] --- # Agent shapes (/docs/agent-shapes) Pleach is built for the multi-tenant production-agent shape. Below are the common shapes that show up in adopter conversations — pick the closest, then read the linked use case for the wiring. Each section names the pattern (what hurts), the mechanism (the primitive that handles it), the team shape (where it typically shows up), and the sharpest vertical (where the pain is loudest). ## 01 — Provider migration [#01--provider-migration] ### The pattern [#the-pattern] Anthropic to OpenAI, OpenRouter to BYOK, native to gateway — mid-session, without breaking the tool-call dialect or refusal handling. ### The mechanism [#the-mechanism] Family-lock freezes tokenizer, prompt-cache key, tool-call dialect, and refusal pattern at session start. The cascade walks in-family rungs only; `permittedFamilies` narrows it to a vetted set. ### The team shape [#the-team-shape] SaaS adding in-product chat. ### The sharpest vertical [#the-sharpest-vertical] Compliance-bound teams (HIPAA, SOC 2, PCI, FedRAMP, 21 CFR Part 11). ## 02 — Tool fan-out and subagent attribution [#02--tool-fan-out-and-subagent-attribution] ### The pattern [#the-pattern-1] One user message triggers a dozen tool calls and spawns three subagents that spawn their own. You need to know which tools ran, which subagents spawned, and where the tokens went. ### The mechanism [#the-mechanism-1] Every row carries `turnId`, `toolName`, and `subagentDepth`. A typed `SpawnTreeState` rolls child-session token spend back to the parent turn instead of stranding it under the child sessionId. ### The team shape [#the-team-shape-1] Internal platform teams exposing "AI for everything". ### The sharpest vertical [#the-sharpest-vertical-1] Coding-agent shops (Cursor-class, terminal-native, autonomous-PR). ## 03 — Portable, replayable state [#03--portable-replayable-state] ### The pattern [#the-pattern-2] Offline-first or sync-conflict-prone sessions — browser extensions, mobile-shaped web apps, multi-device editing. Two clients writing the same session can silently overwrite each other. ### The mechanism [#the-mechanism-2] The session is one append-only event log. IndexedDB + checkpointer + version-vector sync carry it across devices; conflicts surface at write time. `HarnessPlugin` events are first-class log entries. ### The team shape [#the-team-shape-2] Vertical AI startups (legal, radiology, accounting close, devtools security). ### The sharpest vertical [#the-sharpest-vertical-2] Eval and research labs running deterministic replay across model swaps. ## 04 — Per-axis cost attribution [#04--per-axis-cost-attribution] ### The pattern [#the-pattern-3] A `(family, callClass, model)` call has to roll up to whichever axis you're attributing on — your end customers in a SaaS, or your own employees, teams, and cost centers when Pleach runs under one Anthropic Workspace or OpenAI Project on an Enterprise contract. ### The mechanism [#the-mechanism-3] Every `AuditableCall` row carries `tenantId`, `turnId`, `toolName`, `subagentDepth`, `modelId`, and `tokenUsage`. `tenantId` is opaque — `customer-acme` and `cost-center-eng-platform` partition the same way. Finance reads the ledger directly — no parallel cost pipeline. See [Migrating from Anthropic Enterprise](/docs/migrating-from-anthropic-enterprise) and [Migrating from OpenAI Enterprise](/docs/migrating-from-openai-enterprise) for the contract-composition walk-through. ### The team shape [#the-team-shape-3] AI consultancies with per-engagement tenants; internal platform teams under one Enterprise contract attributing across business units. ### The sharpest vertical [#the-sharpest-vertical-3] ISVs embedding agents in a downstream SaaS (customer → end-user nesting); internal-AI platform teams in 500-100,000-person enterprises rolling up to cost-center budgets. ## 05 — Regulator-facing audit [#05--regulator-facing-audit] ### The pattern [#the-pattern-4] A regulator, auditor, or court asks which tools the session invoked, which subagents it delegated to, and proof neither the answer nor the PII gate were altered after the fact. ### The mechanism [#the-mechanism-4] Append-only ledger keyed by `(turnId, toolName, subagentDepth)` answers the first two questions in one query. `@pleach/compliance` ships four scrubbers (`SSN-US`, `Luhn`, `US-DL`, `KeyedRegex`) on the persistence boundary plus inherited CI gates that fail an untenanted write. ### The team shape [#the-team-shape-4] Public-sector deployments (FedRAMP, FOIA-bound, often air-gapped). ### The sharpest vertical [#the-sharpest-vertical-4] Edtech serving K-12 or higher-ed (FERPA-equivalent per-district isolation). ## 06 — Stuck-session debugging [#06--stuck-session-debugging] ### The pattern [#the-pattern-5] A user reports a bad turn. By the time you read the ticket, the session is twenty messages further along. You need to rewind to before the bad decision and try a different path. ### The mechanism [#the-mechanism-5] Per-channel `checkpoint()` / `restore()` under `window.__HARNESS_DEVTOOLS__`. Deterministic fingerprint stream means a support ticket replays byte-identically locally; the bad turn is a known offset, not a guess. ### The team shape [#the-team-shape-5] Founder teams answering their own support email. ### The sharpest vertical [#the-sharpest-vertical-5] Customer-support automation across thousands of live transcripts a day. ## 07 — Region-pinned routing under contract [#07--region-pinned-routing-under-contract] ### The pattern [#the-pattern-6] An enterprise already running AI in production needs procurement- visible routing discipline: region pinned per call class, family pinned per call class, failover constrained to a vetted list, and a parity-validation suite that proves the substitute behaves the same as the primary. ### The mechanism [#the-mechanism-6] `permittedFamilies` scopes the cascade; a per-call-class family pin keys on `(family, callClass)` against the resolution matrix. `@pleach/eval` records a fixture run against the primary and replays it against each family in the permitted list — the diff is what proves the failover is safe to take. ### The team shape [#the-team-shape-6] Direct-API enterprise buyers — Anthropic / OpenAI tier-4+ contracts with six-to-eight-figure annual spend, often with a named AI governance function. ### The sharpest vertical [#the-sharpest-vertical-6] EU-resident enterprises preparing for AI Act Article 12 attestation; financial services with a stated provider-switch clause. ## 08 — Cloud-mediated transport [#08--cloud-mediated-transport] ### The pattern [#the-pattern-7] API keys are forbidden by policy. Models must be reachable inside the customer's VPC. The buyer reaches Anthropic, OpenAI, or Google models through AWS Bedrock, Azure OpenAI Service, or Vertex AI — not the labs' direct APIs. ### The mechanism [#the-mechanism-7] Family stays the model's identity (`anthropic`, `openai`, `google`); transport names the route to it (`bedrock`, `azure-openai`, `vertex`). The credential is an IAM role, a managed identity, or a workload-identity binding — never a long-lived key. The audit row records the principal, the cloud, the region, and the model — joinable to the cloud invoice. ### The team shape [#the-team-shape-7] 1,000-100,000-person enterprises with $1M-$100M annual cloud commit, often with on-prem or VPC-mandate procurement policy. ### The sharpest vertical [#the-sharpest-vertical-7] Regulated financial services and healthcare systems whose existing committed cloud spend is the procurement substrate. ## 09 — Bounded subagent swarms [#09--bounded-subagent-swarms] ### The pattern [#the-pattern-8] One user turn fans out into dozens or hundreds of subagents, each of which may spawn its own. Deep sequential loops, parallel fan-out, supervisor / worker topologies, recursive delegation — different shapes, same runaway-loop risk: a buggy fan-out can spend a five-figure budget in one user turn. ### The mechanism [#the-mechanism-8] Every subagent inherits the originating turn's `turnId` and carries its own `subagentDepth`. The whole tree is one recursive CTE against the ledger. A per-root-turn cost ceiling aborts the turn when cumulative spend across the tree crosses the line; fan-out and depth caps bound the structural shape. ### The team shape [#the-team-shape-8] Builders of coding agents, deep-research products, supervisor- worker platforms, and Claude Code-style recursive delegation — where the topology has already been chosen and what's needed is a substrate that doesn't force a different one. ### The sharpest vertical [#the-sharpest-vertical-8] Agent-product builders adding governance after a runaway-loop incident; enterprise buyers wrapping an existing swarm consumer (Kimi, Deep Research, AutoGen) with cost ceilings and an audit walk. ## Where to go next [#where-to-go-next] --- # Agents (/docs/agents) An agent profile is the variable surface the registry maintains per spec name: invocation count, the last 100 invocations as a rolling window, per-tool success and failure counts, intent affinity, and a first-half-versus-second-half trend on success rate. The substrate doesn't pick which agent runs — that's the orchestrator's job — but it owns the longitudinal record of how each spec has performed for this user. Distinct from [subagents](/docs/subagents): subagents are runtime spawn handles (`@pleach/core/subagents`); agent profiles are persistent registry records (`AgentRegistry`). A subagent invocation writes into the profile of its spec; the profile outlives any individual spawn. See [Memory](/docs/memory) for the per-agent fact namespace that shares this scope, and [Plans](/docs/plans) for how a step's `suggestedAgent` resolves against a profile's `specName`. `AgentRegistry` is re-exported from the package root. The underlying types live under the real `@pleach/core/agents/types` subpath (an explicit, emitted export). ```typescript import { AgentRegistry } from "@pleach/core"; import type { AgentProfile, AgentInvocationEntry, AgentDerivedStats, ToolUsageStat, } from "@pleach/core/agents/types"; ``` ## The composing-agents cluster [#the-composing-agents-cluster] Agent profile is one of three concepts paired with [Skill](/docs/skills) (the unit-of-work definition) and [Subagent](/docs/subagents) (the child runtime that executes one). The cluster sits between the orchestrator and the audit ledger — it names the unit, spawns the runtime, and records the rolling signal that decides whether to spawn it again. The full triplet framing lives at [Concept clusters → Composing-agents](/docs/concept-clusters#composing-agents-cluster); the rest of this page is the deep dive on the registry. ## `AgentProfile` shape [#agentprofile-shape] | Field | Type | Notes | | ---------------- | ------------------------------- | ------------------------------------------ | | `specName` | string | The spec this profile is for | | `orgId` | string | Organization scope | | `userId` | string | User scope, or `"__org__"` for org-wide | | `firstSeen` | ISO-8601 | Created lazily on first `recordInvocation` | | `lastSeen` | ISO-8601 | Bumped on every recorded invocation | | `invocations` | number | Total count, never decays | | `performance` | object | `{ windowSize, entries }` — rolling window | | `toolUsage` | `Record` | Per-tool counts | | `intentAffinity` | `Record` | Intent frequency | | `notes` | string \| undefined | User-provided annotation | The rolling window holds the last 100 invocations (`MAX_WINDOW = 100`). Older entries fall off; `invocations` keeps the lifetime count. ## `AgentInvocationEntry` [#agentinvocationentry] | Field | Type | Notes | | -------------- | ------------------- | --------------------------------------------- | | `sessionId` | string | Which session produced the invocation | | `chatId` | string | Which chat | | `timestamp` | ISO-8601 | When the invocation completed | | `duration` | number | ms | | `status` | enum | `success` / `error` / `timeout` / `cancelled` | | `toolCalls` | number | Total tool calls during the invocation | | `toolErrors` | number | Subset that errored | | `tokenUsage` | number | Total tokens | | `intent` | string \| undefined | Triggering intent classifier label | | `qualityScore` | number \| undefined | 0–1, if the eval harness scored the run | | `toolsUsed` | string\[] | Tool names called | | `errorSummary` | string \| undefined | Short error message on failure | ## `AgentRegistry` methods [#agentregistry-methods] | Method | Returns | Effect | | ----------------------------------- | ------------------- | --------------------------------------------------------------------------------- | | `getProfile(specName)` | `AgentProfile` | Reads or constructs an empty profile; does not persist | | `recordInvocation(specName, entry)` | void | Re-reads the profile, appends, trims to 100, persists | | `listProfiles()` | `AgentProfile[]` | Up to 100 | | `getStats(profile)` | `AgentDerivedStats` | Pure projection over the rolling window | | `formatProfileSummary(profile)` | string | Single-line summary for prompt injection — invocations, success rate, trend arrow | `recordInvocation` re-reads the stored profile before mutating to narrow the lost-update window when concurrent subagents complete near-simultaneously. The registry doesn't ship a conditional-update primitive; if you need stronger guarantees, funnel writes through a per-`specName` lock at the caller. ## `AgentDerivedStats` [#agentderivedstats] | Field | Type | Notes | | -------------- | ------ | ------------------------------------------------ | | `successRate` | number | `success` count / window size | | `avgDuration` | number | ms, mean over the window | | `avgToolCalls` | number | Mean over the window | | `avgQuality` | number | Mean over entries with `qualityScore`; 0 if none | | `errorRate` | number | Fraction of entries with `toolErrors > 0` | | `trend` | number | `secondHalf.successRate − firstHalf.successRate` | | `sampleSize` | number | Window entry count | | `topTools` | array | Top 10 by call count | | `topIntents` | array | Top 5 by frequency | `trend` is the load-bearing signal — positive means the agent is improving over the window, negative means degrading. `formatProfileSummary` renders the trend as `↑`, `↓`, or `→` with a ±2% deadband. ## Example [#example] ```typescript import { AgentRegistry } from "@pleach/core"; import { MyStore } from "./my-store"; const registry = new AgentRegistry(new MyStore(), orgId, userId); await registry.recordInvocation("research-fanout", { sessionId, chatId, timestamp: new Date().toISOString(), duration: 8_240, status: "success", toolCalls: 6, toolErrors: 0, tokenUsage: 12_400, intent: "literature_search", toolsUsed: ["search_corpus", "search_news", "fetch_url"], }); const profile = await registry.getProfile("research-fanout"); const stats = registry.getStats(profile); if (stats.sampleSize >= 10 && stats.successRate < 0.6) { log.warn("research-fanout success rate below threshold", stats); } ``` ## Recording a failed run [#recording-a-failed-run] The entry below shows what to write when an invocation errors — `errorSummary` is the short string the UI surfaces, `toolErrors` counts the underlying tool failures. ```typescript await registry.recordInvocation("research-fanout", { sessionId, chatId, timestamp: new Date().toISOString(), duration: 2_180, status: "error", toolCalls: 3, toolErrors: 2, tokenUsage: 1_900, toolsUsed: ["search_corpus", "fetch_document"], errorSummary: "fetch_document timed out twice", }); ``` The entry lands in the rolling window the same way a success does; the next `getStats(profile)` call reflects the new `errorRate` and the recomputed `trend`. ## Rendering a profile summary [#rendering-a-profile-summary] The snippet pulls the single-line summary `formatProfileSummary` returns and shows the trend arrow it bakes in. ```typescript const profile = await registry.getProfile("research-fanout"); const line = registry.formatProfileSummary(profile); // Example output: // Agent "research-fanout" — 47 prior invocations, 81% success rate, // ↓ 12% trend. Top tools: search_corpus, fetch_document, summarize. console.log(line); ``` The arrow is `↑` / `↓` / `→` with a ±2% deadband against `stats.trend`. An empty rolling window returns the empty string, so the line is safe to concatenate into a system prompt without a guard. ## Persistence layout [#persistence-layout] | Path | Holds | | ------------------------------------------------ | --------------------------------------------------- | | `[orgId, userId, "agents", "profiles"]` | `AgentProfile`, keyed by `specName` | | `[orgId, userId, "agents", , "facts"]` | Per-agent learned facts (see `@pleach/core/memory`) | The shared `agents//` prefix is what lets the `LearningAuditor` correlate a fact's per-agent scope with the profile that owns it — `detectConflicts` walks every profile in `agents/profiles` and reads facts out of `agents//facts` to find disagreements. ## Where to go next [#where-to-go-next] --- # Air-gapped deployment (/docs/air-gapped-deployment) Air-gapped deployment means the runtime makes no outbound TCP calls to public LLM endpoints, ships no telemetry beyond the network perimeter, and installs from a private registry or vendored snapshot. This page is for regulated-environment evaluators — defense contractors, FedRAMP-bound integrators, healthcare system operators, and financial-services teams with on-prem mandates. **Air-gapped deployment is a v2+ roadmap item.** The v1 runtime still has three code paths that call `openrouter.ai` directly (in the memory consolidation, fact extraction, and context summarizer paths). These are call-site gaps, not architectural ones, and are documented in the source repo at `packages/core/docs/air-gapped-architecture.md`. What v1 does ship is the substrate posture that makes the v2 path buildable without rearchitecting the runtime. ## What v1 ships today [#what-v1-ships-today] **Bring-your-own provider.** The `AgentProvider` interface is the only boundary between the runtime and an LLM call. Point it at a private-endpoint URL and no inference traffic leaves the perimeter. `AnthropicSdkProvider` exposes a `baseURL` field for this; any OpenAI-compatible in-perimeter gateway works via `AiSdkProvider`. **Custom provider implementations.** The `AgentProvider` contract is stable and documented. A host that runs an in-perimeter model endpoint can implement it directly — the runtime does not require a bundled provider. **No implicit telemetry.** `@pleach/core` contains no analytics SDK, no crash reporter, and no usage callback to a third-party SaaS endpoint. The only outbound calls the runtime makes are the ones the host configures via the provider interface. **Configurable observability destinations.** `@pleach/observe` ships data where the host configures it to ship. For contained deployments, the destination is a private SIEM or log aggregator inside the perimeter. **Plugin interception surface.** The `HarnessPlugin` contract lets a host inject logic at every major runtime boundary — including tool calls that would otherwise reach external endpoints. This is the extension point for perimeter enforcement before v2's network-policy validator lands. **PII scrubbing before persistence.** `@pleach/compliance` runs scrubbers on the persistence boundary so classified or regulated identifiers don't reach storage. Scrubbers are configurable per environment; the four bundled scrubbers (SSN, Luhn, US-DL, `KeyedRegex`) cover common regulated-domain patterns. ## What changes in v2+ [#what-changes-in-v2] **Dependency vendoring.** A documented, reproducible `node_modules` snapshot or private-registry recipe so the runtime installs without reaching the public npm registry. **SBOM.** A CycloneDX software bill of materials generated at publish time, covering every direct and transitive runtime dependency. This is the artifact supply-chain risk management (SCRM) reviews require. **Signed artifacts.** Sigstore signing for published packages, with SLSA Level 2 provenance attestation. The consumer verifies the bundle before install. **Network-policy validator.** A runnable script that enumerates every outbound call the runtime can make and reports which calls have been rerouted to in-perimeter destinations. The output is the evidence artifact a compliance reviewer signs off on. **Hardcoded-endpoint closure.** The three remaining `openrouter.ai` call sites are replaced with DI-injected provider calls, closing the last gaps that prevent strict perimeter enforcement. ## Where to go next [#where-to-go-next] The GitHub roadmap tracks the v2+ items listed above. Follow the `@pleach/core` repository at `github.com/pleachhq/core` and filter by the `air-gapped` label. --- # API routes (/docs/api-routes) API routes are one surface in the **frontend integration** [thematic island](/docs/concept-clusters#what-lives-outside-the-cluster-pattern) — siblings of [react](/docs/react), [server](/docs/server), [query](/docs/query), and [devtools](/docs/devtools). The eight routes below are the canonical HTTP + SSE wire protocol that runtime clients speak. The Next.js handlers in `@pleach/core` are the reference implementation, but the shapes are language-agnostic — an independent native client consumes them in production, which is what keeps the wire honest. Mount the routes under `/api/harness/*` (the path prefix is convention, not contract — re-mount wherever you like; the relative shapes are what matters). The shipped, published route factory is `createPleachRoute` from `@pleach/core/quickstart` — a Web-standard POST handler for the execute path: ```typescript // app/api/harness/sessions/[id]/execute/route.ts (Next.js) import { createPleachRoute } from "@pleach/core/quickstart"; // Provider auto-detected from the environment; pass // { provider, plugins, tools, storage } to override. export const POST = createPleachRoute(); ``` The full eight-route handler set documented below (`HarnessServer` + `ROUTES`) is an internal substrate surface — see [Server](/docs/server). It is NOT a published `@pleach/core/server` subpath today, so mount it from the reference Next.js handlers or implement the wire contract directly. `createPleachRoute` above is the one published factory and covers the single-POST execute path. ## Route catalog [#route-catalog] | Route | Method | Purpose | | ------------------------------------ | ------ | ------------------------------------------ | | `/api/harness/health` | GET | Health check + component diagnostics | | `/api/harness/sessions` | GET | List sessions for the authenticated user | | `/api/harness/sessions` | POST | Create a new session | | `/api/harness/sessions/[id]` | GET | Retrieve session state | | `/api/harness/sessions/[id]` | PUT | Update session state | | `/api/harness/sessions/[id]` | DELETE | Delete a session | | `/api/harness/sessions/[id]/sync` | POST | Version-vector sync (conflict detection) | | `/api/harness/sessions/[id]/execute` | POST | Execute a message (SSE streaming response) | ## `GET /api/harness/health` [#get-apiharnesshealth] Health probe + component-level diagnostics. Cheap; safe to hit from a load balancer. **Response 200** ```json { "ok": true, "version": "1.1.0", "components": { "storage": { "ok": true }, "checkpointer": { "ok": true }, "providers": { "ok": true, "configured": ["anthropic"] } } } ``` **Response 503** — runtime disabled via `FEATURE_HARNESS_V2_RUNTIME=false`. ## `GET /api/harness/sessions` [#get-apiharnesssessions] List sessions owned by the authenticated user. **Query params** | Param | Type | Purpose | | ---------- | --------- | ------------------------------------------- | | `limit` | `number` | Page size (default 50, max 200) | | `cursor` | `string` | Opaque pagination cursor | | `archived` | `boolean` | Include archived sessions (default `false`) | **Response 200** ```json { "sessions": [ { "id": "018f...", "createdAt": "...", "lastActiveAt": "...", "title": "..." } ], "nextCursor": "01jc..." } ``` ## `POST /api/harness/sessions` [#post-apiharnesssessions] Create a new session. **Request body** ```json { "provider": { "type": "anthropic" }, "model": { "id": "claude-sonnet-4-5" }, "tools": { "enabled": ["search", "calculator"] } } ``` All fields optional; defaults match `SessionRuntime.createSession`. **Response 201** ```json { "session": { "id": "018f...", "createdAt": "...", "..." } } ``` ## `GET /api/harness/sessions/[id]` [#get-apiharnesssessionsid] Retrieve a session's full state. **Response 200** — the `SessionState` shape (messages, pendingToolCalls, completedToolCalls, pendingJobs, artifacts, versionVector, etc.). **Response 404** — session not found or not visible to the authenticated user. Code `2001`. ## `PUT /api/harness/sessions/[id]` [#put-apiharnesssessionsid] Update session state. The request body is the next `SessionState`; the server validates the version vector and rejects stale writes with `3001`. **Request body** ```json { "version": 7, "versionVector": { "client_abc": 4, "server": 3 }, "messages": [/* ... */], "tools": { "enabled": ["search"] } } ``` **Response 200** — the updated state. **Response 409** — version conflict. Code `3001`. Body: ```json { "code": "3001", "error": "Version conflict", "remote": { /* the server's current state */ } } ``` The client should resolve the conflict (typically by surfacing a chooser UI) and re-PUT with the merged result. ## `DELETE /api/harness/sessions/[id]` [#delete-apiharnesssessionsid] Delete a session. Cascades into the checkpointer; audit-ledger rows are preserved (append-only). **Response 204** — no content. ## `POST /api/harness/sessions/[id]/sync` [#post-apiharnesssessionsidsync] Version-vector sync. The client pushes its local vector + queued changes; the server returns missing-from-remote changes plus the merged vector. **Request body** ```json { "clientId": "client_abc", "vector": { "client_abc": 5 }, "changes": [/* SessionChange[] */] } ``` **Response 200** ```json { "mergedVector": { "client_abc": 5, "server": 3 }, "remoteChanges": [/* changes the client hasn't seen yet */], "applied": 3, "rejected": 0 } ``` **Response 409** — concurrent vector that the merger couldn't resolve. Code `3001` or `3002`. ## `POST /api/harness/sessions/[id]/execute` [#post-apiharnesssessionsidexecute] Execute a message. **SSE streaming response.** Each event from `runtime.executeMessage` becomes one SSE `data:` frame. **Request body** ```json { "content": "Summarize the latest changelog entries.", "fileReferences": [] } ``` **Response 200** — `Content-Type: text/event-stream`. Each line: ``` data: {"type":"message.delta","delta":"The"}\n\n data: {"type":"tool.started","toolCall":{...}}\n\n data: {"type":"tool.completed","toolCall":{...},"result":{...}}\n\n data: {"type":"message.complete","message":{...}}\n\n data: {"type":"step.end","step":"post-turn"}\n\n ``` See [Stream events](/docs/stream-events) for the full event catalog. ### Aborting mid-stream [#aborting-mid-stream] Close the SSE connection (`EventSource.close()` or fetch `AbortController.abort()`). The runtime cancels in-flight provider calls and flushes a final ledger row with `outcome.status: "user-aborted"`. ### Reconnection [#reconnection] The runtime emits `stream.reconnecting` and `stream.disconnected` events on retry. A reference client reconnects on `EventSource.error` and replays from the last seen event id. The server is idempotent on `messageId` — re-submitting the same message after a reconnect doesn't fork the turn. ## Auth headers [#auth-headers] The reference handlers expect: | Header | Purpose | | ------------------------------- | ---------------------------------------- | | `Authorization: Bearer ` | User auth — surfaces as `runtime.userId` | | `X-Organization-Id` | Multi-tenant scoping | | `X-Client-Id` | Sync version-vector key for this client | Mock-mode (`HARNESS_MOCK_MODE=true`) accepts all requests as `anonymous` for local development. ## Error response shape [#error-response-shape] Every non-2xx body follows the same shape: ```json { "code": "2001", "error": "Session not found", "recoveryHint": "The session id may be stale; try refreshing the session list." } ``` `code` is the structured error code — see [Error codes](/docs/error-codes) for the catalog. ## Where to go next [#where-to-go-next] --- # Architecture (/docs/architecture) Every LLM call in `@pleach/core` routes through one of four seams, resolves against a family-locked model matrix, and lands as one append-only `AuditableCall` row. Six pieces compose that path: `SessionRuntime`, `CompiledGraph`, `Channels`, `Seams`, the `AuditableCall` ledger, and the event log — the lattice constrains where calls live, the ledger carries what they did. These six are the structural members of the hedge — the rigid frame the agent's branches grow against. The four-stage lattice says where a call can go; the channels say what a call can read and write; the seams say which model a call routes to; the ledger says where its outcome lands. No piece is optional; no piece is replaced silently. CI gates fail the build when any of them drifts. This page is the substrate-wide map. Each section names a piece, the role it plays, and the page that owns the deep-dive. The [boundary rules](#10-boundary-rules) at the bottom are unique to this page — they're the architectural invariants the rest of the design depends on. This page mirrors the [`@pleach/core` architecture deep-dive](https://github.com/pleachhq/core/blob/main/docs/ARCHITECTURE.md) in the package repo. If the two disagree, the repo wins — file an issue against [pleachhq/core](https://github.com/pleachhq/core/issues). ## TL;DR — six pieces, one substrate [#tldr--six-pieces-one-substrate] 1. **`SessionRuntime`** owns per-session state, the compiled graph, and the lifecycle. 2. **`CompiledGraph`** is the declarative topology — nodes wired to channels, edges constrained to a four-stage lattice. 3. **Channels** are typed reactive state slots. A node fires when one of its subscribed channels advances; concurrent writes have well- defined reducer semantics. 4. **Seams** are per-call-class provider entry points. Every LLM call goes through exactly one seam; the `callClass` literal is lint- restricted to the seam factories. 5. **Modelfamily matrix** resolves `(family × callClass)` → model id + transport. Family is locked at session start; the matrix never silently widens. 6. **Audit ledger** + **event log** carry the two write streams: every addressable decision lands in the ledger; every observable event lands in the log. ## The execution-graph cluster [#the-execution-graph-cluster] Three of the six pieces — `CompiledGraph`, `Channels`, and the nodes the graph compiles — form a tighter cluster: the per-turn execution substrate that lives *inside* the lattice. The full triplet framing lives at [Concept clusters → Execution-graph](/docs/concept-clusters#execution-graph-cluster); the deep-dive pages are [Graph](/docs/graph), [Nodes](/docs/nodes), and [Channels](/docs/channels). The piece that *runs* that cluster is the **engine** — the `engine/` scheduler (`SuperstepRunner` and its primitives). Where `CompiledGraph` is the declarative topology, the engine is the deterministic superstep executor that drives it: each superstep fires the set of triggered nodes in parallel (`shouldTriggerNode` resolves channel-version advances into node activations), accumulates their writes, and commits the resulting channel updates atomically at the step boundary. That Pregel-style discipline — run, accumulate, commit — is what makes graph scheduling deterministic and race-free between concurrent writers, and it underwrites the byte-replay property in §9. The engine also owns the cross-cutting execution plumbing the graph depends on: retry-with-backoff (`RetryPolicy`), abort-signal composition (`AbortComposer`), and graph↔session state mirroring (`GraphStateSynchronizer`). It ships in the main `@pleach/core` entry today, not a separate subpath. ## 1. Stage lattice [#1-stage-lattice] Every node in the compiled graph belongs to exactly one of four stages — `anchor-plan` → `tool-loop` → `synthesize` → `post-turn`. `ALLOWED_EDGE_PATTERNS` enumerates nine legal `(from-stage, to-stage)` pairs: five cross-stage transitions (the three forward edges, the `tool-loop → post-turn` recovery-dispatch edge, and the `post-turn → anchor-plan` next-turn rollover) and four intra-stage chains — `anchor-plan`, `tool-loop`, `post-turn`, plus the `messageId`-guarded `synthesize → synthesize` retry. Every other pair is forbidden. The lattice is structural, not advisory — `audit:graph-stages` fails CI on any out-of-lattice edge. Why structural matters: once stages are structural, *cost allocation*, *observability*, and *time-travel* become structural too. A `GROUP BY stage_id` on the audit ledger returns per-stage spend for any turn against a row shape that exists by construction — every row carries a non-null `stage_id` because a node that doesn't declare a stage fails CI before it ever fires. See [Graph](/docs/graph) for the substrate API, [Nodes](/docs/nodes) for the per-node metadata, and [Turn lifecycle](/docs/turn-lifecycle) for the per-stage stream-event and ledger-payload tables. ## 2. Call classes [#2-call-classes] Every LLM call declares one of four classes — `utility`, `reasoning`, `converse`, `synthesize` — which the seam factory carries through to the matrix. The class drives three things: the seam that carries the invocation, the per-turn allotment, and the audit-row slice the call lands in. `synthesize` is structurally capped at exactly one per turn (singleton seam + idempotent counter) so the rendered string and the audited string are the same string. See [Call classes](/docs/call-classes) for the four-class taxonomy, the per-class roles, and the `lint:callclass-literals` gate that restricts the literal. ## 3. Seams [#3-seams] A seam is the per-call-class provider entry point. Four factories live in `graph/seams/`, one per call class. The seam carries the locked `callClass`, threads it as a type parameter into matrix resolution, and dispatches a sync per-chunk observer ladder on the inbound stream — observers return `continue`, `amend`, `emit`, or `halt`. `onChunk` is sync-only because async would race on replay. See [Seams](/docs/seams) for the four factories, the singleton synthesize seam, the observer verdict ladder, and how a node consumes a seam. ## 4. Family-locked routing [#4-family-locked-routing] `(ProviderFamily × CallClass)` keys the model resolution matrix. Family and transport lock at session start. When a provider call fails, the cascade walks the next rung in the same family (`pickNextInFamily`); when the column is exhausted, the runtime emits `family-exhausted` and surfaces the state to the host. No silent cross-family widening. The only carve-out is BYOK and other non-matrix-resolvable models, which never participated in a lock to begin with. See [Family-locked routing](/docs/family-lock) for the lock contract, the four properties it freezes (tokenizer, prompt-cache key, tool-call dialect, refusal pattern), and the family-strict cascade. The per-family per-callclass lookup lives at [Model resolution matrix](/docs/model-resolution-matrix). ## 5. Audit ledger [#5-audit-ledger] Every seam writes one row per call. The four-field identity tuple is non-null by construction. The row is append-only by interface contract — no `update`, no `delete`; a row that needs to mutate is a wire-format break that bumps `auditRecordVersion` instead. The ledger is also what makes per-axis attribution work *inside* one Anthropic Workspace or OpenAI Project: every row carries the opaque `tenantId` the host wires to its billing axis, and the rollup is `GROUP BY tenant_id`. Three compliance plug-points (`TamperEvidence`, `PIIRedactor`, `GDPRSoftDelete`) ride on top of the ledger. Each ships as a no-op default in `@pleach/core`; production implementations land in `@pleach/compliance@0.1.0` (Phase A: scrubber cohort, C8 redaction substrate, attestation Phase A). Hash-chain verification lives in `@pleach/core/eventLog` (`verifyChainForChat`, `generateProof`); `@pleach/replay@0.1.0` composes them. `@pleach/core/attestation` is the cryptographic signing layer on top of the canonical row hash — ed25519 over RFC 8785-canonicalized payload bytes, with a pluggable `AttestationKeyStore` (AWS KMS and Vault Transit stubs at v1 contract; a file-backed test adapter under the `/testing` subpath). A signed row makes any post-hoc mutation detectable per-row; the hash chain makes deletion or reordering detectable across rows. See [Attestation](/docs/attestation). See [Audit ledger](/docs/audit-ledger) for the write interface and the plug-points, [AuditableCall row](/docs/auditable-call-row) for the row shape, and [Hash chain](/docs/hash-chain) for the tamper-evidence layer. ## 6. Event log [#6-event-log] Distinct from the ledger. The event log is the broader stream of *observable events* — messages, tool dispatches, interrupts, subagent spawns, exports, plugin domain events. Three layers: `EventLogWriter` (fire-and-forget enqueue), `durableFlush` (`waitUntil`-routed flush with status-code-aware retries, idempotent on client-generated `id`), and `hydrateFromEvents` (a walk that rebuilds session state from a slice of events). Every write passes through the `Scrubber` allowlist before persistence. The `audit:c8-event-type-allowlist-coverage` CI gate enforces full scrubber coverage — an event type that lands without a scrubber fails the build (see [Scrubbers](/docs/scrubbers)). See [Event log](/docs/event-log) for the full per-layer contract. ## 7. Sessions, storage, sync [#7-sessions-storage-sync] `SessionRuntime` is constructed with three swappable backends — storage adapter, checkpointer, and (optional) sync. Memory / IndexedDB / Supabase implementations of each share one interface; swapping is a one-line change. The runtime also ships `memoryCacheBackend` as the default `cacheBackend` — runtime-side memoization of prepared LLM inputs, default-constructed in the `SessionRuntime` constructor since PA-2 C2 Phase 3 and threaded through the four seam factories. On by day zero, no opt-in required. This is distinct from provider-side prompt caching (see [Cache](/docs/cache) and [Prompt caching](/docs/prompt-caching)). `SessionRuntime` exposes its capabilities through named accessor groups (`runtime.sessions`, `runtime.events`, `runtime.spans`, `runtime.tenant`, `runtime.graph.{recovery, heuristics, config}`), which is what makes the runtime LLM-agent friendly — an agent reading `runtime.` sees a grouped surface rather than a flat 40-method dump. `runtime.spans` in particular is the in-process OTel facet, exposing `inFlightCount`, `isShutdown`, and `snapshot` introspection over the four read-side span types — useful for graceful-shutdown gates and live dashboards that need to know whether the in-process exporter has drained (see [OTel observability](/docs/otel-observability)). The cross-session dependency graph — typed edges between sessions, artifact provenance — is read-side observability sitting alongside the span surface; see [Lineage](/docs/lineage). See [Facets](/docs/facets) for the full inventory and the audit gates that enforce coverage. Sync uses version vectors per session — `compareVectors` detects concurrent writes at push time and the coordinator surfaces them through `sync.conflict` rather than overwriting. See [Storage](/docs/storage), [Checkpointing](/docs/checkpointing), and [Sync](/docs/sync) for the per-axis deep-dives. ## 8. Plugins [#8-plugins] `HarnessPlugin` is the consumer extension contract. A plugin can register tier nodes into the lattice's enrichment slots, stream observers, prompt contributors, safety contributions, and domain event handlers — and crucially, cannot add an out-of-lattice edge, bypass the singleton synthesize seam, reach across the `seams/` boundary, or register an async observer dispatch. Sibling SKUs (`@pleach/compliance`, `@pleach/eval`, `@pleach/gateway`, `@pleach/replay`, `@pleach/mcp`, `@pleach/coding-agent`, `@pleach/sandbox`, `@pleach/langchain`, `@pleach/base-tools`, `@pleach/observe`, `@pleach/recipes`) land as plugins on top of this contract. Every shipping sibling publishes at `0.1.0 · FSL-1.1-Apache-2.0` in the first-wave cut; `@pleach/trust-pack` alone remains a reserved npm name. See [Packages](/docs/packages) for the canonical per-SKU status table. The plugin contract is stable so a host implementing the same plug-points manually swaps into the sibling SKUs without graph-shape changes. See [Plugin contract](/docs/plugin-contract) for the full `definePleachPlugin` surface, every slot, and the breadcrumbs that fire when a capability isn't contributed. ## 9. Determinism [#9-determinism] Several pieces of the substrate are deliberately deterministic — the fingerprint module, channel reducers, stream observers, prompt composition, and the `engine/` superstep scheduler (`SuperstepRunner`). Together they buy the byte-replay property: a recorded turn replayed against the same package version + the same input produces a byte-identical fingerprint stream. This is the property `@pleach/eval@0.1.0` and `@pleach/replay@0.1.0` are built around (both shipping real bodies for the entry-point surfaces; remaining slices throw typed `NotImplemented` sentinels per [Packages](/docs/packages)). See [Determinism](/docs/determinism) for the five contracts and [Fingerprint](/docs/fingerprint) for the pure-function fingerprint that platform-uniformly hashes prepared inputs. ## 10. Boundary rules [#10-boundary-rules] Lint gates enforce the architectural boundaries: | Gate | Forbids | | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `lint:harness-boundary` | Imports from `seams/**` into `modelfamily/**`; imports from app-layer into core | | `lint:callclass-literals` | `callClass: "..."` literal outside the four seam factories + matrix module | | `lint:no-model-reassignment-in-fallback` | Reintroduction of `tier1Model` / `tier2Model` / `SYNTHESIS_CANDIDATES` | | `lint:stream-observer-registration` | Direct seam-binding of detectors (must go through plugin) | | `audit:graph-stages` | Out-of-lattice edges | | `audit:auditable-call` | Audit record version drift | | `audit:domain-string-purity` | Domain-specific literals in `packages/core/src/**` — host vocabulary, vendor backend names, sandbox tool prefixes, identity discriminators, domain phrasing (five pattern families; \~50 patterns; baseline-gated `:strict` mode) | These aren't bureaucracy; they're the structural guarantees the rest of the design depends on. If `callClass` could appear anywhere, the seam model leaks — a node could call `synthesize` outside the singleton seam and the rendered string would diverge from the audited string. If family could silently widen, the modelfamily lock is advisory — a tool that worked on `anthropic`'s tool-call dialect would start failing mid-session when the cascade reached an `openai`-shaped call. If observers could be async, replay determinism is gone — `@pleach/replay` strict mode would throw `ReplayDivergenceError` on the first race between two observer promises resolving in a different order on replay than on record. And if a host name, a vendor backend, or a sandbox tool prefix could land in `packages/core/src/**`, the language-agnostic claim leaks — the Go runtime would have to mirror string literals that only mean something to one TS consumer. `audit:domain-string-purity` makes plugins the only legitimate channel for consumer-specific content. The full inventory of CI- and PR-time gates — package shape, plugin contracts, tool coverage, event-log integrity, runtime soak ledgers, cross-repo reservations — lives at [Audit gates](/docs/audit-gates). ## Where to go next [#where-to-go-next] --- # Async tasks (/docs/async-tasks) `AsyncTaskManager` is the per-runtime catalog of in-flight long-running jobs — subagent runs, sandboxed code execution, anything a tool kicks off that outlives a single LLM turn. Tasks dispatch through a registered `AsyncTaskExecutor` keyed by `AsyncTask.type`, persist through an injected `AsyncTaskStore`, and surface to tools through a small set of synchronous read methods. See [Channels](/docs/channels) for the `asyncTasks` channel the reducer keys into, and [Subagents](/docs/subagents) for the in-tree `"subagent"` task kind. ```typescript import { AsyncTaskManager, asyncTasksReducer, type AsyncTask, type AsyncTaskExecutor, type AsyncTaskStore, } from "@pleach/core/async"; ``` `AsyncTask.type` is `string`, not a closed union. The only in-tree executor is `SubagentTaskExecutor` — auto-registered under the `"subagent"` kind when the manager is lazily built, but its `execute` throws `[SubagentTaskExecutor] SubagentExecutor is server-only`, so it does no work on bare `@pleach/core` until the host wires the server-side executor. There is no `"sandbox_exec"` executor class in-tree: `"sandbox_exec"` is an example kind a host must supply. Neither `SubagentTaskExecutor` nor any `sandbox_exec` executor is exported from `@pleach/core/async` (the barrel exposes the `AsyncTaskExecutor` contract, not the implementations). Domain plugins register additional kinds through `HarnessPlugin.registerAsyncExecutors`; other kinds are host/plugin-supplied obligations. ## The `runtime.async` facet [#the-runtimeasync-facet] The canonical access path is the `runtime.async` facet — the flat `runtime.getAsyncTaskManager()` / `runtime.spawnAgent(...)` methods remain callable but are `@deprecated` per the facet migration sweep. | Member | Returns | Notes | | ------------------------------------------------ | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `runtime.async.taskManager` | `AsyncTaskManager \| undefined` | The catalog described below; lazily wired. | | `runtime.async.manager` | `SubagentManager \| undefined` | The sub-agent registry (see [Subagents](/docs/subagents)). | | `runtime.async.spawn(config, options?)` | `Promise` | Imperative sub-agent spawn. | | `runtime.async.spawnStream(config, options?)` | `AsyncGenerator` | Streaming variant. | | `runtime.async.getResult(subAgentId)` | `SubAgentResult \| undefined` | Replay a recorded sub-agent result. | | `runtime.async.getResultWithContext(subAgentId)` | `SubAgentResultWithContext \| undefined` | Same payload plus the execution context (trace anchor, parent ids, timings) captured at completion time. Use for deterministic replay / re-attribution into a parent graph turn. | `runtime.async.taskManager` is the entry point for arbitrary async-task kinds (`"sandbox_exec"`, plugin-registered) — every method on the table below is reachable through it. The two `getResult` accessors are the sub-agent counterpart and key on the sub-agent id, not the `taskId`. See [Facets](/docs/facets) for the broader facet surface. ## `AsyncTask` shape [#asynctask-shape] | Field | Type | Notes | | ----------- | --------- | -------------------------------------------------------------------- | | `taskId` | string | `task__` — generated at start | | `type` | string | Executor key (`"subagent"`, `"sandbox_exec"`, plugin-registered) | | `threadId` | string | Session id; scopes the reducer namespace | | `status` | enum | `queued` / `running` / `success` / `error` / `cancelled` / `timeout` | | `input` | record | Opaque to the manager; the executor reads it | | `result` | record? | Set on `success` | | `error` | string? | Set on `error` / `timeout` | | `createdAt` | ISO-8601 | Frozen at start | | `updatedAt` | ISO-8601? | Bumps on every status transition | | `checkedAt` | ISO-8601? | Set by `check()` when the executor refreshes status | | `agentName` | string? | Free-form provenance (subagent name, etc.) | | `jobType` | string? | Free-form provenance | | `timeoutMs` | number? | Optional hard ceiling — flips status to `timeout` and aborts | ## The `AsyncTaskExecutor` contract [#the-asynctaskexecutor-contract] The seam each task kind implements: ```typescript interface AsyncTaskExecutor { execute(taskId: string, input: Record): Promise>; checkStatus?(taskId: string): Promise>; sendUpdate?(taskId: string, message: string): Promise; cancel?(taskId: string): Promise; } ``` `execute` is the only required method. The optional three are how the manager promotes a polling-only executor into one that supports live status, inbound messages, and remote cancellation. The manager calls each method only if the executor declares it; otherwise it falls back to the in-memory task as the source of truth. ## `AsyncTaskManager` methods [#asynctaskmanager-methods] | Method | Returns | Notes | | ---------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------ | | `registerExecutor(type, executor)` | `void` | Wire a kind. Last registration wins. | | `start(options)` | `Promise` | Persists `queued`, dispatches, returns the seeded task. | | `startBackground(options)` | `{ taskId, status, error? }` | Synchronous launch — returns immediately, fires persist + execute in the background. | | `check(taskId)` | `Promise` | Calls `executor.checkStatus` if defined; merges + persists. | | `getTaskSync(taskId)` | `AsyncTask \| null` | In-memory read; no executor round-trip. | | `update(taskId, message)` | `Promise` | Forwards to `executor.sendUpdate` if defined. | | `cancel(taskId)` | `Promise` | Aborts, calls `executor.cancel` best-effort, marks `cancelled`. | | `list(filter?)` | `AsyncTask[]` | In-memory filter on `threadId` / `type` / `status`. | | `get(taskId)` | `AsyncTask \| null` | Synchronous in-memory lookup. | `startBackground` is a synchronous launch helper — it returns a `taskId` inside the same tick, then fires persist + execute in the background. It is a host/manager entry point, not the LLM tool path: in bare `@pleach/core` the `start_async_task` tool is not bridged to it. The tool-loop short-circuit handler for `start_async_task` (and `check_async_task` / `update_async_task` / `cancel_async_task`) returns `{ success: false, error: "ASYNC_TASK_BRIDGE_NOT_WIRED" }` and steers the model to call the underlying tool directly instead — `AsyncTaskManager.start()` is server-side only and no bridge routes the tool call through it. ## Cancellation semantics [#cancellation-semantics] `cancel(taskId)` is a no-op if the task isn't in `queued` or `running` — terminal states stay terminal. When the task is live: 1. The `AbortController` for the task fires; any in-flight executor work observing `controller.signal` short-circuits. 2. If the executor implements `cancel`, the manager calls it best-effort — exceptions are swallowed. 3. The task transitions to `cancelled` and persists. The `signal.aborted` check inside the background runner means a result that lands after the abort is dropped — late `success` or `error` writes from the executor do not overwrite the `cancelled` status. Subagent cancellation is currently a `console.warn` placeholder; the in-tree `SubagentTaskExecutor.cancel` doesn't wire through to the running subagent. Treat cancellation as cooperative for now. ## The reducer [#the-reducer] `asyncTasksReducer` is the per-channel merge function on the `asyncTasks` channel. The contract is one line: ```typescript const asyncTasksReducer = ( existing: Record = {}, update: Record = {}, ): Record => ({ ...existing, ...update }); ``` Idempotency rides on `taskId` — a re-applied update for the same `taskId` produces the same final map. Updates merge per-key, so a partial update writing only `{ [taskId]: { status: "success", ... } }` replaces the prior entry for that id and leaves siblings untouched. ## `AsyncTaskStore` [#asynctaskstore] The persistence seam. `AsyncTaskManager` keeps the catalog in memory as the source of truth and persists each transition through the store on a best-effort path — `put` failures are swallowed. ```typescript interface AsyncTaskStore { put(namespace: string[], key: string, value: Record): Promise; get(namespace: string[], key: string): Promise | null>; } ``` Namespace is `["async_tasks", threadId]`; key is the `taskId`. The manager never reads from the store on its own — it's a write-through log for offline inspection. Tasks do not write to the event log or the audit ledger directly; status transitions show up at the channel boundary when the surrounding turn checkpoints. ## Wiring an executor [#wiring-an-executor] ```typescript import { AsyncTaskManager } from "@pleach/core/async"; const manager = new AsyncTaskManager(myStore); manager.registerExecutor("sandbox_exec", { async execute(taskId, input) { const result = await runInSandbox(input.code as string); return { stdout: result.stdout, exitCode: result.exitCode }; }, async cancel(taskId) { await killSandbox(taskId); }, }); const task = await manager.start({ type: "sandbox_exec", threadId: sessionId, input: { code: "print('hi')" }, timeoutMs: 30_000, }); ``` ## Synchronous launch + poll [#synchronous-launch--poll] The pair shows the `startBackground` / `getTaskSync` manager path — launch returns inside the same tick, then a later turn reads the latest in-memory snapshot. This is a host-driven flow; it is not reachable from the LLM tool path in bare core (see the `ASYNC_TASK_BRIDGE_NOT_WIRED` note above). ```typescript const { taskId, status } = manager.startBackground({ type: "sandbox_exec", threadId: sessionId, input: { code: "summarize('search_corpus')" }, }); if (status === "error") { // No executor registered for the kind — fall back synchronously. } // ... a later turn: const snapshot = manager.getTaskSync(taskId); if (snapshot?.status === "success") { await respondWith(snapshot.result); } ``` `getTaskSync` never calls the executor. To force an executor-side status refresh, call `manager.check(taskId)` instead. ## Cancelling a live task [#cancelling-a-live-task] The flow cancels a long-running task and shows what the manager guarantees about late results. ```typescript const ok = await manager.cancel(taskId); if (!ok) { // Task was already terminal — nothing to do. } const snapshot = manager.get(taskId); snapshot?.status === "cancelled"; // true ``` If the executor implements `cancel`, the manager calls it best-effort and swallows exceptions. A `success` or `error` write the executor produces after the abort fires is dropped — the `cancelled` status is final. ## Where to go next [#where-to-go-next] --- # Attestation (/docs/attestation) Attestation extends the [audit-ledger cluster](/docs/audit-ledger#the-audit-ledger-cluster): it sits on top of the [hash chain](/docs/hash-chain). The chain proves a slice of the event log hasn't been mutated after the fact; attestation wraps a signed envelope around that slice so a third party — a regulator, an external auditor, a downstream replay — can verify it without needing read access to the writer's database. The substrate ships as `@pleach/core/attestation`. It's a substrate subpath, not a SKU: signer, verifier, the key-store interface, two stub production adapters (AWS KMS + Vault Transit), and a real file-backed adapter on the `attestation/testing` subpath. Policy, rotation cadence, framework-specific envelope shapes — those land in `@pleach/compliance` or stay host-side. > **Beta / `@experimental`.** While `@pleach/core` ships stable, the > attestation surface (and `@pleach/compliance`) ship **beta** for the > first release. Concretely: envelopes are **unsigned by default** > (`signature: ""` + `compliance.signed: false`) unless you wire a > real key-store — the AWS KMS + Vault Transit production adapters are > stubs, only the file-backed adapter signs today; the `modelVersions` > / `promptVersions` / `toolVersions` maps are emitted empty (Phase-A > thin payload); and `ChainProofV1` has a pending type-unification > across the `attestation` / `eventLog` subpaths. Treat "signed, > third-party-verifiable" as the **wired-keystore** path, not the > out-of-the-box default, until the 1.0 attestation cut. ## What attestation adds [#what-attestation-adds] The chain alone is enough to detect tampering on rows you already control. It's not enough to prove anything to someone who doesn't. A regulator asking "show me what this model decided on 2026-04-12T14:00Z, signed" wants: * A fixed, canonical bytes representation of the slice. * A signature over those bytes by a key whose public half they can resolve out-of-band. * A chain proof showing how the attested HEAD sits inside the broader event log, so the slice can't be lifted from one tenant and replayed under another. `AttestationV1` is the envelope shape that carries all three. The chain provides the canonical hash of the covered slice (`eventChainHash`) and a `ChainProofV1` locating the HEAD inside the tenant's chain; the signer signs the canonical encoding of that envelope; the verifier checks the signature and emits a structured `VerifyResult`. Chain replay itself stays delegated to `@pleach/core/eventLog`'s `verifyChain()` — the two surfaces are independent on purpose. ## The four-piece surface [#the-four-piece-surface] ```typescript import { signAttestation, verifyAttestation, canonicalizeAttestationPayload, computePayloadHash, type AttestationV1, type AttestationPayloadV1, type AttestationFieldVersions, type AttestationSignatureAlgorithm, type ChainProofV1, type VerificationError, type VerifyResult, type AttestationKeyStore, type AttestationSignResult, AwsKmsAttestationKeyStore, VaultTransitAttestationKeyStore, NotImplementedError, } from "@pleach/core/attestation"; ``` The `attestation/keyStores` subpath re-exports the same key-store types and adapters for hosts that only need the key-store surface (e.g. a pure verifier service that resolves public keys but never signs). ```typescript import { AwsKmsAttestationKeyStore, VaultTransitAttestationKeyStore, type AttestationKeyStore, } from "@pleach/core/attestation/keyStores"; ``` The `attestation/testing` subpath ships the real file-backed adapter for unit tests and local development. Kept off the production barrel so it can't be reached by typing `@pleach/core/attestation` in app code. ```typescript import { FileBackedAttestationKeyStore } from "@pleach/core/attestation/testing"; ``` Four surfaces, each with a single responsibility: | Surface | Responsibility | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | `signAttestation` / `verifyAttestation` | The sign/verify pair. Pure functions over an `AttestationKeyStore`. | | `canonicalizeAttestationPayload` + `computePayloadHash` | RFC 8785 JCS encoding + SHA-256. Exported so verifiers and signers share one canonicalizer. | | `AttestationKeyStore` interface + production adapters | Pluggable signing backend. AWS KMS + Vault Transit ship as Phase A scaffolds. | | `attestation/testing` subpath | Real `FileBackedAttestationKeyStore` for dev + unit tests. | ## The envelope shape [#the-envelope-shape] `AttestationV1` is the wire-format. Every field is `readonly`; the signer freezes the returned object. The unsigned form `AttestationPayloadV1` is `Omit` — the exact shape that gets canonicalized and hashed. ```typescript interface AttestationV1 { readonly version: "1"; readonly sessionId: string; // RFC 4122 v4 readonly tenantId: string; readonly attestationId: string; // RFC 4122 v4, generated at sign time readonly issuedAt: string; // ISO 8601 UTC readonly modelVersions: { readonly [modelId: string]: string }; readonly promptVersions: { readonly [promptId: string]: string }; readonly toolVersions: { readonly [toolName: string]: string }; readonly runtimeVersion: string; // @pleach/core package version at attest time readonly eventChainHash: string; readonly firstEventId: string; // event handle; don't assume UUID format readonly lastEventId: string; // event handle; don't assume UUID format readonly eventCount: number; // >= 1 readonly signatureAlgorithm: AttestationSignatureAlgorithm; // "ed25519" readonly signaturePublicKeyId: string; readonly signature: string; // base64(ed25519-sig(canonical(payload))) readonly redactionAllowlistHash: string; readonly chainProof: ChainProofV1; readonly firstChainedSequenceNumber: number; } ``` A few of these earn explanation. **`modelVersions` / `promptVersions` / `toolVersions`.** The attested run's full version surface. A regulator who later wants to replay the slice can pin every model, prompt contribution, and tool version to the exact bytes that were active at sign time. An empty `toolVersions` is legal — tool-free turns still attest. **`eventChainHash` + `firstChainedSequenceNumber`.** Anchors the envelope to the underlying [hash chain](/docs/hash-chain). When a chain reader resolves and yields rows, `eventChainHash` is the `merkleRoot` of the embedded `chainProof` — a real Merkle root over the covered slice — and `eventCount` is the real number of chained events. When no reader is available (or the chain yields zero rows), the envelope falls back to a documented empty-chain sentinel: an all-zero (32-byte) hash with a single-event short-circuit proof (`proofPath: []`, `merkleRoot === leafHash === eventChainHash`). The all-zero shape is greppable on purpose, so a consumer can tell a placeholder root from one computed against a real event log. `firstChainedSequenceNumber` is the first row inside the slice. **`redactionAllowlistHash`.** The hash of the active [scrubber](/docs/scrubbers) allowlist at attest time. Lets a verifier confirm the slice was redacted under a specific policy without needing the policy itself. **`chainProof: ChainProofV1`.** A real Merkle inclusion proof locating the attested leaf inside the tenant's broader chain. `ChainProofV1` is one canonical shape, shared with `@pleach/core/eventLog`: ```typescript interface ChainProofV1 { readonly algorithm: "sha256"; readonly version: "pleach.c9.v1"; // the canonicalization tag readonly tenantId: string; readonly chatId: string; readonly headSequenceNumber: number; readonly leafSequenceNumber: number; readonly leafHash: string; // hex readonly merkleRoot: string; // hex readonly proofPath: ReadonlyArray<{ readonly hash: string; // sibling hash, hex readonly side: "left" | "right"; // which side the sibling sits on }>; } ``` `proofPath` carries sibling-with-side entries (not bare hex strings), enabling real Merkle verification; `leafSequenceNumber` * `headSequenceNumber` are the chain-native position handles (there is no `leafIndex`); and the C9 canonicalization tag is folded into `version` (`pleach.c9.v1`), not a separate field. An empty `proofPath` is legal for a single-event chain — the leaf is the root, so `merkleRoot === leafHash`. ## Canonicalization [#canonicalization] `canonicalizeAttestationPayload(payload)` runs RFC 8785 JCS (JSON Canonicalization Scheme) over the unsigned payload: keys sorted lexicographically, no whitespace, UTF-8 bytes out. `computePayloadHash(canonical)` takes those bytes and returns SHA-256. The two functions are exported separately for the same reason the hash chain exports `canonicalizeRowForChain` and `computeRowHash` separately: a verifier that wants to recompute the hash without running the full `verifyAttestation` path can call them directly, and a custom signing backend can sign the canonical hash without re-deriving canonicalization rules from a guess. Mismatched canonicalization between signer and verifier surfaces at verify time as `kind: "signature-mismatch"`. The signer and verifier import from one file specifically to make that drift impossible. ## The `AttestationKeyStore` interface [#the-attestationkeystore-interface] ```typescript interface AttestationSignResult { readonly signature: Uint8Array; // ed25519 raw signature, 64 bytes readonly publicKeyId: string; // opaque; matches AttestationV1.signaturePublicKeyId } interface AttestationKeyStore { sign(canonicalHash: Uint8Array): Promise; getPublicKey(publicKeyId: string): Promise; } ``` Two paths through the interface: * **Signer path.** `sign(canonicalHash)` returns the raw 64-byte ed25519 signature plus the keyId the verifier needs to resolve the public key. Caller embeds both into the `AttestationV1`. * **Verifier path.** `getPublicKey(publicKeyId)` returns the ed25519 public key bytes (32 bytes), used by `verifyAttestation()` to re-check the signature. The interface is intentionally minimal. No key-rotation surface, no permission model, no audit hooks — adapters layer those on top of their own infrastructure. AWS KMS has its own audit trail; Vault Transit has its own access control; the substrate stays out of their way. ## Production adapters (Phase A scaffolds) [#production-adapters-phase-a-scaffolds] `AwsKmsAttestationKeyStore` and `VaultTransitAttestationKeyStore` ship today as **constructor + interface-conformance scaffolds**. Both adapters' `sign()` and `getPublicKey()` methods throw `NotImplementedError`; the real bodies land in v1.0 alongside the matching `@pleach/compliance` Phase B release. Phase A lets consumers type-check their wiring against the final shape. ```typescript import { AwsKmsAttestationKeyStore, VaultTransitAttestationKeyStore, } from "@pleach/core/attestation"; const kms = new AwsKmsAttestationKeyStore({ region: "us-east-1", keyArn: "arn:aws:kms:us-east-1:123456789012:key/abc-...", }); const vault = new VaultTransitAttestationKeyStore({ vaultAddr: "https://vault.example.com:8200", transitMount: "transit", keyName: "audit-attestation", }); ``` Both adapters are pinned to ed25519 at v1. KMS' `EDDSA` signing algorithm is the only accepted shape; Vault Transit's key type must be `ed25519`. ECC\_NIST\_P256 and other curves are rejected at the type level — `AttestationSignatureAlgorithm` is the literal `"ed25519"`. `publicKeyId` is `vault:///` for Vault and the full KMS ARN for AWS, disambiguating mixed-store deployments. ## Signing is host/operator-provisioned [#signing-is-hostoperator-provisioned] Attestation is an early-stage capability: the sign/verify functions and the envelope shape are stable, but the signing key store is host- or operator-provisioned, and the cloud-KMS path is not yet available. The default and file-backed signers work for local and dev use today. ## The file-backed adapter (dev + tests) [#the-file-backed-adapter-dev--tests] `FileBackedAttestationKeyStore` is the **real** implementation the production adapters' scaffolds reference. It lives on the `attestation/testing` subpath so app code can't reach it through the production barrel. ```typescript import { FileBackedAttestationKeyStore } from "@pleach/core/attestation/testing"; const keyStore = new FileBackedAttestationKeyStore({ privateKeyPath: "/run/secrets/attestation-key.bin", keyId: "test-key-2026-06", }); ``` Key file format is 32 raw bytes — the ed25519 seed. No PEM, no PKCS#8: that's a heavy parser surface for a dev/test adapter. The private key is cached for the lifetime of the instance; callers that need rotation should construct a fresh store. `sign()` uses `@noble/curves/ed25519` directly; `getPublicKey()` derives the public half from the cached seed. ## Worked example [#worked-example] A host plugin that signs each completed `AuditableCall` and a verifier service that checks an attestation envelope over the wire. ```typescript // Signer side — runs in the host plugin's recordCall path. import { signAttestation, type AttestationPayloadV1, type AttestationKeyStore, } from "@pleach/core/attestation"; async function attestCall( call: AuditableCall, keyStore: AttestationKeyStore, publicKeyId: string, ): Promise { const payload: AttestationPayloadV1 = { version: "1", sessionId: call.sessionId, tenantId: call.tenantId, attestationId: crypto.randomUUID(), issuedAt: new Date().toISOString(), modelVersions: call.fingerprint.modelVersions, promptVersions: call.fingerprint.promptVersions, toolVersions: call.fingerprint.toolVersions, runtimeVersion: PLEACH_CORE_VERSION, eventChainHash: call.chainHash, firstEventId: call.firstEventId, lastEventId: call.lastEventId, eventCount: call.eventCount, signatureAlgorithm: "ed25519", signaturePublicKeyId: publicKeyId, redactionAllowlistHash: call.redactionAllowlistHash, chainProof: call.chainProof, firstChainedSequenceNumber: call.firstSeq, }; return signAttestation(payload, keyStore); } ``` The verifier resolves the public key out-of-band — typically through whatever resolver the keyId scheme implies (KMS `GetPublicKey`, Vault Transit `read key`, or a local cache for the file-backed adapter) — and runs `verifyAttestation`. ```typescript // Verifier side — runs in a service that receives signed envelopes. import { verifyAttestation, type AttestationV1, type VerifyResult, } from "@pleach/core/attestation"; async function checkEnvelope( attestation: AttestationV1, resolvePublicKey: (keyId: string) => Promise, ): Promise { const publicKey = await resolvePublicKey(attestation.signaturePublicKeyId); return verifyAttestation(attestation, publicKey); } ``` `verifyAttestation` never throws on signature mismatch. The returned `VerifyResult` is either `{ valid: true }` or `{ valid: false, errors: VerificationError[] }`, with each error tagged by `kind`: | `kind` | Meaning | | -------------------- | ---------------------------------------------------------------------------------- | | `signature-mismatch` | The signature didn't verify against the resolved public key + canonical payload. | | `schema-invalid` | `version`, `signatureAlgorithm`, or `signature` field failed the structural check. | | `key-id-unknown` | `publicKey` arg wasn't a 32-byte `Uint8Array`. | Schema errors short-circuit before the signature check, so a malformed envelope surfaces `schema-invalid` rather than a downstream cryptographic failure. ## Relationship to the audit-ledger cluster [#relationship-to-the-audit-ledger-cluster] | Concept | Where it lives | What it does | | --------------------------------------------- | ------------------------------ | ------------------------------------------------------------------ | | [AuditableCall row](/docs/auditable-call-row) | `@pleach/core/audit-ledger` | Typed per-call record. The unit of evidence. | | [Scrubbers](/docs/scrubbers) | `@pleach/core/scrubbers` | Redact payload fields before the row persists. | | [Hash chain](/docs/hash-chain) | `@pleach/core/eventLog` | `prev_hash` + `row_hash` columns; tamper-detection across rows. | | **Attestation** | **`@pleach/core/attestation`** | **Signed envelope over a chain slice; portable to third parties.** | | [ProviderDecisionLedger](/docs/audit-ledger) | `@pleach/core` (write path) | The persistence surface every row flows through. | Scrubbers shape what gets written; the chain proves it wasn't mutated; attestation signs the proof. Each layer assumes the layer below holds. ## What's not in scope today [#whats-not-in-scope-today] `@pleach/core/attestation` is a substrate subpath, not a SKU. It intentionally does not ship: * **A CLI.** No `pleach attest` / `pleach verify-attestation` commands. Hosts wire the sign + verify functions into their own binaries. * **Managed key rotation.** No rotation scheduler, no key-lifecycle policy. The interface accepts a keyId; the adapter decides what rotation means. * **Framework-specific envelope shapes.** No FedRAMP-shape, HIPAA-shape, or SOC2-shape variants of `AttestationV1`. The envelope is one shape; framework-specific compliance bundles carry their own metadata alongside. * **Compliance-mode policy enforcement.** Nothing in the substrate refuses to start because attestation isn't configured. That's a host-side or `@pleach/compliance` decision. The production KMS + Vault adapters' bodies land in v1.0 alongside the matching `@pleach/compliance` Phase B release. Until then, Phase A consumers wire the scaffolds for type-checking and run their dev paths through the file-backed adapter on `@pleach/core/attestation/testing`. ## Where to go next [#where-to-go-next] --- # Audit gates (/docs/audit-gates) Audit gates are structural checks that protect substrate invariants — a missing barrel re-export, a tool name no definition emits, an event type with no scrubber allowlist, a published `dist/` artifact missing a `.d.ts`. Most gates are pure source-text scans (`.mjs`) that run in under a second. External consumers (plugin authors, sibling SKU authors, hosts embedding `@pleach/core`) care about a subset: the ones that protect the *published surface* they consume. This page covers that subset. The full internal gate set — coordinator-process gates, refactoring scaffolds, soak ledgers gating in-flight substrate moves — lives in [`scripts/audit/`](https://github.com/pleachhq/core/tree/main/scripts/audit) on the source repo. Source there is canonical. This page is organized by subsystem. To read the same gates by the contract they enforce — which clause of the auditability and replayability promise each one holds up — see [CI enforcement of the audit contract](/docs/ci-enforcement). Run any gate via npm: ```bash npm run audit: ``` Suffix conventions are stable across gates. `:strict` is the CI mode (fails on novel drift). `:json` emits machine-readable output. `:list` dumps the inventory. `:update-baseline` regenerates the allowlist after intentional drift. ## Package shape (gates every consumer should know) [#package-shape-gates-every-consumer-should-know] These run before any first-publish and before any `1.x` release tag. They catch silent failure modes invisible to in-monorepo builds — workspace path aliases that resolve cross-tree locally but fail in a real `npm install`, tsup DTS emission that exits 0 but produces no `.d.ts`, peer-deps not declared in `dependencies`. If you're authoring a sibling `@pleach/*` SKU OR a third-party plugin that ships its own npm package, these are the gates to mirror in your own CI. | Gate | Purpose | | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `audit:package-export-validation` | Every active `@pleach/core/*` import resolves through `package.json:exports`. | | `audit:package-runtime-deps` | Every third-party import in `dist/**/*.{js,cjs,mjs}` is declared in `dependencies`, `peerDependencies`, or `optionalDependencies`. Catches the silently-undeclared-runtime-peer class. | | `audit:package-dist-runtime-aliases` | Workspace-alias leak detector. Scans `dist/**` for `@/lib/...`-style imports that resolve only inside the monorepo. | | `audit:package-dts-emission` | Every declared `.d.ts` path in `types`, `exports[K].types`, `typesVersions[*][K]` exists on disk and is non-empty. Closes the silent-tsup-DTS-failure class. | | `audit:package-version-vs-registry` | Compares in-tree `package.json:version` to `npm view versions --json`. Fails on past-publish drift. | | `audit:harness-package` | `@pleach/core` substrate is domain-agnostic. Scans for brand, backend-class, scientific-term, product-surface tokens that would couple the substrate to a specific host. | | `audit:harness-package:local-clone` | Packs `@pleach/core` via `npm pack`, installs in an isolated dir, smoke-imports `SessionRuntime` + `createPleachRuntime`. Catches resolution failures the in-monorepo scan misses. | | `audit:consumer-rehearsal:core` | Six-phase end-to-end publish rehearsal. Builds, packs, installs into scratch dir, smoke-tests facets + subpaths + TypeScript consumer surface + examples. | ## Plugin contract (gates plugin authors should know) [#plugin-contract-gates-plugin-authors-should-know] The plugin substrate is a strict contract between `@pleach/core/plugins` and host code. These gates enforce completeness on both sides — every `contributeX` hook has a paired collector accessor on `PluginManager`, every host extension stays out of domain-coupling-suspect deep paths. If you're writing a `HarnessPlugin`, mirror these in your plugin's repo. | Gate | Purpose | | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `audit:plugin-contract-completeness` | For every `contributeX` hook on `HarnessPlugin`, verifies a paired `collect` accessor exists and ≥1 consumer reads from it. Closes the dead-hook drift class. | | `audit:harness-plugin-deprecated-usage` | No novel usage of `@deprecated` `HarnessPlugin` hooks beyond baseline. Hard-deprecated set retires in `1.1.0`; soft-deprecated retires in `1.2.0`. | | `audit:plugin-contract-purity` | Host code MUST NOT import from domain-coupling-suspect `@pleach/core/` surfaces. Inverse of `lint:harness-boundary`. | | `audit:stream-observer-factory-completeness` | Every observer factory under `observers/*Observer.ts` is registered by ≥1 plugin's `contributeStreamObservers`, and every registration points at an existing factory. | | `audit:plugin-hook-category-assigned` | Every `contributeX` hook on `HarnessPlugin` resolves to exactly one of the nine [contribution namespaces](/docs/plugins/namespaces); a new hook with no namespace fails the build. | ## Tool coverage (gates tool authors should know) [#tool-coverage-gates-tool-authors-should-know] A "reverse-invariant" audit verifies the VALUES (or KEYS) in a runtime lookup table all resolve to canonical tool names. Forward direction (every tool has an intent) is covered by the tool-generation pipeline; these audits close the reverse — every NAMED tool in a map is a REAL tool. | Gate | Purpose | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `audit:intent-tool-map-completeness` | Every tool named in `INTENT_TOOL_MAP` resolves. Drift surfaces as silently-shadowed tools in the LLM-facing condensed catalog. | | `audit:tool-alternatives-completeness` | Every fallback `tool:` field in `TOOL_ALTERNATIVES` resolves. Drift surfaces as silently-broken error-recovery. | | `audit:post-tool-phrases-completeness` | Every key in `POST_TOOL_PHRASES` resolves. Drift surfaces as silently-dropped curated conversational nudges. | | `audit:decision-hints-completeness` | Every tool named in `DECISION_HINTS` resolves. | ## Event log + hash chain [#event-log--hash-chain] The event log is the canonical record of every session turn. These gates enforce the redaction surface, the hash chain, and the producer/projection contracts. | Gate | Purpose | | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `audit:c8-event-type-allowlist-coverage` | Every event-type literal in `EventLogInput` has a matching `SCRUBBABLE_FIELDS` allowlist entry. Empty array `[]` is a valid "audit ran; no fields" signal. | | `audit:c8-union-member-has-producer` | Every `EventLogInput` union member has at least one producer call site. Reverse-invariant complement to the allowlist audit. | | `audit:c9-hash-chain-integrity` | Source-text regression on the C9 hash-chain wire-in (verifyChainForChat, generateProof, computeGenesisSeed, etc). | | `audit:event-log-manifest-hash-completeness` | `EventLogWriter.eventToRow` stamps `manifest_hash` on every row. | ## Config manifest [#config-manifest] The [config manifest](/docs/config-manifest) records which substrate ran each session, content-addressed and referenced by every event-log row. These gates protect its hash determinism and its referential integrity against the event log. | Gate | Purpose | | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `audit:plugin-content-hash-stability` | Two consecutive manifest builds produce identical per-surface child hashes; a `Date.now()` leak in any surface builder fails the specific surface, not "something moved." | | `audit:config-manifest-fk-constraint-applied` | The FK migration carries every canonical clause — `REFERENCES`, `ON DELETE SET NULL`, `DEFERRABLE INITIALLY DEFERRED`. | | `audit:config-manifest-referential-integrity` | Offline: `SessionRuntime` wiring reaches the manifest write. Online: zero event rows reference a missing manifest. | | `audit:config-manifest-retention-completeness` | After a retention GC pass, no surviving event references a dropped manifest — the guard against a GC racing a concurrent event write. | ## Multi-tenant [#multi-tenant] These gates close `tenant_id` propagation end-to-end — every tenant-scoped table has RLS, every event-log write stamps the tenant. | Gate | Purpose | | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `audit:tenant-scoping` | Every `tenant_id`-bearing table in `supabase/migrations/*.sql` has an RLS policy referencing `current_tenant()`. | | `audit:harness-event-log-tenant-id-required` | Every `harness_event_log` write site references `tenant_id` (or carries a documented opt-out marker). | ## Graph + lattice [#graph--lattice] The compiled agent graph obeys the four-stage lattice (`anchor-plan` → `tool-loop` → `synthesize` → `post-turn`, one-way, with a `messageId`-guarded `synthesize → synthesize` retry). These gates enforce that invariant, the recovery-dispatch surface, and the AuditableCall row shape. | Gate | Purpose | | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `audit:graph-stages` | Parses `defaultAgentGraph.ts` + `wiring/` and fails on out-of-lattice edges. Asserts the 44-name `NODE_STAGE_MAP` and nine-pattern `ALLOWED_EDGE_PATTERNS` table; node/edge counts are byte-identical PR-to-PR for the same config. | | `audit:graph-stages:warn` | Warn-only mode. Auto-runs inside `prebuild` to surface drift without blocking builds. | | `audit:edge-inventory-completeness` | Every compiled cross-stage edge resolves to a documented entry in the 61-edge inventory; drift fails. | | `audit:recovery-dispatch-single-surface` | Recovery dispatch lives in exactly one place — the post-turn stream filters. Fails any build that re-introduces a graph-side recovery node or conditional edge. | | `audit:graph-stream-filter-substrate-coverage` | Every lattice stage has the filter slots the post-stage filters dispatch against. | | `audit:graph-barrel-stability` | The graph barrel's public-vs-internal export split doesn't drift — public SDK-contract exports stay public, internal-only exports stay internal. | | `audit:auditable-call` | Composite — runs `audit:auditable-call-shape` + `audit:auditable-call-version` to assert the row shape and that `AuditRecordVersion` hasn't drifted. | | `audit:agentic-flag-respected` | Every recursive `this.streamChat(...)` site in `turnOrchestrator.ts` carries the `agentic:false` marker + guard, so a new recursive call site can't silently re-enter the agentic loop when single-LLM-call mode (`ChatOptions.agentic === false`) is requested. A runtime gate that rides the `ci:graphnoderef` bundle — node `stageId` is enforced by `audit:graph-stages` above, not here. | ## API protection (host-side) [#api-protection-host-side] | Gate | Purpose | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `audit:api-protection` | Every API route is wrapped with an auth middleware. (Host-side gate; reuse the pattern in your own `pages/api/` tree.) | ## Domain purity + frontmatter [#domain-purity--frontmatter] Substrate hygiene at PR time. | Gate | Purpose | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `audit:domain-string-purity` | Complementary to `audit:harness-package`. Catches host-domain string literals (tool names, brand strings) leaking into the substrate. | | `audit:frontmatter` | Non-generated `.ts`/`.tsx` files have canonical YAML frontmatter (`module` + `role` + `related_files`). `:fix` auto-normalizes role drift. | ## Bundled commands [#bundled-commands] Composite scripts that chain related gates. | Command | Runs | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `npm run ci:graphnoderef` | `lint:graphnoderef` (boundary + callclass + observer-registration) + `audit:graph-stages` + `audit:agentic-flag-respected` + graph tests + `audit:auditable-call` | | `npm run ci:plangen-v2` | Plan-generation v2 ladder tests | | `npm run audit:auditable-call` | Shape + version checks (see Graph + lattice above) | ## Internal coordinator-process gates [#internal-coordinator-process-gates] The substrate carries additional gates for in-flight refactoring ladders, soak ledgers, slot reservations, and parallel-session coordination. They protect the *engineering process*, not the *shipped surface*. External consumers will not run them; mention of them in public docs would be misleading. If you're operating the substrate (committer access to the upstream repo, authoring a design pack, running a multi-agent refactoring fleet), see [`scripts/audit/`](https://github.com/pleachhq/core/tree/main/scripts/audit) directly. The script source files carry the canonical purpose + mode documentation per gate. ## Calendar-substitution pattern [#calendar-substitution-pattern] Worth knowing as a general technique. When a "let it bake for N days" soak window has a *deterministic* failure mode the script can catch directly, the calendar collapses into a script gate that runs at PR time. Replace `≥30d of zero fires on probe X` with `last 3 canvas batches at probe X count == 0`. The script gate then becomes the permanent regression-detection surface. The pattern has applied across 8+ internal initiatives. When you see a calendar wall in a design doc, default-search for "what would the script gate look like?" before accepting the wall. ## Mirroring gates in your own repo [#mirroring-gates-in-your-own-repo] The upstream gates protect the substrate. Hosts that build on top of `@pleach/*` often want the same discipline for *their* load-bearing invariants — a per-tenant key never appears in logs, every domain tool has a matching detector, a feature flag hasn't been left on past its sunset date. The pattern is the same: * Write a script that walks the relevant source and asserts the invariant. Exit non-zero with a structured error message that names the offending file and line. * Wire it into `package.json` under an `audit:` script and into the PR-blocking CI job. * Document the gate alongside its sibling gates in your repo's equivalent of this page — the audit only earns its keep when the next contributor knows it exists. The `audit:plugin-contract-completeness` script in the upstream core repo is a small reference shape: it walks the `HarnessPlugin` interface, asserts every `contribute*` hook has both a typed schema and a wire-check entry, and fails with the hook name when one is missing. Host-side audits that follow that shape — one invariant per script, structured failure output, documented in source-tree-adjacent prose — compose cleanly with the substrate's existing gate set. The audit gate is the regression-detection surface; the discipline is that adding a new invariant means adding a new gate, not adding it to a code-review checklist. ## Where to go next [#where-to-go-next] --- # Audit ledger (/docs/audit-ledger) This page is the audit-ledger surface — the write side. For the read-side observability tooling (OTel spans, lineage graph, Datadog and Honeycomb wiring), see [Observability](/docs/observability). ## How the audit cluster pages relate [#how-the-audit-cluster-pages-relate] Six pages cover the audit substrate at different grains. They overlap on purpose — each one is the right page for a different question. Use this table to skip to the one you need: | If you want to... | Read | | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | | Understand the write interface + register an adapter | **This page** (Audit ledger) | | See every field on a single row + how to query it in SQL | [The AuditableCall row](/docs/auditable-call-row) | | See the typed envelope every event flows through (the wire shape) | [Event log](/docs/event-log) | | Build read-side projections off the event log (consumer-side rollups) | [Event log projections](/docs/event-log-projections) | | Verify the hash chain on recorded rows or generate a tamper-evidence proof | [Hash chain](/docs/hash-chain) | | Sign a turn corpus + emit a regulator-shaped attestation bundle | [Attestation](/docs/attestation) | | Ship the OTel span side (latency, error correlation, parent-threading) | [Observability](/docs/observability) + [OTel observability](/docs/otel-observability) | | Pipe scrub gates into the writer for HIPAA / GDPR / PCI-DSS | [Scrubbers](/docs/scrubbers) + [Compliance](/docs/compliance) | The shape, in one paragraph: every LLM call goes through a **seam** → produces a **stream event** → lands in the **event log** as a row → that row is the **AuditableCall** → the write-side stamps a **hash chain** entry → optional **attestation** signs a corpus of rows → **projections** read from the log to compute rollups → **scrubbers** run at write time to redact PII before the row hits disk. Every other page below zooms into one of these stages. That's the lattice carrying weight: every addressable event the agent produces — each LLM call, tool dispatch, subagent spawn — lands in a row at the same grain, which is why cost, compliance, and replay all read from one table instead of three pipelines. With an `AuditEmitter` registered, every LLM call writes one `AuditableCall` row through a `ProviderDecisionLedger`. The row carries `tenantId`, `turnId`, `toolName`, `subagentDepth`, `parentTurnId`, `modelId`, `family`, and `tokenUsage` — that's the variable surface finance, replay, and compliance read from. `@pleach/core` ships the write interface plus two reference implementations; the default emitter is a no-op, so the row only lands once the host registers an emitter — which the quickstart and any production host do. Consumer code registers adapters for Supabase, IndexedDB, S3, or any other store. `tenantId` is the opaque rollup axis. In a multi-tenant SaaS it's your end customer; in an internal-use deployment of an Anthropic / OpenAI Enterprise contract it's typically the employee, team, or cost-center identifier you want to chargeback or audit against. The schema, hash chain, and CI gates don't care which — the column carries whichever rollup your finance and compliance teams actually report on. See [Migrating from Anthropic Enterprise](/docs/migrating-from-anthropic-enterprise) or [Migrating from OpenAI Enterprise](/docs/migrating-from-openai-enterprise) for the composition story with an existing vendor contract. Three compliance plug-points (tamper evidence, PII redaction, GDPR soft-delete) ride on top of the ledger. Each ships as a reference-only no-op today. `@pleach/compliance@0.1.0` ships the four-scrubber bundle (`SSN-US`, `Luhn`, `US-DL`, `KeyedRegex`) — see [scrubbers](/docs/scrubbers). The hash-chain verifier ships in `@pleach/replay@0.1.0` (`verifyChainForChat`, `generateProof`); writer-side stamping lives in `@pleach/core/eventLog` (`chainStep`, `computeRowHash`) behind the `c9PhaseBEnabled` flag. Subject-key-derived redaction wiring on `PIIRedactor` and `GDPRSoftDelete` continues to land through the `HarnessPlugin` contract; the plug-point shapes in `@pleach/core` are the stable contract. See [compliance](/docs/compliance) for the package overview. ```typescript import { MemoryProviderDecisionLedger, NoopProviderDecisionLedger, AUDIT_RECORD_VERSION_HISTORY, tamperEvidenceNoop, piiRedactorNoop, gdprSoftDeleteUnwired, NoopAuditEmitter, } from "@pleach/core/audit"; import type { ProviderDecisionLedger, AuditableCallQuery, AuditableCall, PluginAuditPayload, TamperEvidence, PIIRedactor, GDPRSoftDelete, AuditDecisionInput, AuditEmitter, } from "@pleach/core/audit"; ``` See [AuditableCall row](/docs/auditable-call-row) for the row shape itself; this page is about the persistence and the plug-points. ## The audit-ledger cluster [#the-audit-ledger-cluster] The ledger is one of three concepts paired with [the AuditableCall row](/docs/auditable-call-row) (what gets written) and [the hash chain](/docs/hash-chain) (after-the-fact tamper detection). The row is the grain; the ledger is the write path; the chain is the integrity layer. The full triplet framing lives at [Concept clusters → Audit-ledger](/docs/concept-clusters#audit-ledger-cluster); the rest of this page is the deep dive on the ledger interface. ## `ProviderDecisionLedger` [#providerdecisionledger] The write interface. One method, fire-and-forget. ```typescript interface ProviderDecisionLedger { recordCall(call: AuditableCall): Promise; } ``` Two contract rules: 1. **Append-only.** No update or delete primitive. An audit row that needs to mutate is a wire-format break — bump `auditRecordVersion` instead. 2. **Idempotent on `(sessionId, turnId, stageId, seqWithinTurn)`.** Re-emitting the same coordinates is a no-op rather than a duplicate row. This matters because the seam call site wraps `recordCall` with `.catch(noop)` — retries on transient failure are safe. Adapters that can batch SHOULD buffer at most 50 ms or 32 records (whichever first) and flush unconditionally at end-of-turn. ## `AuditableCallQuery` [#auditablecallquery] The read interface. Adapters that are write-only sinks (forwarding to a SIEM, say) may omit it; adapters that consumers will query (the in-app history UI, eval, SOC2 evidence exports) implement both. ```typescript interface AuditableCallQuery { getTurn( sessionId: string, turnId: string, ): Promise>; getSession( sessionId: string, opts?: { readonly limit?: number; readonly before?: string }, ): Promise>; streamByTimeRange(opts: { readonly fromIso: string; readonly toIso: string; readonly chunkSize?: number; }): AsyncIterable>; } ``` * `getTurn` returns rows in `seqWithinTurn` ascending order. Empty array if the turn has no records — never throws on "not found." * `getSession` returns newest-first, limit defaults to 100. `before` is a `recordId` (ULID) cursor — see `ulid` below for why ULID sort works without a separate timestamp index. * `streamByTimeRange` yields chunks of at most `chunkSize` (default 500\) records. Adapters that can't stream MAY emit a single chunk; consumers MUST treat the iterable as the boundary, not the chunk. ## Reference implementations [#reference-implementations] | Implementation | Use case | | ------------------------------ | ------------------------------------------ | | `MemoryProviderDecisionLedger` | Tests, dev, in-process aggregation | | `NoopProviderDecisionLedger` | Audit isn't wired yet; calls write nowhere | Both ship in `@pleach/core/audit`. Concrete persistence adapters (Supabase, IndexedDB, S3) live in consumer code. ```typescript import { MemoryProviderDecisionLedger } from "@pleach/core/audit"; import { SessionRuntime } from "@pleach/core"; const ledger = new MemoryProviderDecisionLedger(); // The ledger is bound to the runtime via the registry accessor // (`setProviderDecisionLedgerFactory`, shown below) — it is NOT a // `SessionRuntimeConfig` field. In tests you can also read the // MemoryProviderDecisionLedger directly. const runtime = new SessionRuntime({ storage: memoryAdapter, userId: "test-user", }); // drive a turn ... const rows = await ledger.getSession(sessionId, { limit: 100 }); ``` ## Writing your own adapter [#writing-your-own-adapter] A typical Supabase-backed adapter: ```typescript // lib/audit/supabaseLedger.ts import type { ProviderDecisionLedger, AuditableCall } from "@pleach/core/audit"; import type { SupabaseClient } from "@supabase/supabase-js"; export class SupabaseProviderDecisionLedger implements ProviderDecisionLedger { constructor(private client: SupabaseClient) {} async recordCall(call: AuditableCall): Promise { const { error } = await this.client .from("harness_auditable_calls") .upsert(this.toRow(call), { onConflict: "session_id,turn_id,stage_id,seq_within_turn", ignoreDuplicates: true, }); if (error) { console.warn(`[audit-ledger] insert failed: ${error.message}`); // Soft-fail. Never throw. } } private toRow(call: AuditableCall) { return { record_id: call.recordId, audit_record_version: call.auditRecordVersion, session_id: call.sessionId, turn_id: call.turnId, stage_id: call.stageId, seq_within_turn: call.seqWithinTurn, // ... rest of the columns per the schema bundle payload: call.decision, }; } } ``` Register the factory on the runtime substrate via `setProviderDecisionLedgerFactory` (a soft-accessor the runtime reads at construction time): ```typescript import { setProviderDecisionLedgerFactory } from "@pleach/core/runtime"; setProviderDecisionLedgerFactory({ fromSupabase: (client) => new SupabaseProviderDecisionLedger(client), }); ``` The factory is called once per runtime construction — the runtime passes its host-supplied Supabase client to `fromSupabase` and holds the returned ledger for the runtime's lifetime. ## `AUDIT_RECORD_VERSION_HISTORY` [#audit_record_version_history] The wire-format version log. Consumers can render "what changed in v7" at runtime by reading this constant: ```typescript import { AUDIT_RECORD_VERSION_HISTORY } from "@pleach/core/audit"; for (const entry of AUDIT_RECORD_VERSION_HISTORY) { console.log(`v${entry.to} (${entry.landedAt}): ${entry.reason}`); } ``` Bumps are gated by an upstream audit gate (`audit:auditable-call-version`); consumer adapters check this constant during migration to know which payload fields are new in the version they're persisting. ## The three compliance plug-points [#the-three-compliance-plug-points] The audit module ships three plug-point interfaces with reference-only no-op defaults. [`@pleach/compliance@0.1.0`](/docs/compliance) ships the four-scrubber bundle (`SSN-US`, `Luhn`, `US-DL`, `KeyedRegex`). Hash-chain verification rides in [`@pleach/replay@0.1.0`](/docs/replay). Subject-key-derived `PIIRedactor`/`GDPRSoftDelete` wiring continues to be host-side plug-points; the plug-point shapes below are the stable contract. ### `TamperEvidence` [#tamperevidence] Hash-chain over previous records. ```typescript interface TamperEvidence { link(record: AuditableCall, prevAnchor: string | null): string | null; verifySequence( records: ReadonlyArray, initialAnchor: string | null, ): string | null; readonly schemeId: string; } import { tamperEvidenceNoop } from "@pleach/core/audit"; // No-op default — every link returns null, verification always passes. ``` `link` is deterministic given the same input and returns the anchor for `record` (`null` for the first record). `verifySequence` returns `null` if the sequence is intact, or the `recordId` of the first record that breaks the chain. Both are synchronous — verifiers re-run `link` against stored records and compare anchors. `schemeId` is recorded in `payload.tamper.scheme` (default `"none"`) so a verifier knows which implementation to load. **Live impl** at `@pleach/core/eventLog` (`chainStep`, `computeRowHash`, `computeGenesisSeed`, `PLEACH_C9_CANONICALIZATION_VERSION`) behind the `c9PhaseBEnabled` flag — chains each row's hash to the previous so any single-row mutation breaks the chain. Verification ships in `@pleach/replay@0.1.0` as `verifyChainForChat` and `generateProof`. The `TamperEvidence` plug-point above is the audit-ledger-side handle; hosts that already route through `@pleach/core/eventLog`'s chain leave it as the no-op default. ### `PIIRedactor` [#piiredactor] Pattern-based redaction with an allowlist. ```typescript interface PIIRedactor { redact(record: AuditableCall): AuditableCall; readonly policyId: string; } import { piiRedactorNoop } from "@pleach/core/audit"; // No-op default — identity passthrough. ``` `redact` is a pure function (same input → same output); adapters call it exactly once per record before insert. `policyId` is recorded in `payload.redaction.policy` (default `"none"`) so the stored shape carries which policy generated it. **Adjacent surface today.** `@pleach/compliance@0.1.0` ships the row-level scrubber cohort (`SSN-US`, `Luhn`, `US-DL`, `KeyedRegex`) on the event log via `contributeScrubbers`. The ledger-side `PIIRedactor` plug-point above stays a host-injectable no-op for cases where audit-ledger redaction differs from event-log redaction; hosts that share the policy register the same scrubber on both paths. ### `GDPRSoftDelete` [#gdprsoftdelete] Subject-key-derived soft-delete. ```typescript interface GDPRSoftDelete { softDelete( recordId: string, reason: RedactionReason, opts?: { readonly requestRef?: string | null }, ): Promise; } import { gdprSoftDeleteUnwired } from "@pleach/core/audit"; // Throws on use — must be wired by the consumer. ``` `softDelete` replaces the named record's `payload` with a `RedactedPayloadSentinel` and returns it for caller logging. It MUST be idempotent — re-redacting an already-redacted record returns the existing sentinel unchanged; adapters MUST NOT chain redactions. The identity columns and call shape are untouched; only the JSONB payload is replaced. **Host-wired today.** The plug-point shape is the stable contract; concrete wiring (tombstone table, subject-key derivation, read short-circuit) is host-side. The audit rows themselves stay (append-only contract); the tombstone gates rendering. `@pleach/compliance@0.1.0` ships the scrubber-side primitives now; subject-key soft-delete wiring is on the compliance roadmap. ## `AuditEmitter` [#auditemitter] The seam side of the audit surface — what the runtime calls when a decision needs to be recorded. The emitter is what plugin code sees; the ledger is what the emitter ultimately writes to. ```typescript interface AuditEmitter { recordFamilyLockDecision( input: AuditDecisionInput, overrides?: { readonly provider?: ProviderFamily }, ): void; recordFallbackStepDecision(input: AuditDecisionInput): void; recordCacheHitDecision(input: AuditDecisionInput): void; recordProviderCascadeDecision(input: AuditDecisionInput): void; recordToolFallbackStepDecision(input: AuditDecisionInput): void; recordInterruptDecisionDecision(input: AuditDecisionInput): void; recordTokenCostDecision(input: AuditDecisionInput): void; recordToolSelectionDecision(input: AuditDecisionInput): void; recordPlanGenerationDecision(input: AuditDecisionInput): void; recordSynthesisQualityDecision(input: AuditDecisionInput): void; } import { NoopAuditEmitter } from "@pleach/core/audit"; ``` One method per typed payload slot — ten in all. The clustering is structural: future per-kind dispatch (sampling, rate-limiting, redaction) can land without changing emit sites. The default implementation shares a single inner write across all ten — the clusters exist for symmetry and forward-extensibility, not for polymorphism today. All methods are sync-fire-and-forget. Implementations MUST swallow exceptions internally — a probe-side failure to record an `AuditableCall` MUST NOT abort the turn. The signature deliberately returns `void`, not `Promise`, so emit sites can fire from sync hot paths. `AuditDecisionInput` takes a `turnKey` (the orchestrator's messageId). The seam fills in `sessionId` from the active runtime, `principal` from session config, `seqWithinTurn` from a per-turn counter, `recordId` via `ulid()`, and `createdAt` via `new Date().toISOString()`. Emit sites supply only what they already know. ## Plugin-namespaced payloads [#plugin-namespaced-payloads] `PluginAuditPayload` is the extension slot a plugin reaches for when it has typed audit context to record but no existing core slot fits. Per pleachfix #10, landed in `auditRecordVersion: 8`. ```typescript import type { PluginAuditPayload } from "@pleach/core/audit"; interface PluginAuditPayload { readonly pluginId: string; // e.g. "compliance-pharma" readonly subKind: string; // plugin-defined discriminator readonly data: unknown; // plugin-defined shape, plugin documents + casts } ``` Entries land on `AuditableCall.pluginPayloads`. Core consumers continue to switch exhaustively on the named optional slots (`familyLock`, `tokenCost`, …); plugin consumers narrow on `pluginId === ""` then on `subKind` before reading `data`. The core type-level shape of `data` is `unknown` — the plugin owns its wire shape and version policy. This is the contract that lets `@pleach/compliance` record hash-chain evidence, a domain plugin record an extraction-quality score, or an eval harness record a divergence reason — without any of them coring out a new top-level field on every audit row. ## ULID record ids [#ulid-record-ids] The `recordId` on every `AuditableCall` is a ULID — Crockford Base-32, 26 chars, lex-sortable. The ULID encodes creation timestamp in its high bits, so cursor pagination via `recordId > cursor` works without an independent timestamp index. ```typescript import { ulid } from "@pleach/core/audit"; const id = ulid(); // → "01JC8XAEH..." ``` The substrate uses this helper for every record id. Don't roll your own — the format guarantee is what makes lex-cursor reads correct. ## Tamper-evident hash chain [#tamper-evident-hash-chain] `harness_event_log` ships two columns — `prev_hash` and `row_hash` — that link each row to its predecessor over a canonical encoding. The chain detects silent backfill, reorder, and removal after the fact; the verifier reports the first index where the chain breaks. Schema landed; writer-side stamping is in soak behind a flag. The column contract, the verifier signature, the canonical encoding, and the back-compat path for pre-stamping rows all live on [Hash chain](/docs/hash-chain). ## Where to go next [#where-to-go-next] --- # The AuditableCall row (/docs/auditable-call-row) This page is the audit-ledger row shape. For read-side observability (OTel spans, lineage, Datadog patterns), see [Observability](/docs/observability). The row is one of three concepts in the audit-ledger cluster — the row (this page), the [ProviderDecisionLedger](/docs/audit-ledger) write interface, and the [hash chain](/docs/hash-chain) that protects against after-the-fact tampers. See [Audit ledger → the audit-ledger cluster](/docs/audit-ledger#the-audit-ledger-cluster) for the cluster framing. With an `AuditEmitter` registered, every LLM call writes one append-only row to the audit ledger. The row carries `tenantId`, `turnId`, `toolName`, `subagentDepth`, `parentTurnId`, `modelId`, `family`, `inputTokens`, `outputTokens`, plus the optional typed slots (`familyLock`, `tokenCost`, `toolSelection`, `synthesisQuality`, `interruptDecision`, `pluginPayloads`, …). That's the variable surface every replay, per-turn cost rollup, and "what did the agent actually do" query reads from. `@pleach/core` ships the typed row shape plus the emit seam; the host binds the principal, the per-turn sequence, and the ledger that stores the row. The default emitter is a no-op, so a deploy that registers no emitter writes nothing — the quickstart and any production host wire one, which is why the floor holds in every real deployment. The row shape is a cross-SKU contract. `@pleach/core` writes it; `@pleach/compliance` adds the hash-chain columns; `@pleach/gateway` reads `tenantId` for cost rollup; `@pleach/observe` joins on `turnId` for span construction; `@pleach/eval` reads the row to replay. Changing the column set breaks every consumer — there is no plugin path or config field that alters the structural columns (`turnId`, `toolName`, `modelId`, `family`, `inputTokens`, `outputTokens`, `subagentDepth`, `parentTurnId`). Schema changes are upstream contribution only. The **extension surface** lives in two places: * `principal.tenantId` — opaque to the row, scoped however your deployment wants (end-customer, employee, team, cost center). * `pluginPayloads[]` — the typed, plugin-namespaced extension slot. Each entry carries `pluginId`, `subKind`, and an opaque `data` payload the plugin owns the shape of — the way a host attaches downstream-readable per-call context without coring out a new top-level slot. Everything else is fixed. See [Ownership boundaries → `@pleach/core`](/docs/ownership#pleachcore--the-substrate) for the broader locked-vs-configurable map. `tenantId` is opaque to the row. It carries whichever attribution axis your deployment runs on — an end-customer id for a SaaS, or an employee, team, or cost-center id when Pleach composes under an Anthropic Workspace or OpenAI Project on an Enterprise contract. See [Migrating from Anthropic Enterprise](/docs/migrating-from-anthropic-enterprise) and [Migrating from OpenAI Enterprise](/docs/migrating-from-openai-enterprise) for the contract-side framing. With an emitter registered, every call the runtime makes — synthesize, reasoning, converse, utility, retries, fallbacks — writes one such row. ## Row shape [#row-shape] The row spreads the identity columns inline (via `extends AuditableCallIdentity`) and groups the rest into four required nested sub-shapes (`principal`, `call`, `decision`, `outcome`) plus optional typed payload slots. The structure is locked at `AuditRecordVersion`; bumps are gated by an upstream audit gate and recorded in `AUDIT_RECORD_VERSION_HISTORY`. ### Identity [#identity] ### Principal [#principal] `AuditableCallPrincipal` is replicated onto every row so SOC2 user-action queries don't need a session join. ### Call shape [#call-shape] `AuditableCallShape` is post-routing — what actually ran, not what was requested. ### Decision [#decision] `AuditableCallDecision` names the *policy* that picked the rung. ### Outcome [#outcome] `AuditableCallOutcome` carries the provider's coarse result. ## Typed payload slots [#typed-payload-slots] The variable surface — what the ledger has to carry because no structural invariant can derive it. Each slot is stage-correlated and optional. A `tool-loop` row MAY carry `toolSelection`; a `synthesize` row MAY carry `synthesisQuality`. Consumer narrowing happens on `stageId`. | Slot | Wire shape | Where it fires | | ------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `familyLock` | `FamilyLockPayload` | Family-lock resolution boundary; carries `resolverPath`, `emitSite`, `requestedFamily`, `familyMismatch`, `byokActive`, `byokKeyId`, `resolvedViaAlias`, `legacyDefaultModel` | | `fallbackStep` | `FallbackStepRecord` | Post-attempt of every fallback rung; carries `originalFamily`/`originalModel`/`originalCallClass`, `attemptFamily`/`attemptModel`/`attemptCallClass`, `attemptIndex`, `reason` (`FallbackStepReason`), `inFamily`, `originalLadderStep`, `ladderStep`, `ladderRegression` | | `providerCascade` | `ProviderCascadeRecord` | Graph decision-node cascade pivots; carries `source` (`graph` / `imperative-carve-out`), `trigger` (`decision-throw` / `decision-retry` / `stream-error` / `max-retries` / `timeout` / `user-abort-confirmed`), `errorType`, `providerFrom`, `providerTo`, `attemptN`, `userAbortValid` | | `cacheBreakpoint` | `CacheBreakpointLog` | Provider response boundary; carries `fingerprintComposite`, `cacheReadTokens`, `cacheCreationTokens`, `inputTokens`, `hitPct` | | `toolFallbackStep` | `ToolFallbackStepRecord` | Runtime tool-cascade pivot or class-exhaustion; carries `originalTool`, `cascadedTo`, `capabilityId`, `failureClass` (`ToolFallbackFailureClass`), `attemptIndex`, `triedToolCount`, `inClass`, `cascadeApplied`, optional `triggerSite` | | `tokenCost` | `TokenCostRecord` | Token / usage accounting — populated with the real input/output token counts the provider reported | | `synthesisQuality` | `SynthesisQualityRecord` | `synthesize` rows only | | `toolSelection` | `ToolSelectionTrace` | `tool-loop` rows only | | `planGeneration` | `PlanGenerationRecord` | `anchor-plan` rows only | | `interruptDecision` | `InterruptDecisionRecord` | When a human-in-the-loop interrupt fires | | `pluginPayloads` | `readonly PluginAuditPayload[]` | Plugin-namespaced extension slot (pleachfix #10, `auditRecordVersion: 8`). Each entry carries `pluginId`, `subKind`, and an opaque `data` payload the plugin owns the shape of — lets a plugin emit typed audit context without coring out a new top-level slot | `FallbackStepRecord.ladderRegression` is the canonical example of the variable surface earning its keep — a structural invariant says in-family fallback walks the ladder forward; the column says when this specific row went backwards. The lattice can't answer that; the ledger does. ## Joinability [#joinability] * `sessionId` joins to the session record. * `turnId` aggregates every row that fired in service of a single user message — the natural grain for cost reporting and replay. * `(sessionId, turnId, stageId, seqWithinTurn)` is the idempotency key the ledger enforces on `recordCall`. Rows are append-only. The interface ships no update or delete primitive; a row that needs to mutate is a wire-format break and bumps `auditRecordVersion` instead. Deletion runs through the `GDPRSoftDelete` plug-point on the ledger interface — a host implementation today, with retention machinery a planned scope extension for `@pleach/compliance@0.1.0` (the SKU ships the scrubber cohort + attestation today; tombstone-driven retention is the next compliance phase). ## Query patterns [#query-patterns] The exact SQL column names are a property of your persistence adapter (Supabase, IndexedDB, S3). The shape below uses the in-memory shape names directly. ```typescript import { MemoryProviderDecisionLedger } from "@pleach/core/audit"; const ledger = new MemoryProviderDecisionLedger(); // every row for a turn, in seqWithinTurn order const turn = await ledger.getTurn(sessionId, turnId); // newest-first session window, ULID cursor pagination const page = await ledger.getSession(sessionId, { limit: 100, before: cursor }); // streamed export by time range — yields chunks for await (const chunk of ledger.streamByTimeRange({ fromIso: "2026-06-01T00:00:00Z", toIso: "2026-06-02T00:00:00Z", chunkSize: 500, })) { // forward to SIEM } ``` Rows in `getTurn` come back sorted by `seqWithinTurn`. Replay reconstructs the fallback ladder by sorting `WHERE turn_id = X ORDER BY seqWithinTurn`. The tool-cascade chain narrows further: `WHERE turn_id = X AND stageId = 'tool-loop' AND toolFallbackStep IS NOT NULL ORDER BY seqWithinTurn`. ## Retention [#retention] Default retention is indefinite. Per-tenant retention policies are the next planned phase of `@pleach/compliance@0.1.0` — soft-delete (tombstone-then-purge) is the design target as the GDPR-aligned default; hard-delete is opt-in for tenants that contractually require it. Today the shipping `@pleach/compliance` cut handles the scrubber + audit contract substrate; retention scheduling stays host-side via the `GDPRSoftDelete` plug-point. ## Why this lives in core, not a sibling [#why-this-lives-in-core-not-a-sibling] Audit is what makes replay, eval, and fallback-rate dashboards possible. Pushing it into a sibling would mean every consumer choosing whether to ship audit at all — some wouldn't, and the rest of the ecosystem couldn't assume a consistent floor. `@pleach/core` ships the row; siblings layer retention, redaction, hash-chaining, and replay on top. ## Typed record payloads [#typed-record-payloads] Five record shapes were promoted from opaque JSON to typed, stage-correlated slots: `InterruptDecisionRecord`, `TokenCostRecord`, `ToolSelectionTrace`, `PlanGenerationRecord`, and `SynthesisQualityRecord`. Each carries the shape its analytics consumer actually needs, not a bag of `unknown`. The audit-row version log carries the bumps (v8 through v19, all additive — see `AUDIT_RECORD_VERSION_HISTORY`). Older readers keep working; newer readers get the narrowed fields. The v18 bump added resolved model/provider/token-usage values plus the caller's requested model (`decision.requestedModel`); the most recent bump to v19 added the `outcome.why` provider-degradation diagnostics (raw native finish reason, serving provider, gateway attempt). Each typed record is an optional named slot on the row. Consumer code narrows by checking slot presence (`if (call.toolSelection) { … }`) and gets per-slot field narrowing without `as` casts. For the per-record field shape and the derived analytics each one unlocks, see [`/docs/typed-records`](/docs/typed-records). ## Where to go next [#where-to-go-next] --- # @pleach/base-tools (/docs/base-tools) Starter implements for the gardener — shears, watering can, scratchpad, fetcher. `@pleach/base-tools` is the batteries-included bundle of domain-agnostic tools almost every agent ends up needing — arithmetic, time, a per-session scratchpad, unit conversion, in-text search, and an opt-in HTTP fetcher with a citation extractor. It's a separate SKU from [`@pleach/tools`](/docs/tools) on purpose: `@pleach/tools` ships the `defineTool` primitive and the tool-loader contracts; this package ships ready-made tools that drop straight into a `SessionRuntime`. Hosts that hand-roll their toolbelt don't need this package. Hosts that want a sane default surface — without writing seven small Zod schemas to wrap `Date.now()` — install it. The base tools have been hoisted into [`@pleach/core`](/docs/core) at the opt-in subpath **`@pleach/core/base-tools`**. If you already depend on `@pleach/core`, you can import them with zero extra install: ```typescript import { baseToolsPlugin } from "@pleach/core/base-tools"; ``` The subpath is opt-in (it is **not** part of the top-level `@pleach/core` barrel), so it stays tree-shakeable. The standalone `@pleach/base-tools` package below remains published as a thin re-export shim over that subpath — same symbols, two import paths. ## Install [#install] The tools ship with `@pleach/core` already (import from `@pleach/core/base-tools`). Install the standalone shim only if you want to depend on the SKU by name: ```bash npm install @pleach/base-tools ``` ```bash pnpm add @pleach/base-tools ``` ```bash bun add @pleach/base-tools ``` ```typescript import { mathTool, datetimeTool, scratchpadTool, unitConvertTool, textSearchTool, } from "@pleach/base-tools"; ``` The import names above are illustrative. The authoritative export shape lives in the package README on [npm](https://www.npmjs.com/package/@pleach/base-tools) — check it when you wire the tools in, since the bundle's surface evolves faster than this page. ## The tool surface [#the-tool-surface] ### `math` [#math] Arithmetic with a `peek` mode (read the result without committing to the conversation) and a reverse-polish-notation evaluator. Use it when the model needs deterministic numeric work mid-turn instead of guessing at sums in prose. ```json { "name": "math", "args": { "mode": "rpn", "expr": "3 4 + 2 *" } } // → { "result": 14 } ``` ```json { "name": "math", "args": { "mode": "peek", "expr": "1.07 * 249.99" } } // → { "result": 267.4893, "committed": false } ``` ### `datetime` [#datetime] Clock and date arithmetic. Four operations — `now`, `parse`, `format`, and `diff` — selected by the `operation` param. Every output is a string or number, never a JS `Date`, so the wire stays portable across the language-agnostic contract. `now` returns the current UTC instant as an ISO-8601 string plus epoch milliseconds: ```json { "name": "datetime", "args": { "operation": "now" } } // → { "iso": "2026-06-06T16:14:22.117Z", "epochMs": 1781108062117, "timezone": "UTC" } ``` `format` renders an instant for a given IANA `timezone`. The `formatted` field is a wall-clock string (`YYYY-MM-DDTHH:mm:ss`) with **no** offset or `Z` suffix — it is local-to-the-zone, not a round-trippable ISO-8601 instant. The `iso` field alongside it is the original UTC instant if you need the portable form: ```json { "name": "datetime", "args": { "operation": "format", "input": "2026-06-06T16:14:22Z", "timezone": "America/Los_Angeles" } } // → { "iso": "2026-06-06T16:14:22.000Z", "timezone": "America/Los_Angeles", "formatted": "2026-06-06T09:14:22" } ``` `diff` returns the signed difference between two instants in a chosen `unit` (`milliseconds`, `seconds`, `minutes`, `hours`, or `days`; default `milliseconds`): ```json { "name": "datetime", "args": { "operation": "diff", "input": "2026-06-06T00:00:00Z", "other": "2026-06-20T00:00:00Z", "unit": "days" } } // → { "from": "2026-06-06T00:00:00.000Z", "to": "2026-06-20T00:00:00.000Z", "unit": "days", "value": 14 } ``` There is no `shift` operation — to add an interval, compute it host-side (or with the `math` tool) and `parse` the result. ### `scratchpad` [#scratchpad] A per-session key/value store the model can read and write across the tool calls of a session. Keyed by `ToolContext.chatId`, so entries persist across turns within the same session (D-BASE1d). Use it for intermediate workings the model would otherwise have to thread through prose between tool calls. The `operation` param selects the action (`set` / `get` / `list` / `delete` / `clear`); values are strings. ```json { "name": "scratchpad", "args": { "operation": "set", "key": "note", "value": "remember this" } } { "name": "scratchpad", "args": { "operation": "get", "key": "note" } } // → { "found": true, "value": "remember this" } ``` The store is per-session by design — it does not survive session boundaries. A model that wants cross-session persistence should write to a real tool that hits storage, not lean on scratchpad and be surprised when state is gone in a new session. ### `unit_convert` [#unit_convert] SI and imperial unit conversions. The tool rejects category mismatches (length to mass, currency to temperature) with a typed error rather than silently returning garbage. ```json { "name": "unit_convert", "args": { "value": 5, "from": "km", "to": "mi" } } // → { "value": 3.1068559611866697, "unit": "mi" } ``` ```json { "name": "unit_convert", "args": { "value": 5, "from": "km", "to": "kg" } } // → error: { "code": "UNIT_CATEGORY_MISMATCH", "from": "length", "to": "mass" } ``` ### `text_search` [#text_search] Regex and substring search over a text body the caller supplies. No external corpus and no network — the tool is for "find the line that mentions X in this document I'm already holding." ```json { "name": "text_search", "args": { "body": "...long document text...", "pattern": "section 4\\.\\d+", "mode": "regex" } } // → { "matches": [{ "line": 142, "text": "section 4.2" }, ...] } ``` ### `url_fetch` (opt-in) [#url_fetch-opt-in] A guarded HTTP GET. Off by default; the host opts in by registering the policy plugin (see [Safety policies bundled](#safety-policies-bundled) below). The fetcher enforces: * No `localhost` or loopback targets. * No private CIDR ranges (RFC 1918, link-local, etc.). * A configurable allowlist for the domains the agent is permitted to reach. Returns the body and response headers. Don't reach for it before the host has decided which domains the agent is allowed to touch — the defaults are restrictive precisely because "fetch any URL the model emits" is rarely what production wants. ```json { "name": "url_fetch", "args": { "url": "https://example.com/article" } } // → { "status": 200, "headers": { "content-type": "text/html" }, "body": "..." } ``` ## Citation utility — `extractMarkdownLinks` [#citation-utility--extractmarkdownlinks] Given a markdown body, returns the extracted-link envelope. Each link carries the anchor text, the URL, the recognized shape (`inline` / `reference` / `autolink` / `bare`), and the offset into the input where the match started. Useful for "what did the model cite" auditing on top of `url_fetch` output, or for inspecting markdown the model itself produced. ```typescript import { extractMarkdownLinks } from "@pleach/base-tools"; import type { MarkdownLink, MarkdownLinkExtractionResult, } from "@pleach/base-tools"; const result: MarkdownLinkExtractionResult = extractMarkdownLinks(body); // → { // links: [ // { text: "the spec", url: "https://example.com/spec", kind: "inline", index: 42 }, // { text: "issue #142", url: "https://github.com/.../142", kind: "inline", index: 118 }, // ], // truncated: false, // } ``` `truncated` flips to `true` when the input exceeds `MAX_LINKS` (default 1000) and the returned array was capped. Input larger than `MAX_INPUT_BYTES` (default 1 MB) throws — chunk the input first if you're processing larger bodies. This is a utility, not a tool — call it from host code that's processing tool output, not from a tool the model invokes. ## Safety policies bundled [#safety-policies-bundled] The package contributes safety policies via the standard [`HarnessPlugin`](/docs/plugin-contract) contract. The headline policy is the `url_fetch` private-network guard described above; the package ships it as a `contributeSafetyPolicies` entry on its plugin export so that registering the plugin opts the host into the guard. ```typescript import { SessionRuntime } from "@pleach/core"; import { baseToolsPlugin } from "@pleach/base-tools"; const runtime = new SessionRuntime({ storage: myStorage, plugins: [baseToolsPlugin({ urlFetch: { allowList: ["example.com"] } })], userId: "user_123", }); ``` The host stays in control: don't register the plugin and `url_fetch` isn't on the surface at all. Register it with a tight allowlist and the model only reaches the domains the host approved. See [Safety](/docs/safety) for the policy contract the bundled entries implement against. {/* TODO: verified 2026-06-14 against packages/base-tools/dist/index.d.ts — none of toolExecutionLatencyObserver / toolFailureRateObserver / chunkSizeObserver are re-exported from @pleach/base-tools today. The package re-exports safety policies (BASE_TOOLS_SAFETY_POLICIES) and the citation extractor; stream observers must be imported from @pleach/core directly. Section preserved as TODO until either the re-exports land or the claim is replaced with an accurate inventory. ## Observer re-exports The package re-exports a handful of stream observers from `@pleach/core` so a host doesn't need to import from both places to wire common telemetry. The current re-export set: - `toolExecutionLatencyObserver` — emits a `metrics` channel envelope on every `tool.completed` with the wall-clock duration. - `toolFailureRateObserver` — emits a `metrics` envelope on `tool.failed`. - `chunkSizeObserver` — chunk-length histogram for streamed text content. The observer contracts themselves live in [Stream events](/docs/stream-events); the re-exports are convenience, not new behavior. */} ## Registration pattern [#registration-pattern] The tools register through the plugin's `contributeTools` hook, the same way any `HarnessPlugin` adds to the tool registry. The package likely exposes a one-call helper that returns the configured plugin — check the package README for the exact name. The narrative shape is: ```typescript import { SessionRuntime } from "@pleach/core"; import { baseToolsPlugin } from "@pleach/base-tools"; const runtime = new SessionRuntime({ storage: myStorage, plugins: [ baseToolsPlugin({ enable: ["math", "datetime", "scratchpad", "unit_convert", "text_search"], // url_fetch stays off unless explicitly listed: urlFetch: { allowList: ["docs.example.com"] }, }), ], userId: "user_123", }); const session = await runtime.createSession({ tools: { enabled: ["math", "datetime", "scratchpad"] }, }); ``` The session's `tools.enabled` array selects which of the registered tools that particular session can see — registration and exposure are separate concerns, so the same runtime can serve a numeric agent and a search agent without each one inheriting the other's surface. ## Position vs `@pleach/tools` [#position-vs-pleachtools] | Package | What it ships | Reach for it when | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | | `@pleach/tools` | `defineTool` primitive, tool-loader contracts, batching strategy hints | You're writing your own tools | | `@pleach/base-tools` | Pre-built tools (`math`, `datetime`, `scratchpad`, `unit_convert`, `text_search`, opt-in `url_fetch`) plus the safety policies that guard them | You want a sane default toolbelt without re-implementing the common cases | Both compose. A typical host installs `@pleach/tools` to write its domain-specific tools and `@pleach/base-tools` to cover the generic surface; the two plugins register side-by-side and contribute to the same tool registry. ## Where to go next [#where-to-go-next] --- # Branding (/docs/branding) White-label primitives let a host override the brand string, log prefixes, and error-message text the runtime emits. Output reads as the host's product instead of as Pleach. The surface is opt-in; with nothing configured, the runtime's output is byte-identical to running without branding. Two consumer types reach for this earliest. **Consultancies** ship the same runtime to N tenants under N brands — agency-style hosts that need each deployment to wear a different name. **ISVs** embed `@pleach/core` inside a product where exposing the underlying runtime would dilute their own brand. End-user-facing SaaS rarely needs it; if your users never see a log line or an error string, there is nothing to rename. ## The contract [#the-contract] Four functions, all from the top-level `@pleach/core` export. | Function | Role | | ------------------------------------- | ----------------------------------------------------------------------- | | `configureBranding(config)` | Installs an override. Every field is optional and defaults to identity. | | `getBrandingConfig()` | Returns the active configuration with defaults filled in. | | `resolveLogPrefix(defaultPrefix)` | The call-site hook log emitters route through. | | `resolveErrorMessage(defaultMessage)` | The call-site hook `throw new Error(...)` sites route through. | `configureBranding({ brandName, logPrefix, errorMessage })` is the whole installation surface. `logPrefix` and `errorMessage` are transform functions — each receives the default and returns the branded form, so you rename rather than hard-code. ```ts import { configureBranding } from "@pleach/core" configureBranding({ brandName: "Acme", logPrefix: (defaultPrefix) => defaultPrefix.replace("Pleach", "Acme"), errorMessage: (defaultMessage) => `[Acme] ${defaultMessage}`, }) ``` ## Adoption is incremental [#adoption-is-incremental] `v0.x` ships the contract. You can call `configureBranding` today and the resolvers honor it. What's still landing is the substrate retrofit — replacing every hard-coded `[ToolNode]` / `[SessionRuntime]` literal with a call through `resolveLogPrefix` — which arrives as part of the broader logging-refactor track. The override surface widens transparently as call sites adopt the resolvers; nothing you write against the contract breaks when they do. ## Why it's multi-call, unlike `@pleach/observe.init` [#why-its-multi-call-unlike-pleachobserveinit] `@pleach/observe.init` is single-init: it owns infrastructure state — destinations, sampling decisions, async-local-storage scope — that would race or leak across reconfigurations. Branding owns none of that. It's a UX preference, so the constraint is looser. `configureBranding` MAY be called multiple times. The last call wins, and partial overrides do **not** merge with the prior config — each call replaces the whole branding state. A multi-tenant process can adjust branding at the boundary of a request handler without a process teardown. ## The `@pleach/core/branding` subpath [#the-pleachcorebranding-subpath] The same four functions are available at the `@pleach/core/branding` subpath when you only want this slice in your bundle. ```ts import { configureBranding } from "@pleach/core/branding" ``` ## Where to go next [#where-to-go-next] --- # Building chat UI (/docs/building-chat-ui) Pleach ships one styled chat surface (``) and a small hook + facade surface (`@pleach/react`). Neither is a full chat UI library — that's a deliberate scope decision. This page is the honest map of "what to use when," including when to compose with **assistant-ui** or **CopilotKit** instead of writing the component yourself. ## The three layers [#the-three-layers] | You want | Reach for | What you give up | | --------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------ | | **MVP, ship today** | `` from `@pleach/core/quickstart` | Per-message customization, multi-thread UI, generative-UI tool rendering | | **Production chat in your design system** | `@pleach/react` hooks + your own components | Composer primitives, attachment dropzones, dictation, branched edit-mode | | **Multi-thread, attachments, generative UI, polished UX** | `@pleach/react` runtime + **assistant-ui** or **CopilotKit** | An npm dependency on a UI library | There is no shame in the third row. Mastra, OpenAI Agents SDK, Inngest AgentKit, and the LangGraph reference UIs all integrate with assistant-ui or CopilotKit for production chat. The underlying runtime is yours; the component family is theirs. Both sides win. ## Layer 1 — `` [#layer-1--chatbox-] ```tsx // app/page.tsx "use client"; import { ChatBox } from "@pleach/core/quickstart"; export default function Home() { return ; } ``` Unstyled, semantic, ARIA-correct. Composed of plain HTML elements with `data-pleach-*` selectors. Style with a stylesheet that targets those selectors, or pass a `classNames` map. Internally it composes `useChat` — you can replace any subset of the markup without re-implementing the streaming. **Use when:** the chat is one tab in a bigger app, your design system is light, you want to ship today. ## Layer 2 — `@pleach/react` hooks + your own components [#layer-2--pleachreact-hooks--your-own-components] The `@pleach/react` SKU ships the lower-level primitive hooks, factored so you can build your own surface: * **`useSessionRuntime`** — owns one `SessionRuntime` per mount behind a ref (construct-once, cleanup-on-unmount). No provider required. * **`useSessionMessageStream`** — fires one turn at a time against the active runtime/session; you own the UI reduction via `onEvent`. * **`useEventLog`** — facade over a caller-owned `EventLogClient` for rendering audit rows. * **`useInterruptUI`** — routes each pending interrupt to the matching handler; returns `renderActive` / `resolveInterrupt` / `cancelInterrupt`. * **Correction dedup + stream lock** — `createCorrectionDedup` and `createStreamLock` handle the "user edits while streaming" race and serialize concurrent turns. * **Vercel AI SDK adapter** — `useVercelAISDKCompat()` from `@pleach/react/adapters/vercel-ai-sdk` translates Pleach `StreamEvent`s into AI SDK's `UIMessage` taxonomy. The batteries-included facade lives one layer up: `HarnessProvider` and `useHarness` on `@pleach/core/react`, and the Pleach-native `useChat` (returns `{ messages, status, submit }`) on `@pleach/core/quickstart`. See [React](/docs/react) for the full surface. **Use when:** you have a design system, you want full per-message control, the chat is the product (not one tab in something else). ```tsx // app/(chat)/page.tsx "use client"; import { useState } from "react"; import { useSessionRuntime, useSessionMessageStream } from "@pleach/react"; import { createPleachRuntime } from "@pleach/core/runtime"; export default function ChatPage() { const { runtime, sessionId } = useSessionRuntime({ buildRuntime: () => createPleachRuntime({ /* config */ }), initSession: async (rt) => rt.sessions.create(), }); const [messages, setMessages] = useState([]); const { streamMessage, isStreaming } = useSessionMessageStream({ runtime, sessionId, onEvent: (e) => setMessages((prev) => reduceMessage(prev, e)), }); return (
streamMessage({ content: text })} disabled={isStreaming} />
); } ``` `` and `` here are *your* components. `useSessionRuntime` owns the runtime lifecycle behind a ref (no provider), and `useSessionMessageStream` gives you the stream — the rendering is yours, reduced from `onEvent`. ## Layer 3 — compose with a UI library [#layer-3--compose-with-a-ui-library] When you need multi-thread sidebar, attachment dropzones, dictation, branched edit mode, generative-UI per-tool rendering, and a polished design out of the box, compose your `SessionRuntime` with an external UI library. ### Option A — assistant-ui [#option-a--assistant-ui] assistant-ui ships **Radix-style headless primitives**: `ThreadPrimitive.{Root,Viewport,Messages}`, `ComposerPrimitive.{Root,Input,Send,AttachmentDropzone,...}`, `MessagePrimitive.{Root,Parts}`. Unopinionated on styling, very opinionated on the chat state model. Same primitive contract works against web, React Native, and Ink TUI. ```tsx // app/(chat)/page.tsx "use client"; import { AssistantRuntimeProvider } from "@assistant-ui/react"; import { Thread } from "@/components/thread"; // assistant-ui-shaped import { usePleachAssistantRuntime } from "@/lib/pleach-runtime-adapter"; export default function ChatPage() { const runtime = usePleachAssistantRuntime({ api: "/api/chat" }); return ( ); } ``` The adapter is \~30 LoC that maps Pleach's `StreamEvent` discriminated union into assistant-ui's runtime contract. We don't ship the adapter today — file an issue if you'd like one in `@pleach/react`. **Best for:** product-shaped chat where you want compound-component primitives without coupling to a hosted vendor. ### Option B — CopilotKit [#option-b--copilotkit] CopilotKit is the **embedded copilot** pattern — sidebar / popup that knows about your app state. Three packages: `@copilotkit/react-core` + `@copilotkit/react-ui` + `@copilotkit/runtime`. Hooks: `useCopilotReadable` (app state → LLM context), `useCopilotAction` / `useFrontendTool` (generative-UI tool rendering), `useCopilotChat` (programmatic control). ```tsx // app/layout.tsx "use client"; import { CopilotKit } from "@copilotkit/react-core"; import { CopilotSidebar } from "@copilotkit/react-ui"; export default function RootLayout({ children }) { return ( {children} ); } ``` You'd wire `/api/copilotkit/route.ts` to a `CopilotRuntime` whose service adapter delegates to Pleach. As with assistant-ui, we don't ship the adapter today; the integration point is the AI SDK adapter (CopilotKit accepts AI-SDK-shaped streams; pleach's `useVercelAISDKCompat()` from `@pleach/react/adapters/vercel-ai-sdk` produces them). **Best for:** the copilot is a feature of an existing app, not the app itself. Sidebar / popup UX. Generative-UI per-tool rendering. ### Why we don't ship Layer 3 ourselves [#why-we-dont-ship-layer-3-ourselves] A primitive component family is real work — assistant-ui has 25+ primitives and three years of refinement. Building a competing family inside `@pleach/react` would either duplicate that effort or ship a worse one. The pleach surface stays minimal so the substrate (audit ledger, family-lock, replay determinism, multi-tenant) gets the engineering investment instead. If your project hits a wall where `@pleach/react` hooks aren't enough but `` is too little, compose with assistant-ui or CopilotKit. Both ecosystems are healthy, both work with the pleach runtime through a thin adapter, and you keep the audit row in your Postgres on every turn. ## Decision [#decision] | If your situation is... | Layer | Stack | | -------------------------------------------------------------------------- | ----- | ----------------------------------------------------------------- | | "I need a chat tab on my dashboard tonight" | 1 | `` | | "Chat is in our design system, we have 5 message types" | 2 | `@pleach/react` hooks + your components | | "Chat is the product. Multi-thread, attachments, polished UX" | 3 | `@pleach/react` runtime + assistant-ui | | "Add a copilot to our existing SaaS, sidebar pattern, knows our app state" | 3 | `@pleach/react` runtime + CopilotKit | | "We're building Cursor / Lovable / a coding agent" | 3 | `@pleach/react` runtime + assistant-ui + your file-tree / diff UI | ## Where to go next [#where-to-go-next] --- # Cache & memoization (/docs/cache) The runtime memoizes prepared LLM inputs through a `CacheBackend` contract — expanded system prompts, normalized message arrays, the work the seam does *before* it hands a request to the provider. This is runtime-side caching. It's distinct from [provider-side prompt caching](/docs/prompt-caching), which is the Anthropic/OpenAI server-side prefix-reuse mechanism the substrate also rides on. Both layers cut latency. Only this one is under the substrate's direct control, which is why the contract is short and the correctness rules are explicit. ## `CacheBackend` contract [#cachebackend-contract] One interface; three methods. Every implementation conforms. ```typescript interface CacheBackend { get(key: Fingerprint): Promise; set(key: Fingerprint, value: CacheEntry): Promise; metricsSnapshot(): CacheMetricsSnapshot; } ``` Two contract rules: 1. **`set` is fire-and-forget.** The seam invokes it without awaiting in the hot path. A backend that throws synchronously breaks the turn — see [What not to do](#what-not-to-do). 2. **`metricsSnapshot` is O(1).** Counters update on every mutation; the snapshot reads them, never recomputes. The returned `CacheMetricsSnapshot` carries hit/miss counters, entry count, and byte usage: ```typescript interface CacheMetricsSnapshot { readonly hits: number; readonly misses: number; readonly entryCount: number; readonly sizeBytes: number; } ``` A miss returns `null` rather than throwing. A backend outage under the `best-effort` policy degrades to a miss too — the seam falls through to the provider call instead of failing the turn. ### `CacheGetMode` vs `CacheReadPolicy` [#cachegetmode-vs-cachereadpolicy] Two orthogonal policies, both on the read path. Don't conflate them. | Policy | Type | Scope | What it governs | | ----------------- | ---------------------------------------- | ---------------------------- | ----------------------------------------------------------------------------------------- | | `CacheGetMode` | `"strict-fail" \| "best-effort"` | Per-call (passed to `get()`) | Failure policy when the backend itself errors — propagate vs degrade-to-miss | | `CacheReadPolicy` | `"strict-mode" \| "cross-mode-readable"` | Per-fingerprint | Cross-`runtimeMode` boundary — can a `live` turn read a row written by `headless-replay`? | `strict-fail` is correct for `headless-replay` (a missed hit breaks determinism). `best-effort` is correct for interactive production traffic (a cache outage must not break user-facing calls). The two policies compose — `strict-mode` + `best-effort` is the common production shape. ## The default `memoryCacheBackend` [#the-default-memorycachebackend] **The substrate default.** PA-2 C2 Phase 3 promoted `memoryCacheBackend` from "must be configured" to default-constructed in the `SessionRuntime` constructor. Every runtime ships with a live cache out of the box — no `cacheBackend` field on `SessionRuntimeConfig` is required. Default cap: **1000 entries / 64 MB**, whichever fills first. Eviction is LRU over insertion order — `get()` re-inserts on hit, so the oldest unread entry is the next eviction candidate. ```typescript import { SessionRuntime } from "@pleach/core"; import { createMemoryCacheBackend } from "@pleach/core/cache"; // Default — runtime constructs a memoryCacheBackend with 1000/64MB caps. const runtime = new SessionRuntime({ storage, userId: "user_123", }); // Override at construction with a larger memory cache. const runtime2 = new SessionRuntime({ storage, userId: "user_123", cacheBackend: createMemoryCacheBackend({ maxEntries: 10_000, maxBytes: 256 * 1024 * 1024, }), }); // Opt out — pass `null` to disable caching entirely. const runtime3 = new SessionRuntime({ storage, userId: "user_123", cacheBackend: null, }); ``` A custom backend (Redis, Memcached, Cloudflare KV) implements the same `CacheBackend` interface and slots into the same field. The seam doesn't care which it got. ### Reach: all four seam factories [#reach-all-four-seam-factories] The runtime threads `cacheBackend` through to every seam construction site. The four seam factories — `synthesizeSeam`, `reasoningSeam`, `utilitySeam`, `converseSeam` — accept `cacheBackend?: CacheBackend` on their `CreateSeamOptions` and forward it to the inner `createSeam(...)`. So the cache is live at every call class out of the gate; you don't need to wire it per-seam. ## `prepareCacheInputs` adapter callback [#preparecacheinputs-adapter-callback] Hosts hook this to canonicalize what enters the cache key. Without it, volatile fields (request timestamps, monotonic correlation ids, per-request trace headers) would pollute the fingerprint and depress the hit rate without changing the semantic input. The callback receives the prepared input the seam is about to fingerprint and returns the canonicalized form. The substrate ships a no-op default — every input passes through unchanged. See [`src/cache/`](https://github.com/pleachhq/core/tree/main/src/cache) for the exact signature; the shape is intentionally narrow so hosts can express "strip these fields" without subscribing to a larger preprocessing pipeline. A typical implementation strips request-scoped metadata before fingerprinting, leaving the system prompt, the message array, the tool list, and the model id intact. ## The four fingerprint gaps [#the-four-fingerprint-gaps] The cache key includes four fields that consumers must be aware of: | Field | Why it's in the key | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `systemPrompt` | The substrate's prompt fragments expand per turn; two turns with different system prompts produce different outputs by design. | | `temperature` | Sampling temperature changes the response distribution; reusing a `temperature: 0.2` row for a `temperature: 0.9` request would be wrong. | | `runtimeMode` | A turn running in `live` mode reads provider state a `headless-replay` turn doesn't see; the key reflects that. | | `tenantId` | Per-tenant prompt overrides and tool catalogs differ; cross-tenant reads would leak one tenant's prepared input into another's call. | Two reads with different values across any of those four miss the cache. By design. The substrate trades hit rate for correctness — a hit that returns the wrong response is worse than a miss that re-invokes the provider. ## Two read modes [#two-read-modes] The `CacheReadPolicy` governs how strict the `runtimeMode` gap is at read time. Default is `strict-mode`. ### `strict-mode` [#strict-mode] A turn running in mode A cannot read a row written by mode B. The key includes `runtimeMode` and the lookup fails when the modes differ. This is the safe default. `live`-mode turns may have invoked non-deterministic tools whose results shouldn't replay verbatim under `headless-replay`; isolating reads by mode keeps each mode's cache semantics independent. ### `cross-mode-readable` [#cross-mode-readable] A turn may read a row written under a different mode when the prepared input is mode-independent. The seam evaluates the mode-independence claim at read time; the row is returned only when the claim holds. Cross-mode reads stay in soak today. Hosts should keep the default until the cross-mode contract clears — see [Versioning](/docs/versioning) for how the gate moves. ## Telemetry [#telemetry] `metricsSnapshot()` returns live counters. Two of the four fields matter for storage-volume monitoring on production: | Field | What it answers | | ----------------- | -------------------------------------------- | | `hits` / `misses` | Is the cache earning its keep? | | `entryCount` | How many distinct fingerprints have we seen? | | `sizeBytes` | Are we approaching the configured cap? | ```typescript const snapshot = runtime.getCacheBackend().metricsSnapshot(); console.log( `cache: ${snapshot.hits} hits / ${snapshot.misses} misses`, `(${snapshot.entryCount} entries, ${snapshot.sizeBytes} bytes)`, ); ``` The CI gate `audit:c2-cache-hit-rate-clean` is the canonical hit-rate signal in production. It accumulates per-canvas-batch samples of `[UXParity:c2-cache-hit-rate]` emissions and fails `:strict` until a 3-batch soak window shows hit-rate ≥ 5% and median hit-latency \< 50 ms. The clock starts on the first production canvas batch after the default-promotion deploy; the ledger lives in-repo at `scripts/audit/c2-cache-hit-rate-clean.soak-ledger.json`. Wire the snapshot into your existing metrics pipeline the same way you wire the audit ledger — see [Observability](/docs/observability) for the per-call decorator pattern; cache metrics are coarser, so a periodic snapshot read into a gauge is enough. ## What's not in scope today [#whats-not-in-scope-today] Cross-mode read of cached state across `runtimeMode` transitions remains in soak. The contract isn't sealed yet — edge cases around tool-call replay and async-job resumption are still being walked. Keep `strict-mode` until the gate clears. The cost of the stricter read is one extra provider call when a mode boundary is crossed; the cost of a premature cross-mode hit is a correctness bug that won't surface until the row replays. See [Versioning](/docs/versioning) for the gate-moves-the-mode rollout shape. The in-memory backend is **single-process**. Each `SessionRuntime` instance owns its own `Map`; two replicas of the same host process don't share cache state. For multi-process or multi-replica deployments — autoscaling Vercel / Cloudflare Workers, fanned-out Kubernetes pods, anything beyond a single Node process — swap in a shared backend (Redis, Memcached, Cloudflare KV, Supabase) by implementing the same `CacheBackend` contract. The substrate does not ship a Redis or Supabase cache backend today. Both are straightforward against the contract above — the in-tree memory backend is the reference implementation and the shape consumer adapters mirror. ## What not to do [#what-not-to-do] A few patterns that fight the substrate: * **Don't substitute the cache for an audit log.** The cache is best-effort and evictable; the [audit ledger](/docs/audit-ledger) is append-only and durable. A cache entry that aged out is gone; an audit row is not. Cost attribution, compliance evidence, and replay all read the ledger. * **Don't put PII into prepared inputs without scrubbing first.** The cache key derives from the prepared input; a leaked field there shows up in cross-tenant hit-rate diagnostics later. Run inputs through [scrubbers](/docs/scrubbers) before the seam fingerprints them. * **Don't construct a `SessionRuntime` with a custom cache that throws synchronously.** The seam invokes `set` without awaiting; a synchronous throw escapes the fire-and-forget contract and breaks the turn. Wrap I/O in `try`/`catch` inside the backend and degrade to a counter increment. * **Don't tune the fingerprint to chase hit rate.** The four gaps are load-bearing for correctness. A higher hit rate that comes from collapsing `tenantId` or `runtimeMode` is a cross-tenant bug waiting to ship. ## Where to go next [#where-to-go-next] --- # Call classes (/docs/call-classes) Every LLM call in `@pleach/core` declares one of four call classes. The declared class drives three things: which seam carries the invocation, the per-turn allotment that applies, and the audit-row slice the call lands in. The literal is lint-restricted to the four seam factories, so a tool-loop node can't accidentally fire a synthesize call and break the rendered-equals-audited invariant. The taxonomy is small on purpose. Four buckets are enough to distinguish "the runtime is making a routing decision" from "the user is reading this string," and that distinction is what the cost rollup, the matrix routing, and the singleton cap each key off. CallClass is one of three concepts in the routing cluster — paired with [Seams](/docs/seams) (the entry point each class binds to) and [Family-lock](/docs/family-lock#the-routing-cluster) (the session- locked column the seam resolves against). See [Family-lock → the routing cluster](/docs/family-lock#the-routing-cluster) for the cluster framing. ```typescript type CallClass = "utility" | "reasoning" | "converse" | "synthesize" ``` ## The four classes [#the-four-classes] | Class | Purpose | Per-turn allotment | Typical models | | ------------ | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | -------------- | | `utility` | Internal classification (tool-loop "call another tool or finish?" decisions, intent detection, planner routing, cache lookups) | Unbounded | Cheap / fast | | `reasoning` | Internal generator feeding the next call (planner expansion, decomposition) | Unbounded | Mid-tier | | `converse` | Short user-facing prose (refusal hints, retry narration) | Bounded | Mid-tier | | `synthesize` | The final user-facing answer | **Exactly one *terminal* per turn** (a thin/empty first draft may be retried; exactly one row is marked terminal) | Capable | ### utility [#utility] Internal classification calls. The runtime fires these to pick a branch, not to talk to the user. The canonical one is the tool-loop's continuation decision — "call another tool or finish?" — which runs on every step of an agentic turn. Other typical jobs: intent detection, planner routing, cache-bucket selection, tool-arg validation. The output is consumed by the graph, never rendered. Per-turn count is unbounded — a tool loop can fire as many utility calls as it needs to reach a decision. The matrix routes utility to the cheapest cell in the locked family. Keeping the continuation decision in `utility` is what makes the synthesize cap structural: a `utility` call consumes a different seam and can't reach the synthesize counter, so the only call that can land the final answer is the synthesizer. See [Seams — the singleton synthesize seam](/docs/seams#the-singleton-synthesize-seam). ### reasoning [#reasoning] Internal generators feeding the next call. The output is intermediate text that another node will consume — a plan expansion, a decomposition, an intermediate summarisation. Like `utility`, the user never sees it; unlike `utility`, the call expects coherent prose, so the matrix routes it to a mid-tier rung. Unbounded per turn. ### converse [#converse] Short user-facing prose that is **not** the synthesis. Refusal hints, retry narration, "I need to clarify…" interstitials. The reader sees the output, so the matrix routes to a mid-tier model that can hold a voice, but the per-turn count is bounded — a turn that fires four converse messages before a final answer is broken, not chatty. ### synthesize [#synthesize] The final user-facing answer. **Exactly one *terminal* synthesis per turn** — the one row that lands the answer the user sees. The matrix routes to a capable rung; the constraint is structural, not advisory. A turn MAY make more than one synthesize *call*: if the first draft comes back thin or empty, the synthesizer fires a bounded, in-stage recovery retry. Each call records its own append-only audit row, but **at most one** row carries `synthesisQuality.terminalSynthesis: true` — the final answer. It is exactly one on a synthesized turn, and zero on a passthrough that accepts the model's first draft as-is. The invariant is therefore `count(terminalSynthesis === true) <= 1` (never two), not "one synthesize call." ## Why exactly one terminal synthesis per turn [#why-exactly-one-terminal-synthesis-per-turn] The user sees the synthesis. If the runtime marked two rows terminal and rendered one of them, the ledger would say one thing and the UI another. Marking exactly one row terminal means the rendered string and the terminal-audited string are the same string — even when a thin first draft was retried. The constraint is enforced by `SynthesizeSeamHolder` (per-runtime singleton) and `TurnSynthesizeCounter` (idempotent on `messageId`), and recorded on the ledger by `synthesisQuality.terminalSynthesis`. A recovery retry for a thin/empty draft DOES append its own audit row — the ledger is append-only — but only the row that lands the final answer is flagged terminal. An append-only audit can verify the invariant structurally: `count(terminalSynthesis === true) <= 1` (exactly one on a synthesized turn, zero on a passthrough, never two). A consumer (or the dev harness) asserts it with an `atMostOneTerminalSynthesis` check that counts terminal-marked rows, not raw synthesize rows. See [Seams — the singleton synthesize seam](/docs/seams#the-singleton-synthesize-seam) for the invariant in full. ## The callClass lint [#the-callclass-lint] The literal strings `"utility"`, `"reasoning"`, `"converse"`, `"synthesize"` are restricted to the four seam factories in `src/graph/seams/`. Anywhere else in the codebase, `callClass: "..."` fails the lint gate `lint:callclass-literals`. Outside the seams, code reaches a model through `AgentAdapter.resolveModel()` — the locked class travels as a type parameter, not a runtime string. ```typescript // Inside a seam factory — allowed: export function synthesizeSeam(/* ... */): ProviderSeam<"synthesize"> { return makeSeam({ callClass: "synthesize", /* ... */ }) } // Inside a node — fails lint:callclass-literals: const result = await adapter.resolveModel({ callClass: "synthesize" }) // Inside a node — passes: const result = await adapter.resolveModel() ``` Why a lint, not a runtime check? A runtime string lets any node accidentally pick `synthesize` and slip past the singleton seam, and the structural cap goes from "guaranteed by construction" to "hopefully nothing reached around it." The lint catches the escape at the seam boundary. See [Seams](/docs/seams) for the four factories. ## callClass on the audit row [#callclass-on-the-audit-row] Every `AuditableCall` row carries `call.callClass`. Per-turn cost rollup by class is one `GROUP BY`: ```sql SELECT call_class, SUM(token_cost) FROM harness_auditable_calls WHERE turn_id = $1 GROUP BY call_class ``` A `synthesize`-class row that fires twice in the same `turnId` is the canonical "the cap leaked" alert — the query is one predicate. See [The AuditableCall row](/docs/auditable-call-row) for the full row shape and the other slices it supports. ## callClass in the fingerprint [#callclass-in-the-fingerprint] `callClass` participates in the cache key. Different classes resolve to different models in the matrix, so a `utility` call and a `synthesize` call on otherwise-identical inputs sit in different equivalence classes — the cache scope reflects the routing. Concretely: replaying a `utility` call doesn't return a cached `synthesize` result that happened to share the same prompt. The class is part of the equivalence class, so the cache only collapses calls the matrix would route the same way. See [Fingerprint](/docs/fingerprint) for the full key/metadata split. ## How call class flows through a node [#how-call-class-flows-through-a-node] A node declares `acceptsSeam: CallClass | null` in its metadata. The substrate binds the matching seam at compile time and hands the node a `ProviderSeam` where `C` is the declared class. Outside the substrate, the literal never appears. ```typescript const synthesizerMeta = { stageId: "synthesize", acceptsSeam: "synthesize", subscribes: ["plan", "toolResults"], writes: ["finalMessage"], } ``` A node with `acceptsSeam: null` is a seam-free state transform — no LLM call, no class to declare. See [Nodes](/docs/nodes) for the full node-metadata contract. ## Where to go next [#where-to-go-next] --- # Changelog (/docs/changelog) The getpleach.com site changelog is **organized per `@pleach/*` SKU** so readers can find the docs-side history for the package they're using without scrolling past entries for SKUs they don't ship. Each SKU page collects the site-content changelog entries that materially touched that SKU's docs surface — newest-first. The per-SKU pages are not the runtime changelogs. The canonical record of runtime behavior lives in the upstream package `CHANGELOG.md` files and on each SKU's npmjs.org page. If you'd rather read the chronological, multi-SKU narrative, see the [combined view (legacy)](/docs/changelog/combined). ## Per-SKU pages [#per-sku-pages] ## Combined view [#combined-view] ## Format [#format] Each per-SKU page follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) section conventions (`Added`, `Changed`, `Fixed`, etc.) under dated headings. Newest entries appear first. The mirror copy of the combined view at [`/CHANGELOG.md`](https://github.com/pleachhq/getpleach/blob/main/CHANGELOG.md) is the source of truth in the repo. When a site change tracks an upstream pack, the entry cites the upstream commit so the linkage is auditable. --- # Channels (/docs/channels) A channel is a typed reactive state slot. Nodes in the graph subscribe to channels; a node fires when any of its subscribed channels advances. Channels carry the per-turn state the runtime flows through, and the concurrent-write semantics are defined per kind — not handwaved. Channels are one of three concepts in the execution-graph cluster — graph, nodes, channels. See [Architecture → the execution-graph cluster](/docs/architecture#the-execution-graph-cluster) for the cluster framing. The six kinds correspond to the LangGraph channel taxonomy. If you've used LangGraph, the shapes will look familiar; the substrate-level invariants (deterministic reducers, sync-only verdicts) come from `@pleach/core`. See [Checkpointing](/docs/checkpointing) for how `checkpoint()` / `restore()` make per-channel time-travel work, and [Architecture](/docs/architecture) for how channels compose with the lattice and seams. ```typescript import { LastValue, BinaryOperatorAggregate, Topic, EphemeralValue, NamedBarrier, DataChannel, appendReducer, messagesReducer, unionReducer, Overwrite, REMOVE_ALL_MESSAGES, } from "@pleach/core"; ``` ## Picking a channel [#picking-a-channel] | Channel | Concurrent writes | Persists across steps | Use for | | ---------------------------- | -------------------- | -------------------------- | -------------------------------------------------- | | `LastValue` | **Throws** | yes | Scalars: provider config, model id, current intent | | `BinaryOperatorAggregate` | Reduced via operator | yes | Accumulators: messages, plans, retrieved docs | | `Topic` | Appended | configurable | Per-step lists: artifacts, pending writes | | `EphemeralValue` | Last-write-wins | **no** (cleared each step) | Transient: current chunk, in-progress hint | | `NamedBarrier` | Tracked by name | until released | Synchronization: wait for a named set | | `DataChannel` | LRU-evicted | yes (bounded) | Cached lookups, retrieval results | The pick is structural — switching channels mid-flight is rare because each kind encodes a different invariant the graph depends on. ## `LastValue` [#lastvaluet] Keeps the most recent value. Concurrent writes in the same step throw — there's no ambiguity about what the value "should be." ```typescript const intent = new LastValue("intent", "unknown"); intent.update("lookup"); intent.get(); // → "lookup" ``` `update` takes the scalar value directly — the constructor is `new LastValue(name, initialValue)`, and `get()` returns the current value. Use for inputs the graph treats as singular: the resolved provider, the current model id, the active intent. When two nodes legitimately want to write the same `LastValue` in the same step, that's a bug in the graph topology — fan-in through an aggregate instead. Two writes to the same `LastValue` within one step surface as a write conflict on the channel — the runtime emits a write-conflict breadcrumb naming the offending channel. There's no silent "last one wins" behavior. ## `BinaryOperatorAggregate` [#binaryoperatoraggregatet] Reduces concurrent writes via a deterministic operator. The reducer must be **commutative and associative** — that's how concurrent writes produce the same result regardless of arrival order. ```typescript import { BinaryOperatorAggregate, appendReducer, messagesReducer, unionReducer, } from "@pleach/core"; const messages = new BinaryOperatorAggregate( "messages", [], messagesReducer, ); ``` ### Built-in reducers [#built-in-reducers] | Reducer | Shape | Behavior | | ------------------ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `appendReducer` | `(a: T[], b: T[]) => T[]` | Concatenates; order = arrival order | | `messagesReducer` | `(a: T[], b: T[]) => T[]` | Append with `id`-based dedup (the `T extends { id: string }` bound is what makes dedup possible); honors `REMOVE_ALL_MESSAGES` sentinel | | `unionReducer` | `(a: Set, b: Set) => Set` | Set union | Build your own when the built-ins don't fit — just keep it commutative + associative. The substrate doesn't validate this; violating the property silently breaks replay determinism. A worked case: a reducer that subtracts (`(a, b) => a - b`) compiles fine, runs fine, and passes a single live turn — but replaying the same event slice (whether through your own diff harness today or the planned `@pleach/eval` SKU when it ships) produces different final state when `tool.completed` events arrive in a different order, and the diff flags the channel as the source of divergence. The fix is to swap to an order-independent shape (max, set union, keyed merge) before the next replay run. ### The `REMOVE_ALL_MESSAGES` sentinel [#the-remove_all_messages-sentinel] `messagesReducer` recognizes a `REMOVE_ALL_MESSAGES` constant — a write that includes an item whose `id` equals this sentinel clears the accumulator (the reducer returns `[]`). Used by context-compaction nodes to clear a long history, then write the summary on the next update. ```typescript import { REMOVE_ALL_MESSAGES } from "@pleach/core"; // clear the accumulator messages.update([{ id: REMOVE_ALL_MESSAGES }]); // then append the summary (every message needs an `id`) messages.update([{ id: "summary-1", role: "system", content: summary }]); ``` ## `Topic` [#topict] Append-only list. Optional `clearOnStep` flag — when `true`, the channel resets at the start of every step (good for "things emitted this step"); when `false`, accumulates across the whole turn. ```typescript const artifacts = new Topic("artifacts", { clearOnStep: false }); artifacts.update([artifactA, artifactB]); artifacts.get(); // → [artifactA, artifactB] ``` The difference from `BinaryOperatorAggregate` + `appendReducer` is intent: a `Topic` is "many writers contribute items," not "two writers each propose a state." The runtime's tooling treats them differently (`Topic` updates are atomic per item; `BinaryOperatorAggregate` updates are atomic per state). ## `EphemeralValue` [#ephemeralvaluet] Last-write-wins within a step, cleared at the start of the next step. Use for transient per-step state — a current streaming chunk, an in-progress planner hint, a draft fragment. ```typescript const currentChunk = new EphemeralValue("currentChunk"); currentChunk.update("Hello"); currentChunk.get(); // → "Hello" // next step starts: currentChunk.get(); // → undefined ``` The "cleared each step" behavior is what makes ephemeral channels safe to skim from outside the graph — there's no historical state to leak. ## `NamedBarrier` [#namedbarrier] Tracks a named set; releases when every required name has been triggered. Use for synchronization — "wait for every tool in this batch to finish before proceeding to synthesize." ```typescript const toolBarrier = new NamedBarrier("toolBatch", ["tool1", "tool2", "tool3"]); toolBarrier.trigger("tool1"); toolBarrier.trigger("tool2"); toolBarrier.get(); // → false toolBarrier.pending(); // → ["tool3"] toolBarrier.trigger("tool3"); toolBarrier.get(); // → true ``` The constructor takes the barrier name plus the array of required names; `get()` returns `true` only once every name has been triggered, `trigger(name)` records one arrival, and `pending()` lists the names still outstanding. The graph scheduler treats a `NamedBarrier` as a blocker on subscribing nodes until released. Once released, the barrier emits a single advance event and downstream nodes fire. A barrier that never receives one of its required names keeps the synthesizer parked indefinitely — the `tool.failed` from one of the gating tools doesn't auto-trigger the name, so a graph that wires three required names against three tool calls needs an explicit compensating `trigger` on the failure path, or the turn stalls until the parent `AbortSignal` fires. ## `DataChannel` [#datachannelt] Bounded cache with memory-pressure eviction. Use for offloaded results keyed by a `ref` — retrieved docs, large tool outputs, anything where re-computing is expensive but the working set is bounded. ```typescript const retrieval = new DataChannel(); retrieval.upsert("doc:abc123", { ref: "doc:abc123", toolName: "search", summary: { description: "12 results" }, sizeBytes: 4096, createdAt: Date.now(), superstep: 0, }); retrieval.getRef("doc:abc123"); // → DataRef (summary the LLM reads) retrieval.getEntry("doc:abc123"); // → full DataEntry retrieval.get(); // → whole channel state (no key arg) ``` `DataChannel` is the only channel kind with a separate ref-keyed read/write API (`upsert(ref, entry)` to write, `getRef(ref)` / `getEntry(ref)` to read a single entry) alongside the `get()` / `update` pair on the others — `get()` takes no argument and returns the whole channel state. The graph scheduler doesn't trigger nodes on individual `upsert` calls; it triggers on the channel's version, which advances per write. The constructor takes only an options object — there's no positional name. The optional knobs the recovery layer wires through a closure: ```typescript new DataChannel({ hasS3Fallback, // whether evicted entries can be re-fetched from S3 refetch, // (ref) => Promise isGuest, // skip shadow refetch on guest sessions maxMemoryBytes, // override the eviction threshold }) ``` `refetch` is the closure-injected recovery primitive: every LRU eviction fires a background `refetch(ref)` and emits the outcome through the `[UXParity:phase-d-refetch-shadow]` probe. The promise is detached (never awaited) so eviction stays on the hot path. Host runtimes that own a recovery strategy (`recordGarbledOutput` is the parallel example for the recovery node) supply the closure at runtime construction; pure-substrate consumers leave it `undefined` and the channel falls back to graph-canonical recovery without a manifest round-trip. `isGuest` short-circuits the shadow refetch when no manifest exists to recover against. Eviction in action — once cumulative `sizeBytes` crosses the `maxMemoryBytes` threshold, the oldest entries (by insertion order) are evicted to make room: ```typescript const corpus = new DataChannel({ maxMemoryBytes: 8192 }); const mk = (ref: string, sizeBytes: number) => ({ ref, toolName: "search", summary: { description: ref }, sizeBytes, createdAt: Date.now(), superstep: 0, }); corpus.upsert("doc-abc0", mk("doc-abc0", 4096)); corpus.getEntry("doc-abc0"); // → DataEntry corpus.upsert("doc-abc1", mk("doc-abc1", 4096)); corpus.upsert("doc-abc2", mk("doc-abc2", 4096)); // pushes total over 8192 corpus.getEntry("doc-abc0"); // → null (evicted) corpus.getEntry("doc-abc2"); // → DataEntry ``` ## Composing channels in one turn [#composing-channels-in-one-turn] A retrieval turn for a knowledge-base assistant uses three kinds together — `LastValue` for the resolved intent, `Topic` for the docs the retriever emits, `NamedBarrier` to gate the synthesizer until every sub-query returns. ```typescript const intent = new LastValue("intent", "unknown"); const retrieved = new Topic<{ id: string; score: number }>( "retrieval", { clearOnStep: false }, ); const subQueriesDone = new NamedBarrier("subQueries", ["q1", "q2", "q3"]); intent.update("search_corpus"); retrieved.update([ { id: "doc-abc123", score: 0.91 }, { id: "doc-abc124", score: 0.88 }, ]); subQueriesDone.trigger("q1"); subQueriesDone.trigger("q2"); subQueriesDone.trigger("q3"); subQueriesDone.get(); // → true; synthesizer fires ``` ## Sequence-number ordering for projected channels [#sequence-number-ordering-for-projected-channels] Channels derived from event-log projections (via `runtime.events.fold(projection)`) inherit a hard ordering invariant from the underlying table: rows on `harness_event_log` are ordered by `(chat_id, sequence_number)`. The reducer sees events in that order, every replay, every cold-start hydration. This is what lets fold-based projections reach byte-identical state with snapshot writes — the projection is a deterministic function of an ordered event stream, and `sequence_number` is the shared total order across producers. See [Event log](/docs/event-log) for the durability + ordering contract on the source side. ## The `Channel` interface [#the-channelt-interface] All six kinds implement the same contract — building a custom channel is one interface away: ```typescript import type { Channel, ChannelUpdate, ChannelType } from "@pleach/core"; interface Channel { readonly name: string; readonly type: ChannelType; get(): T; update(value: ChannelUpdate): void; reset(): void; readonly version: number; } ``` `update` takes a single value (pass an `Overwrite` to bypass the reducer), `get()` returns the current value, `reset()` returns the channel to its initial state, and `version` is a monotonically increasing getter the scheduler reads to decide which nodes fire. The checkpointer snapshots every channel's `get()` value and rebuilds via `update`/`reset` on rollback — that's what makes per-channel time-travel work. Custom channels MUST report their state honestly; a channel that lies about its value breaks time-travel. ## `Overwrite` and concurrent-write disambiguation [#overwrite-and-concurrent-write-disambiguation] Some channels accept an `Overwrite`-flagged update that bypasses the reducer: ```typescript import { Overwrite } from "@pleach/core"; messages.update(new Overwrite([summary])); // messages is now exactly [summary], regardless of reducer ``` Use this sparingly — it's the escape hatch for context compaction and explicit resets, not a general-purpose "skip the reducer" tool. Every `Overwrite` is a determinism risk if two nodes race to do it. Two `Overwrite` writes to the same channel in the same step collapse to the last-arriving value, but arrival order across nodes is scheduler-defined — meaning a replay (the planned `@pleach/eval` SKU, or your own diff harness) can pick the other write and produce a different final state. Keep `Overwrite` writes single-sourced: the context compactor is one node, the explicit-reset node is one node, and they never run in the same step. ## What CI checks when you add a channel [#what-ci-checks-when-you-add-a-channel] A channel has no gate of its own — it reaches the lattice through a node's `subscribes` / `writes` metadata, so the node gate (`audit:graph-stages`) covers it. There is no `contributeChannels` hook; you add a channel by adding the node that reads or writes it. The one rule the substrate does **not** enforce is the reducer property: a `BinaryOperatorAggregate` reducer must stay commutative and associative, and a custom `Channel` must report its state honestly. Both are [determinism contracts](/docs/determinism) — break one and replay diverges silently rather than failing a gate. The channel is one row in the [extension map](/docs/extending). ## Where to go next [#where-to-go-next] --- # Checkpointing (/docs/checkpointing) A checkpointer snapshots every channel in the session and lets you restore to any prior point. The substrate uses checkpoints for automatic rollback on error; consumers use them for time-travel debugging, eval, and replay. Checkpoint is one of three concepts in the [state-and-persistence cluster](/docs/storage#the-state-and-persistence-cluster) — alongside the [storage adapter](/docs/storage) and the [sync version vector](/docs/sync) — that together carry session state across restarts, rewinds, and concurrent writers. This page covers the rewind axis: per-channel snapshots written at message and event boundaries, restorable in place via `runtime.checkpoints.rollback` or branchable via `TimeTravelAPI.fork`. ```typescript import { MemorySaver, IndexedDBSaver, SupabaseSaver, createSupabaseSaver, type SupabaseSaverConfig, } from "@pleach/core/checkpointing"; ``` Prefer `createSupabaseSaver(config)` over `new SupabaseSaver(config)` — the factory wires schema and RLS context correctly. The plain constructor is fine for tests but skips that plumbing. ## Picking a checkpointer [#picking-a-checkpointer] | Checkpointer | Environment | Pairs naturally with | | ---------------- | ----------- | ---------------------------------- | | `MemorySaver` | Any | `MemoryAdapter` (tests, dev) | | `IndexedDBSaver` | Browser | `IndexedDBAdapter` (offline-first) | | `SupabaseSaver` | Server | `SupabaseAdapter` (production) | You can mix-and-match across storage and checkpointer types, but the natural pairings above are what `HARNESS_MOCK_MODE=true` wires and what production setups land on. On a non-Supabase Postgres (node-postgres, Neon, RDS, pglite), pair the provider-agnostic [`PgStorageAdapter`](/docs/storage#pgstorageadapter-provider-agnostic-postgres) and [`createPgEventLogWriter`](/docs/storage#durable-event-log-writer) — both take an injected `PgClientLike`. Cross-restart durability requires wiring a durable storage adapter **and** event-log writer; without them the runtime defaults to in-memory and loses state on restart. A shipped Postgres `Checkpointer` is not bundled yet — implement the `Checkpointer` interface against your `PgClientLike` if you need durable *checkpoints* on the same store (the [durable local-dev store](/docs/storage#durable-local-dev) is the executable reference). ## Durability levels [#durability-levels] Every checkpoint carries a `_durability` stamp set by the checkpointer **after** the underlying write acknowledges. The stamp lets the runtime know which side of a partition the checkpoint actually survived. | Checkpointer | `_durability.level` | When the stamp fires | Survives | | ---------------- | ------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- | | `MemorySaver` | `"memory"` | Synchronously, immediately on `put()` | Process restart? No. | | `IndexedDBSaver` | `"disk"` | After the IDB transaction's `complete` event fires | Browser refresh? Yes. Tab close + reopen? Yes. Profile wipe? No. | | `SupabaseSaver` | `"replicated"` | After the server-side `insert` returns 200 | Process restart? Yes. Region failover? Depends on your Postgres replication. | This is pleach's shape for the same trade LangGraph documents as `durability: "exit" | "async" | "sync"`. LangGraph offers the dial **per write** against the same checkpointer; pleach pushes the choice to the checkpointer constructor and stamps the outcome on every checkpoint. The reason: a per-write dial means a single session can have some checkpoints durable and others not, which makes "did this turn survive the crash" a runtime decision. Pleach's stamp-after-ack discipline means inspecting any checkpoint tells you the durability level deterministically. If you need LangGraph's per-write dial behavior — async writes for hot loops, sync writes at the turn boundary — wrap a checkpointer that batches `put()` calls and flushes on a signal. The contract intentionally doesn't ship this out of the box; the operator-visible discipline is "one checkpointer, one durability level per session." ## Wiring a checkpointer [#wiring-a-checkpointer] ```typescript import { SessionRuntime } from "@pleach/core"; import { createSupabaseAdapter } from "@pleach/core/sessions"; import { createSupabaseSaver } from "@pleach/core/checkpointing"; const runtime = new SessionRuntime({ storage: createSupabaseAdapter(supabase), checkpointer: createSupabaseSaver(supabase), userId: "user_123", }); ``` `SupabaseSaverConfig` exposes `tableName` and a `maxCheckpoints` cap; the saver auto-prunes on every `put` to keep the per-session checkpoint count under that ceiling. Pass `maxCheckpoints: 0` to disable auto-prune and manage retention yourself. Once wired, the runtime writes a checkpoint at message and event boundaries — each tells you what triggered it through `CheckpointMetadata.source` (`"message"`, `"tool_execution"`, `"job_complete"`, `"subagent"`, `"sync"`, or `"manual"`). You don't have to call anything; the checkpoints exist by the time the turn lands. Each write fires a `checkpoint.created` stream event carrying the full `Checkpoint` envelope — the same event the React DevTools surface listens for to populate its checkpoint picker. The `source` on the envelope is what tells a UI "this checkpoint was written after a tool execution" versus "this one was written on the inbound message." ## Listing checkpoints for a session [#listing-checkpoints-for-a-session] `runtime.checkpoints.list` is an async generator yielding `CheckpointSummary` records, newest first. ```typescript for await (const cp of runtime.checkpoints.list(sessionId)) { console.log(cp.id, cp.createdAt, cp.source); // source: "manual" | "message" | "tool_execution" | "job_complete" | "subagent" | "sync" } ``` Pass a `limit` as the second argument to cap iteration without draining the underlying generator. ## Rolling back to a checkpoint [#rolling-back-to-a-checkpoint] ```typescript const restored = await runtime.checkpoints.rollback(sessionId, checkpointId); ``` The runtime rebuilds session state — messages, in-flight tool calls, pending jobs, planning context — to exactly what it was at the checkpoint, bumps its version vector, and writes a NEW checkpoint with `source: "manual"`, `writes: ["rollback"]`, and `parentCheckpointId` set to the rollback target. The next `executeMessage` continues from the restored state. > **Beta — channel-level re-execution replay.** Rollback restores the > persisted session *state* (durable checkpoints are battery-proven). > Reconstructing every in-memory channel to *re-run* the turn > deterministically from the checkpoint is a completeness slice still > in beta — the runtime wires a minimal channel projection today > (`createChannels: () => ({})`). Treat the continuation as > state-restore, not deterministic channel-replay. ### Rollback vs fork [#rollback-vs-fork] The two shapes look similar but answer different questions. | Operation | Same `sessionId`? | Reach for it when | | ------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `rollback(sessionId, cp)` | Yes — session is mutated in place | A tool ran with bad args (e.g. `search_corpus` called with the wrong `query`) and you want to retry from the pre-call channel state without losing the user's prior messages | | `fork(sessionId, cp, …)` | No — new `sessionId` is minted | You want to keep the original transcript intact and explore an alternate continuation (eval branch, "what if the planner had picked `classify` instead of `summarize`") | Rollback is destructive on the live session; fork is non-destructive and gives you two siblings sharing a common parent checkpoint. The flat methods `runtime.rollbackToCheckpoint(...)` and `runtime.listCheckpoints(...)` remain callable but are deprecated — prefer the `runtime.checkpoints` facet. ## Time-travel from React DevTools [#time-travel-from-react-devtools] In a React app with `useHarnessDevTools()` wired, time-travel is a one-liner from the browser console: ```javascript __HARNESS_DEVTOOLS__.checkpoints(); // → [{ id: "cp_018f...", source: "tool_execution", createdAt: ... }, ...] __HARNESS_DEVTOOLS__.rollback("cp_018f..."); // session state reverts; UI re-renders against the restored state ``` See [DevTools](#devtools) below for the full surface. ## Branching: fork a session from a checkpoint [#branching-fork-a-session-from-a-checkpoint] `TimeTravelAPI` is the substrate-shipped fork surface. Construct it once from any `Checkpointer` and call `fork` to spawn a new session id pointing at the same state envelope. ```typescript import { TimeTravelAPI } from "@pleach/core/time-travel"; const api = new TimeTravelAPI(checkpointer); const forked = await api.fork(sourceSessionId, checkpointId, crypto.randomUUID()); ``` The forked checkpoint records `source: "fork"` and a `forkedFrom` metadata block carrying the source session id + parent checkpoint id so the lineage stays queryable from the audit ledger. ## The `Checkpointer` interface [#the-checkpointer-interface] All three savers implement the same contract. Write your own (Redis, S3, filesystem) by implementing it: ```typescript import type { Checkpointer, Checkpoint, CheckpointMetadata, CheckpointListOptions, PendingWrite, } from "@pleach/core"; interface Checkpointer { put( threadId: string, state: SessionState, metadata: CheckpointMetadata, ): Promise; get(threadId: string, checkpointId?: string): Promise; list( threadId: string, options?: CheckpointListOptions, ): AsyncIterable; delete(threadId: string, checkpointId: string): Promise; prune(threadId: string, keepLast: number): Promise; putWrites?( threadId: string, writes: PendingWrite[], taskId: string, ): Promise; } ``` `threadId` here is the session id — the parameter name carries over from the LangGraph-derived checkpointer contract. `list` is an `AsyncIterable`, not an array; iterate with `for await`. `prune` returns the number of checkpoints deleted and keeps the most recent `keepLast` (it does NOT take an "after" cursor). A typical retention job runs `prune(sessionId, 50)` per session at the end of each turn — `SupabaseSaver` already does the equivalent via `maxCheckpoints`, so you only call `prune` directly when wiring a custom `Checkpointer` against a store the runtime can't auto-prune for you. A `Checkpoint` is a typed envelope carrying the session state plus per-channel snapshots; the shape is part of the language-agnostic contract that keeps the Go and TypeScript implementations in sync. ## DevTools [#devtools] Wire the DevTools hook once near your provider: ```tsx import { useHarnessDevTools } from "@pleach/core/react"; function App() { useHarnessDevTools(); return ...; } ``` Then from the browser console: ```javascript __HARNESS_DEVTOOLS__.session; // current SessionState __HARNESS_DEVTOOLS__.checkpoints(); // list checkpoints __HARNESS_DEVTOOLS__.rollback("cp_..."); // restore to a checkpoint __HARNESS_DEVTOOLS__.tools(); // list registered tools __HARNESS_DEVTOOLS__.syncStatus(); // version vectors __HARNESS_DEVTOOLS__.forceSync(); // push a sync now ``` Gate the hook call behind `process.env.NODE_ENV !== "production"` to keep the DevTools surface out of production bundles. A typed `HarnessDevToolsAPI` is exported for typing `window.__HARNESS_DEVTOOLS__` in consuming apps. ## Where to go next [#where-to-go-next] --- # CI enforcement of the audit contract (/docs/ci-enforcement) The auditability and replayability contract isn't a promise in prose. Each clause is backed by a named `npm run audit:*` gate that fails CI when the invariant breaks. This page reads the [full gate catalog](/docs/audit-gates) through the lens of those two contracts: it pairs every clause with the gate that enforces it and the condition that turns the gate red. For the exhaustive, subsystem-organized list — package shape, plugin contracts, tool coverage, graph lattice — see [Audit gates](/docs/audit-gates). This page is the contract-first index into that catalog. ## How a gate runs [#how-a-gate-runs] Every gate is an `.mjs` or `.ts` script invoked through npm: ```bash npm run audit: ``` Most are pure source-text scans that finish in under a second. They fall into two shapes: * **Source-text regression.** The gate asserts a set of canonical anchors (a flag name, an exported symbol, a column literal) still exist in the production files. Drift in any anchor signals the invariant is being silently rolled back. The script names the anchor in its failure output, so the fix is the file and symbol to restore. * **Soak ledger.** The gate reads a rolling window of runtime probe emissions and passes only when the last N batches are clean. Soak gates verify a behavior reached production, not just that the code still compiles. Suffix conventions are stable across the suite. `:strict` is the CI mode that fails on novel drift; `:json` emits machine-readable output; `:list` dumps the inventory; `:update-baseline` regenerates the allowlist after intentional drift. Gates exit `0` clean, `1` on a contract failure, `2` on a config or IO error. ## The auditability contract [#the-auditability-contract] The promise: every model call writes one typed, tenant-scoped, reconstructable row to the event log. Six gates hold the clauses up. | Contract clause | Gate | What turns it red | | ------------------------------------------ | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | The row shape is locked and versioned | `audit:auditable-call` | A fingerprint diff in `AuditableCall.ts` without a matching `AuditRecordVersion` bump — the silent-breaking-change detector. Composite of `audit:auditable-call-shape` (TS wire-format fingerprint) + `audit:auditable-call-version`. | | Persisted rows match the typed wire format | `audit:auditable-call-soak` | A DB-side row missing a required cluster field, a pre-projected flag disagreeing with its input, an `audit_record_version` below the per-cluster floor (`familyLock ≥ 2`, `cacheBreakpoint ≥ 3`, `fallbackStep ≥ 4`, `providerCascade ≥ 5`), or a gap in `seq_within_turn` (the dropped-write tamper signal). Runs `--print-sql` by default — no DB connection, CI-safe. | | Every row can reconstruct its substrate | `audit:event-log-manifest-hash-completeness` | A `harness_event_log` write site in `SessionRuntime.ts` or `EventLogWriter.ts` that neither stamps `manifest_hash`, routes through the `eventToRow` wire-layer stamper, nor carries a `// @no-manifest-hash-allowlist` annotation. `manifest_hash` is the foreign reference replay uses to recover the runtime config active when the event fired. | | The referenced manifest actually exists | `audit:config-manifest-referential-integrity` | An event row pointing at a manifest that isn't present. | | A referenced manifest survives GC | `audit:config-manifest-retention-completeness` | A surviving event referencing a manifest that retention dropped. | | Every event type is scrubber-covered | `audit:c8-event-type-allowlist-coverage` | An `EventLogInput` union member with no `SCRUBBABLE_FIELDS` allowlist entry — an event type that could slip past redaction. | | Every event type is actually produced | `audit:c8-union-member-has-producer` | An `EventLogInput` union member with zero producer call sites. | | Multi-tenant rows are tenant-scoped | `audit:tenant-scoping` + `audit:harness-event-log-tenant-id-required` | A `tenant_id`-bearing table without an RLS policy, or a `harness_event_log` write that doesn't thread `tenant_id`. | The row shape itself is locked at `AuditRecordVersion` (currently **18**), and `AUDIT_RECORD_VERSION_HISTORY` records every additive bump. See [the AuditableCall row](/docs/auditable-call-row) for the field-by-field shape. ## The replayability contract [#the-replayability-contract] The promise: a recorded session re-executes deterministically against an untampered log, and the chain proves the log wasn't mutated after the fact. Four gates hold these clauses up. | Contract clause | Gate | What turns it red | | ------------------------------------------------------ | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The hash-chain wire-in stays intact | `audit:c9-hash-chain-integrity` | Drift in a C9 anchor (`c9PhaseBEnabled`, `chainStep`, `verifyChainForChat`, `generateProof`) across `EventLogWriter.ts` / `hashChain.ts` / `index.ts`. Also evaluates a 3-batch soak when probe batches are staged: `[UXParity:c9-hash-chain-row-stamp]` count `> 0`, every `[UXParity:c9-hash-chain-verify]` carrying `chainValid: true`, and zero legacy-prefix diagnostics. The soak ledger ships empty, so the gate is informational today; `:strict` is the verifier-CLI promotion gate. | | Canonicalization is versioned | `PLEACH_C9_CANONICALIZATION_VERSION` | Pinned at `"pleach.c9.v1"`. `verifyChain` returns `{ ok: true }` or a row-precise `{ ok: false, failedIndex, expected, actual, reason }`. The verifier and writer share the canonicalization helper by construction. | | The replay surface can't regress before its next phase | `audit:eval-phase-a-coverage` | A drift in the 19 Phase A anchors across `@pleach/eval`'s four subpaths (`./`, `./scorers`, `./replay`, `./report`), or test coverage falling below the floor in `__tests__/eval/`. `:strict` gates the Phase B dispatch — Phase B can't land until this holds clean. | | The graph stays a fixed lattice | `audit:graph-stages` + `audit:edge-inventory-completeness` | A compiled cross-stage edge that isn't in the allowed-edge inventory. Replay can only reproduce a graph whose edges are fixed; an out-of-lattice edge breaks deterministic re-execution. | See [Tamper-evident hash chain](/docs/hash-chain) for what the chain proves and the verifier surface, and [Eval and replay](/docs/eval-and-replay) for the replay path that reads the chain. ## Where the gates run [#where-the-gates-run] Three CI jobs carry the contract gates on every merge: | CI job | What it runs | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `graphnoderef` | `npm run ci:graphnoderef` — chains `audit:auditable-call`, `audit:event-log-manifest-hash-completeness`, the three `audit:config-manifest-*` gates, and the graph-lattice gates. | | `local-clone` | `npm run audit:harness-package:local-clone` — packs via `npm pack`, installs into an isolated scratch dir, and smoke-imports `SessionRuntime` + `createPleachRuntime`. | | `consumer-rehearsal` | A per-SKU matrix running `npm run audit:consumer-rehearsal:` — the six-phase end-to-end publish rehearsal. | The per-SKU consumer rehearsal also runs on a weekly cron (Sundays 08:00 UTC) and nightly, so a packaging break that a PR didn't touch still surfaces within a day rather than at the next publish. ## Reproducing the gates as a consumer [#reproducing-the-gates-as-a-consumer] A host embedding `@pleach/core` inherits the contract but not the CI. Two gates are built to be run from outside the source repo: * `audit:consumer-rehearsal:core` walks six phases against a packed tarball — build, `npm pack` + install of the target and its peers into a scratch dir, facet smoke imports, one subpath import per `package.json:exports` key, a TypeScript `.d.ts` shape check under `strict`, and example runs. It writes only outside the repo and is idempotent across runs. * `audit:harness-package:local-clone` proves the published surface imports cleanly from an isolated install — the check that catches an unconditional-import blocker before a first publish. For the wider mirroring pattern — one invariant per script, structured failure output, documented next to the gate — see [Audit gates → Mirroring gates in your own repo](/docs/audit-gates#mirroring-gates-in-your-own-repo). The discipline is that adding a new invariant means adding a new gate, not a line on a review checklist. ## Where to go next [#where-to-go-next] --- # CLI (/docs/cli) `@pleach/core` ships one binary, `pleach`, registered through the package's `bin` field. It has three subcommands today: * **`pleach dev`** — boot a self-contained local playground in your browser: the four-stage lattice visualizer, the event log, an inspector, a runtime console, and the audit ledger, all driven by a real graph turn. `--demo` runs keyless against a canned model. * **`pleach init`** — interactive wizard. Detects your stack and scaffolds a starter project (route handler, page, optional plugin stub, optional schema copy). This is the default; `npx pleach` with no args runs it. * **`pleach schema`** — copy the harness Postgres SQL bundle into your project so you can apply it against your database. The binary name is `pleach`. The script lives at `scripts/harness-init.mjs` in the package — that internal path shows up in stack traces, but the user-facing name is the bin name. ## `pleach dev` — the local playground [#pleach-dev--the-local-playground] `pleach dev` boots a self-contained local playground in your browser — no host app, no build step, no database. It runs a **real** graph turn through the shipped `createPleachRoute()` and folds the resulting `StreamEvent` stream into five DX panels: * **Lattice visualizer** — the four stages (`anchor-plan`, `tool-loop`, `synthesize`, `post-turn`) light up live as nodes fire and channels are written. * **Event log** — the raw `StreamEvent` stream, the same shape the ledger and `@pleach/react` hooks consume. * **Inspector** — per-node / per-channel state at each superstep. * **Runtime console** — the bracketed runtime log breadcrumbs. * **Audit ledger** — one `AuditableCall` row per LLM call, rendered as it lands. ```bash npx pleach dev # → opens the playground in your browser npx pleach dev --demo # keyless preview — REAL graph, canned model text npx pleach dev --headless --prompt "…" # emit-stream capture, no browser ``` ### Flags [#flags] | Flag | Default | Effect | | ---------------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--demo` | off | Keyless mode. The real graph runs end-to-end; only the model text is scripted (canned, deterministic). Use it to see the lattice execute with no API key. | | `--sql` | off | Durable persistence. Back the route's storage + checkpoint + event-log seams with a local [pglite](https://pglite.dev) (Postgres-in-WASM) store, so chats and checkpoints **survive a restart** — no Docker, no cloud account. Needs the optional peer `@electric-sql/pglite` (`npm install @electric-sql/pglite`); without it, falls back to the in-memory default with a hint. | | `--sql-dir ` | `./.pleach-dev-sql` | Where the pglite store lives. Implies `--sql`. Point it somewhere stable so a restart resumes the same chats. | | `--headless` | off | No browser. Drive one turn and dump the captured emit stream to stdout — for CI, sharing, or piping into another tool. | | `--prompt ` (alias `--once`) | a built-in multi-tool chain prompt | The user turn to run. Pairs with `--headless`; omitted, headless runs the default chain so the full lattice fires. | | `--markdown` (alias `--md`) | off | With `--headless`, render the emit capture as a Markdown session document instead of raw events. | | `--plugin ` | – | Load YOUR `HarnessPlugin` module into the runtime (repeatable). Test it in the browser, or headlessly with the flags below. The module exports its plugin as `default`, a named `plugin`, or a zero-arg factory. | | `--scenarios ` | – | A JSON battery of prompts (array of strings, or `{ name, prompt }`, or `{ scenarios: [...] }`). Drives each headlessly and prints a per-scenario report. Implies headless. | | `--report ` | `json` | Harness report format when running a battery. | | `--assert ` | – | An expectations file. Exits non-zero on any failed expectation — gate it in CI or an agent loop. | | `--watch` | off | Re-run the battery and DIFF the report on every `--plugin` file edit (`run → observe → edit`). | | `--port ` (alias `-p`) | auto | Port for the playground server. | | `--no-open` | off | Start the server but don't auto-open the browser. | | `--help`, `-h` | – | Print the dev help banner and exit 0. | ### Key handling [#key-handling] `pleach dev` reads the active provider's `_API_KEY` from the environment first, then from the in-page key panel you can paste into without restarting. Precedence is `process.env` first, then the opt-in persistence store, then none. | Provider id | Env var | | ------------ | ------------------------------ | | `openrouter` | `OPENROUTER_API_KEY` | | `anthropic` | `ANTHROPIC_API_KEY` | | `openai` | `OPENAI_API_KEY` | | `google` | `GOOGLE_GENERATIVE_AI_API_KEY` | | `deepseek` | `DEEPSEEK_API_KEY` | | `mistral` | `MISTRAL_API_KEY` | | `moonshot` | `MOONSHOT_API_KEY` | Persistence is **opt-in**. When you paste a key into the panel you can choose to save it to `~/.pleach/dev.env` (written `0600`, one `_API_KEY=` line per provider); the key is never logged. Without saving, the key lives only for the running session. If no key is detected and you didn't pass `--demo`, the playground prints guidance and points you at `--demo` for a keyless run. ### Keyless demo [#keyless-demo] `--demo` is the zero-config entry point: the **real** compiled graph executes a full turn — anchor-plan, tool-loop, synthesize, post-turn, channel writes, audit rows — and only the model's text output is canned. It's the honest preview: you watch the actual lattice run, not a mock of it. Headless demo (`--headless --demo`, no `--prompt`) runs the default multi-tool chain so the whole lattice fires for a CI smoke or a shareable capture. ### Plugin-test harness — headless, continuous [#plugin-test-harness--headless-continuous] Point the runtime at your own `HarnessPlugin` and drive a battery of prompts with no browser. Each scenario folds the same surfaces the playground tabs expose — conversation, runtime log, event log, channel snapshot, audit ledger, and the `inspectRuntime` report (`capabilities wired n/total`) — into one structured report, plus derived signals (`reachedTerminal`, `stalled`, `notContributed`, `eventTypes`, `terminalSyntheses`, `auditFamilies`, …): ```bash # load your plugin into the live playground (test it in the browser) npx pleach dev --plugin ./my-plugin.mjs # headless battery → a JSON (or --report md) report per scenario npx pleach dev --plugin ./my-plugin.mjs --scenarios ./scenarios.json --report md # gate in CI / for an agent loop: non-zero exit on a failed expectation npx pleach dev --plugin ./my-plugin.mjs --scenarios ./scenarios.json --assert ./expect.json # iterate: re-run the battery + DIFF the report on every plugin edit npx pleach dev --plugin ./my-plugin.mjs --scenarios ./scenarios.json --watch ``` `scenarios.json` is an array of prompts (or `{ name, prompt }`); `expect.json` declares per-scenario or `all` expectations. Each predicate maps to a documented guarantee, so a green battery is a continuously-enforced value-prop contract: * **Lifecycle** — `reachedTerminal` (the turn reaches a terminal answer — assert this, not the rarer natural `converged`), `notStalled`, `finalContentIncludes`. * **Capability wiring** — `minCapabilitiesWired`, `contributes`, `eventTypes`. * **[Channels](/docs/channels)** — `snapshotChannels` (the channels a feature promises are written, e.g. `costEvents`/`costRollup`). * **[Audit ledger](/docs/audit-ledger)** — `minAuditRows`, `everyAuditRowClassified` (every LLM call carries a `callClass`), `everyAuditRowTenantScoped` (every row carries a `tenantId` — [isolation on every row](/docs/multi-tenant)). * **[Call classes](/docs/call-classes)** — `exactlyOneTerminalSynthesis` (one final answer per turn — counts `terminalSynthesis` rows, so a thin-draft recovery retry doesn't trip it), `atMostOneTerminalSynthesis` (the robust "never two final answers"; passes a passthrough turn too), `maxSynthesizeCalls` (caps the retry budget), `auditCallClassesIn` (every call's class stays within the `utility | reasoning | converse | synthesize` taxonomy). * **Stage lattice** — `auditStagesIn` (every call is stamped with a stage from the `anchor-plan | tool-loop | synthesize | post-turn` lattice). * **[Family lock](/docs/family-lock)** — `singleFamily` (the turn never silently widens across matrix families — at most one resolved `family` across its audit rows). The report is exactly the feedback an agent needs to run a `run → observe → edit` loop on your plugin. To run the **same loop inside your own runtime** (no CLI), flip the [`harness` chatinit flag](/docs/playground-and-devtools#the-harness-chatinit-flag). ## `pleach init` — the wizard [#pleach-init--the-wizard] The wizard detects what you already have and asks four questions: 1. **Template** — chatbot (route + page), headless smoke script, or plugin-only. 2. **Provider** — `anthropic`, `openai`, `openrouter`, `google`. The default is whichever provider env var it finds first. 3. **Plugin stub?** — optional `pleach.plugin.ts` (or `.mjs`) demonstrating three [HarnessPlugin](/docs/plugin-contract) hooks. 4. **Schema scaffold?** — optional. Runs the `schema` subcommand inline. Default is on when a `supabase/` directory already exists. Then it prints a write plan, asks for confirmation, and writes only files that don't already exist. Existing files are skipped, not overwritten. ```bash npx pleach init [--template ] [--yes] [--cwd ] [--help] ``` ### Flags [#flags-1] | Flag | Default | Effect | | ---------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--template ` | `chatbot` (with `--yes`) | Preselect template. One of `chatbot`, `headless`, `plugin-only`. Also `flat` / `bundles` to override the plugin-stub shape (default: `bundles`). | | `--facets ` | `prompts,safety,tools` | Comma-separated facet list for the plugin stub. Valid: `prompts`, `safety`, `tools`, `stream`, `lifecycle`. | | `--facets-file ` | `inline` | Single-file inline (default) or `split` to scaffold `facets/.ts` files. | | `--license ` | `Apache-2.0` | License for the consumer's scaffolded `package.json` (only when `--publishing` is not `none`). Independent of the `@pleach/*` substrate's own FSL-1.1-Apache-2.0 posture. | | `--publishing ` | `none` (under `--yes`) | One of `none`, `npm-private`, `npm-public`. Controls `package.json` + `README.md` scaffold. | | `--author ` | `null` (under `--yes`) | Author for scaffolded `package.json`. Interactive mode reads `npm config get init-author-name` as a default. | | `--repository ` | `null` (under `--yes`) | Repository URL for scaffolded `package.json`. Interactive mode reads `npm config get init-repository`. | | `--yes`, `-y` | off | CI mode — accept all defaults without prompting. | | `--cwd ` | `process.cwd()` | Run in a different directory. | | `--help`, `-h` | – | Print the init help banner and exit 0. | ### What it detects [#what-it-detects] All best-effort, all from the filesystem and env: | Signal | Values | How | | ------------------------ | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | Framework | `next-app`, `next-pages`, `astro`, `sveltekit`, `remix`, `vite`, `react-other`, `node` | `package.json` deps + presence of `app/` | | Package manager | `bun`, `pnpm`, `yarn`, `npm` | Lockfile presence | | `@pleach/core` installed | yes / no | `package.json` deps | | TypeScript project | yes / no | `tsconfig.json` exists | | Supabase migrations dir | yes / no | `supabase/` exists | | Provider env vars | which provider keys are reachable | `process.env` first, then `.env.local`, `.env.development`, `.env` in that order | Detection runs most-specific to least-specific. Next.js wins over Vite over plain React, because plain React often hides under a Next or Vite shell. The fallback for "nothing matched" is plain Node. The `.env*` walk is intentionally minimal — no interpolation, no multi-line values, first occurrence wins. Just enough to surface a provider key you set but never exported. ### Provider env-var matrix [#provider-env-var-matrix] | Provider id | Env var | | ------------ | ------------------------------ | | `anthropic` | `ANTHROPIC_API_KEY` | | `openai` | `OPENAI_API_KEY` | | `openrouter` | `OPENROUTER_API_KEY` | | `google` | `GOOGLE_GENERATIVE_AI_API_KEY` | The wizard offers every provider whose env var it can find. The first reachable one is the default selection; if none is set, the default is `anthropic`. The wizard's 4-provider list is narrower than the runtime's. The [quickstart subpath](/docs/getting-started#the-quickstart-subpath)'s `providerDetection` covers 7 providers (adds `deepseek`, `mistral`, `moonshot`). If only a non-wizard provider env var is set, the wizard reports "none detected" and defaults to `anthropic` — but the runtime `createPleachRoute` your app boots will still detect and use the provider correctly. The wizard's list only drives the prompt question; the deployed handler reads the broader runtime list. ### Templates [#templates] | Template | Framework | Files written | | ------------- | ---------------------- | --------------------------------------------------------------------------------------------- | | `chatbot` | `next-app` | `app/api/chat/route.ts`, `app/page.tsx` | | `chatbot` | `next-pages` | `pages/api/chat.ts`, `pages/index.tsx` | | `chatbot` | `astro` | `src/pages/api/chat.ts`, `src/pages/index.astro` | | `chatbot` | `sveltekit` | `src/routes/api/chat/+server.ts`, `src/routes/+page.svelte` | | `chatbot` | `remix` | `app/routes/api.chat.ts`, `app/routes/_index.tsx` | | `chatbot` | `vite` / `react-other` | `src/App.tsx` (no server route — pair with a Node server) | | `headless` | any | `agent.mjs` — smoke script using `SessionRuntime` | | `plugin-only` | any | `pleach.plugin.{ts,mjs}` (bundled stub by default; `--template flat` for legacy single-facet) | The chatbot route templates import `createPleachRoute` from [`@pleach/core/quickstart`](/docs/subpath-exports); the page templates import `ChatBox` from the same subpath. The headless template uses [`SessionRuntime`](/docs/session-runtime) from `@pleach/core` directly. The plugin stub uses `createPlugin`, `appendPrompt`, and `gatedPrompt` — three of the [plugin contract](/docs/plugin-contract) hooks. For the full quickstart subpath surface (`createPleachRoute`, `useChat`, ``, `defaultPlugin`, `createBenchmarkPlugin`, `providerDetection`), see [Getting started → The quickstart subpath](/docs/getting-started#the-quickstart-subpath). ### Idempotence [#idempotence] Files that already exist are skipped, never overwritten. The wizard's `done` block reports written, skipped, and failed counts. For a clean re-run, delete the files the wizard wrote the first time. ### CI mode [#ci-mode] `--yes` runs without prompts. Defaults: `template = chatbot`, provider = whichever the env detection picked (or `anthropic`), `withPlugin = true` for chatbot or plugin-only, `withSchema = true` if `supabase/` exists. ```bash npx pleach init --yes --template plugin-only --cwd ./my-app ``` Non-TTY invocations (CI runners, piped stdin) take the `--yes` path automatically even without the flag. ### Exit codes [#exit-codes] | Code | When | | ---- | ------------------------------------------------ | | 0 | Success, help, or `--yes` with zero write errors | | 1 | One or more files failed to write | ### Worked example — Next.js App Router [#worked-example--nextjs-app-router] ``` $ npx pleach init pleach init — charm-style wizard for @pleach/core ┌─ environment │ directory › /Users/you/my-app │ framework › Next.js (App Router) (tsconfig.json) │ package mgr › pnpm │ @pleach/core › not yet installed │ provider env › anthropic (.env.local) │ supabase/ › found └─ ? Template ● 1) Chatbot (route + page) default — streaming UI ○ 2) Headless smoke script node agent.mjs, no UI ○ 3) Plugin stub only add to an existing app › (1-3, default 1) ? Provider ● 1) anthropic ANTHROPIC_API_KEY set ○ 2) openai OPENAI_API_KEY unset ○ 3) openrouter OPENROUTER_API_KEY unset ○ 4) google GOOGLE_GENERATIVE_AI_API_KEY unset › (1-4, default 1) ? Generate a sample plugin stub? (y/N) y › plugin name (default: my-plugin) triage-plugin ? Scaffold Postgres schema into supabase/migrations? (Y/n) ┌─ write plan │ • app/api/chat/route.ts │ • app/page.tsx │ • pleach.plugin.ts │ • supabase/migrations/20260608120000_pleach_.sql (from schema bundle) └─ ? Write 4 file(s)? (Y/n) done ✓ wrote app/api/chat/route.ts ✓ wrote app/page.tsx ✓ wrote pleach.plugin.ts ✓ wrote supabase/migrations/20260608120000_pleach_.sql next steps 1. pnpm add @pleach/core 2. ANTHROPIC_API_KEY loaded from .env.local (framework auto-loads at dev time) 3. pnpm dev — chat lives at the root page ``` The "next steps" block adapts to the package manager the wizard detected. With bun it suggests `bun add` and `bun run dev`; with npm, the npm equivalents. Same with the provider-key line — if the key was found in `process.env`, the line is dropped from next-steps entirely. ### Plugin stub shape [#plugin-stub-shape] When `template = plugin-only` (or `chatbot` with `--add-plugin`), the wizard scaffolds a `pleach.plugin.ts` file. The default shape is now **bundle-aware**: each facet (`prompts`, `safety`, `tools` by default) gets its own `definePleachPlugin` const, composed into a single exported plugin array. ```ts // pleach.plugin.ts (default — bundle-aware shape) import { definePleachPlugin } from "@pleach/core"; const promptsFacet = definePleachPlugin("my-plugin", { prompts: [] }); const safetyFacet = definePleachPlugin("my-plugin", { safetyPolicies: [] }); const toolsFacet = definePleachPlugin("my-plugin", { tools: [] }); export const myPlugin = [promptsFacet, safetyFacet, toolsFacet]; ``` Wire at the runtime registration site via array spread: ```ts new SessionRuntime({ plugins: [...myPlugin] }); ``` This shape teaches the multi-facet pattern from day 1 — when a facet grows past \~50 lines, move its const to `facets/.ts` per [plugin authoring standards](/docs/plugin-authoring-standards). The single-facet shape (one `definePleachPlugin` call covering all hooks) is still available via `--template flat`: ```bash npx pleach init --template flat ``` See [plugin bundles](/docs/plugin-bundles) for the rationale behind the multi-facet pattern and the planned `composePlugin` upgrade path. ## `pleach init plugin` — monorepo subcommand [#pleach-init-plugin--monorepo-subcommand] For an existing project that needs to add a plugin without re-running framework detection: ```bash npx pleach init plugin --name corpus-safety --facets safety,prompts ``` Scaffolds `plugins/corpus-safety/` with four files: | File | Content | | ---------------------- | --------------------------------------------------------- | | `src/pleach.plugin.ts` | Bundle-aware stub with the requested facets | | `package.json` | License + peer-dep + scripts, standards-aware defaults | | `README.md` | Install + use + standards reference seed | | `tsconfig.json` | TypeScript config seed (ES2022, strict, declaration emit) | If a `tsconfig.json` exists at the top level, the wizard also inserts a path-map entry (`compilerOptions.paths["corpus-safety"]`) as a courtesy. Best-effort: skips and warns if the tsconfig can't be parsed. ### Subcommand flags [#subcommand-flags] | Flag | Default | Effect | | ----------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `--name ` | required | Plugin identity. Required under `--yes`. | | `--facets ` | `prompts,safety,tools` | Same vocabulary as `pleach init`. | | `--template flat\|bundles` | `bundles` | Stub shape override. | | `--facets-file inline\|split` | `inline` | Single-file vs split-file layout. | | `--at ` | `plugins//` | Scaffold target. | | `--license ` | `Apache-2.0` | License id for the consumer's generated `package.json`. (Independent of the `@pleach/*` substrate's own FSL-1.1-Apache-2.0 posture.) | | `--publishing ` | `npm-private` | One of `none`, `npm-private`, `npm-public`. | | `--author ` | (none) | Skipped under `--yes` unless supplied. | | `--repository ` | (none) | Skipped under `--yes` unless supplied. | | `--yes`, `-y` | off | CI mode. `--name` becomes required. | | `--cwd ` | `process.cwd()` | Run in a different directory. | | `--help`, `-h` | – | Print the subcommand help banner and exit 0. | The subcommand never modifies the top-level `package.json` or app code beyond the optional path-map insertion. It writes only inside the target directory. ## `pleach schema` — copy the SQL bundle [#pleach-schema--copy-the-sql-bundle] Scaffolds the harness storage schema into a target directory. This is the surface that used to live under `pleach init`; `pleach init --apply` still routes here so existing scripts don't break. ```bash npx pleach schema [--target ] [--dry-run|--apply] [--timestamped] [--help] ``` ### Flags [#flags-2] | Flag | Default | Effect | | ---------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | `--target ` | `./supabase/migrations` if it exists, else `./harness-migrations` | Destination for the copied SQL files | | `--dry-run` | on | Print the file-copy plan; write nothing | | `--apply` | off | Actually write the files | | `--timestamped` | off | Prefix each file with `YYYYMMDDHHMMSS_` (UTC) so Supabase migration ordering is stable | | `--help`, `-h` | – | Print the banner and exit 0 | ### What it does [#what-it-does] 1. Locates the schema bundle inside the installed package (`node_modules/@pleach/core/dist/schema/postgres/`). Falls back to the in-repo source tree when running against an unbuilt checkout. 2. Optionally rewrites each filename with a UTC timestamp prefix. Multiple files in the same second get monotonically incremented suffixes — Supabase's lexicographic order stays stable. 3. Copies the files into your target directory (creating it if needed). ### What it doesn't do [#what-it-doesnt-do] * Connect to your database. * Run the migrations. * Overwrite existing files. If a target file is already there, `schema --apply` refuses and exits 3 — read the manual-apply block below. The full apply path is intentionally not in the CLI. Until a real consumer asks for it, the dry-run + manual-apply flow is the supported path and the surface stays small. ### Exit codes [#exit-codes-1] | Code | When | | ---- | ---------------------------------------------------------------------------------- | | 0 | Success or dry-run completed | | 2 | Schema bundle missing (is `@pleach/core` installed?) or no SQL files in the bundle | | 3 | A target file already exists — refused to overwrite | ### Applying the migrations by hand [#applying-the-migrations-by-hand] After `pleach schema --apply`: ```bash for f in supabase/migrations/*pleach*.sql; do psql "$DATABASE_URL" -f "$f" done ``` If you're using the Supabase CLI: ```bash pleach schema --apply --timestamped supabase db push ``` The schema bundle is idempotent at the DDL level — each file uses `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS`, so re-applying is safe but won't migrate column shapes. Schema evolution lands as new files in the bundle; the package's `CHANGELOG.md` records the version each new migration shipped in. ## Local development without a database [#local-development-without-a-database] If you don't want to run any migrations during development, skip the CLI entirely and set: ```bash HARNESS_MOCK_MODE=true ``` The runtime wires `MemoryAdapter` + `MemorySaver` automatically. See [environment variables](/docs/env-vars) for the full list of runtime-read env vars. ## Project layout — after the wizard [#project-layout--after-the-wizard] The [baseline](/docs/project-layout#a-layout-that-works) plus the files `pleach init` writes. This is what a Next.js App Router project looks like one command after `npx pleach init` on the chatbot template with plugin stub and schema scaffold: ``` my-app/ app/ api/ chat/route.ts # wizard-written — createPleachRoute({ provider, plugins }) page.tsx # wizard-written — pleach.plugin.ts # wizard-written — createPlugin + appendPrompt + gatedPrompt supabase/ migrations/ 20260608120000_pleach_*.sql # wizard-copied from the schema bundle .env.local # ANTHROPIC_API_KEY (or your provider key) package.json tsconfig.json ``` What changes from the baseline: * **The route handler is one line.** `createPleachRoute({...})` is what you'd normally write by hand; the wizard writes it for you. Wire your own [`HarnessPlugin`](/docs/plugin-contract) set into the `plugins:` array as you grow. * **`pleach.plugin.ts` is the starting point**, not the final shape. The stub shows three hooks — `prompts`, `runtimePrompts`, and the plugin-construction call. The [plugin contract](/docs/plugin-contract) covers the full set (`contributeTools`, `contributeSafetyPolicies`, etc.); replace the stub when you outgrow it. * **`supabase/migrations/` holds the schema bundle.** Filenames are timestamped so Supabase's lexicographic migration order matches the bundle order. Without `--timestamped`, the files keep their bundle names — fine if you're applying them by hand but loses ordering when fed through `supabase db push`. ## Sibling-SKU binaries [#sibling-sku-binaries] Sibling SKUs (`@pleach/compliance@0.1.0`, `@pleach/eval@0.1.0`, `@pleach/gateway@0.1.0`, `@pleach/replay@0.1.0`, `@pleach/mcp@0.1.0`, `@pleach/coding-agent@0.1.0`) are shipping today. The CLI convention is one bin per package under its own name in the `@pleach/` scope — `@pleach/compliance` exposes `pleach-compliance`, `@pleach/eval` exposes `pleach-eval`, and so on. The `pleach` bin in core is reserved for substrate-level operations. Per-sibling bins land alongside each SKU's first useful command surface; check the package's npm page for the current bin inventory. ## Where to go next [#where-to-go-next] --- # Cloud-routed agent (/docs/cloud-routed-agent) A cloud-routed agent is the shape an enterprise reaches for when direct provider API keys are forbidden by policy and inference has to land inside the customer's VPC. The model family is the same as the direct-API shape — `anthropic`, `openai`, `google` — but the transport is AWS Bedrock, Azure OpenAI Service, or Google Vertex AI. IAM federates the credential; region pinning is enforced by the cloud, not the runtime. **Transport packages not yet published.** `@pleach/transport-bedrock`, `@pleach/transport-azure-openai`, and `@pleach/transport-vertex` are marked `private` in the monorepo and are not on npm yet — the `npm install` / import lines below will not resolve today. This page documents the planned integration shape; the transport packages publish in a later cut. This page walks the four pieces a security architect asks about: the transport-vs-family separation, IAM-federated credential resolution, region constraints, and the cost row that ties the cloud invoice back to the runtime. **Related shapes.** [Region-pinned agent](/docs/region-pinned-agent) if the same buyer also locks family per call class and runs a parity suite. [Multi-tenant SaaS agent](/docs/multi-tenant-saas-agent) if one runtime serves many customers, each reaching their own cloud account. [Regulated-domain agent](/docs/regulated-domain-agent) if the regulator drives the constraint and the cloud is the substrate that makes the deployment reachable. ## What you're building [#what-youre-building] A turn loop whose provider boundary is the cloud, not the lab. The shape is the same regardless of which cloud the buyer landed on: * Family is the model's identity (`anthropic`, `openai`, `google`); transport is the route to it (`bedrock`, `azure-openai`, `vertex`). * The credential is an IAM role, a managed identity, or a workload-identity binding — never a long-lived API key. * The endpoint is a VPC endpoint, a PrivateLink, or a private service connect URL. The cloud enforces that the call doesn't leave the perimeter. * The cost row carries the cloud, the region, and the model — so the runtime ledger reconciles to the cloud invoice. ## Transport vs family [#transport-vs-family] The same family routes through different transports without forcing a different cascade decision. A session asking for `anthropic` can land on the direct API, Bedrock, or both via failover — the cascade rules don't change. ```typescript // lib/runtime.ts import { SessionRuntime, definePleachPlugin } from "@pleach/core"; export function buildCloudRoutedRuntime(req: AuthedRequest) { return new SessionRuntime({ provider: bedrockProvider, // transport storage: new SupabaseAdapter({ client: supabase }), checkpointer: new SupabaseSaver({ client: supabase }), plugins: [definePleachPlugin("cleared-tools", { tools: clearedTools })], permittedFamilies: new Set(["anthropic"]), // family lock // Transport ("bedrock") and region ("us-east-1") are pinned at the // provider — `bedrockProvider` above IS the transport, constructed // against the cleared AWS region — not via a session-config field. tenantId: req.tenantId, }); } ``` Why the separation matters under a contract clause: a buyer who clears `anthropic` through Bedrock for `us-east-1` hasn't cleared the same family through Azure OpenAI for `westeurope`. The transport pin is the structural piece that keeps the cascade inside the cleared substrate. ## IAM-federated credentials [#iam-federated-credentials] The credential resolver is per-request — STS for AWS, managed identity for Azure, workload identity for Google. The runtime doesn't hold a long-lived key; the cloud's identity layer hands out a short-lived token that the transport adapter refreshes. ```typescript // lib/providers/bedrock.ts import { createBedrockProvider } from "@pleach/transport-bedrock"; import { fromContainerMetadata } from "@aws-sdk/credential-providers"; export const bedrockProvider = createBedrockProvider({ region: "us-east-1", credentials: await fromContainerMetadata()(), defaultModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", }); ``` The audit row records the role ARN (or the managed-identity principal, or the workload-identity binding) — not the short-lived token. A regulator asking "what identity made this call" reads one column; the credential itself is never persisted. ## What today ships [#what-today-ships] Today, `@pleach/core` ships the family-strict cascade, `permittedFamilies`, the deterministic replay path, and the audit row that records family, model, and region. Bring-your- own-provider via the `AgentProvider` contract is the supported extension point: a host running a Bedrock, Azure OpenAI, or Vertex endpoint implements the contract directly and threads the cascade. For the direct-API path (provider keys held by the runtime), `AnthropicSdkProvider` and `AiSdkProvider` ship in core. ## Roadmap [#roadmap] Roadmap pieces, in expected order: * **`@pleach/transport-bedrock`.** A first-party Bedrock transport with STS credential resolution, region pinning, and Bedrock-specific cost-event emission. Until it lands, hosts implement `AgentProvider` against the Bedrock Runtime API directly. * **`@pleach/transport-azure-openai`.** A first-party Azure OpenAI transport with managed-identity credential resolution, deployment-name routing, and Azure-specific cost-event emission. * **`@pleach/transport-vertex`.** A first-party Vertex AI transport with workload-identity credential resolution, region pinning, and Vertex-specific cost-event emission. * **Cloud-attestation helpers.** Per-cloud signed manifests that route audit rows into the cloud's native security surface — AWS Security Hub, Azure Sentinel, GCP Chronicle — so the cloud's existing compliance review absorbs the runtime's audit ledger. * **VPC endpoint discovery.** A configuration map at v1; a service-discovery integration that resolves PrivateLink endpoints from VPC metadata later. No dates. Track the upstream package READMEs and CHANGELOG for landing notices. ## Region as a hard constraint [#region-as-a-hard-constraint] Region is enforced by the cloud at the endpoint URL. A request to a `us-east-1` Bedrock endpoint can't land on a `us-west-2` model — the cloud rejects it before the runtime sees the response. The runtime records the region in the audit row, so an out-of-region attempt is visible in the ledger even when the cloud has already rejected it. A buyer with EU residency policy pins the runtime to a `eu-central-1` transport and rejects any session whose context asks for a different region. The check is a one-line precondition on `buildCloudRoutedRuntime`; the cloud is the enforcement authority. ## Cost reconciliation to the cloud invoice [#cost-reconciliation-to-the-cloud-invoice] The audit row carries the cloud, the region, the model, and the token count — the same columns the cloud's billing detail breaks out. Reconciliation is one join, not a parallel cost pipeline. ```sql select family, model_id, region, count(*) as calls, sum(input_tokens) as input_tokens, sum(output_tokens) as output_tokens from harness_auditable_calls where transport = 'bedrock' and created_at >= date_trunc('month', now()) group by family, model_id, region; ``` The cloud invoice rolls up the same dimensions. A finance reviewer reading both surfaces sees the same numbers. ## Where to go next [#where-to-go-next] --- # Coding agent (/docs/coding-agent) A patient gardener — reads the bed, makes one change, checkpoints, moves on. A coding agent is the use case where [checkpointing](/docs/checkpointing) and [replay](/docs/eval-and-replay) earn their keep. The agent makes destructive changes — file writes, shell commands, package installs. When something goes wrong, you don't want to read logs; you want to fork the session from the last checkpoint and replay with one variable changed. This page walks the sandboxed tool surface, the per-edit checkpoint pattern, and the replay-as-debugger workflow. **Related shapes.** [Regulated-domain agent](/docs/regulated-domain-agent) if generated code touches PHI/PII paths or ships under HIPAA / SOC 2. [Research agent](/docs/research-agent) for the subagent fanout pattern many coding agents reach for. [Multi-tenant SaaS agent](/docs/multi-tenant-saas-agent) if one runtime serves many developer workspaces. ## `@pleach/coding-agent/runtime` contract [#pleachcoding-agentruntime-contract] `@pleach/coding-agent` ships a typed `CodingAgentRuntime` contract on the `/runtime` subpath today. All three async methods have real bodies: `start()` composes the underlying `SessionRuntime` from the configured strategy, `stop()` drains it via `destroy()` and dispatches sandbox-provider teardown, and `executeStep()` drives one turn through `SessionRuntime.executeMessage` and projects the trailing turn state into a `CompositeToolResult`. The body throws `PACK_270_D3_EXECUTE_STEP_NOT_STARTED_MESSAGE` if called before `start()` — no auto-start, by design. The locked decisions below shape how the runtime interlocks with `@pleach/core` and `@pleach/sandbox` from here on. ```typescript import { createCodingAgentRuntime, SANDBOX_SHAPE_SENTINEL, type CodingAgentRuntime, type CodingAgentRuntimeConfig, } from "@pleach/coding-agent/runtime"; ``` | Method | Returns | Status | | ---------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `start(input)` | `Promise` | Live. Composes the underlying `SessionRuntime` via the configured strategy, captures the closure handle the `stop()` body drains, and returns a `{ sessionId, sandboxReady, startedAtEpochMs }` envelope. | | `stop(input)` | `Promise` | Live. Drains the in-flight `SessionRuntime` via `destroy()` and duck-type-dispatches an optional `dispose` / `destroy` / `shutdown` on the sandbox provider. Idempotent — `stop()` before `start()` or twice in a row resolves cleanly. | | `executeStep(input)` | `Promise` | Live. Drives one turn through `SessionRuntime.executeMessage(sessionId, content)` with warm-pool semantics, drains to terminal, projects the trailing turn state into `CompositeToolResult`. Throws `PACK_270_D3_EXECUTE_STEP_NOT_STARTED_MESSAGE` if called before `start()`. | | `getContextSnapshot()` | `{ entries: []; totalBytes: 0 }` (frozen) | Returns synchronously — the first-slice convention. | | `readToolEventLog()` | `readonly CodingAgentToolEvent[]` | Live. Returns the raw `tool.started` / `tool.completed` / `tool.failed` events the most-recent `executeStep` emitted, verbatim and in emission order — distinct from the `CompositeToolResult.tools[]` projection. `[]` before the first step. For observability/test harnesses asserting what the runtime emitted. | Pin the version exactly — `0.1.0` is the first cut. ```bash npm install @pleach/coding-agent ``` Pin `0.1.0` exactly in `dependencies` — `^0.1.0` will not pick up later 0.x releases. ### Locked decisions [#locked-decisions] Six contract decisions shape how `@pleach/coding-agent` interlocks with `@pleach/core` and `@pleach/sandbox` from here forward. * `@pleach/sandbox` is a peer dep at `^0.1.0`, not a hard dep. * No plan-generation re-export from the coding-agent surface. * The `_sandbox_shape` sentinel and `CompositeToolResult` live in `@pleach/coding-agent`, not `@pleach/core`. * No `maxSynthesizePerTurn` field on the runtime contract — config-only, consumed by `TurnSynthesizeCounter` at boot. * Guest mode deferred — coding-agent users authenticated at v1.x. * Direction lock — `@pleach/coding-agent` consumes `@pleach/core` + `@pleach/sandbox`; `@pleach/core` must not import any coding-agent surface. The direction lock is the load-bearing one. `@pleach/core` stays sandbox-agnostic and coding-agent-agnostic by construction, so a host that doesn't want either dependency tree doesn't pay the weight. ### When to use the package vs. the inline pattern below [#when-to-use-the-package-vs-the-inline-pattern-below] The rest of this page documents the **inline pattern** — wire `SessionRuntime` directly, register sandbox tools, manage checkpoints yourself. That's the long-form path: useful when you want full control over tool registration, sandbox provisioning, and checkpoint cadence. `createCodingAgentRuntime()` composes the same surface — `start()` + `stop()` + `executeStep()` — without the per-host wiring; pick that when the standard composition fits. ## What you're building [#what-youre-building] An agent that, given a task, can read repository files, write new files, and execute shell commands in an isolated workspace. The workspace is per-session; the [audit trail](/docs/audit-ledger) is per-turn; the checkpoint is per significant edit. The agent doesn't run on your laptop. It runs in a sandbox you control — a Docker container, a Firecracker microVM, an E2B or Modal sandbox, your choice. Pleach's role is the runtime + ledger + checkpointer around it. ## Sandbox tool surface [#sandbox-tool-surface] Four [tools](/docs/tools), each Zod-validated, each scoped to the workspace root. The runtime serializes the input schema into the prompt, so the model can't pass a path it wasn't told about. {/* TODO: ctx.runtime.checkpoint not on ToolContext today; sandbox surface lives on `@pleach/sandbox` provider, not ctx. The `ToolContext` shipped in `@pleach/core@0.1.0` carries only `toolCallId` + `signal?` (per packages/core/dist/tools/defineTool.d.ts). The host wires a sandbox provider externally and closes over it when constructing the tools; the per-edit checkpoint pattern routes through `runtime.checkpoint()` at the SessionRuntime layer, not from inside the handler. Code block below shows the canonical `defineTool` field vocabulary (inputSchema / outputSchema / execute); host-supplied sandbox + checkpoint hooks are stubbed until the surface is documented. */} ```typescript // lib/tools/codingTools.ts import { defineTool } from "@pleach/core"; import { z } from "zod"; // Host-supplied sandbox handle. `@pleach/sandbox` is a sibling SKU // (peer-dep at ^0.1.0) — the runtime stays sandbox-agnostic so the // host wires the provider and closes over it here. declare const sandbox: { readFile(path: string): Promise<{ contents: string; bytes: number }>; writeFile(path: string, contents: string): Promise<{ bytesWritten: number }>; exec(cmd: string, opts: { timeoutMs: number }): Promise<{ stdout: string; stderr: string; exitCode: number }>; list(path: string): Promise>; }; const PathInWorkspace = z.string().regex(/^[^/].*$/, "relative paths only"); export const readFile = defineTool({ name: "read_file", description: "Read a UTF-8 file from the workspace. Returns the file contents.", inputSchema: z.object({ path: PathInWorkspace }), outputSchema: z.object({ contents: z.string(), bytes: z.number().int() }), async execute({ path }, _ctx) { return await sandbox.readFile(path); }, }); export const writeFile = defineTool({ name: "write_file", description: "Write a UTF-8 file to the workspace. Overwrites if present.", inputSchema: z.object({ path: PathInWorkspace, contents: z.string(), }), outputSchema: z.object({ bytesWritten: z.number().int() }), async execute({ path, contents }, _ctx) { // The per-edit checkpoint fires from the SessionRuntime layer (see // "The checkpoint pattern" below) — `ToolContext` does not expose // `ctx.runtime.checkpoint` today. return await sandbox.writeFile(path, contents); }, }); export const runShell = defineTool({ name: "run_shell", description: "Run a shell command in the workspace. Returns stdout, stderr, and exit code.", inputSchema: z.object({ command: z.string().min(1), timeoutMs: z.number().int().max(60_000).default(15_000), }), outputSchema: z.object({ stdout: z.string(), stderr: z.string(), exitCode: z.number().int(), }), async execute({ command, timeoutMs }, _ctx) { return await sandbox.exec(command, { timeoutMs }); }, }); export const listDir = defineTool({ name: "list_dir", description: "List entries in a workspace directory. Returns names and types.", inputSchema: z.object({ path: PathInWorkspace }), outputSchema: z.array(z.object({ name: z.string(), type: z.enum(["file", "dir", "symlink"]), })), async execute({ path }, _ctx) { return await sandbox.list(path); }, }); ``` `write_file` and `run_shell` are the destructive surfaces — the per-edit checkpoint that brackets them is wired at the SessionRuntime layer (next section), not from inside the tool handler. If the action breaks the workspace, the checkpoint is the rollback target. ## Runtime construction [#runtime-construction] The sandbox is the per-invocation context. Each session gets a fresh workspace; the [storage](/docs/storage) adapter persists the session history, and the checkpointer persists workspace snapshots. ```typescript // lib/runtime.ts import { SessionRuntime, AiSdkProvider } from "@pleach/core"; import { SupabaseAdapter } from "@pleach/core/sessions"; import { SupabaseSaver } from "@pleach/core/checkpointing"; import { definePleachPlugin } from "@pleach/core/plugins"; import { createOpenRouter } from "@openrouter/ai-sdk-provider"; const openrouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY! }); export async function buildCodingRuntime(sessionId: string) { const sandbox = await openSandbox({ sessionId }); // Tools register through a `contributeTools` plugin — there is no // `tools` field on the SessionRuntime config. The plugin closes over // the per-session `sandbox` the handlers execute against. const sandboxToolsPlugin = definePleachPlugin("coding-agent-sandbox-tools", { _raw: { version: "1.0.0", contributeTools: () => [readFile, writeFile, runShell, listDir], }, }); return new SessionRuntime({ provider: new AiSdkProvider({ model: openrouter("anthropic/claude-sonnet-4-5"), maxSteps: 5, }), storage: new SupabaseAdapter({ client: supabase }), checkpointer: new SupabaseSaver({ client: supabase }), plugins: [sandboxToolsPlugin], }); } ``` `@pleach/coding-agent/runtime` wraps this wiring in one call: `createCodingAgentRuntime({ tools, providerStream, model })` registers the tools as a `contributeTools` plugin and installs the provider stream's tool-executor relay for you. Reach for the raw construction above only when you want to own the runtime config directly. Shell-command guardrails register as a **safety policy**, not as a `SessionRuntime` field. A plugin contributes them through `contributeSafetyPolicies()`, each built with `defineSafetyPolicy({ name, appliesTo: "pre-dispatch", check })` — capability-subtracting, registered by the plugin, enabled by the operator. Policies are advisory by default (v1.1 routes enforcement through `log_only`); seam-level blocking is forthcoming. See [Safety](/docs/safety). ## The checkpoint pattern [#the-checkpoint-pattern] {/* TODO: `ctx.runtime.checkpoint(label)` is not on ToolContext in @pleach/core@0.1.0. The ToolContext surface today is `{ toolCallId, signal? }`. Per-edit checkpoints land at the SessionRuntime layer — the host code surrounding `executeMessage` invokes the checkpointer between tool calls. Section preserved to describe the intended pattern; concrete signature pending. */} The intended pattern is: each significant edit gets a snapshot row keyed by `sessionId`, `turnId`, and a label. The checkpointer adapter decides what gets snapshotted — for a coding agent, that's the workspace filesystem + the session message history. The concrete API for surfacing this from inside the tool path is still being shaped; today, hosts wire checkpoints from the layer that drives `runtime.executeMessage`, not from inside the handler. A typical agent run on a single task looks like: ```text turn-start tool-call list_dir tool-call read_file src/lib/util.ts checkpoint pre-write:src/lib/util.ts tool-call write_file src/lib/util.ts checkpoint pre-shell:pnpm test tool-call run_shell pnpm test exit=1 tool-call read_file test/util.test.ts checkpoint pre-write:test/util.test.ts tool-call write_file test/util.test.ts checkpoint pre-shell:pnpm test tool-call run_shell pnpm test exit=0 turn-complete ``` Eight checkpoints in one turn. Each one is a viable fork point. ## Replay as debugger [#replay-as-debugger] A user reports: "the agent broke my build at commit X." You don't read logs; you replay the turn and look at the tool sequence. ```typescript import { createReplayRuntime } from "@pleach/replay"; const replayRuntime = createReplayRuntime({ tenantId: "acme-corp", sessionRuntime: runtime, }); const replay = await replayRuntime.replayTurn({ chatId: sessionId, tenantId: "acme-corp", messageId: brokenTurnId, }); const { toolCalls } = replay.state as { toolCalls: Array<{ name: string; input: unknown; output: unknown }> }; for (const call of toolCalls) { console.log(call.name, call.input, call.output); } ``` `replay` walks the turn [deterministically](/docs/determinism): same provider seed, same tool returns (replayed from the ledger), same final text. The only thing that varies is your code under the tool handlers. Change the safety policy, change the prompt, change the model — and rerun. The diff is the answer. See [Eval and replay](/docs/eval-and-replay) for the recording modes. ## Fork from a checkpoint [#fork-from-a-checkpoint] When the user wants to *recover* the workspace, not just debug it: fork from the last good checkpoint and continue the session with a corrective message. Forking runs through the `TimeTravelAPI` facet — `runtime.timeTravel.api.fork(sourceSessionId, checkpointId, newSessionId)`. You supply the new session id; the call clones the checkpoint state onto it. ```typescript const newSessionId = `${brokenSessionId}-recover`; await runtime.timeTravel.api.fork( brokenSessionId, lastGoodCheckpointId, newSessionId, ); for await (const _event of runtime.executeMessage( newSessionId, "The previous edit broke the test suite. Revert that edit and try a different approach.", )) { // drain to terminal } ``` The new session inherits the workspace snapshot and the message history up to the checkpoint. It carries `forkedFrom` lineage metadata (source session id + checkpoint id) so the relationship is queryable. ## Project layout [#project-layout] Three adds on top of the [baseline](/docs/project-layout#a-layout-that-works): a sandbox-scoped tool surface, a *durable* [`SupabaseSaver`](/docs/checkpointing) for the per-edit checkpoints, and a `debug/` entry point so [replay as debugger](#replay-as-debugger) is one command instead of a one-off script. ``` my-app/ src/ pleach/ runtime.ts # SessionRuntime + SupabaseAdapter + SupabaseSaver (durable, mandatory) tools/ sandbox/ # everything here runs inside the sandbox read-file.ts # defineTool write-file.ts # defineTool — destructive; triggers checkpoint run-command.ts # defineTool — destructive; triggers checkpoint checkpoints/ policy.ts # "snapshot before each destructive tool" rule debug/ fork-and-replay.ts # timeTravel.api.fork + replayTurn app/ api/agents/[id]/route.ts ``` What changes from the baseline: * **`tools/sandbox/` is the security boundary.** Everything under it executes against the sandbox provider — not the host filesystem, not host shell. Putting non-sandboxed tools next to these would erase the boundary; keep them in `tools/host/` if you have them. * **Durable checkpointing is mandatory, not optional.** The in-memory default loses every per-edit snapshot at restart — which deletes the entire [fork-from-checkpoint](#fork-from-a-checkpoint) workflow. Use `SupabaseSaver` (or any [Checkpointing](/docs/checkpointing) adapter that persists). * **`checkpoints/policy.ts` is a real file.** The "snapshot before each destructive tool" rule lives in code, not in a docstring. When the destructive-tool set grows, the policy file is the one place it gets added. * **`debug/fork-and-replay.ts` is product code.** Replay-as- debugger isn't a script you write when something breaks; it's a function your support tooling already imports. When the [sandbox-shape decisions](#locked-decisions) shift in a later version, this file is what gets updated. ## Where to go next [#where-to-go-next] --- # Comparison (/docs/comparison) `@pleach/core` differs from the AI SDK, LangChain, LlamaIndex, and Mastra by treating the `AuditableCall` ledger and the four-stage lattice as structural invariants, not opt-in middleware. It's a substrate, not a product, so it overlaps differently with the TS LLM libraries above and with agent harnesses like Claude Code, Goose, OpenHands, AutoGen, and CrewAI. This page exists so you can decide quickly whether `@pleach/core` is the right tool for the job — and when it isn't. The shape of the difference is the same shape the name names: the AI SDK and LangChain hand you a pot and a few stakes — enough to keep one branch upright. Pleach hands you the lattice. The matrix below is just that difference, axis by axis. ## TL;DR [#tldr] Three axes decide whether to reach for Pleach instead of (or alongside) the alternatives: | Axis | `@pleach/core` | AI SDK | LangChain | LlamaIndex | Mastra | | ----------------------------------------------------------------------------------------------------------------------------- | -------------- | ------------------- | ----------------------------- | -------------- | ------------------ | | **Per-call audit ledger** (`AuditableCall` row keyed by `turnId`, `tenant_id` stamped, joinable to billing in one `GROUP BY`) | ✅ structural | ❌ | partial via `callbacks` | ❌ | ❌ | | **Replay determinism + write-side hash chain** | ✅ structural | ❌ | partial (LangSmith, external) | partial (eval) | partial (workflow) | | **Family + transport lock + lattice invariants** (no silent cross-family fallback; four-stage lattice lint-enforced) | ✅ structural | ❌ provider-agnostic | ❌ | ❌ | partial (lattice) | Below: the row-by-row matrix. If you don't need any of the three axes above, the AI SDK is the cleaner pick. If you need all three, you're on the right page. ## Capability matrix [#capability-matrix] The header column is framed as "capability," not "who wins." Other libraries are excellent at their own jobs; this table just records which capabilities each one ships as a first-class concept. Rows are grouped into four themes — observability & accounting, execution shape, routing & replay, storage & contracts — which is the four-axis value-prop story for the substrate. | Capability | `@pleach/core` | Vercel AI SDK | LangChain | LlamaIndex | Mastra | | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | --------------------------------- | --------------------------------- | ------------------------------- | | **Observability & accounting** | | | | | | | Per-call audit row | `AuditableCall` ledger | none | partial via `callbacks` | none | none | | Append-only event log (typed `StreamEvent`) | yes | partial (`UIMessage` stream) | partial via `callbacks` | partial (instrumentation) | yes (workflow events) | | Subagent spawn + depth rollup to parent `turnId` | `SpawnTreeState` | none | partial (LangGraph subgraphs) | partial (workflows) | partial (agent network) | | Per-call cost allocation by `turnId` | yes (`tokenUsage` on row) | manual | manual | manual | partial | | Typed audit records (discriminated `payload.kind`) | yes (TS narrowing on `payload.kind`) | opaque JSON | per-vendor shapes via `callbacks` | per-module shapes | partial (typed workflow events) | | Tamper-evident hash chain (write-side) | yes (`prev_hash` + `row_hash` at schema level) | none | none | none | none | | PII scrubber gate at write time | yes (registered `Scrubber` instances; CI gate `audit:c8-event-type-allowlist-coverage`) | consumer-wired | consumer-wired | consumer-wired | consumer-wired | | Built-in OTel span set with auto parent-threading | yes (`session.turn`, `llm.invocation`, `graph.stage`, `tool.execution`) | partial (per-call spans, no parent threading) | partial (per-operation spans) | partial | partial | | Multi-tenant facet stamping `tenant_id` on every row + span + outbound HTTP | yes (`runtime.tenant` facet, `withTenantHeader`) | consumer responsibility | consumer responsibility | consumer responsibility | consumer responsibility | | **Execution shape** | | | | | | | 4-stage lattice (lint-enforced edges) | yes | none | none | none | partial | | Singleton synthesize seam | yes | none | none | none | none | | Reactive channels (LangGraph-shaped) | yes | none | partial (LangGraph) | none | partial (workflow channels) | | Plugin contract with lattice invariants | yes | none | partial (chains) | partial (modules) | yes | | Human-in-the-loop interrupts | yes | partial (tool approval) | yes (LangGraph `interrupt()`) | partial (workflows) | yes (suspend/resume) | | **Routing & replay** | | | | | | | Family + transport lock (session-scoped) | yes | provider-agnostic, no lock | none | none | none | | Replay-deterministic streaming | yes | none | none | none | none | | Time-travel checkpoints | yes | none | partial (LangGraph checkpointers) | none | partial (suspend/resume) | | Eval / replay diff engine | DIY against ledger today; `@pleach/eval@0.1.0` ships the contract + bootstrap/Welch t-test scoring; `@pleach/replay@0.1.0` ships `replayTurn` + chain verifier | none | partial (LangSmith, external) | partial (eval module) | none | | **Storage & contracts** | | | | | | | BYO-DB storage adapters | Memory / IndexedDB / Supabase | LocalStorage only | none for runtime | vector stores | yes | | Offline-first sync (IndexedDB ↔ server) | yes | none | none | none | none | | Tamper-evident hash chain | `TamperEvidence` plug-point + writer-side stamping in soak; verifier shipping in `@pleach/core/eventLog` (`verifyChainForChat`, `generateProof`). `@pleach/compliance@0.1.0` adds scrubbers + attestation. | none | none | none | none | | Language-agnostic wire contract | yes (Go reference impl) | none (TS-only) | none (separate JS / Py codebases) | none (separate JS / Py codebases) | none (TS-only) | Source notes for the row data live next to the comparison table in the `@pleach/core` README; this page is a mirror so the docs site stays a single discoverable surface. A few cells deserve gloss: * **"Partial via `callbacks`"** for LangChain means a `BaseCallbackHandler` can observe events, but the events don't share a row identity — there's no `(sessionId, turnId, stageId, seqWithinTurn)` tuple to join one callback's observation to the call that caused it. * **"Manual"** on cost allocation means you can compute it, but you write the aggregation pipeline yourself; with `@pleach/core`, the [`AuditableCall` row](/docs/auditable-call-row) ships `tokenUsage` already rolled up to `turnId`. * **"Partial (`UIMessage` stream)"** on the event log for the AI SDK means the streaming surface is typed and parseable, but it's the rendered-message stream — not a durable audit-keyed log you can `GROUP BY` six months later. * **"Language-agnostic wire contract"** means the substrate ships a contract that pins HTTP+SSE shapes, fingerprint canonicalization, and `AuditableCall` row layout — independent implementations (TypeScript reference + Go) must produce the same bytes on the wire. Other libraries publish per-language SDKs that share a name but not a contract; a recorded turn from one can't replay against the other. ## Worked example: adding an agent to a multi-tenant SaaS [#worked-example-adding-an-agent-to-a-multi-tenant-saas] The matrix above is axis-by-axis. Here is the same difference as one narrative. You already run a SaaS where every customer owns their data — per-tenant rows, or a schema or database each. You want an in-app agent that answers questions and takes actions over *that customer's* data. Same feature, five runtimes — what you build yourself versus what the runtime hands you: | The job | `@pleach/core` | AI SDK | LangChain | LlamaIndex | Mastra | | -------------------------------------------------- | -------------------------------------------------------- | -------------------------------- | --------------------------------- | ------------------ | ---------------------------- | | Define the "query this customer's DB" tool | `defineTool` + Zod | `tool()` + Zod | `@tool` / `DynamicStructuredTool` | query engine + Zod | `createTool` + Zod | | Keep tenant A's agent out of tenant B's data | `runtime.tenant` facet — RLS-aware, structural | closure you thread by hand | `config` / closure by hand | per-index, manual | `runtimeContext` per request | | Bill each customer for their agent spend | `AuditableCall` row, `tenant_id`-stamped, one `GROUP BY` | read `usage`, attribute yourself | callbacks, manual | manual | partial | | Show a customer an audit of what the agent did | hash-chained event log, queryable per tenant | build it | external (LangSmith) | none | partial | | Stream the answer into your UI | `@pleach/react` hooks + canonical SSE routes | first-class (`useChat`) | stream events, partial | partial | yes | | Let an enterprise tenant bring their own model key | transport + family locked per session | swap provider, manual | manual per chain | manual | per-workflow | | Debug one customer's broken session after the fact | event-granular replay (`@pleach/replay`) | re-run and hope | logs only | none | partial | The first two rows are close to a wash — everyone writes a tool, everyone can thread a tenant id through a closure. The split is everything below: per-tenant **cost**, **audit**, and **replay** are what a SaaS discovers it needs *after* the agent ships, and where "manual" turns into next quarter's backlog. If you'll ship the DB tool and stop there, the AI SDK or Mastra gets you there with less to learn. If the customer-facing billing, audit, and replay surface is on the roadmap, that's the substrate's territory — and the rest of this page is the axis-by-axis version of why. ## Where Pleach differs structurally [#where-pleach-differs-structurally] Most TS LLM frameworks treat audit, multi-tenancy, OTel, and redaction as *consumer responsibilities* — surfaces you wire on top of the runtime. The runtime stays small; the integration work moves to you. `@pleach/core` treats those four as *substrate-level invariants*. Audit rows have a typed shape. Event-log rows carry `prev_hash` + `row_hash`. OTel spans get auto parent-threading and an auto `tenant_id` attribute. PII scrubbers gate every write before persistence. The mechanism that keeps those properties from drifting is a set of CI audit gates that fail the build when coverage regresses: `audit:facet-coverage`, `audit:c8-event-type-allowlist-coverage`, `audit:tenant-scoping`, `audit:otel-noop-soak`. A feature can't ship a new event type, a new facet, or a new tool surface without the corresponding row, span, scrubber slot, and tenant stamp showing up in the right places. The trade-off is real: more substrate machinery to learn upfront, and a stricter contract for what counts as "a feature." The return is fewer ways for a load-bearing property to regress silently after the feature ships. If the four invariants above don't matter to your product, the upfront cost won't pay back — [see below](#when-to-reach-for-something-else) for what to reach for instead. ## When to reach for something else [#when-to-reach-for-something-else] `@pleach/core` is a substrate, not a chat shell. It pays its overhead in two places: a runtime contract you have to model your agent inside, and a lattice that constrains where calls live. That overhead is the price of the audit ledger, the family lock, and the replay determinism. If you don't need those, the overhead isn't free. A single-shot RAG bot — no persistence, no multi-tenancy, no audit obligation — gets little out of the substrate compared to a 30-line AI SDK script. The lattice and ledger pay off when you're shipping a multi-tenant production agent with replay, OTel, and compliance gates. Below the second category, the AI SDK is the right floor. * **Want a chat shell with tools and nothing more?** The AI SDK's `streamText` / `generateText` is the right shape. Less to learn, fewer constraints, no ledger. The audit story you'd get is "the request id from the provider response and your own log line"; that's enough until finance asks for a per-axis monthly rollup — per end customer in a SaaS, or per employee, team, or cost-center under one Anthropic Workspace or OpenAI Project — at which point you're either re-keying logs or reaching for the `AuditableCall` row. * **Want chain abstractions and a large pre-built component catalog?** LangChain. The ecosystem breadth is the value; the `Runnable` contract is mature. * **Want a retrieval-first system with indexes as the primary primitive?** LlamaIndex. RAG-shaped problems map cleanly. * **Want a workflow runtime with a hosted control plane?** Mastra. The hosted surface is the differentiator. ## When `@pleach/core` is the right call [#when-pleachcore-is-the-right-call] Three signals that move the runtime from overkill to undersell: 1. **Audit obligations.** You need to point a regulator, a customer, or your future self at the **tool invocations and subagent spawns** a session made, attributed to the turn that caused them. The lattice gives you the skeleton for free — anchor-plan, synthesize, post-turn fire every turn by construction. The ledger gives you the dynamic part: which tool ran with what input, which subagent spawned, how deep. 2. **Cost attribution at the turn level.** Synthesis shape is fixed by the lattice; synthesis *size* isn't, and tool-call fan-out is fully variable. A turn is the unit that rolls those variable costs up. The `AuditableCall` row carries `turnId`, `toolName`, and `subagentDepth` so the math is one `GROUP BY`, not a regex pass over logs. 3. **Replay determinism as a hard requirement.** Sync-only stream observers, deterministic channel reducers, and `Math.floor`-quantized prompt budgets exist so a recorded turn replays byte-identical against the same input. If "what would the agent have said?" is a question you need to answer six months from now, this is the property you want. The load-bearing test is strict-mode replay (shipping in `@pleach/replay@0.1.0`; `replayTurn`, `fromSnapshot`, `fork`, and `aggregateMultiTenant` bodies all land at the `0.1.0` cut): a recorded turn replayed against the same `@pleach/core` version + the same fingerprint inputs must yield the same `StreamEvent` sequence in the same order, or strict mode throws `ReplayDivergenceError` with the first diverging event named. If none of the three apply, the AI SDK is probably the right floor. ## Durable-execution platforms (Inngest, Trigger.dev, DBOS, Temporal) [#durable-execution-platforms-inngest-triggerdev-dbos-temporal] Inngest and its siblings are **not** competitors to `@pleach/core` — they occupy the slot one layer up. A durable-execution platform wraps the **request**: step memoization across crashes, retries, scheduling, cron, fan-out, a hosted run dashboard. `@pleach/core` owns the **LLM turn inside the request**: the typed `AuditableCall` row, family-lock, replay-deterministic streaming, subagent cost rollup. | Capability | `@pleach/core` | Inngest / Trigger.dev / DBOS | | ---------------------------------- | -------------------------------------- | ------------------------------------------ | | Category | LLM-turn runtime | durable-execution platform | | Step memoization across crashes | none — per-turn checkpoints only | yes (their core primitive) | | Cron / event-triggered execution | none | yes | | Concurrency, throttling, fan-out | none | yes | | Hosted run dashboard | none | yes | | Per-call audit row in YOUR schema | yes (`AuditableCall` in your Postgres) | run history is vendor-shaped | | Family-locked provider routing | yes | not provider-aware | | Replay-deterministic LLM stream | yes (`StreamEvent` byte-identical) | code-level memoization, not stream-level | | Subagent rollup to parent `turnId` | yes (`SpawnTreeState`) | manual | | Tamper-evident audit trail | yes (`prev_hash` + `row_hash`) | run history isn't cryptographically signed | | Multi-tenant `tenant_id` stamping | enforced by CI gates | your event metadata | The honest pattern is **use both**: wrap `runtime.executeMessage` inside `step.run`, get Inngest's operational trail AND Pleach's business trail. See [Pleach + Inngest](/docs/with-inngest) for the architecture and code shape. ### When you don't need the durable-execution platform [#when-you-dont-need-the-durable-execution-platform] * A request-scoped agent turn that completes inside one HTTP request, no external waits, no cron, no fan-out. Direct `runtime.executeMessage` from an API route is the right shape. ### When you don't need `@pleach/core` [#when-you-dont-need-pleachcore] * The AI SDK + Inngest combo covers single-tenant chatbots with tools, no audit obligation, no replay requirement. Pleach's overhead doesn't pay back at this size. ## Observability, cost, and eval tools (a read-side category) [#observability-cost-and-eval-tools-a-read-side-category] Helicone, Langfuse, LangSmith, LiteLLM, MLflow, Logfire, Braintrust, and OpenMeter are the tools a team actually shops against when it wants "audit, cost, and replay." They're worth a table of their own because the overlap with `@pleach/core` is real — and so is the boundary. These tools **read**: they ingest spans or proxy requests into an analytical store and give you dashboards, eval suites, and cost rollups. `@pleach/core` **writes**: it produces the typed `AuditableCall` row, in your own database, that those tools could read. The split below is the honest one — most of these are excellent at the read job, and several compose with Pleach rather than replace it. | Capability | `@pleach/core` | Helicone / Langfuse / LangSmith | LiteLLM | MLflow / Logfire | Braintrust | OpenMeter | | ------------------------------------ | ------------------------------------------- | -------------------------------------- | ---------------------------------- | -------------------------------- | ------------------- | ----------------------------- | | Category | write-side substrate | observability (proxy / spans) | gateway proxy | tracing | eval platform | usage metering | | Per-tenant cost allocation | row, `tenant_id`-stamped | yes (metadata tag) | yes (`customer_id`) | via span attrs | metadata | yes (its core job) | | Subagent spend rolled to parent turn | parent `turnId` on each `AuditableCall` row | reconstructed from trace tree | none | tree walk | grouping key | none | | One row per call **in your DB** | yes (`AuditableCall`) | their store (self-host = their schema) | yes (`SpendLogs` in your Postgres) | MLflow: your Postgres | their store | events, not call rows | | SQL-queryable, joinable to billing | yes | filter DSL / API | yes (spend rows) | MLflow yes / Logfire their cloud | proprietary | metering join, not call shape | | Tamper-evident hash chain | `prev_hash` + `row_hash` | none | none | none | none | none | | Deterministic replay of the turn | yes (byte-identical) | none (re-run) | none | none | re-generate + score | n/a | | PII redaction at write | `Scrubber` gate | hook (delegates to Presidio) | Presidio guardrail | masking hook | none first-class | n/a | | Sampling | none — every call | expected / recommended | n/a | configurable | n/a | n/a | Glosses where the cell is doing work: * **"Reconstructed from trace tree"** on subagent spend means the child tokens are captured as nested spans, but the parent's cost is summed at query time by walking the tree — not stored on the parent. This is the documented gap most observability tools share. * **"Their store"** vs **"your DB"** is the load-bearing axis. LiteLLM and MLflow are the two that write call/trace records into a Postgres you own; the rest land the data in a vendor-shaped analytical store, and "self-host" means *your infra* running *their schema*, not joinable rows in your application database. * **OpenMeter** is the billing destination, not the attribution source — it ingests usage events and syncs to Stripe, but it doesn't carry the agent run's structure. It pairs with the ledger; it doesn't replace it. The honest pattern, again, is **compose**: let `@pleach/core` write the row, point Langfuse or your OTel collector at the same events for dashboards, and meter to OpenMeter or Stripe off the `tenant_id`-stamped row. The substrate owns the write; the read tools stay good at reading. The [landscape page](/docs/landscape) ranks all twenty capabilities by how much of each is structural here versus available in the tools above. ## Agent-memory & knowledge-graph substrates (a read-side category) [#agent-memory--knowledge-graph-substrates-a-read-side-category] A second read-side category shares Pleach's *primitives* without sharing its object of record. Memory layers and knowledge-graph engines — Letta, Mem0, Zep — persist what an agent has learned so a later turn can read it back. The sharpest form of the shape is event-sourced: an append-only, content-addressed graph where each tool call and decision is an immutable node, and the agent recalls by querying the graph instead of embedding-searching its own scrollback. That form looks adjacent to `@pleach/core`, because the skeleton is the same — append-only rows, content hashes, a causal chain, fork and replay. The object of record is where they split. | Capability | Memory / knowledge-graph substrate | `@pleach/core` | | --------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------- | | Object of record | the agent's **knowledge and decisions** — files, entities, notes, rationale | one **provider call** — model, tokens, cost, family | | Read by | the **agent itself**, to shape its next step | **people** — billing, audit, compliance | | Read paradigm | graph traversal / Datalog over entities | SQL rows joinable to your billing schema | | Fields about the model call | none — no provider, model, token, or cost column | the whole row | | Which path is the product | the **read** — you query it to reason | the **write** — record every call, then read to account | The split in one line: a memory substrate is a **read substrate** — the graph exists so the agent can query it and decide. `@pleach/core` is a **write substrate** — the ledger exists so every call is recorded, and the reads serve the people who answer for the spend. Even where the two overlap — both can record a tool call — a memory graph stores the tool's *semantics* so the agent recalls causally; the ledger stores the call's *economics* so finance attributes it. A memory graph carries no provider, model, or cost column; the ledger was never the agent's recall surface. So they compose rather than compete. A memory graph can sit beside the [`AuditableCall` row](/docs/auditable-call-row), reading the same session — recording what the agent *learned* takes nothing from recording what it *called and cost*. Reach for a memory substrate when the agent's recall is the problem. Reach for `@pleach/core` when the account of what the agent spent is the problem. This is a different read-side from the observability cohort above: those tools read for a **human dashboard**; a memory substrate reads for the **agent's own next step**. Neither is the write-side ledger, and both can read the rows Pleach writes. ## Agent harnesses (a different category) [#agent-harnesses-a-different-category] Harnesses like Claude Code, Goose, OpenHands, AutoGen, and CrewAI aren't competitors to `@pleach/core` — they're the category one layer up. A harness ships a complete agent loop you launch and use; a substrate ships the primitives you'd build a harness on top of. The axis is opinionation vs. embeddability: a harness picks the UX, the tool surface, the loop shape, and the session model for you; a substrate gives you those decisions back. Many shipped harnesses are session/turn/tool-loop/synthesis internally — the same shape the 4-stage lattice formalizes. | Capability | `@pleach/core` | Claude Code | Goose | OpenHands | AutoGen | CrewAI | | ---------------------------------------------------------------------- | ----------------- | --------------------------- | --------------------------- | ----------------------- | -------------------------- | -------------------------- | | Category | runtime substrate | dev harness (CLI) | dev harness (CLI / desktop) | agentic dev environment | multi-agent framework | multi-agent framework | | Embeddable in your product | yes (npm dep) | no (end-user tool) | no (end-user tool) | partial (server mode) | yes (library) | yes (library) | | Per-call audit row keyed `(sessionId, turnId, stageId, seqWithinTurn)` | yes | no | no | no | no | no | | Family + transport lock | yes | provider-locked (Anthropic) | configurable, no lock | configurable, no lock | provider-agnostic, no lock | provider-agnostic, no lock | | Replay determinism | yes | no | no | no | no | no | | Multi-tenant cost attribution by `turnId` | yes | n/a (single-user) | n/a (single-user) | n/a (single-user) | manual | manual | | Time-travel checkpoints | yes | session resume | session resume | session resume | none | none | | Lattice-enforced stage edges | yes | implicit loop | implicit loop | implicit loop | freeform | role-graph | ### When to reach for a harness instead [#when-to-reach-for-a-harness-instead] * **You're the user, not the vendor.** You want to run an agent on your own machine to write code, refactor a repo, or drive a browser. Install Claude Code or Goose; you don't need a substrate. * **The harness's loop is the loop you want.** Off-the-shelf harnesses ship a planning / tool-loop / synthesis cycle that works well for the dev-tooling use case. If your product needs the same shape, the harness saves you the build. * **You're prototyping a multi-agent topology.** AutoGen and CrewAI give you role primitives and conversation patterns out of the box. Reach for them before you reach for a substrate. ### When you'd build on `@pleach/core` instead [#when-youd-build-on-pleachcore-instead] * **You're shipping a customer-facing agent product, not running one yourself.** Harnesses are end-user tools; a substrate is what you embed in a SaaS surface where customers each get their own session, their own audit trail, and their own invoice. * **The audit ledger is load-bearing.** Per-call rows joinable to a billing schema and to a compliance review are the kind of property a harness's session log can't substitute for — different shape, different durability guarantees. * **You're already past a harness.** Teams sometimes start on a harness, hit the per-tenant accounting wall or the replay wall, and discover the harness's session log isn't the row shape they need. `@pleach/core` is where that road leads. A note on positioning: `@pleach/core` itself is vibe-coded inside harnesses — they're how the runtime gets written. The runtime they're writing is the one a *product team* embeds. Different consumer, same surface. ## If you already buy direct from Anthropic or OpenAI [#if-you-already-buy-direct-from-anthropic-or-openai] Different shape from the libraries above. Pleach is not an alternative to an Anthropic Enterprise or OpenAI Enterprise contract — it composes underneath one. SSO/SAML, ZDR, Workspaces or Projects, the Admin / Usage API, dedicated capacity, prompt caching, snapshot pinning, evals, fine-tuning — those stay where they are. The runtime adds the three pieces the vendor contract does not cover: 1. **Per-axis rollup inside one Workspace / Project.** The Admin or Usage API reports the Workspace or Project total. Pleach's `AuditableCall` row carries an opaque `tenantId` you wire to whichever axis you bill, chargeback, or audit against — external customers in a multi-tenant SaaS, or employees / teams / cost-centers when the lab is used internally. Sums reconcile to the vendor total because Pleach records the same calls your workspace key is making. 2. **Hash-chained audit row in your own DB.** ZDR governs what the vendor stores. The `prev_hash` + `row_hash` chain on every `AuditableCall` row governs what *you* store and how you defend the chain to a downstream auditor — a customer's compliance team, an SEC inquiry, an EU AI Act review. 3. **Replay-determinism across snapshots.** A pinned snapshot doesn't make a turn replayable; a fingerprint match does. Drift across snapshots surfaces as a fingerprint-keyed diff in CI eval, not as a customer ticket. No new vendor, no new SOC 2 boundary, no new ZDR review. Pleach is `npm install @pleach/core` plus a Postgres table you already run. A note on cloud-mediated transports (Bedrock, Azure OpenAI, Vertex): same three walls, different BYOK substrate (IAM federation rather than API key) and different network posture (VPC, region pinning, residency). The ledger, fingerprint, and family-lock primitives already compose with any provider you wire through the SDK; cloud-mediated provider adapters land in `@pleach/gateway` v1.x. Two sibling SKUs reach the same Enterprise-contract reader through adjacent doors. [`@pleach/coding-agent`](/docs/coding-agent) is the sandboxed coding-agent surface for the internal dev-tools team that often sits inside the same contract footprint — same audit row, same `tenantId` axis, same chargeback query. The [language-agnostic contract](/docs/language-agnostic-contract) is the procurement-visible answer when Enterprise IT asks "is this TypeScript-only?" — the substrate is wire shapes, and a Go implementation already round-trips a shared corpus of recorded turns against the contract; the official `@pleach` Go runtime SKU is the next planned published implementation. ## Where to go next [#where-to-go-next] --- # @pleach/compliance (/docs/compliance) Pruning shears at the garden wall — what crosses the boundary, in what shape, with what scrub. `@pleach/compliance` is the regulated-environment add-on to `@pleach/core`. The first release ships four `Scrubber` implementations that gate event log writes, plus two CI audit gates that hosts inherit at adoption time. The package is aimed at deployments where the audit ledger is evidence — healthcare, finance, govtech — and where every `harness_event_log` row needs both a tenant scope and a redaction guarantee before it lands on disk. > **Beta.** While `@pleach/core` ships stable, `@pleach/compliance` ships > **beta** for the first release. The scrubbers, tenant-scoping, and CI > audit gates below are production-solid and battery-proven; the > **attestation / audit-report** surface is the beta part — envelopes are > unsigned by default unless a real key-store is wired, and the version > maps are Phase-A-empty (see [Attestation](/docs/attestation#beta)). A > stable `@pleach/compliance` attestation claim lands at the 1.0 cut. The canonical use case is alongside `@pleach/core`'s `EventLogWriter`: scrubbers run on the persistence boundary, the CI gates enforce that no code path writes an untenanted row, and the schema check guarantees `tenant_id NOT NULL` after migration. ## Install [#install] ```bash npm install @pleach/compliance ``` ```bash pnpm add @pleach/compliance ``` ```bash yarn add @pleach/compliance ``` ```bash bun add @pleach/compliance ``` ```typescript import { SsnUsScrubber, CreditCardScrubber, UsDriverLicenseScrubber, KeyedRegexScrubber, } from "@pleach/compliance"; ``` The package exports the four scrubber classes (instantiate with `new`), the `complianceProfilePlugin(profile)` factory that bundles them by named profile (`"hipaa"` / `"gdpr"` / `"pci-dss"` / `"soc2"`), the `createComplianceRuntime` wrapper for transparent interception, and the lower-level `scrubbersForProfile(profile)` helper for hand-rolled plugins. The published README is the source of truth for export names and constructor options — check it before pinning a version. ## The four bundled scrubbers [#the-four-bundled-scrubbers] Each implements the `Scrubber` contract from `@pleach/core/scrubbers`. The contract — `scrub(input, ctx) => ScrubResult` returning `redacted` and matched spans for audit reporting — is documented in [Scrubbers](/docs/scrubbers); this section covers what ships in the bundle. | Scrubber | Pattern | Redaction key | | ------------------------- | -------------------------------------------------------------------------------------------------------- | ----------------- | | `SsnUsScrubber` | US Social Security numbers (`NNN-NN-NNNN`; opt-in unseparated `NNNNNNNNN`) with SSA invalid-range guards | `[REDACTED:SSN]` | | `CreditCardScrubber` | Credit-card-shaped digit runs validated by Luhn check digit AND IIN range gate | `[REDACTED:CARD]` | | `UsDriverLicenseScrubber` | US driver's license numbers — per-state format table, anchored or per-state mode | `[REDACTED:DL]` | | `KeyedRegexScrubber` | Generic pattern + key adapter — the building block hosts extend | caller-supplied | `SsnUsScrubber` matches the canonical hyphenated form and (when opted in) the unhyphenated nine-digit form, with SSA invalid-range guards (area ≠ 000/666/900–999, group ≠ 00, serial ≠ 0000). The redacted form replaces the matched span with `[REDACTED:SSN]`; the original digits never reach storage. `CreditCardScrubber` validates against the Luhn check digit AND the IIN range before redacting. The double gate cuts the false-positive rate that a naive 16-digit regex would produce against arbitrary numeric content — order numbers, session ids, and similar digit runs pass through untouched. Opt out of the IIN gate via `new CreditCardScrubber({ validateIin: false })` if you process exotic card-network ranges. `UsDriverLicenseScrubber` composes a state-keyed pattern table because the driver's license format varies per state. The bundle ships the table; consumers that need a state the upstream table doesn't cover yet should open an issue on the package repo rather than fork the scrubber. `KeyedRegexScrubber` is the parameterized scrubber — the other three are specializations that bind a fixed pattern and redaction key. The next section covers it as the extension hook. ## `KeyedRegexScrubber` — the extension hook [#keyedregexscrubber--the-extension-hook] `KeyedRegexScrubber` is the one scrubber consumers commonly instantiate directly. It's not a custom scrubber — it's a parameterized one — so it stays inside the contract and inherits the matched-spans audit reporting that ships with the bundle. ```typescript // lib/compliance/tenantMrnPlugin.ts import { KeyedRegexScrubber } from "@pleach/compliance"; import type { HarnessPlugin } from "@pleach/core"; const mrnScrubber = new KeyedRegexScrubber({ id: "tenant-mrn", entries: [ { name: "mrn", pattern: /\b\d{7}\b/g, replacement: "[REDACTED:MRN]", }, ], }); export const tenantCompliancePlugin: HarnessPlugin = { name: "tenant-mrn-compliance", contributeScrubbers: () => [mrnScrubber], }; ``` The `name` (here `"mrn"`) is what shows up in the matched-spans report and in any downstream audit consumer — pick a name that reads in a compliance review, not the regex itself. The `replacement` may be a string or a function `(match, name) => string` if the redacted form needs to be derived from match groups (e.g. preserving the last 4 chars). For a host with multiple regulated identifier families — MRN, employee ID, internal account number — register them as separate `entries` on a single `KeyedRegexScrubber`, or use one scrubber per family if you want the audit report grouped per-scrubber-`id`. The per-entry `name` field is the attribution unit either way. ## Inherited audit gates [#inherited-audit-gates] Adopting `@pleach/compliance` adds two CI audit gates to the consumer build. The gates are the load-bearing piece for regulated deployments: they fail the build before a code path that violates tenant scoping can reach production. ### `audit:tenant-scoping` [#audittenant-scoping] Enforces that every event log write threads `tenant_id`. The gate walks the consumer's source for call sites against `EventLogWriter` (and its registered wrappers) and fails if any call site can reach `append` without a `tenant_id` resolved from the runtime. The check is structural — it flags the absence of the threading, not the value. A code path that resolves `tenantId` from a request and then drops it before calling the writer fails the gate at the drop site, with the drop site named in the failure output. ### `audit:harness-event-log-tenant-id-required` [#auditharness-event-log-tenant-id-required] Schema-level check that `harness_event_log.tenant_id` is `NOT NULL` after migration. The gate reads the consumer's applied schema (via the standard migration introspection helper) and fails if the column is nullable. Run it as part of the post-migration smoke step. A nullable `tenant_id` column is the silent-isolation failure mode: writes succeed, rollups miss rows, and the regulator's "show me tenant X's events" query under-counts. ## Deployment pattern [#deployment-pattern] Adopt the package once at the top of your `SessionRuntime` construction via the standard plugin registration. The scrubbers wire into `EventLogWriter` automatically; consumer code doesn't call them directly. ```typescript import { SessionRuntime } from "@pleach/core"; import { complianceProfilePlugin } from "@pleach/compliance"; const runtime = new SessionRuntime({ storage, checkpointer, userId: req.user.id, tenantId: req.user.orgId, plugins: [complianceProfilePlugin("hipaa") /* , gatewayPlugin, ... */], }); ``` `complianceProfilePlugin(profile)` accepts `"hipaa" | "gdpr" | "pci-dss" | "soc2"` and returns a `HarnessPlugin` whose `contributeScrubbers` resolves to the scrubber bundle for that profile. Use `scrubbersForProfile(profile)` directly if you need to hand-roll the plugin shape (e.g. composing your own `name` + `id` fields). Custom scrubbers (additional `KeyedRegexScrubber` instances for tenant-specific identifiers) register through their own plugin alongside this one — composition is additive. The CI gates are picked up by the consumer's existing audit harness; no per-build wiring beyond installing the package and listing it in the audit config. ## What's NOT in this package today [#whats-not-in-this-package-today] Honest about the scope of the 1.0 cut: * **No gateway-level auth or rate limiting.** That lives in [`@pleach/gateway@0.1.0`](/docs/gateway). Today's compliance package does not see the outbound provider call. * **No SBOM or attestation bundle.** `@pleach/trust-pack` remains reserved at `0.0.1 · UNLICENSED`. Supply-chain attestation is out of scope for this cut. (Cryptographic attestation of individual audit rows ships in `@pleach/core/attestation` — see [Attestation](/docs/attestation).) * **Deterministic replay over the event log** ships in [`@pleach/replay@0.1.0`](/docs/replay) (`replayTurn`, `fromSnapshot`, `fork`, and `aggregateMultiTenant` bodies all land at the `0.1.0` cut) and [`@pleach/eval@0.1.0`](/docs/eval) (scoring + diff). The compliance package writes the rows; the replay/eval packages read them. * **Tamper-evidence hash chain** stamping ships in `@pleach/core/eventLog` (`chainStep`, `computeRowHash`) behind the `c9PhaseBEnabled` flag; verification ships in the same `@pleach/core/eventLog` barrel (`verifyChainForChat`, `generateProof`). The `TamperEvidence` plug-point in `@pleach/core/audit` keeps its no-op default for hosts that route attestation elsewhere. * **No broader scrubber set.** Four scrubbers ship in 1.0. Additional patterns (international tax IDs, EU formats, sector-specific identifiers) are planned but not present. Use `KeyedRegexScrubber` for anything outside the bundled four. Check the [package README](https://github.com/pleachhq/compliance) for the current roadmap before assuming a feature. ## Adopting in stages [#adopting-in-stages] A host migrating an existing deployment toward mandatory tenant scoping may want the scrubbers immediately but defer the CI gates until the codebase is ready. The gates fail fast — a multi-week migration with the gates on means a broken build for the duration. Register only the scrubbers via `contributeScrubbers` from a custom plugin, and skip the package's full plugin export: ```typescript // lib/compliance/scrubbersOnlyPlugin.ts import { SsnUsScrubber, CreditCardScrubber, UsDriverLicenseScrubber, } from "@pleach/compliance"; import type { HarnessPlugin } from "@pleach/core"; export const scrubbersOnlyPlugin: HarnessPlugin = { name: "compliance-scrubbers-only", contributeScrubbers: () => [ new SsnUsScrubber(), new CreditCardScrubber(), new UsDriverLicenseScrubber(), ], }; ``` The scrubbers run; the CI gates stay off. Track the migration as a real ticket — running the scrubbers without the tenant-scoping guarantee means redacted rows can still land untenanted, which is a partial-isolation posture, not a finished one. Flip to the full `complianceProfilePlugin("hipaa")` (or the profile matching your posture) once the codebase passes the gates locally. ## Related SKUs [#related-skus] * [`@pleach/core`](/docs/core) — the substrate this package wraps. Scrubbers run at `EventLogWriter`'s persistence boundary; the CI gates check call sites against `@pleach/core/eventLog`. * [`@pleach/gateway`](/docs/gateway) — emits `domain.gateway.cost.recorded` events with the full `RoutingDecision` shape. Compliance's attestation runtime reads those events without round-tripping the audit ledger. * [`@pleach/replay`](/docs/replay) — `verifyChainIntegrity` walks the hash-chained event log. Compliance writes the rows; replay attests them. * [`@pleach/eval`](/docs/eval) — scoring + diff over replayed, scrubbed event logs. For the full SKU map see [Which SKU do I need?](/docs/which-sku). ## Where to go next [#where-to-go-next] --- # Concept clusters (/docs/concept-clusters) This page is the concept map. Every other doc page is the deep dive on one of the concepts named here. The substrate organizes around clusters of three. One triplet covers the whole runtime — *session, turn, row* — and reduces to the same primitive used everywhere else: an append-only typed audit row keyed by `turnId`. Six more cluster triplets sit one click in. Each one is what the substrate uses to do something specific: run a turn, route a call, record a call, compose work across spawns, advance over time, or survive a restart. If you read the [glossary entry on the name](/docs/glossary#p): each cluster triplet is a way the lattice carries one weight a single `streamText` call cannot. Routing, audit, lifecycle, persistence — different forces, same hedge. ## The substrate-wide triplet [#the-substrate-wide-triplet] Everything else reduces to these. The homepage opens with this triplet; the docs index repeats it; every cluster below is built out of one of them. * **Session.** Long-lived state for one user × one product surface. Channels, storage, family-lock, and the event log all hang off it. Mint with `runtime.sessions.create()`; resume by id across processes and clients via version-vector sync. See [SessionRuntime](/docs/session-runtime). * **Turn.** One user-message-in / one-answer-out cycle. Walks the four-stage lattice — `anchor-plan` → `tool-loop` ⇄ `synthesize` → `post-turn` — under singleton-synthesize discipline so exactly one final answer fires per turn. See [Turn lifecycle](/docs/turn-lifecycle). * **AuditableCall row.** One append-only typed row per LLM call, keyed by `turnId`. Carries `tenantId`, `toolName`, `subagentDepth`, `modelId`, and `tokenUsage`. The grain every cost rollup, compliance query, and replay reads from. See [The AuditableCall row](/docs/auditable-call-row). ## The six cluster triplets [#the-six-cluster-triplets] Each cluster lives on a host page that walks the three concepts as parallel bullets. Sibling concept pages open with a one- paragraph pointer to the cluster framing. The clusters are ordered roughly by where they fire in a turn — graph schedules work, the routing cluster picks a model, the audit cluster records the call, the composing-agents cluster spawns child work, the runtime-lifecycle cluster advances and persists, and the state cluster carries everything across restarts. ### Execution-graph cluster [#execution-graph-cluster] The per-turn execution substrate. Lives *inside* the lattice. Full walk in [Architecture](/docs/architecture#the-execution-graph-cluster). * **Graph.** Declarative topology — nodes wired to channels, edges constrained to the four-stage lattice. See [Graph](/docs/graph). * **Node.** An async function plus typed `StateGraphNodeMetadata` declaring `stageId`, `acceptsSeam`, `subscribes`, and `writes`. See [Nodes](/docs/nodes). * **Channel.** Typed reactive state slot. Six kinds with defined concurrent-write semantics and a `checkpoint()` / `restore()` pair. See [Channels](/docs/channels). * **Engine.** The `engine/` superstep scheduler (`SuperstepRunner`) that *runs* the compiled graph — fires triggered nodes in parallel, accumulates their writes, and commits channel updates atomically per step. Deterministic and race-free by construction; also owns retry, abort composition, and graph↔session state sync. See [Architecture → execution-graph cluster](/docs/architecture#the-execution-graph-cluster). ### Routing cluster [#routing-cluster] Decides which model fires for which kind of call, and pins what stays locked across the session. Lives between the execution graph and the LLM transport. Full walk in [Family-locked routing](/docs/family-lock#the-routing-cluster). * **CallClass.** Four-bucket taxonomy every LLM call declares — `utility`, `reasoning`, `converse`, `synthesize`. See [Call classes](/docs/call-classes). * **Seam.** Per-call-class factory that builds a `ProviderSeam`. Four factories live in `src/graph/seams/`. See [Seams](/docs/seams). * **Family-lock.** Session-start lock on one `ProviderFamily` and one `Transport`. See [Family-locked routing](/docs/family-lock). ### Audit-ledger cluster [#audit-ledger-cluster] The write side. The row is the grain; the ledger is the write path; the chain is the after-the-fact-tamper detector. Full walk in [Audit ledger](/docs/audit-ledger#the-audit-ledger-cluster). * **AuditableCall row.** Typed row shape with five typed payload slots. See [The AuditableCall row](/docs/auditable-call-row). * **ProviderDecisionLedger.** Append-only write interface, one method, idempotent on `(sessionId, turnId, stageId, seqWithinTurn)`. See [Audit ledger](/docs/audit-ledger). * **Tamper-evident hash chain.** `prev_hash` + `row_hash` columns detect silent backfill, reorder, and removal. See [Hash chain](/docs/hash-chain). ### Composing-agents cluster [#composing-agents-cluster] How composable work nests inside a session. Names the unit of work, spawns the child runtime that executes it, records the rolling signal that decides whether to spawn it again. Full walk in [Agents](/docs/agents#the-composing-agents-cluster). * **Skill.** Markdown file with YAML frontmatter; `SkillLoader` walks three sources. See [Skills](/docs/skills). * **Subagent.** Child runtime spawned from a tool call against a resolved skill spec. `SUBAGENT_LIMITS.maxDepth = 3` caps the nesting. See [Subagents](/docs/subagents). * **Agent profile.** Persistent registry record keyed by `specName`. See [Agents](/docs/agents). ### Runtime-lifecycle cluster [#runtime-lifecycle-cluster] What happens over time and what gets persisted. Sessions outlive turns; turns outlive their stream frames; the event log outlives both. Full walk in [Session lifecycle](/docs/session-lifecycle#the-runtime-lifecycle-cluster). * **Session lifecycle.** `createSession` / `resumeSession` / `deleteSession` — the arc above the turn. See [Session lifecycle](/docs/session-lifecycle). * **Turn lifecycle.** The per-message arc between `executeMessage` and the final SSE frame. See [Turn lifecycle](/docs/turn-lifecycle). * **Event log.** Append-only typed stream both lifecycles write into. See [Event log](/docs/event-log). ### State-and-persistence cluster [#state-and-persistence-cluster] What makes a session survive a process restart, a fork or rewind, and a multi-client edit. Lives below the execution graph and above the schema bundle. Full walk in [Storage](/docs/storage#the-state-and-persistence-cluster). * **Storage adapter.** Pluggable surface reading and writing `SessionState`. Memory / IndexedDB / Supabase adapters all implement the same interface. See [Storage](/docs/storage). * **Checkpoint.** Per-channel snapshot at every stage boundary. `runtime.checkpoints.rollback` restores in place; `TimeTravelAPI.fork` branches into a new session. See [Checkpointing](/docs/checkpointing). * **Sync (version vector).** `Record` per session. `compareVectors` flags concurrent writes; `SyncCoordinator` surfaces conflicts through `sync.conflict`. See [Sync](/docs/sync). ## What lives outside the cluster pattern [#what-lives-outside-the-cluster-pattern] Eight sidebar groups don't carve cleanly into three-concept triplets. They aren't deficient — they're shapes the pattern doesn't fit. Three are surfaces (facets, plugins, tools); five are heterogeneous concept sets or pairs (safety, observability, frontend, prompts, cross-session state). * **Facets.** Typed accessors on `runtime.` — `runtime.sessions`, `runtime.events`, `runtime.checkpoints`, `runtime.tenant`, `runtime.spans`, and the rest. A surface pattern that groups related methods by domain so the runtime reads as a short list of named accessors instead of a flat method dump. Not a three-concept cluster. See [Facets](/docs/facets). * **Plugins.** The `HarnessPlugin` extension contract — \~40 optional hooks plus four structural invariants the substrate enforces against them. A bounded contract, not a triplet. See [Plugin contract](/docs/plugin-contract). * **Tools.** The `defineTool` contract, Zod-validated input / output, per-invocation context, and batching. One surface end to end — contract, dispatch, result-handling — not a three-concept cluster. See [Tools](/docs/tools). * **Prompts.** Composition of system prompts from typed contributions plus the budgeted composer. A pair ([prompts](/docs/prompts) + [prompt builder](/docs/prompt-builder)), not a triplet. * **Cross-session state.** [Plans](/docs/plans) span multiple turns; [memory](/docs/memory) spans multiple sessions. Two variable surfaces that outlive a single session, each with a distinct mechanism (revision history vs decay curve). Not a three-concept cluster. * **Safety & determinism.** `safety`, `scrubbers`, `fabrication-detection`, `determinism`, `fingerprint`. Five concepts, each with a distinct mechanism; no natural triplet. `safety.mdx` orients the reader directly. * **Observability.** `lineage`, `observability`, `otel-observability`, `runtime-inspector`. Distinct surfaces a reader usually arrives at already knowing which one they need. * **Frontend integration.** `react`, `server`, `api-routes`, `query`, `devtools`. Wiring surfaces, not concepts. ## Where to go next [#where-to-go-next] --- # Config manifest (/docs/config-manifest) The config manifest records *which substrate ran*. A [fingerprint](/docs/fingerprint) binds a turn's inputs to a hash; the manifest binds the substrate that processed them — the plugin set, the system prompts, the graph node bodies, the channel definitions, the post-stage filters. One row per distinct substrate, content-addressable, written to `harness_config_manifest`. Every [event-log](/docs/event-log) row carries a `manifest_hash` foreign reference to that row. So a full replay of any historical session needs exactly two things: the event stream and the manifest hash that was active when each event fired. That tuple is self-contained — no live runtime, no ambient git HEAD, no deploy-record lookup. substrate contract locked · rolling out The `harness_config_manifest` table and the `harness_event_log.manifest_hash` column land as additive schema files — `manifest_hash` is nullable during rollout so rows written before the manifest substrate stay valid. Existing sessions keep working; new sessions stamp the reference. The cutover to a required column is a follow-up migration after every write path stamps the hash. See [Schema](/docs/schema) for the bundle file. ## Why it exists [#why-it-exists] [Determinism](/docs/determinism) promises a turn replays byte-identical against the same package version and the same input. "Same package version" is the load-bearing phrase — but a deployment's *effective* substrate is more than the npm version. It includes the plugins you registered, the prompt templates you shipped, the graph nodes your build composed, the channels and filters in play. Two deployments on the same `@pleach/core` version can run different substrates. The fingerprint doesn't capture that. It binds the turn input. The manifest captures the rest, so the replay contract becomes precise: **same manifest, same input, same output.** ## The five surfaces [#the-five-surfaces] The manifest is a snapshot of five substrate axes. Each is content-hashed independently, then rolled up into one top-level hash. | Surface | Column | What it captures | | -------- | ------------------ | ------------------------------------------------------------------ | | Plugins | `plugin_manifest` | The registered plugin set and the hooks each contributes | | Prompts | `prompt_manifest` | The system-prompt facets and templates in effect | | Nodes | `node_manifest` | The graph node bodies (by content hash) and their stage assignment | | Channels | `channel_manifest` | The channel (state) definitions and their reducers | | Filters | `filter_manifest` | The post-stage filter declarations | Each surface gets its own child hash (`plugin_hash`, `prompt_hash`, …). Per-surface hashes are what make the manifest dedup well: in a multi-tenant deployment most tenants load the same plugin set, so the `plugin_manifest` blob is shared across their manifest rows even when their prompts differ. ## The table [#the-table] ```sql CREATE TABLE harness_config_manifest ( manifest_hash TEXT PRIMARY KEY, -- top-level Merkle roll-up plugin_hash TEXT NOT NULL, -- per-surface child hash prompt_hash TEXT NOT NULL, node_hash TEXT NOT NULL, channel_hash TEXT NOT NULL, filter_hash TEXT NOT NULL, plugin_manifest JSONB NOT NULL, -- full snapshot, not a delta prompt_manifest JSONB NOT NULL, node_manifest JSONB NOT NULL, channel_manifest JSONB NOT NULL, filter_manifest JSONB NOT NULL, reference_count INTEGER NOT NULL DEFAULT 1, -- live event references created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), tenant_id TEXT NOT NULL DEFAULT 'default' ); ``` The primary key is the content hash, so two sessions that ran the same substrate write the same row — the insert is `ON CONFLICT DO NOTHING`. Each per-surface hash gets its own index for the cross-tenant query shapes below, plus a `tenant_id` index for per-tenant scans. Row-level security ships the same two-policy template as the rest of the [schema bundle](/docs/schema#rls-template): a service-role bypass for server-side adapters, and a `tenant_id = current_tenant()` isolation policy for scoped clients. ### Foreign reference on the event log [#foreign-reference-on-the-event-log] ```sql ALTER TABLE harness_event_log ADD COLUMN manifest_hash TEXT -- nullable during rollout REFERENCES harness_config_manifest (manifest_hash) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED; CREATE INDEX harness_event_log_manifest_hash_idx ON harness_event_log (manifest_hash); ``` The event log carries only the *top-level* `manifest_hash`; the per-surface child hashes live on the manifest row, reached by joining. The column is a real foreign key, but two clauses keep it cheap and rollout-safe: * **`DEFERRABLE INITIALLY DEFERRED`** is load-bearing. A session writes its manifest fire-and-forget and writes its first event in the same transaction; deferring the constraint to commit time lets those two writes race without a transient FK violation. * **`ON DELETE SET NULL`** means a retention GC that drops a manifest nulls the reference rather than cascading a delete into the audit trail — the event survives, it just loses its substrate pointer. The constraint is nullable during rollout so rows written before the manifest substrate stay valid. Two gates back the reference: `audit:config-manifest-fk-constraint-applied` asserts the migration carries every canonical clause, and `audit:config-manifest-referential-integrity` asserts zero orphans online (and the `SessionRuntime` wiring offline). ## The hash [#the-hash] Each surface is serialized to canonical JSON ([RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) — sorted keys, no insignificant whitespace) and hashed with SHA-256. The top-level hash is a Merkle roll-up over the five child hashes, ordered, with a versioned prefix: ``` manifest_hash = sha256_hex( "pleach.manifest.v1" ␟ "plugin:" ␟ plugin_hash ␟ "prompt:" ␟ prompt_hash ␟ "node:" ␟ node_hash ␟ "channel:" ␟ channel_hash ␟ "filter:" ␟ filter_hash ) ``` `␟` is the ASCII unit separator (`0x1F`). This mirrors the [hash chain](/docs/hash-chain)'s `pleach.c9.v1` canonicalization — same separator, same versioned-prefix discipline, same hex output shape. The version prefix lets a future schema add a sixth surface without colliding with `v1` manifests. The manifest builder is a pure function of its inputs — no `Date.now()`, no environment reads — so the same substrate always produces the same hash. The `audit:plugin-content-hash-stability` gate runs two consecutive builds and diffs each of the five child hashes independently; a `Date.now()` leak in the prompt builder fails `prompt_hash` and points at the prompt surface specifically, not at "something in the manifest moved." ## Querying [#querying] The manifest turns three audit questions into index-backed queries. **Replay by config** — every event under the substrate active in a session: ```sql SELECT events.* FROM harness_event_log events WHERE events.manifest_hash = $1 ORDER BY events.sequence_number; ``` **Cross-tenant audit by plugin set** — every event across all sessions that ran with a given plugin set, regardless of prompt, node, channel, or filter drift: ```sql SELECT events.* FROM harness_event_log events JOIN harness_config_manifest manifest ON events.manifest_hash = manifest.manifest_hash WHERE manifest.plugin_hash = $1; ``` **Substrate reconstruction** — the full substrate behind a single event, all five surfaces returned as JSONB: ```sql SELECT manifest.* FROM harness_config_manifest manifest JOIN harness_event_log events ON events.manifest_hash = manifest.manifest_hash WHERE events.id = $1 LIMIT 1; ``` A manifest lookup is a primary-key hit, so JOIN cost is dominated by the event-log scan, not the manifest cardinality. A typical incident window of a thousand events references only a handful of distinct manifests. ## Full snapshots, not deltas [#full-snapshots-not-deltas] Each row stores the complete substrate, not a diff against a prior manifest. The storage cost is small — content-addressing dedups identical substrates, and per-surface hashes dedup the shared parts — and the payoff is that replay reads one row. A delta encoding would force the replay tool to walk a parent chain, which breaks the moment retention drops a parent, and it would turn the round-trip audit into a multi-step materialization. One row per manifest keeps replay offline-able. ## Retention [#retention] Manifests are reference-counted against the event log: a manifest survives as long as any event references it. The default floor is **forever** — the compliance posture is "keep the audit trail," not "expire it." Cost-sensitive tenants can opt into a per-tenant retention floor (planned) that drops manifests older than *N* days *and* referenced by no surviving event. This couples manifest retention to whatever event-log retention you run, automatically: | Event-log retention | Manifest retention | | ------------------- | ------------------------------------------------------------- | | Forever | Forever | | *N*-day TTL | Manifest drops within a GC pass of the last referencing event | | Per-tenant policy | Manifest follows per tenant | The `audit:config-manifest-retention-completeness` gate runs after a GC pass and fails if any event references a missing manifest — the guard against a GC racing a concurrent event write. ## One snapshot per session [#one-snapshot-per-session] The manifest is captured once, at session construction — never mid-stream. The content-addressable contract requires a stable snapshot, so a config change mid-session is out of scope: the session represents the substrate it started with. Mutating manifest-relevant state mid-session is the kind of thing the replayability contract is designed to forbid, not silently absorb. ## Where it sits in the replay story [#where-it-sits-in-the-replay-story] The fingerprint binds the inputs; the manifest binds the substrate; the event-log row carries both. Replay reads the stream, dereferences each `manifest_hash`, reconstructs the substrate, and re-runs the events against it. Same manifest, same input, same output. ## Where to go next [#where-to-go-next] --- # Contributing (/docs/contributing) Every shipping `@pleach/*` package ships under **FSL-1.1-Apache-2.0** (Functional Source License with Apache 2.0 as the future license, auto-transitions to Apache 2.0 two years after first stable publish): `@pleach/core`, `@pleach/tools`, `@pleach/react`, `@pleach/replay`, `@pleach/sandbox`, `@pleach/base-tools`, `@pleach/langchain`, `@pleach/compliance`, `@pleach/eval`, `@pleach/gateway`, `@pleach/mcp`, `@pleach/coding-agent`, `@pleach/observe`, and `@pleach/recipes`. `@pleach/trust-pack` alone remains a reserved npm name at `0.0.1 · UNLICENSED`. Contributions land through the standard GitHub workflow on each package's repository. This page is the short version; the upstream `CONTRIBUTING.md` in each package is the canonical source. ## Where each package lives [#where-each-package-lives] | Package | Repository | | -------------------------- | -------------------------------------------------------------- | | `@pleach/core` | [github.com/pleachhq/core](https://github.com/pleachhq/core) | | `@pleach/tools` | [github.com/pleachhq/tools](https://github.com/pleachhq/tools) | | Other `@pleach/*` siblings | Repositories land as each SKU ships its first cut | Issues and PRs land on the relevant package's repository. Cross-cutting issues (something that spans two packages, or something about the docs site itself) land on `pleachhq/core` with a label indicating scope. ## Filing a bug [#filing-a-bug] A useful bug report has four sections: 1. **What you did** — minimal reproduction. A failing test case in `defineTool` shape is ideal; a code snippet that constructs a `SessionRuntime` and reproduces the issue is fine. 2. **What you expected** — what the contract says should happen. 3. **What actually happened** — observed behavior, including stack trace and any structured error code (1xxx–7xxx). 4. **Environment** — `@pleach/core` version, Node version, runtime (Node / Edge / Browser), provider, storage adapter. Bugs without a reproduction are hard to fix. If the bug is non-deterministic, say so explicitly — the substrate's determinism contracts exist so non-determinism is unusual, and calling it out helps triage. ## Proposing a feature [#proposing-a-feature] A feature proposal is an issue with a `feature` label that answers four questions: 1. **What problem are you solving?** Not "I want X" — "X solves Y, which I can't solve today because Z." 2. **What does the API surface look like?** A type signature is enough; a code sample is better. 3. **What does it interact with?** Which existing primitives (`SessionRuntime`, channels, the audit ledger) does the feature touch? 4. **What can't you build today without this?** Concrete; the substrate is intentionally small and resists scope creep. Features that fit the existing contracts (a new built-in tool, a new prompt section primitive, a new stream observer verdict for an existing concern) land quickly. Features that change the substrate's structural commitments (the lattice, the singleton seam, the family lock) get deep design discussion first. ## Submitting a PR [#submitting-a-pr] Three checks every PR runs through: 1. **`npm test`** — the substrate's test suite, including determinism fixtures. 2. **`npm run lint`** — the lint rules that enforce structural invariants (`lint:harness-boundary`, `lint:callclass-literals`, etc.). 3. **`npm run audit:*`** — the audit gates that catch wire-format drift and out-of-lattice edges. A PR that fails any of these is in the wrong shape. The lint rules are the substrate's invariants in CI form — adjust the code, not the lint config. ### Commit shape [#commit-shape] The upstream uses conventional commit prefixes. Common ones: | Prefix | Use | | ----------- | ----------------------------------- | | `feat:` | New feature | | `fix:` | Bug fix | | `docs:` | Documentation only | | `refactor:` | Code change without behavior change | | `perf:` | Performance improvement | | `test:` | Test-only change | | `chore:` | Build / tooling | Commits are squashed at merge; the squashed message takes the PR title's shape. Aim for one logical change per PR. ### Test coverage [#test-coverage] New features ship with tests. The substrate's test suite has three layers: | Layer | What it tests | | ----------- | ------------------------------------------------------------- | | Unit | Pure functions (`computeFingerprint`, reducers, canonicalize) | | Integration | Runtime construction + executeMessage with mock providers | | Determinism | Recorded fixtures replayed for byte equality | A feature touching the fingerprint, the audit ledger, or the channel reducers belongs in all three layers. A feature touching only a peripheral surface (a new React hook, a new query helper) belongs in unit + integration. ### Docs updates [#docs-updates] Public-API changes land with docs updates in the same PR. The docs site here (`pleachhq/getpleach`) is a separate repository; small API additions land as a docs PR after the package merges, or as a single PR that updates the package's own README + inline JSDoc. For substantial new surfaces (a new exported subpath, a new plugin hook), the docs site gets a new page. The [CLAUDE.md](https://github.com/pleachhq/getpleach/blob/main/CLAUDE.md) in this repo documents the voice and structure conventions. ## Local development [#local-development] ```bash git clone https://github.com/pleachhq/core cd core npm install npm test ``` The substrate uses the standard npm workflow — no special build tooling, no monorepo wrangler. The `examples/minimal-agent` and `examples/host-adapter` directories run against the local source. ```bash cd examples/minimal-agent npm install HARNESS_MOCK_MODE=true node index.mjs ``` For changes that touch the schema bundle: ```bash npm run schema:validate # checks SQL bundle integrity npm run schema:apply-test # applies against a test DB ``` The schema bundle's additive rule (no edits to existing files, new files only) is enforced by the validator. ## What the substrate intentionally doesn't accept [#what-the-substrate-intentionally-doesnt-accept] A short list of patterns that consistently get rejected. Not because the work is bad — because the substrate's contracts foreclose them. | Pattern | Why rejected | | ---------------------------------------------------------- | --------------------------------------------------------- | | Async stream observers | Breaks replay determinism by design | | New channel kinds without commutative+associative reducers | Same | | Out-of-lattice graph edges | The lattice is what makes cost / observability structural | | `mode: "replace"` on safety policies | Safety stacks append-only by contract | | Storage adapters that aren't append-only for audit | Wire-format break | | Identity-like fields in the fingerprint cache key | Defeats interactive caching + replay | | `eval("...")` or runtime code generation | Determinism + safety review impossible | If your feature looks like one of these, the design discussion needs to surface what you're really trying to accomplish — the substrate likely has a different primitive for the actual goal. ## Security disclosures [#security-disclosures] Email `getpleach@protonmail.com` for vulnerabilities. Public GitHub issues are the wrong channel — they tip off attackers before a fix ships. Include: * A description of the vulnerability. * A reproduction (or proof-of-concept). * Affected versions if known. * Your preferred attribution (or anonymity). The upstream `SECURITY.md` documents response timelines. Reports get a real response, not a payout. The substrate is small enough that triage and credit happen in the open. ## Code of conduct [#code-of-conduct] Standard Contributor Covenant. The full text lives at `CODE_OF_CONDUCT.md` in each repository. The short version: be kind, don't harass, don't gatekeep, take disagreement to substance rather than personalities. ## License acknowledgment [#license-acknowledgment] By contributing to `@pleach/core`, you agree that your contribution is licensed under the same FSL-1.1-Apache-2.0 the package ships under. No CLA. The license text in the repo is the agreement. ## Where to go next [#where-to-go-next] --- # @pleach/core (/docs/core) The trellis the rest of `@pleach/*` grows on. `@pleach/core` is the agent-runtime substrate built on three load-bearing primitives: a **four-stage lattice**, a **family-locked model resolution matrix**, and an **append-only audit ledger**. Every sibling `@pleach/*` package plugs into it. Every LLM call inside an agent is classifiable, auditable, and replayable — not one opaque `streamText`. Concretely: each call goes through exactly one of four seams (`synthesizeSeam`, `reasoningSeam`, `utilitySeam`, `converseSeam`), each seam stamps the call's row in `harness_auditable_calls` with the declared `callClass`, and each row carries the `(sessionId, turnId, stageId, seqWithinTurn)` identity tuple — joinable to the turn that caused it, replayable in registration order. See the [architecture deep-dive](/docs/architecture) for the walk, or [Auditable call row](/docs/auditable-call-row) for the row shape. ## Install [#install] ```bash npm install @pleach/core ``` ```bash pnpm add @pleach/core ``` ```bash yarn add @pleach/core ``` ```bash bun add @pleach/core ``` For the end-to-end Next.js walkthrough — env var, route handler, React surface — see [Getting started](/docs/getting-started). For the cross-framework matrix (Next App / Pages, Astro, SvelteKit, Remix, Hono, Workers, Bun), see [Install](/docs/install). ## Minimal example [#minimal-example] ```typescript import { createPleachRuntime } from "@pleach/core"; const runtime = createPleachRuntime(); // `runtime` returns a substrate placeholder until an // OrchestratorAdapter is wired — see "Advanced wiring" below. ``` That's the headless-script / eval-harness shape: import, construct, hold a reference. To stream messages against a real provider, the [`@pleach/core/quickstart`](/docs/getting-started) subpath ships `createPleachRoute()` (route handler) and `useChat()` * `` (React). For a keyless first run, `createPleachRoute({ demo: true })` drives a real graph turn over canned model text and writes real audit rows — the mode `npx pleach init` scaffolds behind an `/audit` view. ## What you get [#what-you-get] A scan of what ships. See the [architecture deep-dive](/docs/architecture) for the walk. * **4-stage lattice, lint-enforced.** `anchor-plan → tool-loop ⇄ synthesize → post-turn`. Out-of-lattice edges fail CI. * **4 call classes, declared per call.** `utility` / `reasoning` / `converse` / `synthesize`. The literal is lint-restricted to seam factories. * **Family + transport lock per session.** Tokenizer, prompt-cache key, tool dialect, and refusal pattern freeze at session start. The cascade walks in-family rungs only. * **Singleton synthesize seam.** Exactly one final answer per turn, enforced by `SynthesizeSeamHolder` + `TurnSynthesizeCounter` (idempotent on `messageId`). * **`AuditableCall` ledger.** Append-only, ULID-keyed, typed payloads for family-lock, fallback-step, cache-breakpoint, provider-cascade, plan-generation, synthesis-quality, interrupt-decision, tool-selection, and token-cost decisions. * **Composes under an existing Enterprise contract.** Anthropic Workspace or OpenAI Project stays the vendor axis; `@pleach/core` stamps `tenantId` on every row so per-axis rollup runs inside the contract you already pay for — end customers in a SaaS, or employees, teams, and cost centers when used internally. See [Migrating from Anthropic Enterprise](/docs/migrating-from-anthropic-enterprise) and [Migrating from OpenAI Enterprise](/docs/migrating-from-openai-enterprise). * **Reactive channels.** `LastValue`, `BinaryOperatorAggregate`, `Topic`, `EphemeralValue`, `NamedBarrier`, `DataChannel` with LRU eviction. Deterministic reducers; concurrent-write semantics defined per kind. * **BYO-DB storage + checkpointing + sync.** Memory / IndexedDB / Supabase adapters; per-channel `checkpoint()` / `restore()` for time-travel; version-vector sync detects conflicts at write time. * **Bounded plugin contract.** Fill tier slots, register stream observers, contribute prompts, subscribe to events. Can't rewrite the lattice, bypass the synthesize seam, or reach the modelfamily matrix directly. ## Advanced wiring — the `@pleach/core/runtime` subpath [#advanced-wiring--the-pleachcoreruntime-subpath] The 30-second snippet (`import { createPleachRuntime } from "@pleach/core"`) is the right entry point for tutorials, headless scripts, eval harnesses, and any consumer that does not need to wire an LLM provider through an `OrchestratorAdapter`. The runtime returns a substrate placeholder when no provider is configured. The moment you wire a real provider — registering an `OrchestratorAdapter` ctor, an `AuditEmitter`, a `SupabaseFactory`, or any of the other app-boot registries — import from the **`@pleach/core/runtime` subpath** instead: ```typescript import { createPleachRuntime, setOrchestratorAdapterCtor, } from "@pleach/core/runtime"; // At app boot, before any runtime executes a message: setOrchestratorAdapterCtor(MyOrchestratorAdapter); const runtime = createPleachRuntime({ // host.strategies.orchestratorConfig forwards to the adapter host: { strategies: { orchestratorConfig: { model: "gpt-4o-mini", /* ... */ } } }, }); ``` **Why the split?** `@pleach/core` ships each `package.json:exports` entry as a self-contained bundle (`splitting: false` in `tsup.config.ts`). This is intentional — it keeps tree-shaking honest, lets consumers pull only the subpaths they touch, and isolates the top-level barrel from React / Anthropic / Supabase peer-resolution surprises. The trade-off is that each bundle carries its own module-state copy of the app-boot registries, so writes through one bundle and reads through another do not see each other. The `@pleach/core/runtime` subpath co-locates `createPleachRuntime` with every registry setter the runtime reads, so consumers wiring providers can do so end-to-end through one import path. If you only consume `createPleachRuntime`, both forms work. The subpath is the canonical advanced-wiring path; the top-level is the tutorial path. `@pleach/recipes` and the `npx pleach init` scaffold both already use the subpath. See [Host adapter](/docs/host-adapter) for the full host-injection contract and [Subpath exports](/docs/subpath-exports) for the canonical surface map. ## What lives in this package today [#what-lives-in-this-package-today] The root barrel re-exports the most-used types and functions (`SessionRuntime`, `createPleachRuntime`, channel primitives, the plugin contract, prompt helpers, the event log writer, the cache backend factory). Specialized subsystems are reachable through deep subpath imports — see [Subpath exports](/docs/subpath-exports) for the canonical grouping. The substrate covers: * **Runtime + facets.** `SessionRuntime`, `createPleachRuntime`, `TurnOrchestrator`, and \~18 named facet accessors (`runtime.sessions`, `runtime.events`, `runtime.spans`, `runtime.tenant`, `runtime.graph.{recovery, heuristics, config}`, `runtime.plugins`, …). See [Facets](/docs/facets) for the full inventory and the per-facet audit gates that enforce coverage. * **Persistence.** `MemoryAdapter`, `IndexedDBAdapter`, `SupabaseAdapter` storage adapters; matching `MemorySaver`, `IndexedDBSaver`, `SupabaseSaver` checkpointers; `SyncCoordinator` + version-vector primitives (`incrementVersion`, `mergeVectors`, `compareVectors`). See [Storage](/docs/storage), [Checkpointing](/docs/checkpointing), and [Sync](/docs/sync). * **Event log.** `EventLogWriter` (fire-and-forget enqueue + durable flush), `EventLogClient` (read-side), 10 named `GraphProjection` projections (interrupt, subagent, exports, artifact, …), and the `chainStep` hash-chain primitive that stamps `prev_hash` / `row_hash` per row when `c9PhaseBEnabled` is set. See [Event log](/docs/event-log) and [Event log projections](/docs/event-log-projections). * **Audit ledger.** `MemoryProviderDecisionLedger` default implementation, typed `AuditableCall` payloads (family-lock, fallback-step, cache-breakpoint, provider-cascade, plan-generation, synthesis-quality, interrupt-decision, tool-selection, token-cost), at `auditRecordVersion = 18`. See [Audit ledger](/docs/audit-ledger) and [Typed records](/docs/typed-records). * **Attestation.** `signAttestation` / `verifyAttestation` over the canonical row hash + a pluggable keystore (file-backed for test, AWS KMS, Vault Transit). Ships via the `@pleach/core/attestation` and `@pleach/core/attestation/keyStores` subpaths. See [Attestation](/docs/attestation). * **Cache.** `createMemoryCacheBackend` (default-constructed in the `SessionRuntime` constructor since PA-2 C2 Phase 3) plus the `CacheBackend` interface for swapping in Redis / Upstash. Threads through the four seam factories. See [Cache](/docs/cache). * **Safety.** `SafetyPolicyRegistry` (per-runtime, opt-in via `enabledSafetyPolicies`) + `composeSafetyContent` wired into the prompt resolver. See [Safety](/docs/safety). * **Scrubbers.** `Scrubber` interface, `DefaultScrubberChain`, and `SCRUBBABLE_FIELDS` allowlist enforced by `audit:c8-event-type-allowlist-coverage` at PR time. See [Scrubbers](/docs/scrubbers). * **Prompts + builder.** `PromptContributionRegistry` and `resolvePromptContributions` for the contribution contract; friendly-API helpers (`appendPrompt`, `prependPersona`, `replaceCore`, `scopedPrompt`, `gatedPrompt`, `createPlugin`); 41 universal Phase H core fragment templates; the `@pleach/core/prompt-builder` shell with `composeBudgetedPrompt` and the per-section authoring kit. See [Prompts](/docs/prompts) and [Prompt builder](/docs/prompt-builder). * **Plugin contract.** 67 `contributeX` hooks on `HarnessPlugin` (prompts, runtime-aware prompts, stream observers, safety policies, fabrication detectors, tools, intent-tool map, tool-coupling hints, continuation policy, retry policy, …) plus the `definePleachPlugin` discoverable entry point. See [Plugin contract](/docs/plugin-contract). * **OTel + lineage + fingerprint.** The observability triplet: `runtime.spans` for OTel-style span introspection (`inFlightCount`, `isShutdown`, `snapshot`); `LineageTracker` for cross-session parent/child/forked relationships; the pure-function `fingerprint` module that hashes prepared LLM inputs platform-uniformly (the determinism property `@pleach/eval` and `@pleach/replay` build on). See [Observability](/docs/observability), [Lineage](/docs/lineage), and [Fingerprint](/docs/fingerprint). * **Quickstart.** `createPleachRoute` + the React quickstart surface (`useChat`, ``) at the `@pleach/core/quickstart` subpath for one-line bootstraps. * **Adapters + integration.** Subpaths for query (`/query`), inspector (`/inspector`), MCP (`/mcp` via `createHarnessMCPServer`), mock executors (`/mock`), and finalization helpers (`/finalization`). ## Language-agnostic contract [#language-agnostic-contract] The runtime contract — sessions, checkpoints, storage, tools, sync, SSE wire — is language-agnostic by design. The TypeScript distribution in `@pleach/core` is the reference implementation, in production today. A Go implementation has been built against the same contract and round-trips a shared corpus of recorded turns through the same `AuditableCall` rows and `Checkpoint` envelopes — that's the test that catches anything TypeScript-flavored leaking into the wire (see [Language-agnostic contract](/docs/language-agnostic-contract#how-the-contract-stays-honest) for the three falsification mechanisms — shared fixtures, cross-runtime replay, and the schema gate). The Go implementation isn't published as a SKU yet; an official `@pleach` Go runtime is the next planned published implementation. If a claim in these docs only holds in JavaScript, that's a documentation bug — open an issue against [pleachhq/core](https://github.com/pleachhq/core/issues). ## Related SKUs [#related-skus] Every sibling `@pleach/*` package plugs into `@pleach/core`. The ones first-wave consumers most often pair with it: * [`@pleach/recipes`](/docs/recipes-pleach-recipes) — use-case-targeted factories (chatbot, RAG, compliant chatbot, enterprise agent, swarm) over the core runtime. The shortest path past the quickstart. * [`@pleach/compliance`](/docs/compliance) — scrubbers + CI audit gates that wrap core's `EventLogWriter` at the persistence boundary. Adopt when audit rows are evidence. * [`@pleach/gateway`](/docs/gateway) — multi-tenant routing client over core's family-locked matrix. Per-tenant scoping, BYOK fingerprinting, per-call cost events. * [`@pleach/replay`](/docs/replay) — event-granular replay via the canonical `runtime.events.iterate/fold` surface. Deterministic fork-from-prefix + hash-chain verification. * [`@pleach/eval`](/docs/eval) — `EvalSuite` + four scorers; takes a `ReplayClient` through constructor DI. * [`@pleach/observe`](/docs/observability) — OpenTelemetry / Datadog / Honeycomb wiring over core's two write streams. * [`@pleach/react`](/docs/react) — primitive hooks + `` surface. Either the lower-level `@pleach/react` or the bundled `@pleach/core/quickstart` exports. * [`@pleach/tools`](/docs/tools) — `defineTool` contract + `@pleach/base-tools` for the batteries-included starter set. For the full SKU map see [Which SKU do I need?](/docs/which-sku). ## Where to go next [#where-to-go-next] --- # Customer support agent (/docs/customer-support-agent) A customer support agent is the use case the [audit ledger](/docs/audit-ledger) was shaped for. Every turn is attributable to a customer, every tool call is one row, and every escalation has a `turn_id` you can search by. This page walks the end-to-end shape: tool surface, the multi-turn session, the escalation handoff, and the per-customer rollup query. It assumes you've read [Getting started](/docs/getting-started) and [SessionRuntime](/docs/session-runtime). **Related shapes.** [Research agent](/docs/research-agent) if support questions fan out into multiple parallel investigations. [Regulated-domain agent](/docs/regulated-domain-agent) if the support corpus contains PHI or PII. [Multi-tenant SaaS agent](/docs/multi-tenant-saas-agent) if one runtime serves many customer accounts. ## What you're building [#what-youre-building] A single agent that answers customer questions over an existing support session. It can: * Look up the customer's account and recent orders. * Cite policy text from a knowledge base. * Escalate to a human, attaching the full turn trail. The session lives across many turns. The audit ledger carries one row per LLM call and one row per tool call, all keyed by `sessionId` and `turnId`. ## Tool surface [#tool-surface] Three tools, each with Zod-validated input. The runtime serializes the schema into the prompt and validates returns before the model sees them. ```typescript // lib/tools/supportTools.ts import { defineTool } from "@pleach/core"; import { z } from "zod"; // Caller identity (userId) is NOT on the tool context — it lives on the // SessionRuntime scope. Close over it in a per-request factory so each tool // is bound to the authenticated customer. export function buildSupportTools(scope: { userId: string }) { const lookupCustomer = defineTool({ name: "lookup_customer", description: "Fetch the current customer's profile and plan.", input: z.object({}), output: z.object({ customerId: z.string(), plan: z.enum(["free", "pro", "enterprise"]), createdAt: z.string(), }), async handler() { return await db.customers.byUser(scope.userId); }, }); const lookupOrders = defineTool({ name: "lookup_orders", description: "List the customer's last 10 orders, newest first.", input: z.object({ limit: z.number().int().min(1).max(10).default(10) }), output: z.array(z.object({ id: z.string(), status: z.enum(["pending", "shipped", "delivered", "cancelled"]), total: z.number(), placedAt: z.string(), })), async handler({ limit }) { return await db.orders.recent({ userId: scope.userId, limit }); }, }); const escalateToHuman = defineTool({ name: "escalate_to_human", description: "Page a human support agent. Use when the customer asks for one, or when policy requires it.", input: z.object({ reason: z.string().min(8), severity: z.enum(["low", "medium", "high"]), }), output: z.object({ ticketId: z.string() }), async handler({ reason, severity }, ctx) { const ticket = await pagerDuty.createTicket({ userId: scope.userId, toolCallId: ctx.toolCallId, reason, severity, }); return { ticketId: ticket.id }; }, }); return [lookupCustomer, lookupOrders, escalateToHuman]; } ``` The caller's `userId` comes from the per-request scope the factory closes over — it is not on the tool `ctx`, which carries only the invocation's `toolCallId` and abort `signal`. The escalation tool stamps that `toolCallId` on the human-facing ticket so the responding agent can pull the full turn trail from the ledger. ## Runtime construction [#runtime-construction] A per-request runtime, scoped to the authenticated customer. [Storage](/docs/storage) carries `organization_id`, the [fingerprint](/docs/fingerprint) carries `tenantId`, and each row in the audit ledger gets both for free. ```typescript // lib/runtime.ts import { SessionRuntime, AiSdkProvider, definePleachPlugin } from "@pleach/core"; import { SupabaseAdapter } from "@pleach/core/sessions"; import { SupabaseSaver } from "@pleach/core/checkpointing"; import { createOpenRouter } from "@openrouter/ai-sdk-provider"; const openrouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY! }); export function buildSupportRuntime(req: AuthedRequest) { return new SessionRuntime({ storage: new SupabaseAdapter({ client: supabase }), checkpointer: new SupabaseSaver({ client: supabase }), provider: new AiSdkProvider({ model: openrouter("anthropic/claude-sonnet-4-5"), maxSteps: 5, }), plugins: [definePleachPlugin("support-tools", { tools: buildSupportTools({ userId: req.userId }), safetyPolicies: [piiRedaction, escalationOnlyOnRequest], })], userId: req.userId, organizationId: req.organizationId, tenantId: req.tenantId, }); } ``` ## Running a turn [#running-a-turn] The frontend calls `runtime.runTurn` with the customer's message. The runtime drives the tool loop, streams events to the [channel](/docs/channels), and writes the audit rows on commit. ```typescript const events = runtime.runTurn({ sessionId, message: req.body.message, }); for await (const ev of events) { if (ev.type === "text-delta") res.write(ev.delta); if (ev.type === "tool-call") log.info({ tool: ev.name }, "tool fired"); if (ev.type === "tool-result") log.info({ tool: ev.name, ok: ev.ok }, "tool returned"); if (ev.type === "turn-complete") res.end(); } ``` See [Stream events](/docs/stream-events) for the full event taxonomy. ## The per-customer rollup [#the-per-customer-rollup] Every row in [`harness_auditable_calls`](/docs/auditable-call-row) carries `userId`, `tenantId`, `sessionId`, and `turnId`. One `GROUP BY` answers "what did this customer cost us this month?" ```sql select user_id, count(*) filter (where call_kind = 'llm') as llm_calls, count(*) filter (where call_kind = 'tool') as tool_calls, sum(input_tokens) as input_tokens, sum(output_tokens) as output_tokens from harness_auditable_calls where tenant_id = $1 and created_at >= date_trunc('month', now()) group by user_id; ``` For escalations specifically, join on `payload->>'toolName' = 'escalate_to_human'` and you have a per-customer escalation rate broken down by reason. ## The replay path [#the-replay-path] When a customer complains about an answer, the engineer doesn't guess. The turn is recorded; replay it against the current code and look at the diff. ```typescript import { createReplayRuntime } from "@pleach/replay"; const replayRuntime = createReplayRuntime({ sessionRuntime: runtime, tenantId: req.tenantId, }); const diff = await replayRuntime.replayTurn({ chatId: sessionId, tenantId: req.tenantId, messageId: turnId, }); // `diff.state` is the reconstructed HydratedHarnessState (typed `unknown`) — // the tools that fired this time plus the model's answer. console.log(diff.state); ``` [`runtimeMode`](/docs/eval-and-replay) gates this — `record` writes the inputs that `replay` will replay. The split is a single constructor flag, not a code path fork. ## Safety policies you'll want [#safety-policies-youll-want] * **PII redaction** before the prompt hits the provider. The ledger stores the redacted form via [scrubbers](/docs/scrubbers); the original never leaves your process. * **Escalation-only-on-request**: the model isn't allowed to call `escalate_to_human` unless the customer's message matched a trigger phrase. Enforced as a safety policy, not a prompt instruction. * **Tool-scope-by-plan**: free-plan users don't have access to `escalate_to_human`. Drop it from the tool list at runtime construction; the model never sees it. See [Safety policies](/docs/safety) for the contribution shape. ## Project layout [#project-layout] The [baseline layout](/docs/project-layout#a-layout-that-works) fits unchanged. What's load-bearing is the *split* of the tools directory — one file per integration — and the durable storage adapter the audit row needs to survive between agent runs and QA reads. ``` my-app/ src/ pleach/ runtime.ts # SessionRuntime + SupabaseAdapter + SupabaseSaver tools/ helpdesk.ts # defineTool — your ticketing API billing.ts # defineTool — your billing API escalate.ts # defineTool — escalate_to_human, plan-gated safety/ pii-redaction.ts # → /docs/scrubbers escalation-gate.ts # → /docs/safety app/ api/agents/[id]/route.ts # → /docs/api-routes qa/ rollup.sql # the per-customer GROUP BY against the ledger ``` What changes from the baseline: * **`tools/` splits by integration**, not by capability. One file per upstream API means the BYOK story (per-tenant credentials) has a single place to wire and a single place to revoke. * **`safety/` is a real directory**, not a placeholder. PII redaction and the escalation gate are [safety policies](/docs/safety) — capability-subtracting, not prompt instructions. Lint-checkable as a result. * **Storage must be durable.** The [per-customer rollup](#the-per-customer-rollup) and the [replay path](#the-replay-path) both query the audit ledger weeks after the turn ran. `MemoryProviderDecisionLedger` loses every row at restart — wrong default for this shape. * **`qa/rollup.sql` lives in the repo**, not in a dashboard tool. When the column set on `harness_auditable_calls` changes, the query updates in the same PR. ## Where to go next [#where-to-go-next] --- # Cycle-break contract packages (/docs/cycle-break-contract-packages) When `@pleach/core` needs to reference a type that belongs to a sibling SKU — say, the `ComplianceRuntime` interface implemented by `@pleach/compliance` — a naive `import type { ComplianceRuntime } from "@pleach/compliance"` creates a cycle: `core` depends on `compliance`, and `compliance` depends on `core`. The cycle surfaces as `TS5055` / `TS6307` when one of the two SKUs rebuilds its `dist/` and the other tries to consume a stale `.d.ts`. It also surfaces as latent peer-resolution flakiness on every downstream consumer's `tsc` run. Neither `ai-sdk` nor `langchain` solves this — both ecosystems either inline the type in core or accept the cycle. Pleach extracts a third, zero-dependency package — `@pleach/-contract` — that both sides depend on **one-way**. ## The shape [#the-shape] ``` @pleach/core ├── depends on → @pleach/compliance-contract (one-way, type-only) └── never depends on → @pleach/compliance @pleach/compliance ├── depends on → @pleach/core ├── depends on → @pleach/compliance-contract └── re-exports types from contract for back-compat ``` `@pleach/compliance-contract` itself has **zero runtime dependencies and zero peer dependencies**. It's a pure-type package: the published artifact is `.d.ts` files. The tsup config emits no `.js` (or emits empty stubs for declaration completeness). ## What goes in a contract package [#what-goes-in-a-contract-package] Only **types that cross the SKU boundary**. For `@pleach/compliance-contract` that's: * The `ComplianceRuntime` interface — the shape `@pleach/core` accepts on `SessionRuntimeConfig.complianceRuntime?:`. * The `ComplianceProfile` literal union — `"hipaa" | "gdpr" | "soc2" | "pci-dss"`. * The structural `Scrubber` mirror — the minimal shape a scrubber implementation satisfies. * The tenant-scope sentinel constant. Things that stay **out** of the contract package: * Scrubber implementations (Luhn, SSN, US driver's license, etc.) — those live in `@pleach/compliance`. * The `ComplianceRuntime` runtime class — implementation, not type. * Helper utilities that aren't on the cross-SKU surface. ## The audit gate [#the-audit-gate] CI enforces the boundary with [`audit:no-cross-sku-type-import-in-core`](/docs/audit-gates#no-cross-sku-type-import-in-core). The gate scans every file in `packages/core/src/` and fails on any type-position `import("@pleach/")` or `import type ... from "@pleach/"` **outside the explicit allowlist**. Current allowlist (2026-06-15): * `@pleach/core` itself (self-references through the workspace alias) * `@pleach/compliance-contract` Adding a new contract package adds one entry to the allowlist; nothing else changes in the audit script. ## When to extract a new contract package [#when-to-extract-a-new-contract-package] Extract `@pleach/-contract` when **all three** are true: 1. `@pleach/core` needs to reference a type defined in `@pleach/` at a public seam (a config-object field, a host-strategy DI callback signature, an event-log row column type). 2. The type is **stable enough** to ship under semver — every change to the contract package is a coordinated breaking change across all consumers. 3. The cycle is **load-bearing** — without the contract, either core's `tsc --build` fails or every downstream consumer's `tsc` flakes on stale `.d.ts`. Do **not** extract a contract package for: * Types that only flow `core → sku` (e.g., `HarnessPlugin` lives in core because every SKU depends on core anyway). * Types that only one SKU references (no cycle). * Types that are implementation details — those stay in the SKU they belong to. ## Authoring checklist [#authoring-checklist] When extracting a new contract package: 1. Author `packages/-contract/` mirroring `compliance-contract`: * `package.json` with zero `dependencies` + zero `peerDependencies`, `FSL-1.1-Apache-2.0` license, type-only emit. * `src/index.ts` exporting only the types that cross the boundary. * `tsup.config.ts` with `dts: true`, `format: ["esm", "cjs"]`. 2. Add the new package name to the allowlist in `scripts/audit/no-cross-sku-type-import-in-core.mjs`. 3. Update the consumer SKU to re-export contract types for back-compat: ```ts // packages/compliance/src/index.ts export type { ComplianceRuntime, ComplianceProfile } from "@pleach/compliance-contract"; ``` 4. Add a row to the table at the top of the [boundary rules](/docs/architecture#10-boundary-rules) page. 5. Cut a `0.1.0` of the contract package and bump the consumer to depend on it. ## Versioning [#versioning] A contract package follows **its own semver** independently — that's the whole point of Changesets. A breaking change to `ComplianceRuntime` is a major version of `@pleach/compliance-contract`, which forces a major version of every package that depends on it (both `@pleach/core` and `@pleach/compliance`). In practice this means: contract changes are **rare** and **coordinated**. The contract is the slowest-moving piece of the architecture by design. ## Related [#related] * [Plugin contract](/docs/plugin-contract) — the other one-way type boundary in `@pleach/core` (host → core via `HarnessPlugin`). * [Language-agnostic contract](/docs/language-agnostic-contract) — the cross-runtime version of the same idea (TypeScript → Python via JSON schema). * [Audit gates](/docs/audit-gates) — the `audit:no-cross-sku-type-import-in-core` definition. --- # Decisions (/docs/decisions) Pleach ships a handful of close-cousin primitives that often collapse into one in a reader's head. Different branches of the same lattice — looking similar from a distance, doing different work at the joint. This page is the catalog of "when to reach for which" — short answers with links to the deeper page on each side. If your question is "do I need this SKU at all?" — see [Which SKU do I need?](/docs/which-sku). If it's "Pleach vs. another framework" — see [Comparison](/docs/comparison). This page is for choices *inside* Pleach. ## Session vs Turn vs Subagent [#session-vs-turn-vs-subagent] The three substrate-grain levels. They nest: **Session → Turn → Subagent**. | Concept | Lifetime | What it represents | Reach for | | ------------ | -------------------------- | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | **Session** | Long-lived (days / months) | One user × one product surface. Holds channels, storage, family-lock, the event log. | Persisting conversation state across pages, processes, and clients. Per-user resource attribution. | | **Turn** | One round-trip | One user-message-in / one-answer-out cycle. Walks the four stages. | Auditing what one user message cost. The grain of the `AuditableCall` row. | | **Subagent** | Inside a turn | A specialist sub-call spawned by the root turn — research / verify / refactor / etc. Rolls up to parent `turnId`. | Decomposing one user message into parallel specialist tasks; bounded fan-out with cost ceiling. | A real example: a user types "summarize this PDF and email me the key risks." That's **1 session, 1 turn, N subagents** (one to read the PDF, one to extract risks, one to draft the email). All N subagent rows roll up to the same `turnId`. **Read:** [SessionRuntime](/docs/session-runtime) · [Turn lifecycle](/docs/turn-lifecycle) · [Subagents](/docs/subagents). ## Ledger vs Observability vs Eval [#ledger-vs-observability-vs-eval] Three observability planes that look identical from a distance. | Plane | Grain | Latency | Reach for | | -------------------------- | --------------------------------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------- | | **`AuditableCall` ledger** | One row per LLM call, keyed by `turnId` | Write-time | Per-tenant cost rollup, compliance review, regulator-shaped queries. Joinable to your billing in one `GROUP BY`. | | **OTel spans** | One span per stage / tool / call, with parent threading | Sampled, async | Distributed tracing, latency profiling, error correlation across services. Pipes to Honeycomb, Tempo, Datadog. | | **Eval / replay** | Fixture-driven regression run against the recorded ledger | Offline | "Does this change regress against my golden corpus?" CI-runnable scoring with Welch t-test. | You usually want **all three**. The ledger is structural (every turn writes); OTel is sampled (only some make it to your backend); evals are explicit (you run them in CI). They don't replace each other. **Read:** [Audit ledger](/docs/audit-ledger) · [OTel observability](/docs/otel-observability) · [Eval](/docs/eval) · [Replay](/docs/replay). ## Scrubber vs ComplianceRuntime [#scrubber-vs-complianceruntime] Both fire on the path from runtime to event log. They cover different shapes. | Primitive | Where it lives | What it transforms | Reach for | | ----------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **`Scrubber`** | `@pleach/core/scrubbers` contract; concrete instances in `@pleach/compliance` | Field-level: regex / Luhn / dictionary scrub on a string value (SSN, CC, driver license, custom keyed regex) | Surgical PII / PHI removal at the field level. Composable into bundles per regulatory profile. | | **`ComplianceRuntime`** | `@pleach/compliance-contract` (interface) + `@pleach/compliance` (impl) | Whole-profile: assembles the right scrubber bundle for `HIPAA` / `GDPR` / `PCI-DSS` / `SOC2` and wires it into `EventLogWriter` | Profile-driven compliance posture — pass `{ profile: "HIPAA" }` and get the canonical scrubber set + attestation hooks without enumerating individual scrubbers. | You compose `Scrubber`s when you want bespoke control. You hand the `ComplianceRuntime` a profile when you want the canonical answer. Both are real — neither is sugar over the other. **Read:** [Scrubbers](/docs/scrubbers) · [Compliance](/docs/compliance). ## Recipe vs direct `SessionRuntime` [#recipe-vs-direct-sessionruntime] Same substrate, different abstraction layer. | Path | What you write | What you give up | Reach for | | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | | **`@pleach/recipes` factory** (`simpleChatbot`, `ragChatbot`, `compliantChatbot`, `verticalAgent`, `subagentSwarm`, `enterpriseAgent`) | One function call with a config object | Fine-grained channel registration, custom interrupt managers, novel plugin compositions | Use-case-shaped agents that match a known pattern. Under 100 lines of glue. | | **`new SessionRuntime(...)`** | Full constructor + channel registry + plugin set + interrupt manager + extension loader | Boilerplate; choice paralysis on what to actually wire | Novel agent shapes, multi-tenant SaaS, regulated deployment, custom transport. | The recipes are the same `SessionRuntime` underneath — they're not a separate runtime. You can always reach into `recipe.runtime` for direct access. Start with the recipe; graduate to the constructor when you outgrow it. **Read:** [Recipes](/docs/recipes) · [Runtime construction](/docs/runtime-construction). ## `AiSdkProvider` vs `AnthropicSdkProvider` vs `AgentProvider` [#aisdkprovider-vs-anthropicsdkprovider-vs-agentprovider] Three transport-layer choices. | Provider | Wraps | Reach for | | ------------------------------ | ---------------------------- | ----------------------------------------------------------------------------------------------------- | | **`AiSdkProvider`** | `streamText` from `ai@6.x` | Unified provider switching via OpenRouter or any `@ai-sdk/` package. The recommended default. | | **`AnthropicSdkProvider`** | `@anthropic-ai/sdk` directly | Native Anthropic features without a wrapper — prompt caching, extended thinking, tool-use beta flags. | | **`AgentProvider` (your own)** | Anything that streams | OpenAI directly, a custom gateway, a local Ollama, a proprietary vendor — implement one interface. | Most projects pick `AiSdkProvider` with OpenRouter — one key, six families, identical request shape. The other two exist because some teams need them. See [Providers](/docs/providers) for the swap pattern and config matrix. ## Memory vs Storage vs Checkpointer [#memory-vs-storage-vs-checkpointer] Three persistence layers that all touch the database. | Layer | What it persists | When it writes | Reach for | | --------------------------------- | ----------------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------- | | **Memory** | Long-term cross-session facts the agent should recall | Explicit `memory.store()` calls in your plugin | RAG-shaped recall across sessions; user preferences; learned habits. | | **Storage** (`StorageAdapter`) | Per-session state — messages, channels, the event log | Every turn, automatically | The session as a unit. Resume across processes / clients via version-vector sync. | | **Checkpointer** (`Checkpointer`) | Snapshots of channel state at each superstep | Per-step within a turn | Time-travel debugging, replay-deterministic regression, interrupt-resume. | Storage is mandatory (`MemoryAdapter` for tests; `SupabaseAdapter` for prod). Checkpointer is optional but required for time travel. Memory is a separate concern entirely — agent-facing, not infrastructure. **Read:** [Memory](/docs/memory) · [Storage](/docs/storage) · [Checkpointing](/docs/checkpointing) · [Time travel](/docs/time-travel). ## Channels vs Event log vs Stream events [#channels-vs-event-log-vs-stream-events] Three event surfaces that look related but serve different audiences. | Surface | Audience | Shape | Reach for | | ----------------- | ------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- | | **Channels** | The graph engine itself | Reactive write-many / read-once values inside a turn | Wiring graph nodes; coordinating between stages within one turn. **Internal.** | | **Event log** | Your audit / billing / compliance pipeline | Append-only typed `StreamEvent` rows in your DB | Per-tenant cost rollup, regulator-shaped queries, replay determinism. **Durable.** | | **Stream events** | The client (browser / mobile / CLI) | The same `StreamEvent` discriminated union, served as NDJSON / SSE | Rendering streaming UI, driving `useChat`, building custom transports. **Wire-level.** | The event log and the stream-events wire are the SAME typed union; the difference is durability vs transport. Channels are internal — you don't read them from outside the graph. **Read:** [Channels](/docs/channels) · [Event log](/docs/event-log) · [Stream events](/docs/stream-events). ## Plugin contract vs Plugin bundle [#plugin-contract-vs-plugin-bundle] A composition question. | Shape | What you author | Reach for | | ------------------------------ | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | **Single-facet plugin** (flat) | One `definePleachPlugin({ ... })` const with all hooks inline | Quick scaffold; one-facet plugins (just prompts, just tools, just safety). | | **Multi-facet bundle** | Multiple `definePleachPlugin({ facet: "prompts" })` consts composed into an array | Bigger plugins where each facet (prompts + safety + tools) can be reviewed / tested / shipped independently. The default `pleach init` template. | Bundles aren't required — the runtime accepts a single plugin and N composed plugins identically. But review surface scales better when prompts.ts and safety.ts are separate files. **Read:** [Plugin contract](/docs/plugin-contract) · [Plugin bundles](/docs/plugin-bundles) · [Plugin authoring standards](/docs/plugin-authoring-standards). ## Coding-agent SKU vs DIY tool wiring [#coding-agent-sku-vs-diy-tool-wiring] Pleach ships `@pleach/coding-agent` as a SKU. You also have `@pleach/tools` + `@pleach/sandbox` to roll your own. | Path | What you get | Reach for | | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | **`@pleach/coding-agent`** | SWE-bench-shaped runtime — `start()` / `stop()` / `executeStep()` + LRU `CodingContextManager` for file-context budget management. | A coding agent that matches the SWE-bench shape; benchmark adapters; out-of-the-box file-context budgeting. | | **Tools + sandbox, hand-wired** | Compose `@pleach/tools` definitions with `@pleach/sandbox` execution; full control over context management | Coding agents with bespoke context-management strategies, multi-language sandboxes, or non-SWE-bench-shaped flows. | The SKU is a specific opinionated shape. The hand-wired path gives up that opinionation for flexibility. **Read:** [`@pleach/coding-agent`](/docs/coding-agent) · [Tools](/docs/tools) · [`@pleach/sandbox`](/docs/sandbox). ## Where to go next [#where-to-go-next] --- # Deployment (/docs/deployment) 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 [#environments] The runtime supports three named environments distinguished by the `runtimeMode` [fingerprint](/docs/fingerprint) field: | Mode | What changes | | ---------------- | ----------------------------------------------------------------------- | | `production` | Full ledger writes, [cache](/docs/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 [#pre-deployment-checklist] * [ ] Schema bundle applied to the production database * [ ] `FEATURE_HARNESS_V2_RUNTIME=true` set (or unset — true is the default) * [ ] `HARNESS_MOCK_MODE` not set (or explicitly `false`) * [ ] 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 [#schema-migrations] Two paths to apply the schema bundle. ### Supabase CLI [#supabase-cli] ```bash 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` [#manual-psql] ```bash 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](/docs/cli) and [Schema](/docs/schema) for the details. ## Runtime construction patterns [#runtime-construction-patterns] The right construction pattern depends on the hosting model. ### Long-lived process (Node, containers) [#long-lived-process-node-containers] Construct one runtime at startup; reuse for every request. ```typescript // 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](/docs/multi-tenant). ### Serverless functions (Fluid Compute, Lambda, Cloudflare Workers) [#serverless-functions-fluid-compute-lambda-cloudflare-workers] Two patterns, depending on cold-start sensitivity. **Pattern A — Construct in handler.** Simple; pays runtime construction on every cold start. ```typescript 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. ```typescript // 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 that streams the response and forwards the client's abort signal looks like this: ```typescript // app/api/chat/route.ts import { SessionRuntime } from "@pleach/core"; import { SupabaseAdapter } from "@pleach/core/sessions"; import { SupabaseSaver } from "@pleach/core/checkpointing"; export const runtime = "edge"; 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 events = rt.executeMessage(sessionId, message, { abortSignal: req.signal }); const stream = new ReadableStream({ async pull(controller) { const { value, done } = await events.next(); if (done) { controller.close(); return; } controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(value)}\n\n`)); }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform" }, }); } ``` For Vercel Fluid Compute specifically: register `waitUntil` with the runtime's durable-flush pipeline so the event log survives function teardown: ```typescript // app/api/chat/route.ts import { setWaitUntilImpl } from "@pleach/core/eventLog"; export async function POST(req: Request, ctx: { waitUntil: (p: Promise) => void }) { setWaitUntilImpl(ctx.waitUntil.bind(ctx)); // ... handle request } ``` See [Event log](/docs/event-log) for the durable-flush contract. ### Edge runtime constraints [#edge-runtime-constraints] Edge runtimes (Cloudflare Workers, Vercel Edge) restrict which Node APIs are available. The substrate avoids Node-only APIs in its hot path — `fingerprint`, [`channels`](/docs/channels), `audit`, and `prompt-builder` are all isomorphic. The Supabase adapter and the Anthropic SDK provider work on edge runtimes. The storage adapter's choice of `crypto.randomUUID()` vs a Node-only UUID generator is the most common edge-incompatibility gotcha; both `MemoryAdapter` and `SupabaseAdapter` use the Web Crypto API explicitly. The other concrete limitation: edge runtimes cap response time per request (Cloudflare Workers' default is \~30s of CPU time per invocation; Vercel Edge gives you \~25s of wall-clock streaming before the connection idle-cuts). A turn that fans out to three subagents each running a 20s tool call exceeds the budget on edge and needs to move to Fluid Compute or a long-lived Node process. Anchor-plan + tool-loop + synthesize turns that complete in under \~20s stay edge-safe; longer-running orchestrations belong on Fluid Compute with `waitUntil` registered via `setWaitUntilImpl`. ## Observability [#observability] The substrate emits two streams ready for observability sinks: ### `runtime.on(event)` [#runtimeonevent] Every `StreamEvent` type also fires as a `SessionRuntime` event. Wire a long-lived subscriber: ```typescript 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-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: ```typescript class DualLedger implements ProviderDecisionLedger { constructor( private primary: ProviderDecisionLedger, private telemetry: TelemetryClient, ) {} async recordCall(call: AuditableCall): Promise { 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 [#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 [#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](/docs/audit-ledger) as the durable record; treat logs as ephemeral. ## Rollback strategy [#rollback-strategy] Three layers of rollback, in order of granularity. ### 1. Per-session checkpoint rollback [#1-per-session-checkpoint-rollback] The runtime's built-in [time-travel](/docs/time-travel). Use during incident response to revert a single session to a prior point: ```typescript 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 [#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 [#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`](/docs/event-log) if `harness_sessions` needs to be rebuilt. ## Feature flag rollouts [#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: ```typescript 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 [#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`: ```yaml # 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 ``` ```json { "ok": true, "version": "1.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 [#where-to-go-next] --- # Determinism (/docs/determinism) Determinism is one concept in the **safety & determinism** [thematic island](/docs/concept-clusters#what-lives-outside-the-cluster-pattern) — the others are [safety](/docs/safety), [scrubbers](/docs/scrubbers), [fabrication detection](/docs/fabrication-detection), and [fingerprint](/docs/fingerprint). Each carries its own mechanism; no natural cluster triplet. A recorded turn replays byte-identical against the same package version + the same input. That's the property `@pleach/eval@0.1.0` and `@pleach/replay@0.1.0` are built around — both ship real bodies for the entry-point surfaces (`replayTurn`, `verifyChainIntegrity`, `compareScored`, `diffReports`); remaining slices throw typed `NotImplemented` sentinels per [Packages](/docs/packages). It's the reason for several otherwise-confusing decisions across the substrate — sync-only stream observers, quantized temperature buckets, canonicalized JSON, deterministic reducers, ULID record ids. This page tells the story end-to-end. Each primitive's contribution to the chain shows up in its own reference page; here they're connected. ## The chain [#the-chain] Same input → same composed prompt → same fingerprint → same cached or recorded result → same observer verdicts → same channel state → same audit row. Each arrow in the chain is a contract. Break any one and replay determinism is gone for the whole turn. ## The five contracts [#the-five-contracts] ### 1. Composed prompts are deterministic [#1-composed-prompts-are-deterministic] `composeBudgetedPrompt` quantizes the active budget, sorts contributions by declared order + priority, and emits byte-identical bytes for identical inputs. The fingerprint module's `canonicalize` helper sorts object keys at every nesting level and throws on cycles / BigInt / function / symbol values so the canonical form is a total function over fingerprintable payloads. What can break it: * A static prompt contribution that reads session state — moves it from fingerprint-eligible to runtime-aware. * A custom section primitive that uses `Date.now()` or `Math.random()` — must be pure. See [Prompt builder](/docs/prompt-builder) and [Prompts](/docs/prompts). ### 2. Fingerprints are pure and canonical [#2-fingerprints-are-pure-and-canonical] `computeFingerprint` is a pure function over a typed input. The key/metadata split rules out identity-like fields; the canonicalization sorts object keys before hashing. What can break it: * Putting identity-like fields (`sessionId`, `messageId`, `userId`) into the input — breaks cross-session cache reuse. * Reading `process.env` mid-compute instead of at substrate init — the env can drift between runs. See [Fingerprint](/docs/fingerprint). ### 3. Stream observers are sync [#3-stream-observers-are-sync] `onChunk` returns `Verdict` synchronously. No `Promise`, no `async`. The contract is structural — the type signature forbids the async variant. What can break it: * A custom observer that fires off a `fetch` and returns `continue` without awaiting — the work happens, but at a non-deterministic point relative to the next chunk. * Observers that mutate captured state across chunks based on wall-clock conditions. The legal pattern is: observe synchronously, emit a named-channel envelope, do async work in a post-turn node. The runtime guarantees post-turn nodes see the named envelope; the timing is controlled by the lattice, not the observer. See [Plugin contract](/docs/plugin-contract). ### 4. Channel reducers are commutative + associative [#4-channel-reducers-are-commutative--associative] Concurrent writes to the same channel must produce the same result regardless of arrival order. The built-in reducers (`appendReducer`, `messagesReducer`, `unionReducer`) satisfy this; custom reducers must too. What can break it: * A reducer that uses `Date.now()` to tie-break — wall clock isn't deterministic across runs. * A reducer that reads from a global mutable state — the global state at replay time isn't what it was at record time. The substrate doesn't validate the property. Violating it silently breaks replay; tests are what catch it. See [Channels](/docs/channels). ### 5. Identifiers carry creation order [#5-identifiers-carry-creation-order] `recordId` on every audit row is a ULID — 26-char Crockford-Base32 that lex-sorts to creation order. The same ULID generator at record time produces a different id at replay time (the timestamp embedded in the ULID is wall-clock); the contract is that the *ordering* matches, not the values. For replays that need the original ids, the runtime threads `replayOfEventId` through `FingerprintMetadata` — the new ULIDs are different, but each row carries the link to the original. See [Audit ledger](/docs/audit-ledger). ## The fingerprint test [#the-fingerprint-test] The single-best test for determinism: run the same turn twice and compare fingerprints. ```typescript import { computeFingerprint } from "@pleach/core/fingerprint"; import type { FingerprintInput } from "@pleach/core/fingerprint"; const input: FingerprintInput = { family: "anthropic", model: "claude-sonnet-4", callClass: "synthesize", runtimeMode: "interactive", messages: [{ role: "user", content: turnText }], }; // computeFingerprint is pure: the same input produces the same // Fingerprint object, byte-for-byte. expect(computeFingerprint(input)).toEqual(computeFingerprint(input)); ``` End-to-end, the recorded audit row carries the composite hash as a string on `fingerprintComposite` — compare that field across two runs of the same turn: ```typescript await runtime.executeMessage(sessionA, content); await runtime.executeMessage(sessionB, content); const [rowA] = await ledger.getSession(sessionA); const [rowB] = await ledger.getSession(sessionB); expect(rowB.fingerprintComposite).toEqual(rowA.fingerprintComposite); ``` If the fingerprints diverge between runs of the "same" turn, something in the chain has slipped. Walk back through the five contracts above; the divergence is almost always one of: | Symptom | Likely contract violated | | -------------------------------- | ------------------------------------------------ | | Prompt bytes differ | Static contribution reading runtime state | | Cache misses on identical inputs | Identity-like field in fingerprint input | | Stream produces different chunks | Provider non-determinism (forgot to pass `seed`) | | Channel state diverges | Custom reducer not commutative | | Audit row order shifts | Wall-clock-tied id generation | ## Determinism modes [#determinism-modes] `runtimeMode` on the runtime distinguishes five operating contracts. The fingerprint includes it, so a turn recorded in one mode never collides with the cache of another. | Mode | Behavior | | ----------------- | -------------------------------------------------------- | | `interactive` | User-facing streaming chat; cache reads + writes enabled | | `headless-eval` | Batch eval; seed-pinned at runtime construction | | `headless-replay` | Cache MUST hit; a miss throws `ReplayDivergenceError` | | `headless-job` | Scheduled cron / async callback | | `coding-agent` | Multi-synthesize per turn | Cross-mode cache reads follow `CacheReadPolicy` — one-way only, `interactive → headless-eval → headless-replay`. Writes are always single-mode. ## What determinism doesn't give you [#what-determinism-doesnt-give-you] A few things people sometimes assume but the contract doesn't promise: | Not promised | Why | | -------------------------- | --------------------------------------------------------------------------------- | | Cross-provider equivalence | Two providers serving the same model id can return different bytes | | Cross-version equivalence | Substrate upgrades intentionally invalidate the cache via `pleachVersion` | | Cross-runtime equivalence | A custom provider that drifts from the standard ones isn't bound by this contract | | Latency equivalence | Determinism is about output bytes, not timing | The contract is "same inputs → same outputs" within a fixed substrate version, fixed provider, fixed model id. That's the useful guarantee. ## Why this matters [#why-this-matters] Three real workflows depend on the chain: 1. **Eval reruns.** A regression test that replays last week's production failure against this week's prompt only works if the bytes line up. Without determinism, every "regression" looks like noise. 2. **Cache correctness.** A cache that returns wrong-looking results is a quiet incident — the user sees the result, the audit row records it, but the trace doesn't reflect what would have happened on a fresh call. Determinism makes the cache sound. 3. **Compliance audit.** "Show me what the model would have said" is a real regulator question. The audit ledger + replay are the answer; both require the chain to hold. The substrate's many "weird" decisions — sync-only observers, quantized temperature buckets, canonicalized JSON, deterministic reducers, ULID identifiers — are the cost of buying these three properties. ## Where to go next [#where-to-go-next] --- # DevTools (/docs/devtools) DevTools is one surface in the **frontend integration** [thematic island](/docs/concept-clusters#what-lives-outside-the-cluster-pattern) — siblings of [react](/docs/react), [server](/docs/server), [api-routes](/docs/api-routes), and [query](/docs/query). In development, the runtime exposes a debugging interface on `window.__HARNESS_DEVTOOLS__`. The surface is small on purpose — just enough to inspect what the runtime sees, walk back through checkpoints, and force-sync the outbox without leaving the browser console. Gate the wiring behind `NODE_ENV !== "production"` so the surface doesn't ship in production bundles. ```typescript import { useHarnessDevTools, updateDevToolsSession, } from "@pleach/core/react"; import type { HarnessDevToolsAPI } from "@pleach/core/react"; ``` ## Wiring [#wiring] Call the hook once near the provider: ```tsx function App() { if (process.env.NODE_ENV !== "production") { // eslint-disable-next-line react-hooks/rules-of-hooks useHarnessDevTools(); } return ...; } ``` Once mounted, `window.__HARNESS_DEVTOOLS__` is the active runtime's debug surface. Refreshing the page rebuilds it. For TypeScript shims: ```typescript // types/global.d.ts import type { HarnessDevToolsAPI } from "@pleach/core/react"; declare global { interface Window { __HARNESS_DEVTOOLS__?: HarnessDevToolsAPI; } } ``` ## The surface [#the-surface] | Property | Returns | Use | | ---------------- | -------------------- | ---------------------------------------------------------------- | | `session` | `SessionState` | Current full state — messages, tools, channels | | `checkpoints()` | `Checkpoint[]` | List checkpoints for the current session | | `rollback(cpId)` | `Promise` | Time-travel to a checkpoint | | `tools()` | `ToolDefinition[]` | Active tool registry | | `syncStatus()` | `SyncStats` | Version vectors, pending changes, last sync | | `forceSync()` | `Promise` | Drain the outbox now | | `events()` | `HarnessEvent[]` | Recent event log entries | | `interrupts()` | `PendingInterrupt[]` | Outstanding HITL approvals | | `ledger()` | `AuditableCall[]` | In-memory audit rows (when using `MemoryProviderDecisionLedger`) | ## `session` [#session] A property, not a method. Always reflects the latest state. ```javascript __HARNESS_DEVTOOLS__.session.messages.length __HARNESS_DEVTOOLS__.session.pendingToolCalls __HARNESS_DEVTOOLS__.session.versionVector ``` For React state debugging — when the UI looks stale, compare `session` against the rendered transcript. If they disagree, the hook's subscription got dropped. ## `checkpoints()` [#checkpoints] Returns the checkpoint list for the current session, in creation order (ULID-sorted). ```javascript const cps = __HARNESS_DEVTOOLS__.checkpoints(); cps.map((c) => `${c.id} — ${c.stageId} — ${new Date(c.createdAt)}`); ``` Each checkpoint carries `id`, `sessionId`, `stageId`, `createdAt`, and the channel snapshot map. ## `rollback(checkpointId)` [#rollbackcheckpointid] Time-travel. Restores the session to the checkpoint and re-renders. ```javascript await __HARNESS_DEVTOOLS__.rollback("cp_018f..."); ``` The next `executeMessage` continues from the restored point. Subsequent checkpoints are preserved by default — pass `{ prune: true }` as a second argument to drop them: ```javascript await __HARNESS_DEVTOOLS__.rollback("cp_018f...", { prune: true }); ``` The pruning option is what you want when branching for an eval re-run. ## `tools()` [#tools] The currently-registered tool definitions. ```javascript __HARNESS_DEVTOOLS__.tools().map((t) => t.name); // → ["search_corpus", "calculator", "fetch_url"] ``` Inspect schemas: ```javascript const tool = __HARNESS_DEVTOOLS__.tools().find((t) => t.name === "search_corpus"); console.log(tool.inputSchema, tool.description); ``` Useful when the LLM is calling a tool name you don't recognize — verify it's actually in the registry. ## `syncStatus()` [#syncstatus] Coarse-grained sync state. ```javascript __HARNESS_DEVTOOLS__.syncStatus(); // → { // local: { clientId, vector }, // remote: { vector }, // pending: 3, // lastSyncedAt: 1717350000000, // errors: [] // } ``` For the rich shape, the React `useSyncStatus` hook returns the full `SyncStats` + `SyncError[]`. DevTools is the quick-look surface. ## `forceSync()` [#forcesync] Drains the outbox immediately rather than waiting for the next `flushIntervalMs` tick. Returns when the cycle completes. ```javascript await __HARNESS_DEVTOOLS__.forceSync(); __HARNESS_DEVTOOLS__.syncStatus().pending; // → 0 if cycle succeeded ``` Useful when you want to verify a write made it through before closing the tab. ## `events()` [#events] Recent event log entries. Returns the last N (default 100); pass a filter for typed slices: ```javascript __HARNESS_DEVTOOLS__.events(); __HARNESS_DEVTOOLS__.events({ types: ["tool.failed"], limit: 20 }); __HARNESS_DEVTOOLS__.events({ since: "01jc8..." }); ``` The shape is the same `HarnessEvent` shape the [event log](/docs/event-log) documents. ## `interrupts()` [#interrupts] Outstanding HITL approvals on the current session. Useful when the UI's approval modal isn't surfacing what the runtime is waiting on. ```javascript __HARNESS_DEVTOOLS__.interrupts(); // → [{ id, action_request, config, description, ... }] ``` Resolve from the console: ```javascript const [pending] = __HARNESS_DEVTOOLS__.interrupts(); await runtime.resolveInterrupt(pending.id, { type: "accept", args: null }); ``` ## `ledger()` [#ledger] The in-memory audit ledger contents. Only populated when the runtime is configured with `MemoryProviderDecisionLedger`. Returns empty when the production Supabase adapter is wired (use the [query](/docs/query) API for that). ```javascript __HARNESS_DEVTOOLS__.ledger().filter((r) => r.callClass === "synthesize"); // → typically exactly one row per turn ``` The one-synthesize-per-turn invariant is the easiest property to spot-check from DevTools — if you see two synthesize rows for a single `turnId`, something has drifted. ## `updateDevToolsSession(state)` [#updatedevtoolssessionstate] The manual push API. Normally the hook subscribes to runtime events and updates the DevTools surface automatically; this is the escape hatch for tests or imperative state writes. ```typescript import { updateDevToolsSession } from "@pleach/core/react"; updateDevToolsSession(synthesizedState); ``` Use sparingly. The hook subscription is the supported path. ## Production safety [#production-safety] `useHarnessDevTools` does **not** check `NODE_ENV` internally — the caller is responsible. The hook body unconditionally writes to `window.__HARNESS_DEVTOOLS__`; ship it in production and your production bundle gains a debug surface and a tree-shake escape for the underlying modules. Three options to gate it: ```tsx // Option 1 — conditional hook (lint rule will complain; disable it): if (process.env.NODE_ENV !== "production") { // eslint-disable-next-line react-hooks/rules-of-hooks useHarnessDevTools(); } // Option 2 — separate dev-only component, code-split by env: const DevTools = process.env.NODE_ENV !== "production" ? require("./DevTools").default : null; // Option 3 — always-on but pre-stripped at build time via dead-code elimination: if (false /* @__PURE__ */) useHarnessDevTools(); ``` Option 1 is the simplest. Option 2 is the cleanest for bundle size. Option 3 is for build pipelines that don't tree-shake conditionals well. ## Where to go next [#where-to-go-next] --- # What Pleach writes to your database (/docs/emitted-data) The runtime writes to *your* database. Point it at your Postgres, your Supabase project, an OpenTelemetry collector, or an in-memory buffer, and that's where the rows land — your storage, your control plane. The package has no phone-home: nothing it persists leaves the destination you configure. This page is the data-emission map. It answers two questions: what appears in your database when you run Pleach, and which of it is on by default versus enabled by a config flag or a plugin. For the column-by-column schema, see [Schema](/docs/schema). This page is the lens above it — what gets written, and why. ## The destination is yours [#the-destination-is-yours] With the [Memory storage adapter](/docs/storage), nothing is persisted at all — rows live in process and vanish on restart. With a Postgres or Supabase adapter, the tables below are created in your database and written by the adapter you configured. The [`@pleach/observe`](/docs/observe) SDK widens the destination set further — the same audit rows can go to your OTel collector instead of a SQL table. Nothing is written to a Pleach-operated service. The runtime is a library; the storage is a target you own. ## What gets written [#what-gets-written] The package writes two primary streams plus a set of substrate and plugin tables. Each row below names the table, when it's written, and what enables it. | Table | Written when | Enabled by | | ---------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------- | | `harness_sessions` | A session is created or its state changes | A persistent [storage adapter](/docs/storage) (default for Postgres / Supabase) | | `harness_event_log` | Every observable event during a turn | The [event log](/docs/event-log) — on with a persistent adapter | | `harness_auditable_calls` | One row per LLM call | The [audit ledger](/docs/audit-ledger) adapter | | `harness_config_manifest` | Once per session, content-addressed | The [config manifest](/docs/config-manifest) substrate | | `harness_checkpoints` | At stage boundaries | [Checkpointing](/docs/checkpointing) enabled | | `harness_outbox` | A mutation is buffered for the backend | [Sync](/docs/sync) enabled | | `harness_errors` | A structured error fires | `enableErrorPropagation: true` on the runtime | | `chat_session_links` | A session binds to an upstream chat | `SessionRuntimeConfig.chatId` set | | `harness_audit_records` | A cross-cutting audit record is logged | [`@pleach/compliance`](/docs/compliance) or a custom audit hook | | `harness_session_members` | A user is granted session access | Multi-user sessions (`useTeam`) | | `harness_session_comments` | A comment is posted on a session | Optional surface; the runtime never writes it | | `pleach_gateway_cost_events` | A routed call resolves a cost | [`@pleach/gateway`](/docs/gateway) cost attribution | | `pleach_byok_credentials` | A tenant key is stored | [`@pleach/gateway`](/docs/gateway/byok) BYOK | ## The two write streams [#the-two-write-streams] Most of what the runtime emits lands in one of two streams, and the distinction matters when you decide what to retain. The **[audit ledger](/docs/audit-ledger)** (`harness_auditable_calls`) records load-bearing *decisions* — which model fired, why it was chosen, what it cost, whether the cache hit. One row per LLM call, joinable to your billing schema by `tenantId` and `turnId`. This is the stream you query for cost and compliance. The **[event log](/docs/event-log)** (`harness_event_log`) records *what happened* — messages sent, tools dispatched, interrupts raised, subagents spawned, exports queued. Append-only, ULID-keyed. This is the stream you hydrate a session from and replay through [`@pleach/eval`](/docs/eval). The [config manifest](/docs/config-manifest) sits beside the event log: each event-log row references the `manifest_hash` of the substrate that produced it, so the two together are a complete, offline-replayable record. ## What's opt-in [#whats-opt-in] The substrate tables — sessions, the event log, the audit ledger, the config manifest — are the runtime's working state with a persistent adapter. The rest is something you turn on: * **Checkpointing** writes `harness_checkpoints` only when you enable it. Off means no per-stage snapshots. * **Sync** writes `harness_outbox` only when the durable-sync outbox is configured. Off means writes go straight to the adapter with no buffering table. * **Error propagation** writes `harness_errors` only under `enableErrorPropagation: true`. Off means errors surface as stream events without a persisted table. * **Compliance records** (`harness_audit_records`) are written by [`@pleach/compliance`](/docs/compliance) and custom audit hooks — nothing lands here unless a plugin writes it. * **Gateway tables** (`pleach_gateway_cost_events`, `pleach_byok_credentials`) exist only when you run [`@pleach/gateway`](/docs/gateway). * **Team and comment tables** (`harness_session_members`, `harness_session_comments`) are written only by the multi-user surfaces, never by a single-user runtime. ## What's in the rows — and what isn't [#whats-in-the-rows--and-what-isnt] Rows carry runtime metadata: identifiers, timestamps, event types, model and cost fields, content hashes. Message content and tool payloads land in the event log when you persist them; the audit ledger stores decision metadata, not prompt bodies. Before any PII-adjacent field is persisted, the [compliance scrubbers](/docs/scrubbers) can redact it on the write path — the redaction is applied to the row, not bolted on at read time. Tenant isolation is enforced by the [RLS template](/docs/schema#rls-template) every table ships, keyed on `tenant_id` or `organization_id`. What never gets written: telemetry to a Pleach service, usage beacons, license-check pings. The runtime doesn't call home. See [Security](/docs/security) for the full posture. ## Where to go next [#where-to-go-next] --- # Environment variables (/docs/env-vars) The runtime reads a small, named set of environment variables. Anything not listed here is either an application-level concern or a sibling package's contract. ## Runtime flags [#runtime-flags] A hard invariant (`audit:no-process-env-feature-flags-in-core`) keeps `@pleach/core` free of `process.env.FEATURE_*` reads. Flags like `FEATURE_HARNESS_V2_RUNTIME` and `FEATURE_TOOL_CALL_RUNAWAY_HARD_BLOCK` are read by the *embedding host* (e.g. the Ivy application's `src/config/featureFlags.ts`), never by the substrate — see [Host-read flags](#host-read-flags) below. Setting them has no effect on a bare `@pleach/core` install. ## Provider credentials [#provider-credentials] These map to the `native` transport for the corresponding family. The `openrouter` transport only needs `OPENROUTER_API_KEY` regardless of which family is locked. | Env var | Required for | | ------------------------------ | ------------------------------------------------------------------------------------------------------------- | | `ANTHROPIC_API_KEY` | `native` Anthropic transport. | | `OPENAI_API_KEY` | `native` OpenAI transport. | | `GOOGLE_GENERATIVE_AI_API_KEY` | `native` Google transport (pending). Primary; `GOOGLE_API_KEY` is accepted as a legacy fallback. | | `DEEPSEEK_API_KEY` | `native` DeepSeek transport (pending). | | `MISTRAL_API_KEY` | `native` Mistral transport (pending — Mistral has no native provider today; requests collapse to OpenRouter). | | `OPENROUTER_API_KEY` | The `openrouter` transport for any family. | ## Storage credentials (when using `@pleach/core/sessions` Supabase adapter) [#storage-credentials-when-using-pleachcoresessions-supabase-adapter] | Env var | Required for | | --------------------------- | -------------------------------------------------------------- | | `SUPABASE_URL` | Both `SupabaseAdapter` (browser) and `SupabaseSaver` (server). | | `SUPABASE_ANON_KEY` | Browser-side `SupabaseAdapter` (RLS-bound). | | `SUPABASE_SERVICE_ROLE_KEY` | Server-side `SupabaseSaver`. | The `Memory` and `IndexedDB` adapters don't read any env vars — they exist for tests and for browser-only sessions where storage is owned by the page. ## Provider key resolution order [#provider-key-resolution-order] When a call fires, the runtime looks for credentials in this order and stops at the first hit: 1. The `OrchestratorClient` constructor's `apiKey` option (set by the embedding application). 2. The session's BYOK key, if the session was started with one. 3. The matching process env var from the table above. 4. If nothing matches, the call fails at session start rather than at the first request — a hard failure at construction time is easier to diagnose than a transient one at first traffic. ## Host-read flags [#host-read-flags] These are read by an embedding host, not by `@pleach/core`. They are listed here because they commonly appear alongside a Pleach deployment, but a bare `@pleach/core` install ignores them entirely. | Env var | Read by | Effect | | -------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `FEATURE_HARNESS_V2_RUNTIME` | Host (e.g. Ivy `featureFlags.ts`) | Host-level toggle for whether the app routes turns through the runtime substrate at all; when `false`, the host's routes return 503. No effect inside `@pleach/core`. | | `FEATURE_TOOL_CALL_RUNAWAY_HARD_BLOCK` | Host (e.g. Ivy `featureFlags.ts`) | Host-side gate for the tool-call runaway hard-block enforcement. Default-on (only `"false"` disables). No effect inside `@pleach/core`. | ## Retired flags [#retired-flags] | Env var | Status | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `HARNESS_C1_DUAL_WRITE` | Retired. Pre-1.0 dual-write flag; the runtime now writes the canonical event log shape unconditionally. Setting it has no effect. | ## Where to go next [#where-to-go-next] --- # Error codes (/docs/error-codes) Every error the runtime emits carries a structured `code` field plus a `recovery` hint. Codes are organized into ranges by subsystem so a single `switch` statement can group them meaningfully without enumerating every value. The same code that lands in an `error` stream event also lands on the thrown `HarnessCodedError` instance for synchronous code paths. For `audit:*` gate failures (a different surface — CI-time rather than runtime), see [Audit gates](/docs/audit-gates). ## Error shape [#error-shape] The user-facing throwable is `HarnessCodedError`: ```typescript import { HarnessCodedError, isHarnessCodedError } from "@pleach/core"; class HarnessCodedError extends Error { errorCode: HarnessErrorCode; // e.g. "SESSION_NOT_FOUND" code: number; // e.g. 2001 message: string; // "[2001] Session not found" recovery: string; // human recovery instruction docs: string; // anchor link context: Record; // ctor-supplied diagnostic context } ``` In stream form: ```typescript { type: "error", error: "Session not found", code: "PROVIDER_ERROR", codeNum: 5006 } ``` **Stream `code` (string) vs `codeNum` (numeric).** On the *thrown* `HarnessCodedError`, `code` is always the numeric catalog value (e.g. `2001`). On the *stream* `error` event, `code` is a **string** discriminator a host may key on behaviorally (a graph fallback trigger, an exhaustion signal) — so it is not the numeric catalog code. The stream event also carries a dedicated numeric **`codeNum`** field (present when a catalog code is known at the emit site) that *is* the catalog value — use it for range dispatch (`Math.floor(codeNum / 1000)`) and numeric matching, and treat `code` as an opaque host-signal string. This keeps both values available without overloading the host trigger. `isHarnessCodedError(err)` is the type-guard. `getErrorInfo("SESSION_NOT_FOUND")` and `getErrorInfoByCode(2001)` look up the catalog entry without constructing an instance. `HarnessError` (interface, exported from the same barrel) is a separate shape — it's the persisted row used by `ErrorPropagator` for centralized error tracking, not the throwable. ### Typed subclasses [#typed-subclasses] In addition to `HarnessCodedError`, several subsystems throw domain-specific `Error` subclasses you can catch by class: | Subclass | Thrown from | Catch when | | ------------------------------------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------------------------- | | `TenantIdEmptyError` | `runtime.tenant` facet | Constructing a runtime without `tenantId` in tenant-scoped mode | | `HarnessModuleLoaderUnregisteredError` | `runtime/moduleLoaderRegistry` | A host adapter forgot to call `setHarnessModuleLoader` | | `ReplayDivergenceError` / `ReplayCacheMissError` / `ReplayUnknownEventError` | `@pleach/replay` | Deterministic-replay parity broke | | `NotImplementedError` | `@pleach/replay`, `@pleach/mcp` | A surface is declared but unwired | | `GatewayFamilyDeniedError` / `GatewayFamilyExhaustedError` / `GatewayTransportMissingError` | `@pleach/gateway` | Multi-key routing failed | | `ReservedNamespaceError` / `UnnamespacedIdError` / `DuplicateContributionIdError` | `prompts/types` | Plugin contributed a prompt id that collides with a reserved namespace | `HarnessCodedError` itself is `instanceof Error`, so the catch-all path still works. ## Ranges [#ranges] | Range | Category | Typical handling | | ----- | --------------------------- | ------------------------------------------------------------------------------------------------------------- | | 1xxx | Tool errors | Retry once, then surface to the user with the tool name | | 2xxx | Session errors | Re-create or re-load the session | | 3xxx | Sync errors | Conflict UI; let the user pick | | 4xxx | Storage errors | Surface as "could not save"; queue retry | | 5xxx | Provider errors | Fallback cascade; surface family-exhausted to the user | | 6xxx | Checkpoint errors | Skip the checkpoint operation; log; continue | | 7xxx | Validation errors | Treat as programmer error; fix the input shape | | 8xxx | Interrupt / approval errors | Timeout, rejection, or cancel of an approval gate | | 9xxx | Async job errors | Backend dispatch / execution failures for long-running jobs | | 10xxx | Caller-auth errors | The runtime caller's own identity/credential (distinct from the provider key in 5xxx and the session in 2xxx) | | 11xxx | Runtime / infra errors | Cross-cutting: transport network, upstream service, cost budget, abort, control-flow | ## 1xxx — Tool errors [#1xxx--tool-errors] | Code | Meaning | Recovery | | ---- | ---------------------- | ------------------------------------------------------------------------ | | 1001 | Tool not found | Check the tool registry; tool may have been disabled mid-session | | 1002 | Tool validation failed | Parameter validation failed; check arg types against the tool's schema | | 1003 | Tool execution failed | Inspect `cause`; one retry usually safe | | 1004 | Tool timeout | Lengthen the timeout if the tool is legitimately slow; otherwise surface | Tool errors surface on the stream as `tool.failed` first; the generic `error` event fires only when the failure escapes the tool boundary (e.g. the registry itself errored). ## 2xxx — Session errors [#2xxx--session-errors] | Code | Meaning | Recovery | | ---- | ----------------- | ------------------------------------------------------------------------ | | 2001 | Session not found | The id may be stale or the session was deleted | | 2002 | Session conflict | Another writer modified the session; fetch the latest state and retry | | 2003 | Session expired | Re-create; carry over what you need into the new session | | 2004 | Session locked | Another writer holds the lock; wait + retry, or surface "open elsewhere" | `2004` is the one to handle carefully — it usually means the same session is open in another tab. The user has context the runtime doesn't. ## 3xxx — Sync errors [#3xxx--sync-errors] | Code | Meaning | Recovery | | ---- | ------------------------ | ------------------------------------------------------------------- | | 3001 | Sync network error | Transient transport failure; changes are queued locally and retried | | 3002 | Sync conflict unresolved | Conflict reached a state the merger couldn't resolve; manual pick | | 3003 | Sync version mismatch | Local version vector is behind; pull the latest and retry | `3002` is the canonical "two devices wrote to the same session and the merger declined to choose" case. The accompanying `sync.conflict` event carries the `resolution` the merger chose; `3002` fires when it declined. `3001` is a plain transport failure — retry once connectivity returns. ## 4xxx — Storage errors [#4xxx--storage-errors] | Code | Meaning | Recovery | | ---- | ---------------------- | ---------------------------------------------------- | | 4001 | Storage write failed | Queue + retry with backoff; surface after N attempts | | 4002 | Storage read failed | Retry once; fall back to last-known state | | 4003 | Storage quota exceeded | Delete old sessions or checkpoints to free up space | Writes route through the durable-flush pipeline, which already handles transient `4001`s with retries. A `4001` on the stream means the durable-flush retries also failed. ## 5xxx — Provider errors [#5xxx--provider-errors] | Code | Meaning | Recovery | | ---- | -------------------------- | ------------------------------------------------------------------------------------ | | 5001 | Provider not configured | Set up the provider credentials in environment variables | | 5002 | Provider auth failed | Check API-key validity; the key may have expired or been revoked | | 5003 | Provider rate limited | Cascade; if every in-family rung is rate-limited, surface family-exhausted | | 5004 | Model not found | Check the model id; it may have been deprecated | | 5005 | Context window exceeded | Compact context (`context.summarized` event) and retry once | | 5006 | Stream error | The provider response stream was interrupted; retry the request | | 5007 | Provider timeout | The provider took too long; retry with a smaller context | | 5008 | Provider cascade exhausted | Every in-family rung failed; switch to a different model family or wait for capacity | A `5003` rarely escapes the cascade — the family-strict fallback walks every in-family rung first. When the consumer sees one as an `error` event, every rung has failed; the terminal signal is `5008` (cascade exhausted), carried by the typed `ProviderFamilyExhaustedError` / `RetryFamilyExhaustedError`. `5001` (not configured) and `5002` (auth failed) are terminal — no rung succeeds if credentials are missing or invalid. `5006` (stream error) and `5007` (timeout) are the transient mid-stream failures the retry loop attempts before giving up to `5008`. The runtime emits `family-exhausted` separately so a UI can ask the user to pick a different family explicitly. ## 6xxx — Checkpoint errors [#6xxx--checkpoint-errors] | Code | Meaning | Recovery | | ---- | ------------------------- | ------------------------------------------------------------------------------ | | 6001 | Checkpoint not found | Refresh the checkpoint list; id may be stale | | 6002 | Checkpoint corrupt | The checkpoint envelope was corrupt or schema-incompatible; use an earlier one | | 6003 | Checkpoint restore failed | State restoration failed; try a different checkpoint | Checkpoint errors never block the turn — the runtime skips the failing operation and continues. A `6001` on a manual `runtime.checkpoints.rollback` call is the one case where the consumer needs to surface the failure. ## 7xxx — Validation errors [#7xxx--validation-errors] | Code | Meaning | Recovery | | ---- | ---------------------- | ------------------------------------------------------ | | 7001 | Invalid input | Programmer error; fix the call site | | 7002 | Missing required field | Programmer error; pass the required field | | 7003 | Type mismatch in input | Programmer error; check value types against the schema | `7xxx` codes mean "the runtime was called incorrectly." They shouldn't appear in production once the integration is shaken out. Schema-version drift between the package and the database surfaces as `2006` (Session schema mismatch), not as a 7xxx — if you see `2006` in production, the schema bundle has advanced past what's applied; re-run `npx pleach init --apply` and apply the new migrations. ## 8xxx — Interrupt / approval errors [#8xxx--interrupt--approval-errors] | Code | Meaning | Recovery | | ---- | ---------------------------- | -------------------------------------------------------------- | | 8001 | Interrupt approval timed out | The user did not respond in time; the tool call was cancelled | | 8002 | Tool call rejected by user | The user declined; surface the decline and offer a rephrase | | 8003 | Interrupt cancelled | The interrupt request was cancelled before a decision was made | The 8xxx range surfaces approval-gate outcomes, not bugs. Don't alert on `8002` — user rejections are normal flow. Distinguish in dashboards the same way the subagent `terminalStatus` discriminator does: `8001` (timeout) and `8003` (cancelled) are operational signals; `8002` is product feedback. ## 9xxx — Async job errors [#9xxx--async-job-errors] | Code | Meaning | Recovery | | ---- | -------------------- | ------------------------------------------------------------------------------- | | 9001 | Job not found | The job may have expired or been deleted — check the job id | | 9002 | Job dispatch failed | Backend service is unhealthy; retry the dispatch | | 9003 | Job execution failed | Check job logs for error details; the job may need to be retried | | 9004 | Job cancelled | The job was cancelled before completion; resubmit if the result is still needed | The 9xxx range surfaces failures from the long-running job substrate. A `9002` typically means the backend dispatcher (Modal, queue worker, etc.) is unreachable — retry with backoff. A `9003` is the job itself failing; the runtime can't recover, the caller decides whether to retry or surface. `9004` (cancelled) is not a failure — it's a user/operator action; don't alert on it. ## 10xxx — Caller-auth errors [#10xxx--caller-auth-errors] | Code | Meaning | Recovery | | ----- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | 10001 | Caller auth required | Sign in or supply a valid API credential before retrying | | 10002 | Caller credential invalid | The presented credential was rejected — check the key value; this is a credential problem, not an expired session, so do **not** force a full sign-out | | 10003 | Caller forbidden | Authenticated but not authorized; request the required scope/role | The 10xxx range is the runtime **caller's own** identity — distinct from `5002` (the LLM provider key) and `2003` (the harness session). `10002` is the one to handle carefully: a rejected API key is a credential problem, not a session expiry, so the affordance is "fix your key", not "sign in again". A host maps its transport-level 401/403 reasons into these via a `contributeErrorClassifiers` plugin. ## 11xxx — Runtime / infra errors [#11xxx--runtime--infra-errors] | Code | Meaning | Recovery | | ----- | -------------------- | ------------------------------------------------------------------------------- | | 11001 | Network error | Transient transport failure; retry | | 11002 | Service unavailable | Upstream returned 5xx/unavailable; retry after a short delay | | 11003 | Cost budget exceeded | Raise the budget ceiling or reduce the work requested for the turn | | 11004 | Aborted | The request was cancelled (caller, interrupt, or signal); retry if unintended | | 11005 | Agent loop error | The turn could not converge; inspect the last steps and retry, possibly simpler | The 11xxx range is cross-cutting infra not owned by a single subsystem. `11001`/`11002` are transient (retry); `11003` is a policy stop (budget); `11004` is usually intentional (user abort — don't alert); `11005` is the turn-level "couldn't make progress" catch-all. ## Handling errors at the call site [#handling-errors-at-the-call-site] ```typescript for await (const event of runtime.executeMessage(sessionId, prompt)) { if (event.type === "error") { // Range-dispatch on the NUMERIC codeNum (the stream `code` is a string host // signal — see the callout above). codeNum is absent when no catalog code was // known at the emit site, so it falls through to the generic branch. const range = Math.floor((event.codeNum ?? 0) / 1000); // 2 for 2xxx, etc. switch (range) { case 2: return reload(); case 3: return surfaceConflictUI(); case 5: return showFamilyExhaustedDialog(); case 8: return surfaceApprovalOutcome(event); case 9: return surfaceJobFailure(event); default: return showGenericError(event.error); } } } ``` For thrown errors (storage construction failures, config errors): ```typescript import { isHarnessCodedError } from "@pleach/core"; try { const session = await runtime.createSession(config); } catch (err) { if (isHarnessCodedError(err) && err.code === 7002) { // missing required field (VALIDATION_MISSING_REQUIRED) — fix the caller } throw err; } ``` ### Catching a model-not-found error [#catching-a-model-not-found-error] A `5004` fires when the requested model id no longer resolves — usually because the model was deprecated or rotated out from under a pinned config. There is no dedicated safety-refusal code in the enum; a provider-side refusal surfaces through the ordinary provider-error path (`5006` stream error / non-clean `finishReason`) after the family cascade gives up. Translate a `5004` into a user-readable message and offer a next step: ```typescript for await (const event of runtime.executeMessage(sessionId, prompt)) { if (event.type === "error" && event.codeNum === 5004) { showToast({ title: "That model is no longer available.", body: "The requested model id could not be resolved — it may have been deprecated.", action: { label: "Pick another model", onClick: openModelPicker }, }); return; } } ``` ### Transient vs terminal dispatcher [#transient-vs-terminal-dispatcher] Not every code deserves a retry. Group `1xxx` / `4xxx` / parts of `5xxx` as retryable; treat `2xxx` / `6xxx` / `7xxx` as terminal for the current call site: ```typescript import type { HarnessCodedError } from "@pleach/core"; const TRANSIENT = new Set([1003, 1004, 4001, 4002, 5003, 5006, 5007, 9002]); const TERMINAL = new Set([2001, 2003, 5001, 5002, 6001, 7001, 7003, 8002]); function dispatch(err: HarnessCodedError) { if (TRANSIENT.has(err.code)) return { retry: true, after: 500 }; if (TERMINAL.has(err.code)) return { retry: false, surface: err.message }; return { retry: false, surface: "Unexpected error", log: err }; } ``` The split tracks the recovery column in the range tables above — keep it in one place so call sites don't drift. ## Where to go next [#where-to-go-next] --- # Eval and replay (/docs/eval-and-replay) This page is the DIY pattern against the substrate. For the published SKU references, see [`@pleach/eval`](/docs/eval) and [`@pleach/replay`](/docs/replay). The substrate ships the primitives `@pleach/eval` and `@pleach/replay` build on: deterministic fingerprints, the append-only audit ledger, per-channel checkpoints, and the three `runtimeMode` modes. `@pleach/replay@0.1.0` and `@pleach/eval@0.1.0` are shipping today — `createReplayRuntime`'s four entry points (`replayTurn`, `fromSnapshot`, `fork`, `aggregateMultiTenant`) plus `verifyChainIntegrity` all have real bodies, and the event-granular `ReplayHandle.step()` / `seek()` / `replayTurn()` stepper is wired against `runtime.events.iterate`. This page documents the underlying workflow so you can DIY against the substrate before adopting the SKUs, or read what the SKUs do. This page walks the eval / replay workflow end-to-end. Each section is implementable with only the substrate; sibling-SKU adoption layers typed contracts and scoring on top. ## The three workflows [#the-three-workflows] | Workflow | What you measure | Primitive used | | ------------------- | -------------------------------------------------------- | --------------------------------------- | | **Replay** | Did the same input produce the same output? | Fingerprint + recorded ledger | | **Regression eval** | Does the new version produce the same output as the old? | Fingerprint diff across `pleachVersion` | | **Behavioral eval** | Did the new version produce a *better* output? | Custom scorer + ledger metadata | Replay and regression eval are *correctness* signals — yes/no. Behavioral eval is a *quality* signal — score-based. ## `runtimeMode` is the eval seam [#runtimemode-is-the-eval-seam] The runtime distinguishes three operating modes via `runtimeMode` in the fingerprint key. Picking the right mode is how you opt into the eval workflow. | Mode | Provider calls | Cache reads | Cache writes | | ---------------- | ------------------------------------ | ----------- | ------------ | | `production` | Real | Enabled | Enabled | | `replay` | Intercepted; cached results returned | Enabled | Disabled | | `eval-noncached` | Real | Disabled | Disabled | Set via runtime config or `HARNESS_RUNTIME_MODE`. The mode is in the fingerprint, so a turn recorded in one mode never collides with the cache of another. ## Recording a turn for replay [#recording-a-turn-for-replay] In production mode, every turn writes a full audit-ledger row per LLM call. The row carries the fingerprint, the metadata, the model id, the token usage, and the decision payload — enough to reconstruct the call. ```typescript const runtime = new SessionRuntime({ storage: supabaseAdapter, userId: "user_123", // runtimeMode defaults to "production" }); for await (const event of runtime.executeMessage(sessionId, prompt)) { // ... handle the stream } // After the turn, every call's row is in `harness_auditable_calls`. ``` No additional capture step. The ledger is the recording. ## Replaying a turn [#replaying-a-turn] Build a fresh runtime around the same storage and ledger, open a `ReplayHandle` over the chat's event log via `@pleach/replay`, then advance one assistant turn with `handle.replayTurn()`: ```typescript import { SessionRuntime } from "@pleach/core"; import { ReplayClient, type ReplayRuntimeFacet } from "@pleach/replay"; const runtime = new SessionRuntime({ storage: supabaseAdapter, userId: "user_123", }); // ReplayClient reads only the runtime's `events` facet (iterate + fold). const replay = new ReplayClient({ runtime: runtime as unknown as ReplayRuntimeFacet, }); const handle = await replay.fromEventLog(sessionId, { tenantId: "tenant_xyz", // REQUIRED — replay inherits the runtime's RLS posture }); const turn = await handle.replayTurn(); // advance to the next turn.completed boundary console.log(turn.messageId); // the assistant message that closed the turn console.log(turn.stepResults); // the per-event step results, in fold order console.log(turn.nextState); // the folded HydratedHarnessState after the turn await handle.close(); ``` `replayTurn(messageId?)` loops `step()` to the next `turn.completed` boundary, folding each event through `@pleach/core`'s shared `hydrateFromEvents` reducer. Because incremental `step()` and a full `seek()` re-fold share one reducer, N steps produce a state byte-identical to `seek(N)` — the determinism invariant. To replay against a changed seam (a different provider, an updated prompt contribution, a new safety policy), construct a fresh runtime around the same storage with that seam swapped in, then open a new handle on the new runtime. The runtime has no `withProvider()` / `withSystemPrompt()` mutators — every seam change is a fresh construction. ### Verifying byte-identical replay [#verifying-byte-identical-replay] `createStrictHandleReplay` from `@pleach/replay/strict` is the callable form of the determinism invariant. It opens N independent handles over the same `(chatId, tenantId, window)`, walks each to `done`, and byte-compares the folded state at every step — surfacing the step index where two replays first diverge. ```typescript import { ReplayClient, type ReplayRuntimeFacet } from "@pleach/replay"; import { createStrictHandleReplay } from "@pleach/replay/strict"; const replay = new ReplayClient({ runtime: runtime as unknown as ReplayRuntimeFacet, }); const strict = createStrictHandleReplay({ client: replay }); const verdict = await strict.replay({ chatId: sessionId, tenantId: "tenant_xyz" }); // → { chatId, deterministic: true, steps: 12 } // or { deterministic: false, firstDivergenceAt: 4 } ``` If the verdict is `deterministic: true`, the replay is byte-identical. If it diverges, `firstDivergenceAt` is the step where the chain slipped — walk back through the five contracts in [Determinism](/docs/determinism). ## Regression eval across versions [#regression-eval-across-versions] The fingerprint includes `pleachVersion`. A turn recorded in `1.1.0` will not cache-hit in `1.2.0` automatically — the new version invalidates the bucket. The regression workflow: replay the recorded turn against the new version *without* the cache, capture the new output, and diff. ```typescript // 1. Record under the old version. const recordRuntime = new SessionRuntime({ /* ... */ runtimeMode: "production", // pleachVersion = "1.1.0" }); await recordRuntime.executeMessage(sessionId, prompt); // 2. Re-run under the new version in eval-noncached mode. // (Update the package first; pleachVersion = "1.2.0" now.) const evalRuntime = new SessionRuntime({ /* ... */ runtimeMode: "eval-noncached", }); await evalRuntime.executeMessage(newSessionId, prompt); // 3. Diff the audit ledger rows. const recorded = await ledger.listBySession(sessionId); const evaluated = await ledger.listBySession(newSessionId); const diffs = recorded.map((r, i) => ({ callIndex: i, modelChanged: r.modelId !== evaluated[i]?.modelId, tokenDelta: (evaluated[i]?.tokenUsage.out ?? 0) - r.tokenUsage.out, outcomeChanged: r.outcome.status !== evaluated[i]?.outcome.status, })); ``` The diffs are the regression report. A clean migration produces zero `outcomeChanged: true` rows; otherwise you've found a behavior change to triage. ## Behavioral eval with scorers [#behavioral-eval-with-scorers] Behavioral eval scores model output for quality (helpfulness, accuracy, safety) rather than checking byte equality. Run the turn, then score the output: ```typescript import type { AuditableCall } from "@pleach/core/audit"; interface Scorer { name: string; score(call: AuditableCall, output: string): Promise; } const scorers: Scorer[] = [ { name: "factual-accuracy", async score(call, output) { // Use a judge model to score factual claims: const judge = await judgeModel.evaluate({ prompt, output }); return judge.factualAccuracy; }, }, { name: "concision", async score(call, output) { return output.length < 1000 ? 1.0 : 1000 / output.length; }, }, ]; const synthRow = (await ledger.listBySession(sessionId)) .find((r) => r.callClass === "synthesize"); const scores = await Promise.all( scorers.map(async (s) => ({ name: s.name, score: await s.score(synthRow, synthRow.payload.output as string), })), ); ``` The scorers are pluggable. Build a library matched to your domain — output-format compliance, citation accuracy, refusal calibration — and run them as a post-hoc pass over the recorded ledger. ### The fingerprint pins the comparison [#the-fingerprint-pins-the-comparison] Every score is keyed on `(fingerprint, scorer, version)`. Same input + same model + same scorer = same score. That's what makes "score drift" a real signal rather than measurement noise. ## Forking from a checkpoint [#forking-from-a-checkpoint] Replay can branch from any checkpoint. The substrate ships the checkpoint envelope; the sibling `@pleach/replay` will ship a typed fork API when published. Today's pattern: ```typescript import { SessionRuntime } from "@pleach/core"; // 1. Pick a checkpoint to fork from. const checkpoints = await runtime.listCheckpoints(sessionId); const forkPoint = checkpoints.find((c) => c.stageId === "tool-loop"); // 2. Restore into a fresh session. const forkedSession = await runtime.createSession({ parentSessionId: sessionId, forkFromCheckpoint: forkPoint.id, }); // 3. Replay (or branch with a different prompt). for await (const event of runtime.executeMessage(forkedSession.id, "different prompt")) { // ... } ``` The forked session's audit ledger carries `parentSessionId` and `forkPoint` so the lineage stays queryable through [`LineageTracker`](/docs/lineage). ## Eval CI [#eval-ci] A typical eval-in-CI shape: ```yaml # .github/workflows/eval.yml jobs: regression-eval: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm install - run: HARNESS_RUNTIME_MODE=eval-noncached npm run eval:replay - run: npm run eval:diff > eval-report.md - uses: actions/upload-artifact@v4 with: name: eval-report path: eval-report.md ``` The `eval:replay` script runs every fixture against the current build; `eval:diff` compares the new ledger rows against the golden ledger rows committed in the repo. A PR is mergeable when the diff is empty (or all diffs are explained in the PR description). ## What the sibling SKUs will add [#what-the-sibling-skus-will-add] When `@pleach/eval` and `@pleach/replay` ship their first cuts, they layer convenience on top of the primitives above: * **`@pleach/eval`** — fixture format, scorer registry, diff rendering, CI integration. The workflow above expressed as typed config. * **`@pleach/replay`** — `forkFromCheckpoint(runtime, opts)`, divergence-detection on partial replays, replay-mode optimization for cold-cache scenarios. Until they ship, the primitives are enough — the workflow above is the substrate's own test suite, with one fingerprint diff per turn the canonical signal. ## Where to go next [#where-to-go-next] --- # @pleach/eval (/docs/eval) How the garden grew this season — fixed seeds, measured shoots, comparable rows. `@pleach/eval` is the evaluation harness for `@pleach/core` agent runtimes. The package ships an `EvalSuite` class that accepts cases as a discriminated union, four built-in scorers, three report formatters (JSON, Markdown, JUnit XML), and a Phase B `EvalRuntime` factory with live bodies for batch execution, report rendering, structural `diffReports`, and statistical `compareScored` (bootstrap + Welch's t-test). Coupling to `@pleach/replay` is opt-in via dependency injection — the two SKUs install and adopt independently. This page is the SKU reference. For the DIY workflow using `@pleach/core` primitives directly today (without this SKU), see [Eval and replay](/docs/eval-and-replay). ## Install [#install] ```bash npm install @pleach/eval @pleach/core ``` ```bash pnpm add @pleach/eval @pleach/core ``` ```bash bun add @pleach/eval @pleach/core ``` `@pleach/core` is a peer dependency. ```typescript import { EvalSuite } from "@pleach/eval"; import { tokenF1 } from "@pleach/eval/scorers"; import { formatMarkdown } from "@pleach/eval/report"; import { NoopReplayClient } from "@pleach/eval/replay"; ``` The import names above are illustrative. The authoritative export shape lives in the package README on [npm](https://www.npmjs.com/package/@pleach/eval) — check it when pinning a version, since the surface evolves between Phase A and the 1.0 cut. ## The `EvalSuite` class [#the-evalsuite-class] `EvalSuite` is the entry point. Construct it with a `suiteId` (the identifier that surfaces in reports), register cases, then call `run()` to produce an `EvalReport`. ```typescript import { EvalSuite } from "@pleach/eval"; const suite = new EvalSuite({ suiteId: "my-eval" }); suite.register({ kind: "expected", id: "greeting", input: "hello", expected: "hello", }); const report = await suite.run(); console.log(report.summary); // { total: 1, passed: 1, failed: 0, errored: 0 } ``` Cases run in registration order. The report carries a stable `runId`, the start and completion timestamps, every case result, and a summary count. The constructor takes a `runtime` (any `EvalSessionRuntimeLike` — a real `SessionRuntime` from `@pleach/core` satisfies the shape), a `cacheBackend` (defaults per-suite — see below), and a `cacheReadPolicy` (`"strict-mode"` for research replication, `"cross-mode-readable"` for graded coursework). The `runtime` is optional in Phase A so the surface unit-tests against a mock; in production callers always provide a real runtime. ## Case shapes [#case-shapes] `EvalCase` is a discriminated union with three shapes. There is no global registry — scorers ride inline with the case. ### `expected` — pass/fail equality [#expected--passfail-equality] A string or RegExp equality check. ```typescript suite.register({ kind: "expected", id: "section-cite", input: "the model output", expected: /section 4\.\d+/, }); ``` ### `scored` — numeric score from a scorer [#scored--numeric-score-from-a-scorer] A scorer returns a score in `[0, 1]`. Reference a built-in by id or ship a custom function inline. ```typescript suite.register({ kind: "scored", id: "f1-against-golden", input: { expected: "the quick brown fox" }, scorer: { kind: "builtin", id: "tokenF1" }, }); suite.register({ kind: "scored", id: "length-bound", input: "...", scorer: { kind: "custom", fn: async (actual) => ({ score: actual.length > 100 ? 1 : 0, explanation: `length=${actual.length}`, }), }, }); ``` A `scored` case passes when its score is `>= 1`. Anything lower is a fail, but the numeric score still rolls into the report's mean. ### `judged` — LLM-as-judge ensemble [#judged--llm-as-judge-ensemble] A list of judge models that score the actual output. Each judge carries a model id, a rubric prompt, and a score schema (`"binary"`, `"likert-5"`, or `"numeric"`). ```typescript suite.register({ kind: "judged", id: "summary-accuracy", input: "summarize this paper: ...", judges: [ { model: "anthropic/claude-opus-4-7", rubric: "Rate the summary's factual accuracy on a 1-5 Likert scale.", scoreSchema: "likert-5", }, ], }); ``` Phase A ships the contract; judge dispatch is stubbed. Judged cases return a placeholder score and an explanation noting the stub. The Phase B landing wires dispatch through the runtime's provider seam so judges route through `@pleach/gateway` when present. ## The four built-in scorers [#the-four-built-in-scorers] Exported from `@pleach/eval/scorers`. All four implement the `EvalScorerFn` contract — `(actual, ctx) => EvalScore` — so they work as both inline values and `EvalScorerRef` references. | Scorer | Input expectation | Score | | ------------------- | ------------------------------------------ | --------------------------------------------- | | `exactMatch` | `expected` string in case input | 1 iff `actual === expected` | | `substringContains` | `expected` string in case input | 1 iff `actual.includes(expected)` | | `regexMatch` | `pattern` + optional `flags` in case input | 1 iff the regex tests true | | `tokenF1` | `expected` string in case input | Token-level F1 (whitespace-split, lowercased) | `exactMatch`, `substringContains`, and `tokenF1` read the expected value from either a bare string input or an `{ expected }` field on a record input. `regexMatch` reads `{ pattern, flags? }` (or treats a bare string input as the pattern with no flags). When the expected value is missing, the scorer returns score 0 with an explanation naming the missing field — no thrown exceptions for misconfigured cases. `tokenF1` returns the F1 over the token multiset: `2PR / (P + R)`, where P is overlap divided by `actual` token count and R is overlap divided by `expected` token count. Both sides empty score 1 by convention; one side empty scores 0. ## The three report formats [#the-three-report-formats] Exported from `@pleach/eval/report`. The same `EvalReport` feeds all three formatters — pick one (or several) depending on where the report lands. ```typescript import { formatJSON, formatMarkdown, formatJUnitXML } from "@pleach/eval/report"; const report = await suite.run(); console.log(formatJSON(report)); // pretty-printed JSON console.log(formatMarkdown(report)); // human-readable summary console.log(formatJUnitXML(report)); // CI consumer ``` `formatJSON` sorts case results by `caseId` and emits stable key ordering — two runs against identical input produce byte-identical JSON output, which makes the formatter usable as a golden-file target. Two-space indent matches `jq` defaults. `formatMarkdown` emits a summary block plus a per-case table with status, score, and explanation. Pipe character escaping is handled. `formatJUnitXML` produces a single `` element with one `` per eval case. Errored cases include ``, failed cases include ``. The shape matches the `junit-xml` convention used by `mocha-junit-reporter` and `jest-junit`, so the output plugs into GitHub Actions, GitLab CI, and Jenkins without a custom reporter. ## Replay coupling via constructor DI [#replay-coupling-via-constructor-di] `@pleach/eval` does not import `@pleach/replay`. The two SKUs adopt independently and couple through a structural contract — `EvalReplayClient` — that consumers (or `@pleach/replay` itself) implement. ```typescript import { EvalSuite, type EvalReplayClient } from "@pleach/eval"; import { createReplayRuntime } from "@pleach/replay"; const suite = new EvalSuite({ suiteId: "replay-eval", runtime }); // @pleach/replay's turn-granular runtime, adapted to eval's structural // `EvalReplayClient` contract (`replay(chatId) => { output }`). const tenantId = "tenant_xyz"; const replayRuntime = createReplayRuntime({ tenantId, sessionRuntime: runtime }); const replayClient: EvalReplayClient = { async replay(chatId) { const { state } = await replayRuntime.replayTurn({ chatId, tenantId }); return { output: String(state ?? "") }; }, }; suite.setReplayClient(replayClient); const report = await suite.replay("chat_01HKXJ..."); ``` `replay(chatId)` reconstructs the turn's final output via the injected client and runs every registered case against it. Without a client, `replay()` throws with a message that names the missing DI. `@pleach/eval/replay` ships two test stubs: * `NoopReplayClient` — returns empty output for any chat id. Useful when unit-testing the `replay()` wiring without dragging in the real replay package. * `StaticReplayClient` — returns a canned output for any chat id. Useful when unit-testing scorer dispatch against a known output. ```typescript import { NoopReplayClient, StaticReplayClient } from "@pleach/eval/replay"; suite.setReplayClient(new StaticReplayClient("the canned output")); ``` The stubs ship in the same subpath as the contract, so consumers that only need the structural type — not the real replay client — don't pick up a transitive dependency. ## Per-suite cache backend [#per-suite-cache-backend] The cache backend defaults to a fresh in-memory backend per `EvalSuite` instance. That isolation is the load-bearing piece: parallel suites in the same process can't poison each other's cache, and a `run()` against a deterministic runtime produces byte-identical reports on repeat. Override when you want the suite to share state with a production runtime: ```typescript import { createMemoryCacheBackend } from "@pleach/core/cache"; const suite = new EvalSuite({ suiteId: "shared-cache-eval", runtime, cacheBackend: createMemoryCacheBackend({ maxEntries: 10_000 }), cacheReadPolicy: "strict-mode", }); ``` `cacheReadPolicy: "strict-mode"` is the research-replication setting — a cache miss in strict mode is an error, not a silent fallthrough. `"cross-mode-readable"` is the graded-coursework setting where reads can cross between `interactive` / `headless-eval` / `headless-replay` buckets. See [Cache](/docs/cache) for the policy contract. ## Phase A status [#phase-a-status] The package shipped at `0.1.0` as Phase A of the SKU. The Phase A `EvalSuite` contract is in place; two pieces of behavior are stubbed and land in a follow-up: * **Judge dispatch.** Judged cases record the ensemble shape but do not invoke any provider. Judge dispatch wires through `runtime.providers` so judges route through `@pleach/gateway` when it's configured. * **Default actual-output resolver.** Phase A's default resolver echoes the case input back as the actual output. Production callers override via `setActualResolver(resolver)` to drive a real runtime turn. The default keeps the surface unit-testable; nothing about the report shape changes when the resolver is swapped. Both behaviors are tracked against the package's published changelog on npm; pin a version with the change you need. ## Phase B runtime — `createEvalRuntime` [#phase-b-runtime--createevalruntime] The Phase B `EvalRuntime` factory (`createEvalRuntime`) ships alongside the Phase A surface and exposes the batch / report / comparison surface used for cross-config and cross-run analysis. At the current cut: | `EvalRuntime` method | Status | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `runScenario(input)` | Live — validates input + emits a stub `EvalScenarioOutput` (real LLM invocation routes through `harnessRuntime` in a follow-up) | | `runBatch(input)` | Live — iterates `configs × rows`, dispatches each cell via `runScenario`, aggregates per-config + global cost rolls | | `produceEvalReport(input)` | Live — renders a versioned report in JSON / Markdown / CSV format | | `recordOutcome(input)` | Live — appends to an in-memory outcomes ledger (persistent storage via `@pleach/replay` cache is a follow-up) | | `diffReports(prior, next)` | Live — pure-function deterministic structural diff over two `EvalReportOutput` instances | | `compareScored(prior, next, opts?)` | Live — bootstrap resampling + Welch's t-test over two scored runs | | `runStreaming(input, callbacks)` | Live — streaming variant of `runBatch` with per-cell callbacks | ### `diffReports(prior, next)` [#diffreportsprior-next] Pure-function structural diff between two `EvalReportOutput` instances. Both inputs must carry the same `version` and `format: "json"` (the canonical JSON format is parsed and walked cell-by-cell). The returned `EvalReportDiff` carries per-cell status (`added`, `removed`, `changed`, `unchanged`) and a summary roll-up. Use it to verify two pinned report snapshots match, or to surface what changed between a pre- and post-refactor batch. ### `compareScored(prior, next, opts?)` [#comparescoredprior-next-opts] Statistical comparison of two scored eval runs. Pairs cells by `(config, row)` identity, then runs bootstrap resampling and Welch's t-test on the paired score deltas — paired-sample comparison with unequal variances (Welch's t-test is the unequal-variance generalization of Student's t-test; bootstrap resampling gives a non-parametric confidence interval that holds without distributional assumptions on the score). The returned `EvalComparisonResult` carries the bootstrap CI, the t-test statistic + p-value, and per-row pairing diagnostics so a caller can reject "the new config is statistically better" claims that fail the significance bar. Pair this with [`@pleach/replay`](/docs/replay) when the eval batches you're comparing are themselves derived from chat replays — `@pleach/eval` consumes `EvalReportOutput`s, and `@pleach/replay` is the substrate that produces deterministic outputs to score. ## Where to go next [#where-to-go-next] --- # Event-log projections (/docs/event-log-projections) A projection is a deterministic fold over the event log that produces a typed view. Instead of re-implementing "last message per channel" or "active tool calls" against the raw row stream in every consumer, the substrate ships the fold contract — `GraphProjection` — plus `runtime.events.iterate` and `runtime.events.fold` to drive it. The contract is stable. The built-in projections are **in soak** — their names and exact signatures may drift between releases. Depend on `GraphProjection`; treat the shipped projections as reference implementations whose API surface hasn't fully settled yet. Today's roster: | Projection | What it folds into | Status | | --------------------------------------- | ----------------------------------------------------------- | -------- | | `configProjection` | Resolved per-session config snapshot | shipping | | `messageProjection` | Ordered message log | shipping | | `toolCallProjection` | Per-turn tool-call timeline | shipping | | `jobProjection` / `createJobProjection` | Async job state per session | shipping | | `artifactProjection` | Lineage-aware artifact set + `asset.consumed` reducer | shipping | | `interruptProjection` | Active human-interrupt chain | shipping | | `subagentProjection` | Active subagent registry | shipping | | `exportProjection` | Sandbox-export lineage | shipping | | `userCardProjection` | User-card state per session | shipping | | `reconstructSessionState` | Composite function over the above (not a `GraphProjection`) | shipping | ```typescript import type { GraphProjection } from "@pleach/core"; import { configProjection, messageProjection, toolCallProjection, jobProjection, artifactProjection, interruptProjection, subagentProjection, exportProjection, userCardProjection, reconstructSessionState, } from "@pleach/core/eventLog"; ``` `runtime.events.iterate({ chatId, fromSequenceNumber?, tenantId? })` returns a paginated async-iterable over the persisted event-log rows ordered by `sequence_number`; `runtime.events.fold(projection)` composes a `GraphProjection` over that stream and returns the accumulator. The read side is **store-agnostic**: it delegates to a `HarnessEventLogReader` you inject at runtime construction (`eventReader` on the runtime config — the read-side counterpart to `eventLogWriter`). The reader is the only thing that knows your store, so you can back the same `iterate`/`fold`/resume surface with Postgres, Supabase, SQLite/pglite, or an HTTP API — and the reader is free to use whatever query mechanism your store offers (a raw `SELECT`, a PostgREST builder, a fetch). When no reader is injected, `iterate` yields nothing (a bare runtime has no event store), exactly as on the browser / `MemoryAdapter` path. See [Backing the reader](#backing-the-reader) below. See [Event log](/docs/event-log) for the underlying row stream and the durable-flush + hydration pipeline this builds on. ## The `GraphProjection` contract [#the-graphprojectiont-contract] A projection declares a stable `name`, an `initial` state factory, a `reduce` step, and an optional `finalize` step that runs once after all rows are folded. ```typescript interface GraphProjection { readonly name: string; readonly initial: () => T; readonly reduce: (acc: T, row: EventLogRow) => T; readonly finalize?: (acc: T) => T; } ``` Rules: 1. **Pure.** `reduce` is a pure function of `(acc, row)`. Same event slice in, same `T` out — that's what makes projections safe to re-derive on every render instead of caching. 2. **Order-respecting.** Rows arrive in `sequence_number` order (`(chat_id, sequence_number)` ascending). A projection that needs order-independence has to model it explicitly; the substrate doesn't shuffle for you. 3. **Total.** `reduce` must treat unknown `event_type` values as no-ops (return the accumulator unchanged), not throws — the event space accumulates new types over time. Filter to the event types you care about with an early `return acc` inside `reduce`. 4. **`finalize` is post-fold.** Use the optional `finalize(acc)` step for cross-row resolution — matching lifecycle pairs by ID, sorting, terminal-state derivation — after every row is consumed. ## Reading via `runtime.events` [#reading-via-runtimeevents] Two accessors. The first hands you the raw row stream; the second runs a projection to completion against the current event log. `runtime.events.iterate(opts)` is an async iterator over event log rows for a session, optionally scoped by `chatId`, `fromSequenceNumber`, or `tenantId`. ```typescript for await (const row of runtime.events.iterate({ chatId, fromSequenceNumber: lastSeenSequence, })) { console.log(row.event_type, row.payload); } ``` `runtime.events.fold(projection, opts)` runs a projection against the (filtered) row stream and returns the final state. ```typescript import { toolCallProjection } from "@pleach/core/eventLog"; const toolState = await runtime.events.fold(toolCallProjection, { fromSequenceNumber: turnStartSequence, }); // toolState carries the pending and completed tool calls for this turn. ``` Both accessors take the same options — `chatId`, `fromSequenceNumber`, and `tenantId` — so you can scope a projection to a session, resume from a checkpoint cursor, or filter to a single tenant without leaving the contract. ## Backing the reader [#backing-the-reader] `runtime.events.iterate` / `.fold` read whatever you wire as the runtime's `eventReader`. The contract is one method: ```typescript import type { HarnessEventLogReader } from "@pleach/core/eventLog"; const eventReader: HarnessEventLogReader = { iterate({ chatId, fromSequenceNumber, tenantId } = {}) { return { async *[Symbol.asyncIterator]() { // ANY query mechanism your store offers — here, a raw SELECT. const rows = await db.query( `SELECT * FROM harness_event_log WHERE session_id = $1 ORDER BY sequence_number ASC`, [chatId], ); for (const row of rows) yield row; // EventLogRow shape }, }; }, }; createPleachRuntime({ storage, eventReader }); ``` Each yielded row is an `EventLogRow` (the same shape `reconstructSessionState` and the shipped projections fold). Because the runtime delegates to your reader, there is no PostgREST grammar to emulate and no provider lock-in — the reader picks the cheapest query for its store. The host casts its client into this shape **at construction**, keeping `@pleach/core` itself free of any backing-store knowledge. The reader is **required** to read the durable log: call `runtime.events.iterate` / `.fold` without an `eventReader` and it throws a typed error at the call site. `@pleach/core` ships no built-in reader — there is no implicit store fallback — so a bare runtime never silently reaches for a database you didn't wire. ## Shipped projections — in soak [#shipped-projections--in-soak] Nine built-in projections ship in `@pleach/core/eventLog` (`configProjection`, `messageProjection`, `toolCallProjection`, `jobProjection`, `artifactProjection`, `interruptProjection`, `subagentProjection`, `exportProjection`, `userCardProjection`) — alongside the `reconstructSessionState` composite function. Their shapes are reference implementations; the names and exact return types may move between releases. If you build against one, plan to re-pin on upgrade. If you build against `GraphProjection` directly with your own reducer, you're insulated. ### `configProjection` [#configprojection] Folds the `session.created` lifecycle-root event into an identity snapshot per session — `sessionCreatedAt` and `sessionId`. Every other event type is a no-op; duplicate `session.created` rows collapse last-write-wins. ### `messageProjection` [#messageprojection] Folds streaming chunk events (`content.delta`) plus the terminal message events (`message.user`, `message.assistant`) into the reconstructed message stream, keyed by message ID. Pair with the `ContentLedger` accumulator from [Event log](/docs/event-log) when the consumer needs the final materialized message text rather than the per-message envelope. ### `toolCallProjection` [#toolcallprojection] Folds `tool.started`, `tool.completed`, and `tool.failed` events into the pending/completed tool-call set. Useful for "what tools are still in flight" panels and for replay parity checks. ### `jobProjection` [#jobprojection] Folds async-job lifecycle events under the `domain..job.*` namespace into a per-job state machine — `pending`, `completed`, `failed`, `timeout`. The reducer is plugin-agnostic; any plugin emitting events under the `domain..job.*` convention plugs in for free. ### `artifactProjection` [#artifactprojection] Folds artifact-emission events into the artifact set for a session. Returns a map keyed by `artifactId` with the latest envelope per artifact. ### Composite session-state reconstructor [#composite-session-state-reconstructor] `reconstructSessionState` is a **function** (not a `GraphProjection`) that takes an ordered array of event-log rows and composes the shipped projections into a single `SessionState` value — returning `null` when the stream is empty, carries no `session.created` row, or references multiple distinct sessions. Replay harnesses and `@pleach/eval` (npm name reserved; not yet shipping) use it to derive end-of-turn state from event log rows alone, with no snapshot-table dependency. ```typescript import { reconstructSessionState } from "@pleach/core/eventLog"; const rows = []; for await (const row of runtime.events.iterate({ chatId })) rows.push(row); const sessionState = reconstructSessionState(rows); // SessionState | null ``` ## `content.delta` streaming chunks [#contentdelta-streaming-chunks] Streaming model output emits one `content.delta` event per chunk — typically per token, sometimes per coarser chunk depending on the provider's stream shape. Each delta carries `messageId`, `channelId`, and the delta text. `messageProjection` folds these into the in-progress message until a terminal `message.assistant` event seals the message. The shape mirrors what the user saw at end-of-turn — same deltas in, same final message out. For the wire-level event shape, see [Stream events](/docs/stream-events). Projections read what stream events become once they land in the event log; the stream-events page documents the live wire format. ## The dual-write → dual-read → snapshot-retire ladder [#the-dual-write--dual-read--snapshot-retire-ladder] The substrate is migrating shapes that used to live in dedicated snapshot tables to the event log fold. The migration happens in three observable steps: 1. **Dual-write.** The runtime writes both the legacy snapshot and the event log rows that a projection would fold. Consumers still read the snapshot. 2. **Dual-read.** The projection runs alongside the snapshot read. Audit gates compare the two for parity over a defined window — for example, read parity over 100 consecutive turns on a representative workload. 3. **Snapshot retired.** Once parity holds, the snapshot table for that shape is retired and the projection becomes the default read path. Consumers don't drive this ladder — they observe its position in the changelog. The "in soak" label on a shipped projection means it's in step 1 or 2; "default read path" in the changelog means it's graduated. ## Soak-gated projection parity [#soak-gated-projection-parity] The dual-read step is gated by operator-side soak audits — small ledger-backed scripts that accumulate per-batch parity samples and refuse to clear the gate until the last several batches all hold clean. They take the place of a wall-clock soak window: the signal that the projection has matched the snapshot N times in a row is what the gate actually wants. Two audits ship today, both two-mode (source-text regression on the required projection sites, plus a per-batch ledger): | Audit | Watches | | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `audit:event-log-canonical-clean` | The canonical projection set (interrupts, subagents, exports / user-cards, hydration entry) plus the wire-layer's tenant-stamping site | | `audit:asset-consumed-projection-clean` | The `asset.consumed` dual-write fire site and projection arm | Each invocation appends a `BatchRecord` keyed on `--label` with sample size, OK count, mismatch count, error count, and the first few mismatch samples (capped so the ledger doesn't grow without bound). The `--strict` mode exits non-zero unless the last three batches all carry `mismatchCount === 0 && errorCount === 0`. Run them from the cutover PR — typically once per representative production canvas batch — before propagating a projection from "in soak" to the default read path. The source-text mode of each audit also doubles as a regression-detection floor: if a future refactor accidentally deletes the projection's emit site or fold arm, the audit fails loud before any data parity check runs. ## Custom projections [#custom-projections] Stay inside the contract and projections survive substrate upgrades. A small example — count tool failures by `toolName`: ```typescript import type { GraphProjection, EventLogRow } from "@pleach/core/eventLog"; interface ToolFailureCounts { readonly byTool: Readonly>; } const toolFailureCounts: GraphProjection = { name: "toolFailureCounts", initial: () => ({ byTool: {} }), reduce(state, row) { if (row.event_type !== "tool.failed") return state; const toolName = (row.payload as { toolName?: string }).toolName ?? "unknown"; return { byTool: { ...state.byTool, [toolName]: (state.byTool[toolName] ?? 0) + 1, }, }; }, }; const counts = await runtime.events.fold(toolFailureCounts, { fromSequenceNumber: turnStartSequence, }); // counts.byTool → { "search_corpus": 2, "fetch_url": 1 } ``` The early `return state` keeps the fold tight — `tool.failed` rows only mutate the accumulator — so the projection scales with failures, not with total event volume. ## `domain..*` event namespace [#domainplugin-event-namespace] Host plugins contribute event types under their own `domain..*` prefix. A projection written against `domain.your-plugin.foo.*` survives substrate upgrades because the core type space and the plugin type space are disjoint by construction — see `resolveDomainEventType` in [Event log](/docs/event-log). When writing a projection for a plugin you own, use the prefix as the reducer boundary: ```typescript const myPluginProjection: GraphProjection = { name: "myPlugin", initial: () => initialState, reduce(state, row) { if (!row.event_type.startsWith("domain.my-plugin.")) return state; /* ... */ return state; }, }; ``` That way a substrate release that adds new core event types can't silently feed rows into your reducer. ## What projections are NOT [#what-projections-are-not] * **Not a query language.** For SQL aggregation over the persisted `harness_event_log` table — "tool failure rate per tenant per day" and similar — see [Query](/docs/query). Projections fold the full row stream in memory; SQL is the right tool for aggregations that should run in the database. * **Not real-time subscriptions.** A projection runs to completion against the current event log. For streaming new events as they arrive (UI live updates, server-sent events), see [Stream events](/docs/stream-events). * **Not a snapshot store.** Projections compute on demand from the event log. There's no projection cache the substrate maintains for you — if you need cheap repeated reads, memoize at the consumer or pin against a checkpoint via [Checkpointing](/docs/checkpointing). ## Where to go next [#where-to-go-next] --- # Event log (/docs/event-log) The event log is the runtime's record of *observable events* — messages sent, tools dispatched, interrupts raised, subagents spawned, exports queued, domain events from plugins. It's distinct from the [`AuditableCall` ledger](/docs/audit-ledger): the ledger records load-bearing decisions (which model fired and why); the event log records what happened. Both write streams matter. The ledger is what you query for audit and cost; the event log is what you hydrate a session from. See [Stream events](/docs/stream-events) for the shapes that become event-log rows. The event log is the persistence layer for the [runtime-lifecycle cluster](/docs/session-lifecycle#the-runtime-lifecycle-cluster). [Session lifecycle](/docs/session-lifecycle) mints and resumes against it; [Turn lifecycle](/docs/turn-lifecycle) writes one row per observable event during `executeMessage`. The other two cluster members are the producers; this page is the substrate. ```typescript import { EventLogWriter, } from "@pleach/core"; import { ContentLedger, replayContentEvents, resolveDomainEventType, } from "@pleach/core/eventLog"; import type { HarnessEvent, EventSeverity, ActorType, } from "@pleach/core"; import type { DomainEventTypeConfig } from "@pleach/core/eventLog"; ``` ## Three layers [#three-layers] The event log has three concerns, separated cleanly. | Layer | Module | Concern | | ------------- | ------------------- | ------------------------------------------------------------ | | Write | `EventLogWriter` | Enqueue events; fire-and-forget at the call site | | Durable flush | `durableFlush` | Survive teardown via `waitUntil`; retry on transient failure | | Hydration | `hydrateFromEvents` | Walk events and rebuild session state | ## `EventLogWriter` [#eventlogwriter] Per-runtime writer. Calls return synchronously; the actual write is enqueued and drained by the durable-flush pipeline. ```typescript import { EventLogWriter } from "@pleach/core"; const writer = new EventLogWriter(supabase); writer.write({ type: "message.added", severity: "info", actor: { type: "user", id: userId }, payload: { messageId, content: "Hello" }, }); ``` ### `sequence_number` stamping (always-on) [#sequence_number-stamping-always-on] Every `chatId`-bearing row is stamped synchronously with a per-chat monotonic `sequence_number` column. The writer issues a cold-start `SELECT MAX(sequence_number) FROM harness_event_log WHERE chat_id = ?` the first time it sees a given chat, then increments an in-process counter for every subsequent write. Concurrent first-writes for the same chat coalesce on a per-chat `sequenceInitPromises: Map>` so the MAX query fires at most once per chat per writer lifetime. The column lands via migration `20260530170000_harness_event_log_sequence_number.sql`. The previous `HARNESS_C1_DUAL_WRITE` flag-gated mode is retired — dual-write is always-on for `chatId`-bearing writes; there is no opt-out. The column carries the canonical sequence ordering that `hydrateFromEvents`, `runtime.events.iterate({ fromSequenceNumber })`, and the fold-vs-snapshot equivalence audit (PA-1 Phase A.2) all depend on. ### `chat_id` is required for stamping [#chat_id-is-required-for-stamping] `eventToRow` always emits `chat_id` when present on the input event. A write without `event.chatId` skips sequence stamping — the column stays `NULL` and the row is excluded from per-chat folds. If you want a row to participate in projection folding, populate `chatId` on the event. Substrate writes always do; legacy / out-of-band producers may not, and those rows are intentionally invisible to `runtime.events.iterate({ chatId })`. ### `prev_hash` + `row_hash` (C9 hash chain) [#prev_hash--row_hash-c9-hash-chain] When `c9PhaseBEnabled` is on (default `true`), the writer also stamps `prev_hash` + `row_hash` BYTEA columns on every row. The flow: 1. Read the per-`(tenant_id, chat_id)` in-process `prevHashCache`. 2. On miss, cold-start via `resolvePrevHashColdStart` — `SELECT row_hash FROM harness_event_log WHERE chat_id = $1 AND row_hash IS NOT NULL ORDER BY sequence_number DESC LIMIT 1`, coalesced via `prevHashInitPromises` (mirrors the `sequenceInitPromises` precedent). 3. Call `chainStep` from `@pleach/core`'s [`hashChain`](https://github.com/pleachhq/core/blob/main/src/eventLog/hashChain.ts) module with the resolved `prevHash` + the canonical row fields. 4. Stamp `prev_hash` + `row_hash` on the row. 5. Update `prevHashCache` for the next write on the same chat. The cold-start runs inside the same `chat_id`-keyed PG advisory lock scope that protects `sequence_number` allocation, so the SELECT-then-INSERT race is mitigated. The chain verifier lives in `@pleach/replay@0.1.0` — `verifyChainForChat` walks rows in sequence order and re-derives each `row_hash` from its predecessor; a mismatch surfaces as a tamper-detection signal. See [Hash chain](/docs/hash-chain) for the verifier surface and proof shape. The CI gate `audit:c9-hash-chain-integrity` guards the wire-in surface against silent rollback (10 canonical anchors covering the writer-side stamping path). ### `manifest_hash` (config reference, rolling out) [#manifest_hash-config-reference-rolling-out] Each row also carries a `manifest_hash` — a foreign reference to the [config manifest](/docs/config-manifest) row for the substrate active when the event fired. The writer stamps it from the session's snapshot, computed once at construction. The column is nullable during rollout: rows written before the manifest substrate stay valid, and the reference is what makes a session offline-replayable (event stream + manifest = the full tuple). Integrity is enforced by the `audit:event-log-manifest-hash-valid` gate — every distinct `manifest_hash` in the log must resolve to a surviving manifest row — rather than a database foreign key, so the write path stays cheap. See [Config manifest](/docs/config-manifest) for the join shapes that this column unlocks. ### `HarnessEvent` shape [#harnessevent-shape] Every event extends a common base. Optional id columns let projections join on whichever entity the event is about. ```typescript interface BaseEvent { chatId: string; // load-bearing — events are chat-scoped sessionId?: string; actorId?: string; actorType?: ActorType; toolCallId?: string; subagentId?: string; jobId?: string; checkpointId?: string; requestId?: string; durationMs?: number; } type HarnessEvent = BaseEvent & { type: string; // dot-namespaced, e.g. "tool.completed" payload: Record; }; type EventSeverity = "info" | "warning" | "error" | "audit"; type ActorType = "user" | "system" | "subagent" | "guest"; ``` The persisted row stamps `severity` per event type — interrupts, retries, safety, and action-rail events land at `"audit"`; tool + session lifecycle land at `"info"`; failures at `"error"`. Events carry a client-generated `id` (ULID); the durable-flush layer is idempotent on this id, so retries don't double-write. ### Event type conventions [#event-type-conventions] The runtime emits dot-namespaced types — `session.created`, `message.added`, `tool.completed`, `interrupt.requested`. Plugins emit `domain.event` stream events that the writer can resolve into typed event log entries via `resolveDomainEventType`: ```typescript const config: DomainEventTypeConfig = { prefix: "compliance", kinds: ["redaction.applied", "tamper.evidence.written"], }; const resolved = resolveDomainEventType(config, "redaction.applied"); // → "compliance.redaction.applied" ``` Use this so plugin-namespaced events don't collide with the substrate's reserved type space. ### `asset.consumed` [#assetconsumed] Pairs with the existing `asset.offloaded` event to make artifact consumption a first-class event-log entry instead of a sidecar manifest mutation. Emitted at the consumption call site; the asset-projection arm folds the row in place by flipping the matching artifact's `consumed` field to `true`. ```typescript interface AssetConsumedEvent extends BaseEvent { type: "asset.consumed"; payload: { s3_key: string; // join key against asset.offloaded consumed_at: string; // ISO-8601 by_tool_call_id?: string; // present when LLM-driven; absent for UI surfaces by_surface: | "tool-result-injection" | "ui-canvas-open" | "ui-export" | "manifest-recovery"; }; } ``` The join key is `s3_key` (the canonical content address) so the projection arm walks `asset.offloaded` rows by content rather than cross-row identifiers. `by_surface` discriminates consumption provenance — LLM tool-call injection, user-driven canvas open or export, or server-driven manifest recovery. `by_tool_call_id` is the only PII-adjacent field and rides the compliance scrubber's allowlist. ### `asset.offloaded.payload.mime_type` [#assetoffloadedpayloadmime_type] `AssetOffloadedEvent.payload.mime_type` is a required string field. The producer is expected to populate it via a shared derivation helper that ladders `known mimeType → extension-derived → application/octet-stream`. Empty-string emission is treated as a regression-detection signal — the producer arm guarantees non-empty. Back-historic rows that predate the field land extension-derived at projection read time without a database backfill. ## `durableFlush` [#durableflush] Wraps the write queue with retry + `waitUntil` so teardown on Fluid Compute / Lambda / Cloudflare Workers doesn't lose writes. ```typescript import { setWaitUntilImpl } from "@pleach/core/eventLog"; // In your edge / function handler: setWaitUntilImpl(ctx.waitUntil.bind(ctx)); ``` After registration, every write the runtime emits is wrapped in a promise routed through `waitUntil` — the platform keeps the function alive until the write lands. ### Retry policy [#retry-policy] * 3 attempts * 100 ms / 300 ms / 800 ms backoff * Idempotent on event `id` — re-sends after a transient failure don't double-write When all retries fail, the event surfaces as an `error` stream event with code `4001`. The runtime keeps running — durable flush is observability, not control flow. The dropped event's client-generated `id` is included in the `error` payload, so a recovery job can re-derive the missing row from the audit ledger (if the event corresponds to a `ProviderDecisionLedger` write) or reconstruct it from `hydrateFromEvents` against the surrounding slice. ## `hydrateFromEvents` [#hydratefromevents] Rebuild session state from an event slice. Used for resuming a session after restart, recovering from a checkpoint that's older than the latest event, and replaying through `@pleach/eval`. ```typescript import { hydrateFromEvents } from "@pleach/core/eventLog"; const events = await client.listEvents({ sessionId, since: lastSeenId }); const state = hydrateFromEvents(events); // state.messages, state.pendingToolCalls, state.pendingJobs, // state.completedJobs, state.interrupts — all rebuilt from events. ``` The hydration walks events in order and applies each to a working state, mirroring how the runtime mutates state during a live turn. Same event sequence = same final state — that's the contract. ## `ContentLedger` + `replayContentEvents` [#contentledger--replaycontentevents] For message content specifically — streaming deltas, corrections, truncations — there's a content-shaped accumulator. It honors `content.correction` events (post-stream fabrication guards) and `stream.truncated` events, producing the same final message shape the user saw. ```typescript import { ContentLedger, replayContentEvents } from "@pleach/core/eventLog"; // One-shot: rebuild the final user-visible content from a stored event slice. const content = replayContentEvents(events); // Or drive a ledger directly (feed deltas/resets/corrections as they arrive), // then materialize the committed view. const ledger = new ContentLedger(); const finalContent = ledger.materialize().content; // → final message content ``` Useful when a UI cache is out of sync with the underlying event stream — re-derive instead of refetch. ## Projections [#projections] A projection is a deterministic fold over an event slice that produces a derived view. The package ships nine built-in projections plus a composite session-state reconstructor: | Projection | Returns | Use | | ------------------------- | ------------------------ | ------------------------------------------------------------------------------------- | | `interruptProjection` | `InterruptAccumulator` | Outstanding approvals queue | | `subagentProjection` | `SubagentAccumulator` | Subagent tree per turn | | `exportProjection` | Export queue accumulator | Action-rail exports awaiting completion | | `userCardProjection` | User-card accumulator | `canvas.user_created` rows folded into the canvas state | | `configProjection` | Per-chat config snapshot | Resolved session config at any point in the slice | | `messageProjection` | Final committed messages | Folds `content.delta` + correction + truncation rows | | `toolCallProjection` | Tool-call accumulator | Pending + completed tool calls per turn | | `jobProjection` | Job accumulator | Async job lifecycle state (uses `createJobProjection` factory for per-host resolvers) | | `artifactProjection` | Artifact accumulator | `asset.offloaded` + `asset.consumed` folded into the artifact state | | `reconstructSessionState` | Composite session state | Single-call hydrator that runs all projections above | ```typescript import { interruptProjection, toInterruptArray, subagentProjection, toSubagentArray, } from "@pleach/core/eventLog"; const interruptAcc = events.reduce(interruptProjection.reduce, interruptProjection.empty); const interrupts = toInterruptArray(interruptAcc); const subagentAcc = events.reduce(subagentProjection.reduce, subagentProjection.empty); const subagents = toSubagentArray(subagentAcc); ``` Projections are pure; same input slice = same output. Build your own for domain-specific views — the contract is `{ empty, reduce, toArray? }`. The `interruptProjection` reduce shape is the canonical example. `empty` is `{ byId: {}, order: [] }`; `reduce(acc, event)` walks the event types — `interrupt.requested` inserts a pending entry keyed on `interruptId`, `interrupt.resolved` flips the entry's `decision` field and leaves it in place, and `interrupt.timeout` flips it to a timeout-shaped record. `toInterruptArray(acc)` materializes `acc.order.map((id) => acc.byId[id])` so the consumer iterates in arrival order. Same event slice in, same array out — that's what makes the approval queue safe to re-derive on every render instead of caching it. ## Reading events — the `runtime.events` facet [#reading-events--the-runtimeevents-facet] Reads go through the `runtime.events` facet, backed by a `HarnessEventLogReader` the host supplies via `SessionRuntimeConfig.eventReader`. `@pleach/core` ships no concrete reader — cast your store (Supabase, Postgres, an HTTP API) into the `iterate` shape. ```typescript import type { HarnessEventLogReader, EventLogRow } from "@pleach/core/eventLog"; // Walk rows for a chat, optionally from a sequence cursor. async function recentRows( reader: HarnessEventLogReader, chatId: string, ): Promise { const rows: EventLogRow[] = []; for await (const row of reader.iterate({ chatId, fromSequenceNumber: 0 })) { rows.push(row); } return rows; } ``` | Surface | Returns | Use | | --------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------- | | `runtime.events.iterate(filter)` | `AsyncIterable` | Walk a chat's rows (server-side, service-role) | | `runtime.events.fold(projection, filter)` | `ProjectionResult` | Fold rows into typed state via a `GraphProjection` | | `queryHarnessEvents` / `getAllChatEvents` / `countEventsByType` | rows / counts | Direct read helpers exported from `@pleach/core/eventLog` | ## `EventLogRow` (read shape) [#eventlogrow-read-shape] What `hydrateFromEvents` walks. Snake-cased — these are the column names you query against directly. ```typescript interface EventLogRow { id: string; // ULID, primary key session_id: string | null; event_type: string; actor_id: string | null; actor_type: string; tool_call_id: string | null; subagent_id: string | null; job_id: string | null; checkpoint_id: string | null; request_id: string | null; payload: Record; duration_ms: number | null; severity: string; created_at: string; domain?: string | null; // plugin namespace, when domain.* row kind?: string | null; // plugin event kind, when domain.* row manifest_hash?: string | null; // config-manifest reference; NULL on pre-rollout rows } ``` Plugin-namespaced rows populate `domain` + `kind`; core rows leave both `null`. `hydrateFromEvents` flattens both shapes — same input slice produces the same hydrated state regardless of whether the slice predates the namespacing migration. ## Schema [#schema] The event log persists to `harness_event_log` (file 003 in the schema bundle). The table is append-only, ULID-keyed, indexed by `(session_id, id)` for cursor pagination. RLS templates ship in the file; production deployments using a service-role client bypass them. The append-only contract is what makes `hydrateFromEvents` safe to call from any point in time — a row never mutates, so the same `since` cursor against the same table produces the same hydrated state next year. Durability is the storage adapter's job, not the event log's. On the quickstart path (`createPleachRoute`) the runtime writes the `message.user` / `message.assistant` / `tool.*` rows that `hydrateFromEvents` and the tool/manifest projection fold from on turn finalization — so message and tool restoration work out-of-the-box, **provided a durable storage adapter is configured**. The default in-memory adapter keeps these rows in the process heap only: they survive in-process resume but are lost on restart and absent on a fresh serverless instance. Wire a durable adapter (Supabase / Postgres / Redis) for the event log to outlive the process. ## Projections — folding rows into state [#projections--folding-rows-into-state] `GraphProjection` is the substrate piece for folding event-log rows into typed runtime state. Consumers depend on the contract instead of rolling their own iterators. The runtime exposes the canonical read surface as the `runtime.events` facet (landed PA-1 Phase A.2): ```typescript // Walk raw rows for a chat, optionally starting at a sequence number. for await (const row of runtime.events.iterate({ chatId, fromSequenceNumber: lastSeen, })) { // ... } // Fold rows into typed state via a GraphProjection. const result = await runtime.events.fold(interruptProjection, { chatId }); // result.state, result.rowsProcessed, result.projection (name) ``` `iterate` is a paginated async iterable (1000 rows per page, ordered by `chat_id`, then `sequence_number`, then `created_at`, then `id`). `fold` walks `iterate` under the hood and applies the projection's `reduce` → optional `finalize`. Both are server-side only (require a Supabase service-role client). For the deep write-up and a custom-projection example, see [Event log projections](/docs/event-log-projections). ## `content.delta` streaming chunks [#contentdelta-streaming-chunks] `content.delta` is the canonical streaming-chunk event type. The producer emits one row per token-or-chunk during model output. A producer-side capture helper exists for hosts that want to record the chunk stream into the event log without wiring it by hand. `messageProjection` folds the `content.delta` rows for a turn into the final committed message — the same shape the user saw at end-of-turn. See [Stream events](/docs/stream-events) for the wire-level shape and [Event log projections](/docs/event-log-projections) for the fold. ## `InterruptManager` terminal writes and host-plugin job events [#interruptmanager-terminal-writes-and-host-plugin-job-events] When the interrupt manager resolves an interrupt — approve, deny, or edit — it writes a terminal event-log row. Replay tools rely on that row as the deterministic boundary for the interrupt. Host plugins contribute job-lifecycle event types under their own `domain..*` namespace. Two common shapes: `domain..job.timeout` and `domain..job.completed`, the latter typically carrying a `duration_ms` payload field. The `domain..*` convention keeps host-contributed events isolated from substrate-emitted events. Substrate code never writes under a host plugin's namespace. See [Interrupts](/docs/interrupts) and the [Plugin contract](/docs/plugin-contract). ## Authoring a custom projection [#authoring-a-custom-projection] The bundled projections (`messageProjection`, `toolCallProjection`, `jobProjection`) cover the common reads — committed message text, tool-call pairs, job lifecycle. Domain reads — a per-tenant retry-rate dashboard, a citation-source histogram, a refund-event audit — author against the `GraphProjection` interface. ```typescript import type { GraphProjection, EventLogRow } from "@pleach/core/eventLog" interface RetryCount { total: number byTool: Record } export const retryCountProjection: GraphProjection = { name: "retry-count", initial: () => ({ total: 0, byTool: {} }), reduce: (acc, row: EventLogRow) => { if (row.event_type !== "tool.retried") return acc const toolName = row.payload?.toolName as string | undefined if (!toolName) return acc return { total: acc.total + 1, byTool: { ...acc.byTool, [toolName]: (acc.byTool[toolName] ?? 0) + 1 }, } }, // Optional finalize — runs once after all rows are consumed. // Use it for cross-row resolution; return the input unchanged for a no-op. } ``` Three contract rules every projection must satisfy: * **Pure.** Same input rows produce the same output state. No wall-clock reads, no RNG, no network calls. The same projection folded over the same row stream twice produces byte-identical results — that's what makes projections replay-safe. * **Order-preserving.** The reducer processes rows in the order supplied by the caller. The caller (typically `hydrateFromEvents` or the upcoming `SessionRuntime.events.fold`) is responsible for ordering by `(chat_id, sequence_number)` or `(created_at, id)`. * **Total.** Unknown `event_type` values are no-ops, not throws. The event-log surface accumulates new event types over time; projections must not break on rows from a newer schema version. The reducer returns the same `acc` reference when no change applies — the conventional no-op signal. See the bundled [`messageProjection`, `toolCallProjection`, `jobProjection`](https://github.com/pleachhq/core/tree/main/src/eventLog/projections) sources for full exemplars; the runtime accessor that folds a projection over a session's events ships in a later phase. ## Where to go next [#where-to-go-next] --- # Extending Pleach (/docs/extending) The audit gates assume you'll extend the substrate. Every page that calls a gate "structural" is also saying: when you add a node, a writer, or a scrubber for your own use case, this gate is the spec your addition has to satisfy. This page is the map from extension point to that spec. It pairs each thing you can add with three facts: the interface you implement, the call that registers it, and the named [audit gate](/docs/audit-gates) that turns red when the addition is wrong. Treat the gate as the checklist — it names the file and line to fix in its failure output. **Host extension** — you add a plugin, a prompt block, a tool, or a runtime strategy from *your* repo against the published `@pleach/*` surface. You edit no substrate source. The gates already ran upstream to keep that surface honest; the ones below fire in *your* CI only if you mirror them (see [Mirroring gates](#mirror-the-gates-in-your-own-repo)). **Upstream contribution** — you add a node to the canonical builder, a member to `EventLogInput`, or a sibling `@pleach/*` SKU. You edit substrate source, so the gates run against your PR directly. The [contributing](/docs/contributing) flow covers this path. Most consumer work is host extension. The gate column below tells you which surface each gate belongs to. ## The extension map [#the-extension-map] | You're adding | Interface | Register via | Gate that fails if wrong | Check locally | | ------------------------------------------ | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | | A **graph node** | `StateGraphNodeMetadata` + `NodeFn` ([nodes](/docs/nodes)) | `graph.addNode(name, fn, meta)`, or a plugin's `extraGraphNodes()` | `audit:graph-stages` (declares a `stageId`, edges stay in the lattice), `audit:edge-inventory-completeness` | `npm run ci:graphnoderef` | | A **state channel** | `Channel` ([channels](/docs/channels)) | a node's `subscribes` / `writes` metadata — there is **no** `contributeChannels` hook | reached through a node, so `audit:graph-stages` covers it; reducer commutativity is a [determinism contract](/docs/determinism), not gate-checked | `npm run audit:graph-stages` | | An **event type / writer** | `PluginEventDefinition` ([event log](/docs/event-log)) | `contributeEventTypes()` | `audit:c8-union-member-has-producer` (has a producer), `audit:c8-event-type-allowlist-coverage` (has a scrubber entry) | `npm run audit:c8-event-type-allowlist-coverage` | | A **scrubber** (the "clearer") | `Scrubber` ([scrubbers](/docs/scrubbers)) | `contributeScrubbers()` | `audit:c8-event-type-allowlist-coverage` — your new event type needs a gate, even a pass-through one | `npm run audit:c8-event-type-allowlist-coverage` | | A **plugin hook** (sibling SKU / upstream) | a `contribute*` hook on `HarnessPlugin` ([plugin contract](/docs/plugin-contract)) | the hook itself, returning the contribution | `audit:plugin-contract-completeness` (paired collector + consumer), `audit:plugin-hook-category-assigned` (resolves to a [namespace](/docs/plugins/namespaces)) | `npm run audit:plugin-contract-completeness` | | A **system-prompt block** | `PromptContribution` ([prompts](/docs/prompts)) | `contributePrompts()` / `contributeRuntimeAwarePrompts()` — helpers `appendPrompt`, `prependPersona` | no dedicated gate; static blocks fold into the [config-manifest](/docs/config-manifest) fingerprint (`audit:plugin-content-hash-stability` guards its determinism) | `npm run audit:plugin-content-hash-stability` | | A **custom audit field** | `PluginAuditPayload` ([typed records](/docs/typed-records)) | `contributeAuditEmitter()` → `pluginPayloads` — **not** a new column | `audit:auditable-call` — the structural column set is locked; a column add fails the shape+version check | `npm run audit:auditable-call` | | An **audit-ledger adapter** | `ProviderDecisionLedger` ([audit ledger](/docs/audit-ledger)) | `setProviderDecisionLedgerFactory(...)` | `audit:auditable-call` (the row your adapter persists must match the locked shape) | `npm run audit:auditable-call` | | A **storage adapter** | `StorageAdapter` ([storage](/docs/storage)) | runtime config / `appRegistries` | no shape gate — application code is unchanged by the adapter swap | — | The shape is the same across rows: a gate fails because the addition omitted the one fact the ledger needs to stay joinable — a stage, a producer, a scrubber entry, a version bump. Read the gate name as the missing fact. ## Nodes: the consumer path vs the builder path [#nodes-the-consumer-path-vs-the-builder-path] The [new-node checklist](/docs/nodes#new-node-checklist) on the nodes page describes editing the canonical builder — `NODE_STAGE_MAP` in `src/graph/topology.ts`. That's the **upstream** path. A host adding a node from a plugin takes a different one: ```typescript // A plugin contributes nodes; the builder calls each factory once at compile time. const retrievalPlugin: HarnessPlugin = { name: "tenant-retrieval", extraGraphNodes: () => [{ name: "tenantRetrieval", factory: (ctx) => async (state) => ({ retrieved: await ctx.search(state.query) }), metadata: { stageId: "tool-loop", acceptsSeam: null, subscribes: ["query"], writes: ["retrieved"] }, }], } ``` A plugin-registered node carries its `stageId` in its own `metadata`. The lattice gate (`audit:graph-stages`) rejects an out-of-stage edge on the *compiled* graph, but the node never enters the substrate's static `NODE_STAGE_MAP` — that table covers the canonical builder only, which is why the node/edge counts stay byte-identical PR-to-PR. The fact your node must satisfy is the same (a valid stage, valid edges); the surface it's checked against differs. ## The locked row, and the slot that isn't [#the-locked-row-and-the-slot-that-isnt] The single most common extension instinct the substrate refuses: adding a column to the audit row. The structural column set — `turnId`, `toolName`, `modelId`, `family`, `inputTokens`, `outputTokens`, `subagentDepth`, `parentTurnId` — is a cross-SKU contract. No plugin, config field, or adapter alters it. A column add fails `audit:auditable-call` by design: changing the shape without bumping `AuditRecordVersion` is the silent-breaking-change the gate exists to catch. The sanctioned path for "I need to record *my* thing on the row" is `pluginPayloads` — a namespaced, versioned slot the locked contract reserves for exactly this: ```typescript const extractionQualityPlugin: HarnessPlugin = { name: "extraction-quality", contributeAuditEmitter: () => ({ // Emitted alongside the locked columns; your plugin owns the wire shape. record: (call) => ({ pluginId: "extraction-quality", subKind: "score", data: { confidence: scoreFor(call), schemaVersion: 1 }, }), }), } ``` Your payload rides one row per call, joinable by `turnId` like every other field, without touching the structural columns every consumer reads. The version lives in your `data`, not in the substrate's `AuditRecordVersion` — the gate stays green because the contract didn't move. See [typed records](/docs/typed-records) for the five first-party payload kinds this slot generalizes. ## Mirror the gates in your own repo [#mirror-the-gates-in-your-own-repo] Host extension means the upstream gates protected the surface you consume — not *your* invariants. A per-tenant key that must never reach logs, a domain tool that must have a matching detector, a feature flag past its sunset: those are yours to gate. The pattern is the one the substrate uses, documented at [Audit gates → Mirroring gates in your own repo](/docs/audit-gates#mirroring-gates-in-your-own-repo): one invariant per script, structured failure naming file and line, wired under `audit:` in your PR-blocking CI. The discipline carries: adding a new invariant means adding a new gate, not a line on a review checklist. The [`audit:plugin-contract-completeness`](/docs/audit-gates#plugin-contract-gates-plugin-authors-should-know) script is a small reference shape to copy. ## Agent-driven self-extension [#agent-driven-self-extension] The gates aren't only a CI wall — they're a feedback loop an agent can drive. Because every gate names the offending file, line, and the fact that's missing, an agent extending the runtime can run the loop unattended: 1. Write the addition — a node, a scrubber, a `pluginPayloads` emitter for the tenant's need. 2. Run the matching `npm run audit:*` gate. 3. Read the structured failure: it names the file, the symbol, and the missing fact (a stage, a producer, an allowlist entry). 4. Apply the fix the gate points at. Re-run. Green means the addition preserved the ledger contract. The gate output is the correction signal. An agent that can't see why its node was rejected can't self-correct; one that reads `missing-stage: tenantRetrieval` can. This is the same property that makes the substrate "built for and by agents" — the contract is legible to the thing operating it. To push self-optimization further, feed the extension contract into the agent's *own* system prompt. A host that wants its agent to tune its plugin set, prompt blocks, or tool selection to a tenant's needs can inject the rules at turn time: ```typescript const selfTuningPlugin: HarnessPlugin = { name: "self-tuning", // Per-turn prompt the operating agent reads — the extension contract as context. contributeRuntimeAwarePrompts: (ctx) => [{ id: "self-tuning.extension-guide", mode: "append", content: extensionContractFor(ctx.tenant), // gates, slots, and the locked columns }], } ``` The agent then proposes additions that already respect the gates, because the gates are in its context. Static guidance (the locked column set, the `pluginPayloads` slot, the gate names) belongs in `contributePrompts` so it folds into the fingerprint; per-tenant guidance belongs in `contributeRuntimeAwarePrompts` so it stays out of the cache key. See [prompts](/docs/prompts) and the [plugin contract](/docs/plugin-contract) for the composition rules. The boundary holds either way: an agent can author freely inside the gates, and the gates are what keep its additions from making the audit ledger unusable. Self-extension is safe precisely because the contract is enforced, not advised. ## Where to go next [#where-to-go-next] --- # Fabrication detection (/docs/fabrication-detection) Fabrication detection is one concept in the **safety & determinism** [thematic island](/docs/concept-clusters#what-lives-outside-the-cluster-pattern) — siblings of [safety](/docs/safety), [scrubbers](/docs/scrubbers), [determinism](/docs/determinism), and [fingerprint](/docs/fingerprint). `applyFabricationGuard` rewrites a synthesized turn before it reaches the user. The variable surface — what it removes and reports — is named tool calls the model invented, paragraphs that reference tools that failed or never ran, quantitative content fabricated when most tools failed, and confessions where the model retracts prior work mid-message. It returns `FabricationGuardResult`: a `triggered` boolean, `preservedContent`, paragraph counts, the `unavailableToolNames` it acted on, and optional `phantomTools`, `phantomReplacements`, `ungroundedEntities`, `dataInflation`, and `phantomWipeEscalation` slots populated only when those signals fire. ## Public exports [#public-exports] | Export | Kind | Purpose | | ----------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------- | | `applyFabricationGuard` | function | The multi-signal pipeline. Wipes paragraphs, returns the typed result. | | `contentReferencesMissingTools` | predicate | Bytewise scan: does text mention any tool in `unavailableToolNames` (bare, readable, or alias)? | | `detectBulkFailureFabrication` | detector | Fires when failure ratio ≥ 0.8 and the response carries ≥ 3 distinct quantitative signals. | | `detectSelfFabricationConfession` | detector | Two-gate fire: confession phrase + an 8-hex prefix matching a prior `PriorToolUseEntry.jobId`. | | `detectMethodResultFabricationConfession` | detector | Phrase-only confessions retracting fabricated method-result tables from this turn. | | `detectDegenerateProseQuality` | detector | Garbled-text signal — fragment density + short-word ratio. | | `detectDataInflation` | detector | LLM presented more rows than the tool returned. | | `isGuardBlockedFailure` | predicate | Classify a failed-tool error string as guard-blocked (`BLOCKED:`, `DUPLICATE:`, …) vs real API failure. | | `partitionFailedTools` | function | Split a failed-tool list into `{ apiFailedToolNames, guardBlockedToolNames }`. | ## Public types [#public-types] | Type | Purpose | | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | | `FabricationGuardResult` | Pipeline result envelope (see fields below). | | `FabricationGuardResultWithPluginFindings` | Extends `FabricationGuardResult` with `pluginFindings: readonly FabricationFinding[]` — what `applyFabricationGuard` returns. | | `FabricationGuardStrategyBundle` | Canonical strategy shape carried on `SessionRuntimeConfig.fabricationGuardStrategy`. Re-exported via `@pleach/core/types/strategies`. | | `FabricationDetector` | Plugin-contributed detector: `{ id, version?, detect(ctx) }`. | | `FabricationDetectorContext` | What a detector receives: `completedTools`, `assistantContent`, `userText`, `knownToolNames`, `guestDeniedTools?`, `callClass`. | | `FabricationFinding` | What a detector returns: `{ detectorId, severity, reason, preservedContent?, evidence? }`. | | `CompletedToolRecord` | Tool envelope a detector indexes against: `{ id, name, status, error?, arguments?, result? }`. | | `PriorToolUseEntry` | Per-call envelope the self-confession detector cross-references. | `applyFabricationGuard` has signature `(params: ApplyFabricationGuardParams, config: ApplyFabricationGuardConfig) => FabricationGuardResultWithPluginFindings`. `partitionFailedTools(failedToolNames, errors)` returns `{ apiFailedToolNames, guardBlockedToolNames }`; `isGuardBlockedFailure(errorMessage)` is the underlying predicate. ## Result shape [#result-shape] `FabricationGuardResult` carries the variable surface back to the caller. | Field | Type | Set when | | --------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------- | | `triggered` | boolean | Any signal fired. | | `preservedContent` | string | Always — the cleaned turn body. | | `preservedParagraphs` | number | Paragraphs kept after wipe. | | `wipedParagraphs` | number | Paragraphs removed. | | `unavailableToolNames` | `string[]` | Failed ∪ empty-result ∪ detected phantoms. | | `apiFailedToolNames` | `string[]` | Real upstream failures (the partition split). | | `guardBlockedToolNames` | `string[]` | Tools the guard suppressed (`BLOCKED:`, `DUPLICATE:`, …). | | `phantomTools` | `string[]` | Tool names cited in prose that were never executed. | | `phantomToolsNotInRegistry` | `string[]` | Subset of `phantomTools` not in `allKnownToolNames`. | | `phantomReplacements` | `{ phantom; replacement }[]` | Phantom → canonical-tool mappings from `phantomWipeStrategy`. | | `phantomWipeEscalation` | `{ overlappingTools; sessionWipeCount }` | Same phantom set wiped twice or more in the session. | | `ungroundedEntities` | `string[]` | Named entities in prose absent from tool results. The detector slot defines what counts. | | `dataInflation` | `{ toolName; claimedRows; actualRows }[]` | Model presented more rows than the tool returned. | ## `FabricationGuardStrategyBundle` [#fabricationguardstrategybundle] The canonical strategy shape is `FabricationGuardStrategyBundle` from `@pleach/core/types/strategies`. Hosts populate it once at runtime construction (`SessionRuntimeConfig.fabricationGuardStrategy`); every consumer site reads from the bundle instead of threading strategies inline. `DEFAULT_FABRICATION_GUARD_STRATEGY_BUNDLE` exports an empty bundle (`quantitativePatterns: []`) — tests and hosts without domain patterns use it as-is; the algorithm gracefully skips every optional slot. | Slot | Required | What it does | | -------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------- | | `quantitativePatterns` | yes | RegExp set the bulk-failure detector counts hits against. Domain-specific. | | `phantomDetect` | no | Returns phantom tool names cited in `content` that aren't in `executedToolNames`. | | `phantomWipeStrategy` | no | Map a phantom name to a canonical real-tool replacement. | | `phantomWipeTracker` | no | Per-session tracker — populates `phantomWipeEscalation` when a phantom set is wiped repeatedly. | | `ungroundedEntityDetect` | no | Returns entity names cited in prose but absent from `toolResultsRaw`. The host defines what an entity is. | | `postExecFabricationDetect` | no | Returns `true` when prose fabricates results for failed-or-empty tools. | | `degenerateProseConfig` | no | Augments the garbled-prose detector (`additionalShortWords`, thresholds). | | `detectUnqueriedCitations` | no | Structured-shape detector consumed by graph nodes G2/G6/G7 — flags citations to sources never queried. | | `detectIdentifierMismatch` | no | Structured-shape detector — flags identifier mismatches between prose and tool results. | | `detectMarkdownSmilesMismatch` | no | Structured-shape detector — flags SMILES strings that don't match the source table. | | `detectCellLineAttributionFabrication` | no | Structured-shape detector — flags cell-line claims with no grounded source. | | `detectProcessClaimWithoutCompute` | no | Structured-shape detector — flags process claims with no successful compute. | The first seven slots feed the lifted `applyFabricationGuard` algorithm (parameter shape mirrors `ApplyFabricationGuardConfig`); the five structured-shape detectors at the tail are read directly by graph node consumers and preserve their bag-side return shapes so consumer rewires stay structural-not-semantic. ### `contributeFabricationGuard` plugin hook [#contributefabricationguard-plugin-hook] A plugin returns a `FabricationGuardImpl` from `contributeFabricationGuard()`. The impl is a single object carrying `applyFabricationGuard`, `isGuardBlockedFailure`, the per-signal detectors (`detectBulkFailureFabrication`, `detectUnqueriedCitations`, `detectSelfFabricationConfession`, `detectMethodResultFabricationConfession`, `detectIdentifierMismatch`, `detectCellLineAttributionFabrication`, `detectMarkdownSmilesMismatch`, `detectProcessClaimWithoutCompute`), and the `contentReferencesMissingTools` predicate. Plugin findings emitted by `FabricationDetector[]` (registered via the sibling `contributeFabricationDetectors` hook) merge into the returned `FabricationGuardResultWithPluginFindings` — the substrate's extension of `FabricationGuardResult` adding a `pluginFindings: readonly FabricationFinding[]` field. Legacy consumers ignore the new field; H-3.2 consumer rewires read it. See [`contributeFabricationGuard`](/docs/plugin-contract#contributefabricationguard) for the full method list and registration shape. ### Bag-entry retirement (NEAR-READY) [#bag-entry-retirement-near-ready] `OrchestratorHotpathModules.fabricationGuard` is the legacy untyped-bag entry for the same 11-function surface (the surface widened from 1 → 9 functions; today the bag carries 11). The typed `FabricationGuardStrategyBundle` slot + the `contributeFabricationGuard` hook supersede it; the entry is in **NEAR-READY** retirement status. The H-3.3 runtime probe `recordFabricationGuardResolverPath` emits per-invocation `via:"bundle"|"bag-fallback"|"unwired"` telemetry so the soak window can close before the bag-fallback path retires. Two audit gates lock the retirement state: | Gate | What it catches | | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `audit:fabrication-guard-bag` | Source-text regression. Fails on novel `dynamicImportApp("orchestrator.fabricationGuard")` call sites beyond the single baselined documentation-comment hit at `appRegistries.ts`. | | `audit:fabrication-guard-resolver-clean` | 3-batch runtime soak ledger. Operator stages canvas dumps via `--add-batch`; `:strict` fails until the last 3 batches show auth `via:"bundle" > 0` AND auth `via:"bag-fallback" == 0`. Replaces the calendar bake. | See [`/docs/host-adapter`](/docs/host-adapter) for the broader bag-retirement story across the four `OrchestratorHotpathModules` entries. ## Calling the pipeline [#calling-the-pipeline] ```typescript import { applyFabricationGuard, } from "@pleach/core/strategies/fabricationDetector"; const result = applyFabricationGuard( { fullContent: synthesizedTurn, failedToolNames: ["search_corpus"], emptyResultToolNames: [], isResynthesis: false, executedToolNames: ["search_corpus", "fetch_url"], allKnownToolNames: registry.list(), totalToolCount: 2, toolResultsRaw: rawToolResultsBlob, }, { quantitativePatterns: DOMAIN_QUANTITATIVE_PATTERNS, }, ); if (result.triggered) { await respondWith(result.preservedContent); } else { await respondWith(synthesizedTurn); } ``` The two required arguments are the params envelope and the config. Everything else degrades gracefully. ## Running a single detector [#running-a-single-detector] The detectors are exported individually so plugin authors and tests can compose them outside the pipeline. ```typescript import { detectBulkFailureFabrication, DEFAULT_BULK_FAILURE_PATTERN_THRESHOLD, } from "@pleach/core/strategies/fabricationDetectors"; const bulk = detectBulkFailureFabrication({ content: turnBody, failedToolCount: 4, totalToolCount: 5, quantitativePatterns: DOMAIN_QUANTITATIVE_PATTERNS, }); if (bulk.detected) { console.warn( `bulk-failure fabrication: ${bulk.matchedPatterns} pattern hits`, bulk.matchedExamples, ); } ``` `DEFAULT_BULK_FAILURE_PATTERN_THRESHOLD` (3), `DEFAULT_BULK_FAILURE_MIN_LENGTH` (800), and `DEFAULT_BULK_FAILURE_RATIO_THRESHOLD` (0.8) ship as exported constants so consumers can tune the gates without re-deriving them. ## Contributing a detector through a plugin [#contributing-a-detector-through-a-plugin] The graph's `FabricationNode` iterates the union of every plugin's `contributeFabricationDetectors()` return. A detector reads `completedTools` from the context and returns a `FabricationFinding` or `null`. ```typescript // lib/plugins/corpusGuard.ts import type { HarnessPlugin } from "@pleach/core"; import type { FabricationDetector } from "@pleach/core/plugins"; const emptyResultDetector: FabricationDetector = { id: "empty-result-claim", detect(ctx) { const empty = ctx.completedTools.filter( (t) => t.name === "search_corpus" && Array.isArray(t.result) && t.result.length === 0, ); if (empty.length === 0) return null; if (!/\bfound\b|\bresults? show\b/i.test(ctx.assistantContent)) return null; return { detectorId: "empty-result-claim", severity: "high", reason: "Prose claims results when search_corpus returned an empty array", evidence: { emptyToolCalls: empty.map((t) => t.id) }, }; }, }; export const corpusGuardPlugin: HarnessPlugin = { name: "corpus-guard", version: "0.1.0", contributeFabricationDetectors: () => [emptyResultDetector], }; ``` Register the plugin once at runtime construction and every turn runs the detector against its `completedTools` slice. ## Host-supplied tool-argument fabrication detectors [#host-supplied-tool-argument-fabrication-detectors] The detectors above run against synthesized prose. A second class of detector runs against tool-call **arguments** before dispatch — when the model invents an argument value that has no provenance in the turn's prior tool results. The pattern composes a host-supplied verdict, a `safetyTier` field on the tool definition, and one of two routing destinations the runtime offers out of the box. Worked example: a host wires a detector that flags HTTP URLs the model invented (no prior tool result returned them) versus URLs that appeared in a resolver tool's output earlier in the same turn. The detector returns a verdict; the runtime decides what to do with it based on the tool's `safetyTier`. ### The pattern in four parts [#the-pattern-in-four-parts] 1. **Tool authors tag the tool with `safetyTier`.** Three values: `"critical"`, `"standard"`, `"advisory"`. The field is part of the [Tools](/docs/tools#safetytier) contract — see that page for the values and the contribution path. 2. **The host contributes a detector that returns a verdict.** The verdict carries a `reason` discriminator and the suspect argument. The detector reads the tool name, the args, and the prior tool results from the dispatch context. 3. **The verdict promotes through `ApprovalDecisionKind`.** The runtime maps the verdict to a discriminator on the approval-decision enum. Hosts can contribute new discriminators alongside the detector (see [Interrupts](/docs/interrupts#approvaldecisionkind-host-supplied-discriminators)). 4. **Routing splits on `safetyTier`.** Two destinations: | `safetyTier` | Destination | User can override? | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------- | | `"critical"` | A hard-halt pipeline stage short-circuits dispatch with `_recoverable: false`. The operator's pre-committed safety policy. | No | | `"standard"` | The `InterruptApprovalCard` surfaces with a ground-truth panel showing the suspect arg, the mismatch reason, and the recovery tools. | Yes — accept, edit, or reject | | `"advisory"` | Probe-only. The detector still fires for observability; no halt and no card. | N/A — nothing to override | ### The hard-halt stage [#the-hard-halt-stage] The hard-halt routing registers as a pipeline stage at an explicit slot (today, slot `6d2` between the seed validator and the approval stage). Stages at that position run after the model-recoverable predicates but before the user-facing approval card — so a critical tier never reaches the card and the user is never offered a button that would override the operator's policy. The stage returns an unrecoverable envelope (`_recoverable: false`, plus a structured error code) and emits a routing-discriminator probe so dashboards can count hard-halts separately from the broader suspect-fire metric. ### Why this composes cleanly [#why-this-composes-cleanly] The four parts are independent: a tool author sets `safetyTier` once on the definition. The host wires a detector once at plugin construction. The runtime owns the routing — there's no per-tool glue code. New tools inherit the routing for free as long as the detector recognizes their arg shape. The audit invariant the substrate enforces: **every tool tagged `safetyTier: "critical"` must be reachable by the host's detector.** A `"critical"` tool with no detector coverage would silently bypass the hard-halt — the registration is the load-bearing artifact and CI fails when coverage is missing. ## Adding a custom detector [#adding-a-custom-detector] The bundled detectors handle the common shapes (hallucinated UUIDs, malformed identifiers, file paths that don't exist). Domain-specific detectors register through the `contributeFabricationDetectors` plugin hook. The hook returns `readonly FabricationDetector[]`; each detector is an object with an `id`, an optional `version`, and a synchronous `detect(ctx)` method that returns a `FabricationFinding` or `null`. ```typescript import type { HarnessPlugin } from "@pleach/core" import type { FabricationDetector, FabricationDetectorContext, FabricationFinding, } from "@pleach/core/plugins" const orderIdDetector: FabricationDetector = { id: "order-id-format", version: "0.1.0", detect(ctx: FabricationDetectorContext): FabricationFinding | null { // Order IDs are 8 digits prefixed ORD-. Flag any order-shaped token // in the synthesized prose that doesn't match the canonical format. const malformed = ctx.assistantContent.match(/\bORD-\d{1,7}\b/g) if (!malformed) return null return { detectorId: "order-id-format", severity: "medium", reason: "Prose cites an order ID that isn't 8 digits prefixed ORD-", evidence: { suspects: malformed }, } }, } export const myPlugin: HarnessPlugin = { name: "domain-detectors", version: "0.1.0", contributeFabricationDetectors: () => [orderIdDetector], } ``` Three rules hold for any custom detector: * **`detect` must be synchronous.** Detectors fire on the hot path — per chunk in the streaming case — so a `Promise` return would block the stream observer contract. * **The detector is side-effect-free.** No telemetry writes, no storage reads, no network calls. Detectors classify; they don't enrich. * **The `severity` field is required.** One of `"low"`, `"medium"`, `"high"`. It reports how serious the finding is and is hoisted into the guard's `[FabricationGuard] plugin-detector-fired` breadcrumb. It does not by itself trigger the hard-halt stage or the approval card — that routing belongs to the tool-argument verdict mechanism above, which keys on the tool's `safetyTier`, not on a finding's `severity`. A related hook — `contributeFabricationDetectorRules` — lets a plugin contribute per-detector configuration (regexes, allowlists, thresholds) without re-implementing the detector itself. See [Plugin contract](/docs/plugin-contract) for the full surface. ## Where to go next [#where-to-go-next] --- # Facet accessors (/docs/facets) The runtime exposes its capabilities through **facets** — small named accessors that group related methods by domain. `runtime.tenant` gathers every tenant-scoping primitive; `runtime.events` gathers every event-log primitive; `runtime.spans` gathers the in-process OTel span surface. The grouping is the contract. The methods inside each facet are the implementation. The design choice is to make the runtime **discoverable by construction**. A human reading `runtime.` in an IDE sees a short list of named domains, not a flat dump of methods. An LLM agent introspecting the runtime to plan a next action reads the same short list. Both audiences route their queries through the facet name (`spans`, `events`, `tenant`) and arrive at the right method. > **Facets are a thematic island.** Not one of the [six cluster > triplets](/docs/concept-clusters#the-six-cluster-triplets) — a > facet is a surface pattern (typed accessors grouped by domain), > not a three-concept cluster. See [What lives outside the cluster > pattern](/docs/concept-clusters#what-lives-outside-the-cluster-pattern). ## The facet inventory [#the-facet-inventory] Facets live on two host objects: the `SessionRuntime` your host code constructs, and the `TurnOrchestrator` handle each turn receives. The per-turn graph also exposes a `runtime.graph.*` namespace that mirrors the facet pattern for graph-layer-only helpers. ### On `SessionRuntime` [#on-sessionruntime] | Facet | What it carries | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `runtime.sessions` | Session lifecycle — `create`, `resume`, `find`, `save`, `delete` (returns a `SessionDeleteReceipt`), `updateProviderModel` | | `runtime.checkpoints` | Checkpoint surface — `rollback`, `list` (async-iterable of summaries) | | `runtime.tenant` | `id` + `subId` properties. See [Tenant facet](/docs/tenant-facet) | | `runtime.sync` | `execute` (callable directly — `runtime.sync(sessionId, opts)` delegates), `resolveConflict` (returns a `SyncConflictResolveReceipt` — `resolved`, `sessionId`, `conflictId`, `resolution`, `resolvedAt`; **FSL build: last-writer-wins — logs the resolution + returns `resolved: true` with NO CRDT merge or state mutation; enterprise builds perform the merge**), `subscribeToStream` | | `runtime.diagnostics` | `checkReadiness()` — mailbox, history, perf-state report | | `runtime.plugins` | 25+ `collectX` accessors (fabrication detectors, finalization passes, synthesis directive blocks, detection rules, intent classifiers, tool-coupling hints, intent-tool map, sandbox bridge, interrupt UI handlers, citation rule set, chat manifest provider, fabrication guard, guest denied tools, entity name counter, structure prefetcher, artifact cache reader, get-data handler factory, stream observers, ...) plus `listAvailablePromptContributions`, `listAvailableSafetyPolicies`, `getActiveSafetyPolicies`, `getSafetyRegistry`, `registerExtension`, `getPluginManager`. The canonical surface for reading what plugins have contributed. | | `runtime.prompts` | `list`, `get`, `getAll`, `listByOrigin`, `listByPlugin`, `listByAnnotation`, `count` over registered prompt contributions | | `runtime.safety` | `listActivePolicies`, `listAvailablePolicies`, `getRegistry` | | `runtime.tools` | `list`, `get`, `listByApprovalRequired`, `listByAnnotation`, `count` over registered tools. (`listByCallClass` is **deprecated** — it returns `[]` unconditionally because `ToolDefinitionLite` carries no `callClass` field; use `list()` + your own call-class mapping.) | | `runtime.observerRouter` | Host-side stream observer registration — `register`, `unregister` (returns `ObserverUnregisterReceipt`), enumeration helpers | | `runtime.degradation` | `getDegradedModels`, `isModelDegraded`, `getDegradedModelRecord` (returns receipt), `getPlanningContext` | | `runtime.timeTravel` | `api` — the `TimeTravelAPI` property (lazily constructed; `undefined` when the runtime has no checkpointer) for state snapshot get/list, fork, revert | | `runtime.dev` | DevTools-only surface — store + stream-manager accessors, `onRepetitionGuard`, `createTimestamp` | | `runtime.async` | `spawn`, `spawnStream`, `getResult`, `getResultWithContext`, plus task-manager/subagent-manager accessors | | `runtime.interrupts` | `resolve` (returns `InterruptResolveReceipt`), `manager` (the `InterruptManager` property, `undefined` when none is attached) | | `runtime.adapter` | `get` / `set` the model adapter; `getCapabilityContextForTool`, `getCapabilityRegistry` | | `runtime.events` | Event log read surface — `iterate({chatId, fromSequenceNumber?})` paginated async-iterable + `fold(projection)` GraphProjection reducer; extends the live event-bus (`on` / `once` / `off`). See [Event log projections](/docs/event-log-projections) | | `runtime.spans` | In-process OTel span surface — `start`, `flush`, `shutdown`, `inFlightCount`, `isShutdown`, `snapshot`. See [OTel observability](/docs/otel-observability). **Wire `config.otelExporter` or spans are dropped** — the default is a `NoopOtelExporter` that accepts every span and discards it on `flush` (`inFlightCount`/`isShutdown`/`snapshot` still track accurately). | | `runtime.observe` | `record(row)` — emit one `ObserveRow` to the registered observe destination; silent no-op when none is installed (host call sites stay guard-free). Shares the dispatch slot `@pleach/observe`'s `init()` writes to, so one `init()` activates both the top-level `recordCall` path and `runtime.observe.record`. See [Observe](/docs/observe) | | `runtime.probes` | `emit(label, payload)` plus a TS-typed `typed(key, payload)` over the augmentable `ProbesRegistry`. Emits a probe into the per-runtime registry aggregated by `collectProbes` (the `contributeProbes` plugin hook); no-ops when no plugin contributes a probe at that label | | `runtime._internal` | Substrate-only escape hatches. Two sub-facets: `_internal.observerRouter` (local-flag/observer-halt bookkeeping per messageId) and `_internal.lifecycle` (seam prompt resolution, job-dispatch/complete notifications). Off-limits to consumer code. | Several facets return **receipts** rather than bare booleans — `sessions.delete`, `sync.resolveConflict`, `observerRouter.unregister`, `degradation.getDegradedModelRecord`, `interrupts.resolve`. The receipt shape carries enough context (resolution timestamps, the resolved id, the resolution decision) to be logged or asserted against without a follow-up read. `runtime.graph.*` is **not** a facet on `SessionRuntime`. It is the graph-layer-only namespace assembled inside the per-turn graph itself; the K.1 ladder carved `graph.recovery`, `graph.heuristics`, `graph.config`, and `graph._internal.builders` into the graph facets tree. The per-turn graph constructs these; the runtime does not re-expose them. Alongside those, the graph namespace exposes **one read-only introspection facet per stage** — `graph.anchor`, `graph.toolLoop`, `graph.synthesize`, and `graph.postTurn`, mirroring the four-stage lattice. Each carries the compiled-graph nodes whose `stageId` matches that stage (a frozen `nodes` array in deterministic registration order) plus `getNode(id)` for a stage-scoped lookup. `graph.synthesize` additionally exposes `getSeamIdentity()` — the reader for the singleton synthesize seam's `nodeId`. The graph builder holds no reference to the per-runtime seam holder, so this reader always returns `undefined` today; a future layer may inject a live seam-identity reader from `SessionRuntime`. | Graph stage facet | Stage | Carries | | ------------------ | ------------- | ------------------------------------------- | | `graph.anchor` | `anchor-plan` | `nodes`, `getNode(id)` | | `graph.toolLoop` | `tool-loop` | `nodes`, `getNode(id)` | | `graph.synthesize` | `synthesize` | `nodes`, `getNode(id)`, `getSeamIdentity()` | | `graph.postTurn` | `post-turn` | `nodes`, `getNode(id)` | ### On `TurnOrchestrator` [#on-turnorchestrator] The per-turn orchestrator handle each turn receives. `TurnOrchestrator` was renamed from `OrchestratorClient` under D-PO-3; the old name survives as a `@deprecated` path re-export until `@pleach/core@2.0.0`. Facets here expose what's in scope for the current turn — config, history, tools, model resolution. | Facet | What it carries | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `client.config` | Turn config inspection — `get`, `update`, `getBackend` | | `client.history` | Message history reader/writer — `get`, `load`, `clear`, `add` | | `client.context` | Per-turn context — `setArtifacts`, `setJobHistorySummary`, `clearArtifactsByJobId`, `setLastUserMessageFileRefs` | | `client.tools` | Tool execution surface — `getResolved`, `resolveForCurrentTurn`, `getPreResolvedIntent`, `getCurrentIntent`, `getPendingJobs` | | `client.model` | Model selection + fallback — `selectForQuery`, `getLastSelection`, `registerPreHook`, `registerPostHook`, `setOnProviderAttempt` | | `client.prompts` | Composed-prompt surface — `buildSystemPrompt`, `getGraphConfig`, `drainPendingSystemNotices` | | `client._internal.graph` | INTERNAL composite for the 7 `*Public` / `*ForGraph` graph-internal cohort — `executeTool`, `applyPreModelTransforms`, `runPostModelHooks`, `getAuthToken`, `getThinkingConfig`, `getFallbackConfig`, `recordToolOutcomes`, `getConversationHistory`. Substrate-only; not on the user-facing surface. | The flat methods that existed before the facet carve — `getConfig`, `getHistory`, `selectModelForQuery`, `buildSystemPrompt`, and the rest — remain on the class with `@deprecated` JSDoc pointing at the facet equivalent. They forward to the same implementation; the deprecation cycle is the contract. ## Why facets, not flat methods [#why-facets-not-flat-methods] A flat namespace with fifty methods is searchable only if you already know what you're looking for; a facet surface is searchable by **typing the concern**. `runtime.tenant.` lists every tenant-scoping primitive; `runtime.events.` lists every event-log primitive; `runtime.spans.` lists every in-process span accessor. The grouping IS the documentation — a new contributor reads the facet names first, picks the one that names their concern, and the IDE shows them every primitive inside. The same property holds for an LLM-driven coding agent reading the runtime's type surface to plan its next action: a handful of facet names with one-line descriptions costs far less token budget than fifty method signatures with overlapping naming. The flat methods (`runtime.createSession()`, `client.getConfig()`) remain available as forwarders for back-compat. The facet path is canonical; the flat surface is a migration aid. ## Surface stability [#surface-stability] A facet groups methods that share a domain. Refactors inside that domain — renaming a private helper, swapping the internal store, adding a new method to the group — leave the facet shape unchanged. Consumer code that destructures the facet keeps working: ```typescript const { id, subId } = runtime.tenant; const { iterate, fold } = runtime.events; ``` The facet shape is the contract; the methods inside are the implementation. Code that pins to a facet sees fewer breakage events at minor bumps than code that pins to individual methods, because the substrate can churn underneath the facet without changing the facet's exported shape. ## What's internal — don't use [#whats-internal--dont-use] Two namespaces are off-limits to consumer code: * `runtime._internal.*` — substrate-only escape hatches with two sub-facets (`observerRouter` for local-flag/halt bookkeeping, `lifecycle` for seam prompt resolution + job notifications). Subject to change without a deprecation cycle. The leading underscore is the signal; treat the field as if it weren't exported. * `TurnOrchestrator._internal.graph` — same rules. The composite holds the 7 `*Public` / `*ForGraph` graph-internal methods the per-turn graph consumes. The public surface for graph inspection is `runtime.graph.*` inside the graph layer, not `_internal.graph` on the orchestrator. Consumer code reading it will break the next time the snapshot shape changes. Code that touches `_internal` is breakage waiting to happen at the next patch release. CI gates (see below) flag consumer imports of `_internal` symbols so the breakage surfaces in CI, not at runtime. ## CI gates enforcing facet coverage [#ci-gates-enforcing-facet-coverage] Three audit gates ride on the facet surface. Each gate runs in upstream CI; all three are **WARN-DRIFT** today (non-blocking) and will promote to strict per D-AGENT-8 once in-tree migration of the last flat-method call sites finishes. ### `audit:facet-coverage` [#auditfacet-coverage] Asserts every public capability on `SessionRuntime` has a facet entry. Adding a new public method without placing it under a facet — or under the explicit "stays flat" allowlist — surfaces in the report. The intent is to keep the surface organized by domain as the runtime grows; new capabilities land under the facet that names their concern. ### `audit:orchestrator-facet-coverage` [#auditorchestrator-facet-coverage] The same gate, scoped to `TurnOrchestrator`. Adding a new field on the client handle without a facet placement (or an `_internal` prefix) surfaces in the report. ### `audit:graph-facet-coverage` [#auditgraph-facet-coverage] The same gate, scoped to the `runtime.graph.*` sub-tree. The graph namespace is fast-growing as the per-turn graph absorbs more substrate logic; the gate keeps the sub-tree organized by domain (`recovery`, `heuristics`, `config`, `_internal.builders`, plus the per-stage introspection facets `anchor` / `toolLoop` / `synthesize` / `postTurn`) rather than letting every new helper land at the top level. ## Deprecation contract for flat methods [#deprecation-contract-for-flat-methods] Flat methods (e.g. `runtime.createSession()`, `client.getConfig()`) ship with `@deprecated` JSDoc that points at the facet equivalent (`runtime.sessions.create()`, `client.config.get()`). IDEs render the strike-through; TypeScript surfaces the deprecation in the language service. The lifecycle: 1. **Minor bump.** Adds the facet. Marks the flat method `@deprecated` with a pointer at the facet. 2. **Next major.** Removes the flat method. The facet is the only path. Flat methods don't drop in minor versions. The deprecation cycle is the contract — consumer code that ignores the `@deprecated` warning still works through the minor series, and the next major is the announced breakage window. The `OrchestratorClient` → `TurnOrchestrator` rename follows the same shape: the old name is a path re-export until `@pleach/core@2.0.0`. ## Migrating to the facet path [#migrating-to-the-facet-path] The rename is mechanical — the flat method's name appears verbatim inside the facet, prefixed with the facet accessor. ```typescript // Flat (back-compat forwarder) const id = await runtime.createSession({ provider }); const messages = client.getHistory(); const model = client.selectModelForQuery(userMessage); // Canonical (facet path) const id = await runtime.sessions.create({ provider }); const messages = client.history.get(); const model = client.model.selectForQuery(userMessage); // Facet-first from the start for await (const row of runtime.events.iterate({ chatId })) { // ... } ``` The last block uses a facet that was facet-first from the start. Mixed codebases are expected during the migration window; the deprecated JSDoc on the flat methods is what flags the lines that still need rewriting. A codemod that walks the AST and rewrites every flat-method call into its facet equivalent is straightforward — the rename map is 1-to-1. Hosts running large migrations should write the codemod once and run it across the consumer tree rather than rewriting call sites by hand. ## Where to go next [#where-to-go-next] --- # Family-locked routing (/docs/family-lock) At session start the runtime locks one `ProviderFamily` and one `Transport`. The lock freezes four properties for the session's lifetime — the tokenizer, the prompt-cache key, the tool-call dialect, and the refusal pattern. When a provider call fails, the cascade walks the next rung in the same family; it never silently widens to another. The only allowed cross-family path is a narrow carve-out for models that never resolved through the matrix. The `(ProviderFamily × CallClass)` resolution matrix itself is **host-supplied** — `@pleach/core` reaches it through the `AgentAdapter.resolveModel()` seam rather than shipping the table, so a consumer wires their own model map without forking the package. The in-family cascade (`pickNextInFamily`) and the family-exhausted terminal live in the package; the lookup table they consult does not. ## Why the lock matters: provenance [#why-the-lock-matters-provenance] The model that answers a session can change for ordinary reasons. A transient provider failure triggers a failover; a migration moves you from one provider to another over a release cycle. When it changes, an audit-bound deployment has to answer two questions about every turn: which provider and model actually produced it, and when — and why — the session changed. Family-lock makes both answerable by constraining what can change without a record. Within a session the family is pinned; a transient failure falls back in-family only (`claude-opus` → `claude-sonnet`), recorded as a `fallbackStep` row carrying `inFamily: true` and the originating and attempted models. A genuine provider change is never a silent widen — it's an explicit `sessions.updateProviderModel` that re-locks the session and emits a `session-lock-resynced` event. Every `AuditableCall` row carries the `provider` and `transport` that ran, so "which model produced this answer, and when did it change" is a query against the ledger, not a reconstruction after the fact. The contrast that matters isn't whether another tool can switch models mid-thread — many can, and re-render the conversation cleanly into the new provider's format. It's whether the switch leaves a durable, queryable record you can put in front of an auditor. A model label in UI state, or a per-message field in a local dev tool's storage, isn't the same thing as a typed row in your own database — hash-chained, tenant-scoped, and joinable to the rest of the session's audit trail. That's the provenance family-lock is built to keep honest. ## The routing cluster [#the-routing-cluster] Family-lock is one of three concepts that decide how a call reaches a model — paired with [CallClass](/docs/call-classes) (what kind of call this is) and [Seam](/docs/seams) (the per-class entry point). The cluster sits between the execution graph and the LLM transport. The full triplet framing lives at [Concept clusters → Routing](/docs/concept-clusters#routing-cluster); this page is the deep-dive on the family lock itself. The matrix at [Model resolution matrix](/docs/model-resolution-matrix) is the reference table the seam consults; the wiring to a real SDK is at [Providers](/docs/providers). ## What locks at session start [#what-locks-at-session-start] | Lock | What it freezes | | ----------------- | ----------------------------------------------------------------- | | Tokenizer | Per-family token-count semantics | | Prompt-cache key | Provider-side prompt cache identity | | Tool-call dialect | Schema shape the provider accepts (OpenAI vs Anthropic vs Google) | | Refusal pattern | The shape and language of provider-side refusals | Why these four. The tokenizer decides how many tokens a prompt is, which decides cost and context-window fit. The prompt-cache key decides which calls share a provider-side cache hit. The tool-call dialect decides whether a tool-loop turn parses at all — Anthropic's `input_schema` shape and OpenAI's `function.parameters` shape are not interchangeable mid-session. The refusal pattern decides whether a retry loop recognizes "I can't help with that" and stops, or treats the refusal as a transient error and retries forever. The lock is per-session and holds for the session's lifetime unless you explicitly re-lock it. A session locked to `anthropic` stays on Anthropic until something re-locks it; switching family is an explicit operation — `sessions.updateProviderModel` re-locks the session and emits a `session-lock-resynced` event — never a silent mid-turn widen. See [Session lifecycle](/docs/session-lifecycle) for how that boundary is drawn and what carries across. ## The family-strict cascade [#the-family-strict-cascade] The cascade rule is one function: `pickNextInFamily(family, currentModel, triedSet)`. It walks the locked family's column in the [model resolution matrix](/docs/model-resolution-matrix) — same family, next rung. The matrix's per-family ladders are the source of truth for ordering. When every in-family rung is tried, the runtime emits a `family-exhausted` state. The consumer surface — UI, orchestrator, host — picks a different family explicitly. No silent cross-family widening. A concrete walk. A session locked to `anthropic` on `synthesize` starts at the matrix's `(anthropic, synthesize)` cell. The first call fails with a provider-side 529. The cascade calls `pickNextInFamily("anthropic", "claude-sonnet-4-6", new Set([...]))` and gets the next rung in the same column. A `fallbackStep` row lands in the ledger with `inFamily: true` and the new `attemptModel`. When the column runs out, the runtime emits `family-exhausted` and stops — no row crosses into `(openai, synthesize)`. Why structural. A tool-call dialect that worked on Anthropic would start failing mid-session if the cascade silently widened to OpenAI: the same agent code, the same session id, two incompatible tool dialects within one conversation. The lock saves the session from in-flight schema drift; the explicit pivot makes the family change visible to the host instead of invisible inside the runtime. ## Provider families [#provider-families] ```typescript type ProviderFamily = | "anthropic" | "openai" | "google" | "deepseek" | "moonshot" | "mistral" | "xai" ``` The union is closed in the substrate matrix. Adding a family means editing the matrix and shipping a new minor release. BYOK and non-matrix-resolvable models bypass the union via the carve-out below. ## Transports [#transports] ```typescript type Transport = | "native" | "openrouter" | "byok-native" | "byok-openrouter" ``` Transport is per-session, locked alongside family. `native` calls the provider SDK directly. `openrouter` routes through OpenRouter — useful for evaluation and for families without a native transport. `byok-native` and `byok-openrouter` use customer-supplied credentials instead of the deployment's. See [Providers](/docs/providers) for the per-transport wiring. Mid-session escalation across transports doesn't silently happen. A session that starts on `anthropic` + `byok-native` and exhausts the user's key surfaces the exhaustion through the ledger as a `providerCascade` row with `outcome.status: "exhausted"`, not a silent flip to the deployment's own credentials. ## The BYOK / non-matrix carve-out [#the-byok--non-matrix-carve-out] Non-matrix-resolvable models — BYOK rigs, multimodal-only slugs, unrecognized model identifiers — preserve the legacy cross-family fallback. Those sessions never had a family lock to honor in the first place, so the lock can't constrain a cascade it never participated in. The carve-out is opt-in via BYOK configuration. It never fires silently against matrix-resolved models. A session that resolves through the matrix gets the family-strict cascade; a session operating on a BYOK slug gets the legacy fallback. See [Providers](/docs/providers) for the wiring and [Env vars](/docs/env-vars) for the credential surface. ## Host-internal planner calls [#host-internal-planner-calls] The family lock governs the conversation's seam calls — the `synthesize`, `converse`, `reasoning`, and `utility` calls that reach a model through the [provider seam](/docs/seams). A host can also make internal model calls that never pass through the seam, and those calls are outside the lock by construction. The clearest case is the planner cold-start. The [`anchor-plan`](/docs/plans) stage generates the turn's plan. A host may run that generation on its own configured planner model — a fixed model chosen by host config, not the session's locked family. That call is host-internal, not a seam call: it consults no `(family x callClass)` matrix cell and walks no in-family cascade, because the planner model is pinned, not resolved. It still writes a ledger row. Identify it by its stage and class — `stage: "anchor-plan"`, `callClass: "utility"`, `payload.kind: "planGeneration"` — and by what it lacks: no `fallbackStep`, no `family-exhausted` state, no cross-family `providerCascade`. There is no family decision to record because the planner model was never a matrix candidate. This is a documented exception, not a leak. The lock constrains the cascade for matrix-resolved seam calls; a pinned host planner model sits beside that system, the same way a BYOK slug does. Watch it the way you watch the BYOK carve-out: on a matrix-resolved session, the only row whose family differs from the session's locked family should be this planner cold-start. Any *other* non-locked-family row is the leak signal — wire an alert that allows the `anchor-plan` / `utility` planner row and reds on everything else. ## On the audit row [#on-the-audit-row] Every `AuditableCall.call` carries `provider: ProviderFamily` and `transport: Transport` — what actually ran, after resolution. Three typed payload slots track family decisions: | Slot | When it lands | | ----------------- | ------------------------------------------------------------------------------------------------------------ | | `familyLock` | At the resolution boundary; carries `resolverPath`, `requestedFamily`, `familyMismatch`, `byokActive` | | `fallbackStep` | After each in-family rung attempt; carries `originalModel`, `attemptModel`, `inFamily: true`, `attemptIndex` | | `providerCascade` | At a cross-family pivot — BYOK carve-out only under the lock | A `providerCascade` row whose `source` is not the carve-out is the canonical "the lock leaked" signal. Wire a dashboard alert on it; under the matrix, that row shouldn't exist. The query is direct: `WHERE payload.kind = 'providerCascade' AND payload.source != 'imperative-carve-out'` returns zero rows for a healthy matrix-resolved fleet. See [Auditable call row](/docs/auditable-call-row#typed-payload-slots) for the per-payload field shape. ## In the fingerprint [#in-the-fingerprint] `family` is part of the fingerprint cache key. Two calls with the same prompt and tools but different families never share a cache entry — correct, because the tokenizer differs, the tool dialect differs, and the provider-side cache key differs. A cache hit across families would mean serving an Anthropic-tokenized response to an OpenAI-tokenized call; the byte counts and tool-arg shapes wouldn't match. `transport` participates in the same way. A session on `byok-native` and one on `openrouter` route through different wire shapes; sharing a cache entry would serve a response shaped for one transport to a call expecting the other. See [Fingerprint](/docs/fingerprint#whats-in-the-key) for the full cache-key shape and the metadata fields the key deliberately excludes. ## Where to go next [#where-to-go-next] --- # FAQ (/docs/faq) ## What is `@pleach/core`? [#what-is-pleachcore] A TypeScript-first agent runtime substrate published under the `@pleach/*` scope on npm. It treats every LLM call inside an agent as classifiable, auditable, and replayable — sessions, checkpointing, reactive channels, family-locked model routing, and an append-only per-call audit ledger. The contract is language-agnostic by design. The TypeScript distribution is the reference implementation, in production today. A Go implementation has been built against the same contract and round-trips a shared corpus of recorded turns — proof the wire shapes aren't TypeScript-flavored. The Go implementation isn't published as a SKU yet; an official `@pleach` Go runtime is the next planned published implementation. ## How is this different from the Vercel AI SDK? [#how-is-this-different-from-the-vercel-ai-sdk] Pleach wraps the AI SDK; it doesn't replace it. `AiSdkProvider` keeps the AI SDK as your provider layer, and the runtime adds what sits around it: sessions, family-locked routing, the four-stage lattice, the audit ledger, replay determinism, and checkpointing. If your app is "chat with tools and stream the response," reach for the AI SDK. If you need per-call audit, per-turn cost attribution, replay determinism, or family-locked routing, `@pleach/core` is doing structural work the AI SDK isn't built for. The concrete shape difference: an AI SDK `streamText` call returns a stream of text deltas and tool events with no shared identity tuple; an `executeMessage` call yields the same surface shape but every call beneath it lands as a row keyed on `(sessionId, turnId, stageId, seqWithinTurn)` — that's the join key the AI SDK doesn't ship. See [Comparison](/docs/comparison) and [Migrating from the AI SDK](/docs/migrating-from-ai-sdk). ## How does this compare to Claude Code, Goose, OpenHands, AutoGen, CrewAI? [#how-does-this-compare-to-claude-code-goose-openhands-autogen-crewai] Different category. Those are *agent harnesses* — complete running products you launch and use. `@pleach/core` is the *substrate* one layer below: the primitives a harness is built on. If you're the end user, install a harness. If you're shipping a customer-facing agent product where each tenant needs their own session, audit trail, and invoice, the harness's session log isn't the row shape you need. See [Comparison → Agent harnesses](/docs/comparison#agent-harnesses-a-different-category) for the capability matrix. ## Is this open source? What's the license? [#is-this-open-source-whats-the-license] Yes — every shipping `@pleach/*` package ships under **FSL-1.1-Apache-2.0** (Functional Source License with Apache 2.0 as the future license). FSL is source-available, usable in production, free of charge during the FSL window; the only restriction is on competing offerings. Each package auto- transitions to permissive Apache 2.0 two years after first stable publish. `@pleach/trust-pack` alone remains a `0.0.1 · UNLICENSED` placeholder; it'll ship FSL-1.1-Apache-2.0 when its first cut lands. ## Does Pleach replace my Anthropic or OpenAI Enterprise contract? [#does-pleach-replace-my-anthropic-or-openai-enterprise-contract] No — it composes underneath. SSO/SAML, ZDR, Workspaces or Projects, the Admin / Usage API, dedicated capacity, prompt caching, snapshot pinning all keep doing their job. Pleach adds the three things the vendor contract doesn't cover: per-axis rollup by `tenantId` inside one Workspace or Project (external customers in a SaaS, or employees, teams, and cost centers when used internally), a hash-chained `AuditableCall` row in your own Postgres, and replay-deterministic regression across model snapshots. No new vendor on the security questionnaire — npm install plus a table. See [Migrating from Anthropic Enterprise](/docs/migrating-from-anthropic-enterprise) and [Migrating from OpenAI Enterprise](/docs/migrating-from-openai-enterprise). ## Who's behind this? [#whos-behind-this] `@pleach/*` is the public FSL-1.1-Apache-2.0 source-available ecosystem (auto-transitions to Apache 2.0 two years after first stable publish). The substrate was extracted from a production agent runtime and generalized; the upstream deployment is one private consumer of many possible. Contact for both general questions and security disclosures: `getpleach@protonmail.com`. ## What can I build with this? [#what-can-i-build-with-this] Anything that wants the structural guarantees: * A multi-tenant AI product where finance needs per-customer invoicing. * A regulated agent surface where a compliance team will ask "show me every decision this session made." * A long-running agent with planning + tool loop + synthesis + safety review, where each step needs to be observable. * An eval / regression-test loop replaying production failures. * A product mid-migration between providers that can't break tool-call dialect mid-conversation. * An offline-first browser app needing IndexedDB sync. * Stuck-session debugging where you need to rewind and try a different path. What you're probably *not* building if `@pleach/core` is overkill: * A single-shot RAG bot (query → paragraph). Use the AI SDK. * A static workflow with fixed branches. Use Temporal or Inngest. * A chat UI with no tools and no audit needs. Use `streamText`. See the README's "Who is this for?" table for the long version. ## Is this designed for AI agents to use? [#is-this-designed-for-ai-agents-to-use] Yes — agents are first-class consumers, not bolted on. Every `AuditableCall` row is a typed record; `turnId`, `toolName`, `subagentDepth`, and `tokenUsage` are addressable from code the same way they're addressable in SQL. `SpawnTreeState` rolls nested-spawn cost back to the parent `turnId`, so an agent reading its own trace sees recursion as a tree, not a flattened log. The runtime also lets an agent debug itself: the deterministic fingerprint stream replays last week's failure against this week's prompt byte-identically; per-channel `checkpoint()` / `restore()` under `window.__HARNESS_DEVTOOLS__` rewinds from before a bad decision instead of escalating to a human. This site renders to `/llms.txt` and `/llms-full.txt` — an agent ingesting Pleach reads the docs themselves, not a stripped sitemap. The reference distribution is TypeScript a developer can read. The same surface — typed rows, deterministic fingerprints, checkpoints — is what an agent reads from code. Agent-friendly first; usable by devs because devs read the same surface. `@pleach/core` itself is written largely by agents under that surface. The four-stage lattice fails CI on out-of-lattice edges; the audit ledger makes the agent-written code reviewable in SQL. The runtime authored by agents is the same one auditable by humans. ## Why does it require so many decisions up front? [#why-does-it-require-so-many-decisions-up-front] It doesn't. With zero config, `new SessionRuntime()` wires in-memory adapters and runs. You add a provider, a storage adapter, plugins, and safety policies *when you need them*. Every `SessionRuntimeConfig` field except the implicit defaults is optional. The reference docs surface the full config space because that's their job; the actual happy-path setup is small. The [Getting started](/docs/getting-started) page is six lines of code. ## Why the 4-stage lattice? Isn't it limiting? [#why-the-4-stage-lattice-isnt-it-limiting] The lattice is what makes cost allocation, observability, and time-travel *structural* instead of bolted on. Once stages are declared, you can budget a turn's `synthesize` calls independently from its `tool-loop` calls, slice the audit ledger by stage, and rewind to the start of `synthesize` to re-run. "Slice the audit ledger by stage" is a literal SQL move: every row in `harness_auditable_calls` carries a non-null `stage_id` drawn from the four lattice members, so `GROUP BY stage_id` gives you a real partition. There's no second source of truth for which stage a call belonged to. If you don't need those properties, yes — the lattice is overhead. The substrate is overkill for "I want a chat shell with tools." See [Architecture](/docs/architecture) §1. ## Why exactly one `synthesize` call per turn? [#why-exactly-one-synthesize-call-per-turn] Because the user sees the synthesis. If the runtime fires two `synthesize` calls and shows one of them, the audit ledger and the rendered UI disagree on what the model said. Structurally capping at one means the rendered string and the audited string are the same string. Sequential plan-then-verify chains land via an upcoming per-runtime synthesize policy; concurrent debate topologies (many synthesizers per turn, agent-handoff networks) are v1.1+ work. ## Why family-locked routing? [#why-family-locked-routing] Because tokenizer, prompt-cache key, tool-call dialect, and refusal pattern differ across families (Anthropic vs OpenAI vs Google). When a provider fails mid-conversation and the cascade silently widens to a different family, the tool dialect changes and tools that worked stop working. The lock makes the cascade walk in-family rungs only; cross-family widening is explicit and visible. See [Model resolution matrix](/docs/model-resolution-matrix). ## Why a separate `AuditableCall` ledger from the event log? [#why-a-separate-auditablecall-ledger-from-the-event-log] Two distinct concerns. The event log is the broader stream of *observable events* (messages, tools, interrupts); the audit ledger records *load-bearing decisions* (which model fired, why, with what fingerprint, against which policy). A regulator's "show me every decision this session made" question has a one-row-per-call answer from the ledger that doesn't require event-log archeology. See [Event log](/docs/event-log) and [AuditableCall row](/docs/auditable-call-row). ## Can I use this without Supabase? [#can-i-use-this-without-supabase] Yes. `MemoryAdapter` and `MemorySaver` cover testing and dev; `IndexedDBAdapter` and `IndexedDBSaver` cover browser apps; `SupabaseAdapter` and `SupabaseSaver` cover server production. Custom adapters are one interface away. See the SQLite recipe in [Recipes](/docs/recipes) for a complete custom-adapter example. ## Can I use this with my own LLM provider? [#can-i-use-this-with-my-own-llm-provider] Yes. `AiSdkProvider` covers any AI SDK provider (Anthropic, OpenAI, Google, Mistral, xAI, Groq, Bedrock, plus community providers); `AnthropicSdkProvider` wraps Anthropic's SDK directly; `AgentProvider` is the interface for everything else (local Ollama, a custom gateway, your own SDK wrapper). See [Providers](/docs/providers). ## Can I use this without React? [#can-i-use-this-without-react] Yes. `@pleach/core/react` is one subpath; the rest of the runtime is framework-agnostic. Server-rendered apps, Svelte/Vue/Solid frontends, native mobile clients, and CLI tools all consume the runtime through its async-iterator stream and the HTTP+SSE wire contract. See [API routes](/docs/api-routes) for the wire shapes any language can implement. ## Does it work with Edge runtimes? [#does-it-work-with-edge-runtimes] Yes. The substrate avoids Node-only APIs in its hot path — `fingerprint`, `channels`, `audit`, `prompt-builder` are all isomorphic. Supabase + Anthropic SDKs both run on edge runtimes; the storage adapter uses Web Crypto explicitly. The fingerprint module reaches `globalThis.crypto.subtle` rather than Node's `node:crypto`, which is why a recorded turn under Node and the same turn replayed under Cloudflare Workers produce the same fingerprint stream — same hash function, same input canonicalization, same byte output. See [Deployment](/docs/deployment). ## Why FSL-1.1-Apache-2.0? [#why-fsl-11-apache-20] Every shipping `@pleach/*` package publishes under FSL-1.1- Apache-2.0 — the Functional Source License with Apache 2.0 as the future license. Source-available, usable in production, free of charge during the FSL window; the only restriction is on competing offerings. Each package auto-transitions to permissive Apache 2.0 two years after first stable publish. Pre-`0.1.0` placeholder publishes (`0.0.1`) carried the FSL grant under the same posture. See the [fair-source post-mortem](/blog/why-fair-source) for the reasoning behind the license-posture history. ## Can I contribute? [#can-i-contribute] Yes. The upstream source is at [github.com/pleachhq/core](https://github.com/pleachhq/core). Bug reports, feature requests, and PRs are welcome. The upstream `CONTRIBUTING.md` documents the workflow. For sibling SKUs that aren't published yet, the design is tracked in upstream planning docs; opening an issue with your use case helps prioritize the publish wave. ## Where do I report security issues? [#where-do-i-report-security-issues] `getpleach@protonmail.com`. The upstream `SECURITY.md` documents the disclosure policy — initial acknowledgement within two business days, a fix-or-mitigation plan for confirmed reports, and a CVE filed when a published `@pleach/*` package needs one. No paid bug-bounty program today; reports get a real response, not a payout. ## Is there a hosted version? [#is-there-a-hosted-version] Not today; in development. `@pleach/gateway@0.1.0` ships the self-hostable routing substrate — `GatewayClient` wraps `@pleach/core`'s model-family substrate with per-tenant BYOK key routing, family-strict cascade pivot, per-call cost emission (`domain.gateway.cost.recorded`), and OTel `llm.invocation` span emission. See [Gateway](/docs/gateway) for the surface. `@pleach/observe@0.1.0` ships as a destination-flexible brownfield SDK — buyers write `AuditableCall` rows to their own Postgres, Supabase, OTel collector, or any custom destination; the SDK layer stays free. The hosted products on the roadmap are enterprise products: * **Hosted Gateway** — multi-tenant routing, per-tenant cost caps, provider failover. Per-tenant subscription pricing with optional consolidated billing on dollars Pleach holds; **no markup on inference** (BYO-key buyers route through the gateway at provider list price). * **Hosted Observe at enterprise scope** — SSO/SAML/SCIM, multi-seat dashboards, retention SLAs, SOC 2 wrapper, signed audit retention, per-tenant evidence pack export. The managed export bridge routes audit rows into the review surface your team already runs (Datadog, Splunk, Grafana, SIEM, or custom destination). **Compliance attestation is bundled inside Hosted Observe Enterprise, not sold as a separate SKU** — the agent-runtime-specific axes (`AuditableCall` row + `subagentDepth` + `tenantId`) are the wedge that makes it defensible alongside Vanta or Drata, but they don't carry a separate procurement line item. * **Marketplace bundles** — AWS, Azure, GCP. Procurement-readable packaging of the hosted products on the buyer's preferred cloud contract surface. Self-serve tiers for smaller teams are in development. Pricing specifics land on `/pricing` before they ship. None of these gate runtime features. The runtime stays trust-protected: no telemetry phone-home, no remote license check, no account required to run, and no retro-paywall on a release that already shipped. FSL-1.1-Apache-2.0 is irrevocable per release — `npm install @pleach/core@` works forever, and that version's two-year clock to Apache 2.0 keeps ticking independently. ## How do I stay updated? [#how-do-i-stay-updated] * The upstream `CHANGELOG.md` on every `@pleach/*` package. * The `pleachhq/core` repo's releases page. * Security: `getpleach@protonmail.com` for advisories. There's no marketing list. The packages are the canonical broadcast surface. ## Capabilities [#capabilities] Yes — `@pleach/compliance` ships a four-scrubber bundle (`SSN-US`, `Luhn`, `US-DL`, `KeyedRegex`) plus two inherited CI audit gates (`audit:tenant-scoping`, `audit:harness-event-log-tenant-id-required`). Wider scope (gateway integration, additional scrubbers, evidence exports) is planned but out of scope for the 1.0 cut. See [Compliance](/docs/compliance). The substrate emits four span types directly (`session.turn`, `llm.invocation`, `graph.stage`, `tool.execution`) — wiring them to a backend is one OTLP exporter call. The `runtime.spans` facet reads spans in-process without an exporter for tests and DevTools. See [OTel observability](/docs/otel-observability). Yes — `memoryCacheBackend` is now the substrate default with a 1000-entry / 64 MB cap. For multi-process or multi-region deployments where a single process's cache is too narrow, swap in a Redis-backed `CacheBackend` implementation. The contract is two methods (`get`, `set`) plus a `metricsSnapshot()`. See [Cache](/docs/cache). No — the chain is an after-the-fact tamper detector. Writer-side stamping is in soak behind the `c9PhaseBEnabled` flag; `@pleach/core/eventLog` ships `verifyChainForChat` + `generateProof` to walk the chain in code today. The recursive-CTE SQL pattern on the [Hash chain](/docs/hash-chain) page is the manual fallback. Real-time write integrity is a runtime-attestation problem and out of scope for the chain (see [Attestation](/docs/attestation)). Set `tenantId` (and optionally `subTenantId`) on `SessionRuntimeConfig`. The runtime stamps `tenant_id` on every event log row, every audit ledger row, every OTel span (as `pleach.tenant_id`), and any outbound HTTP that rides through `withTenantHeader`. RLS at the database layer is the enforcement; the facet provides the value. See [Tenant facet](/docs/tenant-facet). Safety policies act on prompt inputs *before* the model sees them — they're a synthesis-time concern. Scrubbers act on event log writes *before* they hit storage — they're a persistence-time concern. A redaction that needs to influence what the model generates belongs in a safety policy; a redaction that needs to influence what gets persisted belongs in a scrubber. See [Safety](/docs/safety) and [Scrubbers](/docs/scrubbers). Five `AuditableCall` payload shapes were promoted from opaque JSON to discriminated unions: `InterruptDecisionRecord`, `TokenCostRecord`, `ToolSelectionTrace`, `PlanGenerationRecord`, `SynthesisQualityRecord`. Narrow via `payload.kind` to get per-arm field narrowing. The version log on the audit row tracks which bump introduced each shape. See [Typed records](/docs/typed-records). It's published intentionally pre-1.0 — the surface will move before 1.0. Pin via `~0.x.y` (allow patch only). The adapter is appropriate for hosts running LangChain primitives alongside Pleach during a migration; for a one-time conversion off LangChain, see the migration guide. See [`@pleach/langchain`](/docs/langchain). Reach for the facet (`runtime.tenant.getTenantId()`). Flat methods stay shipped with `@deprecated` JSDoc that points at the facet; removal happens at the next major. New consumer code uses facets; existing code with `@deprecated` warnings still works through the minor series. See [Facets](/docs/facets). ## Where to go next [#where-to-go-next] --- # Fingerprint (/docs/fingerprint) Fingerprint is one concept in the **safety & determinism** [thematic island](/docs/concept-clusters#what-lives-outside-the-cluster-pattern) — siblings of [safety](/docs/safety), [scrubbers](/docs/scrubbers), [fabrication detection](/docs/fabrication-detection), and [determinism](/docs/determinism). A fingerprint is the equivalence class for an LLM call. Two calls with the same fingerprint are interchangeable: the cache returns one for the other; replay reuses the recorded result; audit groups them together. `computeFingerprint` is a pure function. Same inputs in any order produce the same fingerprint, byte-for-byte. That's what makes deterministic replay possible and what lets the cache survive across processes. ```typescript import { computeFingerprint, buildMetadata, canonicalize, sha256, isCacheEligible, PLEACH_CORE_VERSION, } from "@pleach/core/fingerprint"; import type { Fingerprint, FingerprintInput, FingerprintConfig } from "@pleach/core/fingerprint"; ``` ## The key/metadata split [#the-keymetadata-split] The single most load-bearing decision in the fingerprint contract is what goes into the cache key versus what stays as metadata. ### What's IN the key [#whats-in-the-key] Anything that changes the model's output bytes or defines a soundness boundary. | Field | Why it's in the key | | -------------------- | ------------------------------------------------------------------------------------------ | | `model` | Different models produce different outputs | | `promptHash` | sha256 of canonicalized `messages` | | `toolsHash` | sha256 of the tool catalog, sorted by `name` | | `systemPromptHash` | sha256 of the composed system prompt (omitted when absent) | | `temperatureBucket` | Quantized temperature — `Math.round(temp * 10) / 10` at default precision | | `seed` | When set, deterministic provider seed | | `family` | Tokenizer / prompt-cache key / tool dialect lock | | `callClass` | Determines the downgrade ladder | | `runtimeMode` | Determinism contract (see below) | | `tenantId` | Soundness — never leak one tenant's cache to another | | `pleachVersion` | Schema invalidation on substrate upgrades | | `safetyPoliciesHash` | sha256 of the `"@"` list, sorted by id; omitted when zero policies are active | ### What's deliberately OUT of the key [#whats-deliberately-out-of-the-key] Identity-like fields. Putting these in the key would make caching nearly useless and break replay. | Field | Why it's NOT in the key | | ------------------------------- | ------------------------------------------------------------------ | | `chatId` | Two fresh chats with identical input should hit the same cache | | `sessionId` | Same call in different sessions should reuse | | `messageId` | Re-emitting an idempotent message shouldn't fork the cache | | `userId` / `orgId` | Cache by content, not identity (tenant isolation handles security) | | `environmentId` / `workspaceId` | Soft scopes; not soundness boundaries | | `evalRunId` / `replayOfEventId` | These are *reuse* keys, not invalidation keys | | `recordedAt` | Wall-clock time would invalidate the cache on every read | These live on `FingerprintMetadata` instead — joinable to the fingerprint at audit time, never part of the equivalence class. If you put `sessionId` in the key, two identical calls in different sessions miss the cache. If you leave `tenantId` out, the cache leaks across tenants. The split is the load-bearing decision. ## Computing a fingerprint [#computing-a-fingerprint] ```typescript import { computeFingerprint } from "@pleach/core/fingerprint"; const fp = computeFingerprint({ model: "claude-sonnet-4-5", family: "anthropic", callClass: "synthesize", runtimeMode: "interactive", messages: [{ role: "user", content: "Hello" }], systemPrompt: composedSystemPrompt, tools: toolDescriptors, temperature: 0.7, seed: 42, tenantId: "org_abc", activeSafetyPolicies: [{ id: "compliance.pii-redaction", version: "1.2.0" }], }); // fp.promptHash / fp.toolsHash / fp.systemPromptHash — sha256 hex digests // fp.safetyPoliciesHash — present iff activeSafetyPolicies is non-empty // fp.temperatureBucket / fp.seed — present iff the call set them ``` The returned `Fingerprint` carries both the canonical hash (for joins, dedup, cache lookup) and the typed component fields (for debugging, dashboards, and audit queries). ### Temperature bucketing [#temperature-bucketing] Floats aren't a reliable cache key — two paths that compute "`0.7`" through different arithmetic produce different bytes. The fingerprint quantizes: ```typescript temperatureBucket = Math.round(temperature * 10 ** precision) / 10 ** precision; ``` Default precision is 1 (`0.7` → `0.7`). Override via `FingerprintConfig.temperaturePrecision` when you need finer buckets — higher precision = lower cache hit rate. Bucketing is a fallback for ineligible-but-fingerprintable calls. Callers that want strict-zero matching should set `temperature: 0` and rely on `isCacheEligible()` (below). ### Safety policies are sorted [#safety-policies-are-sorted] `activeSafetyPolicies` canonicalizes by sorting the `@` tuples by id before hashing, then stores the sha256 as `safetyPoliciesHash`. Two operators who enable the same set in different orders produce the same key. ## `canonicalize` and `sha256` [#canonicalize-and-sha256] Two helpers exported for building related cache keys: ```typescript import { canonicalize, sha256 } from "@pleach/core/fingerprint"; const json = canonicalize({ b: 2, a: 1 }); // → '{"a":1,"b":2}' — keys sorted, whitespace stripped, no key-order ambiguity const hash = sha256(json); // → 64-char hex digest, platform-uniform (Node, browser, edge) ``` `canonicalize` is the canonical-JSON encoder. Use it when you're hashing structured data for cross-process comparison — it's the same routine the fingerprint uses internally, so your derived keys join cleanly. ## `isCacheEligible` [#iscacheeligible] The eligibility predicate. A call is eligible iff it's deterministic — every other condition is a consumer-side concern layered on top. ```typescript import { isCacheEligible } from "@pleach/core/fingerprint"; const eligible = isCacheEligible({ temperature, seed }); ``` A call is eligible when either: * `temperature === 0` (greedy / argmax sampling — no stochasticity), or * `seed` is set (`!== undefined` and `!== null`) — caller pinned the RNG. Calls with `temperature > 0` and no seed sample from a distribution; a cache hit would replay a stale sample in place of a fresh one. The predicate is pure, has no side effects, and is equivalent across all `runtimeMode`s — eligibility is a property of the call, not the runtime. Ineligible calls can still have a fingerprint computed (for audit / debug / cost projection). The cache MUST NOT serve a hit or write an entry under their key. Enforcement lives at the cache boundary, not inside `computeFingerprint()`. ## `buildMetadata` [#buildmetadata] The identity-like sibling of the fingerprint. Carries the fields the cache key deliberately excludes so the audit ledger can still join them. ```typescript import { buildMetadata } from "@pleach/core/fingerprint"; const meta = buildMetadata({ // buildMetadata takes the SAME FingerprintInput as computeFingerprint and // extracts only the identity-like fields; the equivalence-class fields are // required inputs. family: "anthropic", model: "claude-sonnet-4-5", callClass: "synthesize", runtimeMode: "interactive", messages: [{ role: "user", content: "Hello" }], // Identity-like fields — the ones the cache key deliberately excludes. chatId: "chat_abc", sessionId, messageId, userId: "user_123", orgId: "org_abc", environmentId: "prod", evalRunId, replayOfEventId, recordedAt: new Date().toISOString(), }); ``` Pass both fingerprint and metadata when writing a ledger row; the fingerprint defines the equivalence class, the metadata says which row this particular write is. ## `RuntimeMode` values [#runtimemode-values] The fingerprint includes `runtimeMode`, so a turn recorded in one mode never collides with the cache of another. The closed union: | Mode | Determinism contract | | ----------------- | ----------------------------------------------------------------------------------- | | `interactive` | User-facing streaming chat; latency-sensitive | | `headless-eval` | Batch eval; seed-pinned at runtime construction | | `headless-replay` | Replay from recorded events — cache MUST hit; a miss throws `ReplayDivergenceError` | | `headless-job` | Scheduled cron / async callback | | `coding-agent` | Multi-synthesize per turn | Cross-mode read direction is one-way: `interactive → headless-eval → headless-replay`. Headless modes never read `interactive` entries — an interactive call was not produced under a determinism contract, so a headless consumer that borrowed from it would lose its replay guarantee. The direction is gated by `CacheReadPolicy` (`strict-mode` is the v1 default; `cross-mode-readable` is v1.x). ## `ReplayDivergenceError` [#replaydivergenceerror] ```typescript import { ReplayDivergenceError } from "@pleach/core/fingerprint"; ``` Thrown by replay-mode lookups when the cache misses. Carries the `Fingerprint` that failed to resolve. `headless-replay` is the one mode where a miss is fatal — a replay session cannot fall through to a live call without breaking the recorded-equivalence contract. ## `PLEACH_CORE_VERSION` [#pleach_core_version] The substrate version. Lives in the fingerprint key so a substrate upgrade invalidates the cache automatically. If the runtime composes prompts differently after a version bump (a common reason for output drift), the cache miss is automatic rather than silent corruption. ```typescript import { PLEACH_CORE_VERSION } from "@pleach/core/fingerprint"; console.log(PLEACH_CORE_VERSION); // e.g. "1.1.0" ``` Override only in tests where you want to assert a specific version behavior — production code should always read the constant. ## Replay determinism [#replay-determinism] The fingerprint exists so the replay story works. The contract: 1. Same fingerprint input → same fingerprint hash. 2. Same fingerprint hash + same provider → same output bytes. 3. Output bytes feed into channel reducers deterministically. 4. Channel reducers + scheduling are deterministic given the superstep state. Therefore: a recorded turn replays byte-identical against the same package version + the same input. `@pleach/eval@0.1.0` and `@pleach/replay@0.1.0` are built around this property and ship today (entry-point bodies real; later-slice methods throw typed sentinels — see [Packages](/docs/packages)). The fingerprint is what keeps the chain honest. ### Common ways the chain breaks [#common-ways-the-chain-breaks] | What breaks it | How to fix | | -------------------------------------------------------------- | ------------------------------------------------------ | | A static prompt contribution reads session state | Move it to the runtime-aware hook | | An async stream observer (forbidden but sometimes attempted) | Make it sync; route fan-out through named-channel emit | | Wall-clock read inside a reducer | Read once at turn start; pass as a channel write | | A custom channel that mutates `checkpoint()`/`restore()` state | Implement honestly — snapshot must round-trip | Each of these silently breaks replay. The fingerprint test catches them: two runs of the "same" turn produce different fingerprints when one of these slips in. ## Where to go next [#where-to-go-next] --- # Gate failures → fixes (/docs/gate-failures) When an audit gate goes red it names the file, the symbol, and the missing fact in its output. This page maps each gate's failure string to what it means and the fix, so an agent — or a human — reading CI output can go from the red line to the change without re-deriving it. Every gate exits `0` clean, `1` on a contract failure, `2` on a config or IO error, and prints a bracketed, greppable prefix. Grep the prefix; match the row. This is the companion to [Audit gates](/docs/audit-gates) (the catalog by subsystem) and [CI enforcement](/docs/ci-enforcement) (the catalog by contract clause) — this page is indexed by the string you actually see. ## You added a graph node [#you-added-a-graph-node] | Failure string (grep this) | What it means → fix | | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `[graph-stages] Node "" missing stageId in NODE_STAGE_MAP` | The node has no lattice stage. Declare `stageId` in the node metadata (plugin path), or add a `NODE_STAGE_MAP` entry in `topology.ts` (upstream path). See [Extending Pleach](/docs/extending) and the [new-node checklist](/docs/nodes#new-node-checklist). | | `[graph-stages] FORBIDDEN edge () → ()` | An edge crosses the lattice outside the nine allowed patterns. Don't route directly — write a state field the next stage's nodes subscribe to. See [the lattice gate](/docs/graph#the-lattice-gate). | | `[graph-stages] Singleton violated: "synthesizer" registered N times` | Two nodes occupy the `synthesize` stage; it's a singleton. Move recovery or post-synthesis shaping to a [post-turn stream filter](/docs/plugins/stream-observers), not a graph node. | The gate also prints a summary line — `[graph-stages] FAIL — N forbidden, N unstaged of N nodes / N edges` — so the count in the failure tells you how many sites to fix. ## You added an event type or a writer [#you-added-an-event-type-or-a-writer] | Failure string (grep this) | What it means → fix | | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `[audit:c8-event-type-allowlist-coverage] FAIL — N event type(s) missing from SCRUBBABLE_FIELDS` | A new event type has no redaction decision. Add an entry to `scrub/allowlist.ts` — `[]` is valid and documents "no fields to redact." See [Event-type allowlist coverage](/docs/scrubbers#event-type-allowlist-coverage). | | `[C8:unscrubbed-event-type]` | The **runtime** fail-closed marker for the same gap — an event type reached the writer with no allowlist entry. Same fix: register the type in `scrub/allowlist.ts`. (This is emitted at write time, not by the gate's stdout.) | | `[audit:c8-union-member-has-producer] FAIL — N union member(s) with no producer` | You declared an `EventLogInput` member that nothing writes. Add a producer that calls `writer.write({ type: "", ... })`, or add a documented baseline entry. See [Event log](/docs/event-log). | ## You changed the audit row [#you-changed-the-audit-row] | Failure string (grep this) | What it means → fix | | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `[auditable-call-shape] FAIL — shape diverged from baseline` | The `AuditableCall` TS wire shape changed. If intentional, run the gate with `--update-fingerprint` **and** bump `AuditRecordVersion` if any field's meaning moved. See [the AuditableCall row](/docs/auditable-call-row). | | `[auditable-call-version] FAIL rule 3: fingerprint advanced ... but AuditRecordVersion did not move` | You changed the row shape without versioning it — the silent-breaking-change the gate exists to catch. Bump `export type AuditRecordVersion`. If you were trying to add a column, don't: use the `pluginPayloads` slot instead — see [the locked row](/docs/extending#the-locked-row-and-the-slot-that-isnt). | ## You added or changed a plugin hook [#you-added-or-changed-a-plugin-hook] These fire on the substrate's own hook set and on sibling `@pleach/*` SKUs — the upstream surface, not a host plugin that merely *uses* hooks. | Failure string (grep this) | What it means → fix | | ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `[audit:plugin-contract-completeness] FAIL — N missing-collector, N novel awaiting-consumer` | A `contributeX` hook has no paired `PluginManager.collectX` accessor, or a collector with no consumer. Add both, or baseline an intentional bypass. The run also emits the probe `[UXParity:audit-plugin-contract-completeness:run:drift]`. See [Plugin contract](/docs/plugin-contract). | | `[audit:plugin-hook-category-assigned] FAIL — N hook(s) declared on HarnessPlugin but NOT classified into any namespace bucket` | A new hook belongs to none of the nine namespaces. Add it to a bucket in `plugins/namespaces.ts`. See [contribution namespaces](/docs/plugins/namespaces). | ## Reading the loop [#reading-the-loop] The failure string is the correction signal. An agent extending the runtime runs the gate, reads the named fact (a stage, a producer, an allowlist entry, a version bump), applies it, and re-runs — green means the addition preserved the ledger contract. That loop is the point of [agent-driven self-extension](/docs/extending#agent-driven-self-extension): the gate output is legible enough to act on without a human in the middle. ## Where to go next [#where-to-go-next] --- # @pleach/gateway (/docs/gateway) The gate in the hedge — every call walks through here, in family, on budget, attributable to one tenant. `@pleach/gateway` is the multi-tenant routing SKU layered over the `@pleach/core` model-family substrate. One client per tenant accepts a `(family, callClass, model, prompt)` call, picks a transport, walks the in-family cascade on transient failure, emits one cost event per successful call, and never silently widens across families. The substrate's in-family cascade behavior — what locks at session start and how `pickNextInFamily` walks the column — lives in [Family-locked routing](/docs/family-lock). The [model resolution matrix](/docs/model-resolution-matrix) defines the `(family × callClass)` cells the cascade walks. This page is the multi-tenant routing client layered on top: per-tenant scoping, operator allowlist, BYOK fingerprinting, and the per-call cost event. The package is intentionally thin. The routing math, family matrix, walk order, and BYOK transport resolution all live in [`@pleach/core`'s model-family module](/docs/model-resolution-matrix); gateway re-exports `CallClass`, `ProviderFamily`, and `RoutingDecision` so consumers don't need to import from both places, but it doesn't reimplement any of them. ## Install [#install] ```bash npm install @pleach/gateway ``` ```bash pnpm add @pleach/gateway ``` ```bash yarn add @pleach/gateway ``` ```bash bun add @pleach/gateway ``` ```typescript import { GatewayClient, asTenantId, fingerprintByokKey, GatewayFamilyDeniedError, GatewayFamilyExhaustedError, GatewayTransportMissingError, type CostEvent, type GatewayResponse, type GatewayTransport, } from "@pleach/gateway"; ``` The package re-exports `CallClass`, `ProviderFamily`, and `RoutingDecision` from `@pleach/core` as well, so a consumer that talks only to the gateway doesn't need a second import line for the substrate types. ## `GatewayClient` [#gatewayclient] One client per tenant is the canonical pattern. `tenantId` is required at construction; the constructor throws if it's missing or empty. Every cost event the client emits carries that tenant id, and the operator-supplied `allowedFamilies` allowlist is scoped to the client instance. ```typescript const gateway = new GatewayClient({ tenantId: asTenantId("acme-corp"), transports: new Map([ ["anthropic", anthropicTransport], ["openai", openaiTransport], ]), allowedFamilies: new Set(["anthropic", "openai"]), costEventEmitter: { emit(event) { eventLog.append(event); }, }, }); const response = await gateway.route({ family: "anthropic", callClass: "synthesize", model: "claude-sonnet-4-6", prompt: "Summarize the meeting notes …", }); ``` `asTenantId(raw)` is the helper that turns a plain string into the branded `TenantId` the constructor expects. It validates the input is non-empty so the silent-isolation case (an unset env var interpolated as `""`) becomes a load-bearing throw at init rather than a months-later billing incident. ### Constructor options [#constructor-options] | Option | Required | What it does | | ------------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------- | | `tenantId` | yes | Branded `TenantId` — every cost event the client emits carries it. | | `transports` | effectively | `Map`. Omitted → every `route()` call throws `GatewayTransportMissingError`. | | `allowedFamilies` | no | `Set`. Calls with a family outside the set throw `GatewayFamilyDeniedError` before any transport invocation. | | `byokKey` | no | Client-scoped BYOK key. Header-only; never persisted. Per-call `route({ byokKey })` overrides it. | | `costEventEmitter` | no | Sink for `CostEvent`. Defaults to a no-op so tests and dry-runs don't NPE; production must wire a real sink. | ### `route()` [#route] Returns a `GatewayResponse` on success. The response carries the final `modelInvoked`, the `usage` token counts, a `cost` block with the family and rate snapshot, the full `routingDecision`, the `cascadeWalks` history (empty when the first rung succeeded), and a `familyExhausted` boolean. ### `close()` [#close] Marks the client closed; subsequent `route()` calls reject. Phase A has no underlying resources to release — transports are caller-supplied — so `close()` is purely a state flag today. ## The transport seam [#the-transport-seam] `GatewayClient` doesn't ship concrete HTTP clients. It consumes a narrow `GatewayTransport` interface and a `Map` the host supplies at construction. ```typescript interface GatewayTransport { readonly kind: | "openrouter" | "native-anthropic" | "native-openai" | "native-google" | "bedrock" | "azure" | "vertex"; invoke(req: GatewayTransportRequest): Promise; } ``` The contract is intentionally minimal — the host wraps its existing provider client (OpenRouter HTTP, the Anthropic SDK, an in-house Bedrock adapter) in a `GatewayTransport` and hands the map to the constructor. Gateway never opens a socket of its own. A transport map keyed by `family` is the Phase A shape. Region pins and `(family, callClass)` transport arbitration are future work. ## Per-call cost event [#per-call-cost-event] Every successful `route()` emits one event to the configured sink. The cadence is per-call, not batched: accuracy and reproducibility for compliance attestation outweigh the batching savings. A sink that wants its own batching can implement it internally; the gateway's contract with consumers is one event per call. ```typescript interface CostEvent { readonly type: "domain.gateway.cost.recorded"; readonly tenantId: TenantId; readonly family: ProviderFamily; readonly callClass: CallClass; readonly modelInvoked: string; readonly costUsd: number; readonly promptTokens: number; readonly completionTokens: number; readonly byokActive: boolean; readonly byokKeyHash?: string; readonly routingDecision: RoutingDecision; readonly timestamp: string; } ``` The full `routingDecision` shape ships inside the payload so a downstream consumer — typically `@pleach/compliance`'s attestation runtime — has the provenance to attest the call without a round-trip to the audit ledger. `raw_provider_cost_usd` and `markup_pct` live inside the decision separately from the marked `costUsd`, so a consumer that needs the pre-markup figure has it directly. `response.cost.usd` is `raw_provider_cost × (1 + markupPct)`. The 20% flat markup is sourced from `MODEL_FAMILY_MATRIX`; the gateway doesn't compute it independently. ## BYOK is header-only [#byok-is-header-only] A BYOK key passed to the constructor or to an individual `route()` call is forwarded to the transport via the request payload and never written anywhere else. The gateway computes a 16-character sha256 fingerprint of the key for three purposes: * The cache fingerprint key, so the same BYOK gets cache reuse without ever comparing raw keys. * The `byok_key_id` field on the routing decision for audit attribution. * The `byokKeyHash` field on the emitted cost event. ```typescript import { fingerprintByokKey, isSameByokKey } from "@pleach/gateway"; fingerprintByokKey("sk-ant-api03-abc…"); // → "8f3a2b1c4d5e6f78" isSameByokKey(a, b); // → true iff fingerprintByokKey(a) === fingerprintByokKey(b) ``` The 16-character hex slice is opaque — enough entropy to de-dup within a tenant, not enough to reverse. `brandedFingerprintByokKey` returns the same string under a branded `ByokKeyFingerprint` type for code that wants to distinguish gateway hashes from arbitrary hex strings at the type layer. The caller is responsible for never logging the raw key. Once it's fingerprinted, gateway forgets the plain value. ## Family-strict cascade on 503 [#family-strict-cascade-on-503] On a transient transport failure (status 502 / 503 / 504, rate-limit 429, timeout, abort), `GatewayClient` walks the in-family rung ladder via `pickNextInFamily(family, currentModel, attempted)` from the substrate. The walk follows the same order the runtime uses — `synthesize → reasoning → utility → converse` — skipping any rung already tried this call. Each attempt produces a `CascadeWalk` entry on the response. When every rung fails, the gateway throws `GatewayFamilyExhaustedError` with the attempted-model list attached. It does **not** widen across families. That's the gateway-side mirror of the runtime-side [Family-Strict Cascade Pivot](/docs/model-resolution-matrix#cross-family-fallback-policy): cross-family is a consumer decision, made explicitly, not a silent behavior of the routing layer. ```typescript try { const response = await gateway.route({ family: "anthropic", … }); } catch (err) { if (err instanceof GatewayFamilyExhaustedError) { // err.family — "anthropic" // err.attemptedModels — ["claude-sonnet-4-6", "claude-haiku-4-5", …] // Consumer chooses: widen to "openai", surface to user, or fail. } } ``` The graceful `familyExhausted: true` flag on the response is reserved for a future shape where a transport reports a degraded-but-non-failed rung; Phase A throws on hard exhaustion to keep the error contract honest. The cascade is bounded at 16 steps so a pathological transport can't infinite-loop the gateway. ## `allowedFamilies` governance [#allowedfamilies-governance] `allowedFamilies` is the operator-facing governance hook. When set, `route()` checks the requested family against the allowlist **before any transport invocation** and throws `GatewayFamilyDeniedError` on denial. The throw is synchronous-within-the-promise — denied calls never touch the wire, never count against rate budgets, and never emit a cost event. ```typescript const gateway = new GatewayClient({ tenantId: asTenantId("acme-corp"), transports: transportMap, allowedFamilies: new Set(["anthropic", "openai"]), }); await gateway.route({ family: "deepseek", … }); // → throws GatewayFamilyDeniedError // err.family — "deepseek" // err.allowedFamilies — Set { "anthropic", "openai" } ``` Use it to enforce per-tenant family policy ("acme-corp is on anthropic-only"), to block a family during a rollback, or to gate a preview family behind an explicit operator opt-in. The mechanism is structural — a tenant on the allowlist cannot reach a family that's not on it, regardless of what the model id string says. ## Errors [#errors] Three error classes, three failure modes. All thrown via promise rejection from `route()`; the constructor's `TypeError` for invalid `tenantId` is the fourth. | Error | When | Thrown before transport? | | ------------------------------ | --------------------------------------------------- | ------------------------------------- | | `GatewayFamilyDeniedError` | `family` is not in `allowedFamilies`. | Yes — governance check is first. | | `GatewayTransportMissingError` | No transport configured for the resolved family. | Yes — configuration check is second. | | `GatewayFamilyExhaustedError` | Cascade walked every in-family rung and all failed. | No — at least one rung was attempted. | `GatewayFamilyDeniedError` and `GatewayTransportMissingError` are configuration failures and never reach the wire. `GatewayFamilyExhaustedError` is a cascade outcome and carries the attempted-model list so the consumer can decide what (if anything) to widen to. ## Tenant scoping [#tenant-scoping] `tenantId` is required at construction and stamped on every emitted `CostEvent`. The gateway's tenant-scope contract aligns with the substrate's [tenant facet](/docs/tenant-facet) and the [multi-tenant deployment](/docs/multi-tenant) pattern — same field, same partition key. The gateway's cost numbers come from the transport's reported `usage`, not from reading the core audit ledger; the gateway never queries the ledger. A consumer that wants both surfaces partitioned the same way (`GROUP BY tenantId`) gets that because both stamp the same opaque `tenantId`, not because the gateway joins through the ledger. The `tenantId` field is opaque the same way the audit row's is. A `GatewayClient` per end customer is the SaaS pattern; a `GatewayClient` per cost center is the internal-Enterprise pattern (one Anthropic Workspace or OpenAI Project sits behind the gateway, and the cost event partitions spend across teams). The cost-event sink reads the same `GROUP BY tenantId` either way. One `GatewayClient` instance per tenant is the canonical usage pattern. Per-tenant API keys (rotating credentials scoped to the tenant, separate from BYOK) are deferred to a later phase. ## Phase A status [#phase-a-status] What ships in the Phase A cut: * `GatewayClient` with `route()` and `close()`. * Per-call `domain.gateway.cost.recorded` event emission with full `RoutingDecision` payload. * BYOK fingerprinting (`fingerprintByokKey`, `isSameByokKey`, `brandedFingerprintByokKey`). * Family-strict cascade-on-503 via `pickNextInFamily`. * Operator `allowedFamilies` allowlist governance. * Three typed error classes. * Re-exports of `CallClass`, `ProviderFamily`, and `RoutingDecision` from `@pleach/core`. * `createGatewayRuntime()` contract + `routeChatCompletion()` body. `routeChatCompletion()` validates the input shape (`tenantId` / `family` / `messages`) and the `allowedProviders` governance hook, resolves the family default provider, and returns a structurally-valid `RouteChatCompletionOutput`. The response is stub-shape — the `content` field carries a placeholder stub value, `usage` is zero, and no transport invocation occurs. The shape is lossless against the steady-state output so slice 3+ drops in real transports without consumer churn. `routeEmbedding()`, `getProviderHealth()`, and `getTrafficStats()` continue to throw the slice-1 sentinel. What's **not** in Phase A — lands in subsequent passes: * **Concrete HTTP transports.** Phase A consumes a caller-supplied `GatewayTransport` map for `GatewayClient`, and `routeChatCompletion()` returns a stub-shape response without invoking any wire transport. Production hosts wire a minimal HTTP wrapper around OpenRouter or their existing native provider client. A bundled OpenRouter + native-Anthropic / native-OpenAI / native-Google adapter set, plus the Bedrock / Azure / Vertex region-pin routing inside `routeChatCompletion`, arrives in subsequent Phase B slices. * **OTEL `llm.invocation` span emission.** The C7 telemetry rung that records every gateway call as an OTEL span lands once `runtime.otel` is consumable from `@pleach/core`'s exported surface. Until then, callers thread their own span context if they need it. * **Failover policy primitives.** Region pins, native-vs-openrouter transport arbitration, and per-call failover policy objects are v1.x work. Phase A's cascade is family-strict and walk-order-strict; there's no policy knob between them. ## Related SKUs [#related-skus] * [`@pleach/core`](/docs/core) — the family-locked matrix the gateway routes against. `pickNextInFamily`, `MODEL_FAMILY_MATRIX`, and the `CallClass` / `ProviderFamily` / `RoutingDecision` types all live there; gateway re-exports them for convenience. * [`@pleach/compliance`](/docs/compliance) — downstream consumer of `domain.gateway.cost.recorded` events. Reads the full `RoutingDecision` payload for attestation provenance without a round-trip to the audit ledger. * [`@pleach/observe`](/docs/observability) — wires the gateway's cost events into OpenTelemetry / Datadog / Honeycomb spans. For the full SKU map see [Which SKU do I need?](/docs/which-sku). ## Where to go next [#where-to-go-next] --- # Getting started (/docs/getting-started) Two paths from zero to a working chat surface. The wizard is the faster one; the snippets are if you'd rather see what gets generated. Pick whichever fits your taste. ## Path A — `npx pleach init` (recommended) [#path-a--npx-pleach-init-recommended] ```bash npx pleach init ``` The wizard detects your framework (Next.js App / Pages, Astro, SvelteKit, Remix, Vite, plain Node), asks four questions (template, provider, plugin stub, schema scaffold), and writes a starter project. On Next.js App Router that's a route handler, a `` page, and an `/audit` view — plus, optionally, a plugin stub and the Postgres schema bundle. See [CLI → `pleach init`](/docs/cli#pleach-init--the-wizard) for the per-framework template matrix. Run `npm run dev`. With no key set, the scaffold runs a keyless demo: the real graph, base-tools, and audit ledger execute with canned model text, so `/audit` shows real rows — model family, row count, tokens, est. cost — from your first message. Set `ANTHROPIC_API_KEY` (or `OPENAI_API_KEY` / `OPENROUTER_API_KEY`) to go live; the same route streams from the provider instead. \~60 seconds wall-clock. ## Path B — wire it by hand [#path-b--wire-it-by-hand] Two snippets. The `@pleach/core/quickstart` subpath is the canonical wire-level surface and ships in `@pleach/core@0.1.0`. ```bash npm install @pleach/core ``` Set one provider env var — pick whichever you already have a key for: The runtime auto-detects whichever key is set. Detection priority matches the tab order above. ```typescript // app/api/chat/route.ts import { createPleachRoute } from "@pleach/core/quickstart"; export const POST = createPleachRoute(); ``` ```tsx // app/page.tsx "use client"; import { ChatBox } from "@pleach/core/quickstart"; export default function Home() { return ; } ``` That's it. Run `npm run dev` and you have a streaming chat. The handler accepts `{ sessionId?, message }` JSON and streams `StreamEvent` NDJSON back. `` is an unstyled, semantic, ARIA-correct chat surface composed of plain HTML with `data-pleach-*` selectors. ## What you just got [#what-you-just-got] The five `@pleach/core/quickstart` exports together form the canonical wire-level surface: | Export | Shape | Job | | -------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | `createPleachRoute(opts?)` | `(req: Request) => Response \| Promise` | Fetch-handler around `SessionRuntime`. Works in App Router, Workers, Bun, Hono — anything `Request → Response`. | | `useChat(opts)` | React hook | Owns the in-flight stream, `messages`, `sessionId`, status (`idle \| submitting \| streaming \| error`). | | `` | React component | Unstyled, semantic, accessible. Composes `useChat`. | | `defaultPlugin` | `HarnessPlugin` | Empty baseline meeting the structural contract — drop into `plugins` as the "no domain customization" floor. | | `providerDetection` | helpers + constants | `detectProvider`, `detectAvailableProviders`, `DEFAULT_PROVIDER_ENV_VARS`, `DEFAULT_PROVIDER_PRIORITY`. | ## Two things to know before you ship [#two-things-to-know-before-you-ship] * **No provider env var set → HTTP 503.** The handler fails loudly at install time rather than silently falling back to a stub. Surface the 503 in your client; it's the signal that `ANTHROPIC_API_KEY` (or your equivalent) didn't reach the runtime. For keyless local dev, pass `createPleachRoute({ demo: process.env.NODE_ENV !== "production" })` — with no key it drives a real graph turn over canned model text and writes real audit rows (response header `x-pleach-mode: demo`). A resolved key always wins, and demo never engages in a production build. This is the mode the wizard scaffolds. * **`useChat` is Pleach-native, not Vercel-AI-SDK shape.** The hook speaks Pleach's `StreamEvent` discriminated union directly. If you're already on the AI SDK event taxonomy, see [Migrating from the AI SDK](/docs/migrating-from-ai-sdk) for the compat layer. ## Custom storage, plugins, or provider pinning [#custom-storage-plugins-or-provider-pinning] The zero-config defaults work for tutorials, evals, and local dev. Production hosts pass options through: ```typescript // app/api/chat/route.ts import { createPleachRoute } from "@pleach/core/quickstart"; import { SupabaseAdapter } from "@pleach/core/sessions"; import { myPlugin } from "./pleach.plugin"; export const POST = createPleachRoute({ storage: new SupabaseAdapter({ client: supabase }), plugins: [myPlugin], provider: "anthropic", // pin one; skip detection systemPrompt: (req) => promptFor(req), // per-request override }); ``` When you need to drop below the route handler — multi-tenant SaaS, regulated deployment, custom transport — reach for the [runtime construction](/docs/runtime-construction) page. ## A progressive path through the docs [#a-progressive-path-through-the-docs] You can stop reading at any of these tiers and have a working deployment. Tier 2 adds production concerns; tier 3 adds the audit, compliance, and replay differentiation. ### Tier 1 — \~5 minutes — streaming chat [#tier-1--5-minutes--streaming-chat] You're here. Path A or Path B above. Add a system prompt and you're shipping. **Read next:** [Providers](/docs/providers) for provider switching · [`` styling](/docs/react) for matching your design system. ### Tier 2 — \~30 minutes — production-ready chat [#tier-2--30-minutes--production-ready-chat] Persistent storage, custom plugins, multi-tenant cost rollup, OTel observability. **Read next:** [Which SKU do I need?](/docs/which-sku) · [Runtime construction](/docs/runtime-construction) · [Multi-tenant](/docs/multi-tenant) · [`@pleach/observe`](/docs/observe). ### Tier 3 — \~2 hours — audit + compliance + replay [#tier-3--2-hours--audit--compliance--replay] The `AuditableCall` ledger, the four-stage lattice, scrubbers for HIPAA / GDPR / PCI-DSS, hash-chain attestation, replay-deterministic regression testing. **Read next:** [Audit ledger](/docs/audit-ledger) · [Compliance](/docs/compliance) · [`@pleach/replay`](/docs/replay) · [`@pleach/eval`](/docs/eval) · [Decisions](/docs/decisions). ## Where to go next [#where-to-go-next] ## License [#license] `@pleach/core` is published under **FSL-1.1-Apache-2.0** (Functional Source License with Apache 2.0 as the future license, auto-transitions to Apache 2.0 two years after first stable publish). See [versioning](/docs/versioning) for the policy. --- # Glossary (/docs/glossary) When the docs disagree with a published package README, the README wins — these definitions are aligned with the public state of each `@pleach/*` package as of the most recent cut. The six clusters below orient you to the substrate; the A-Z reference further down is the lookup table. ## Concept clusters [#concept-clusters] ### Lifecycle [#lifecycle] The four-stage path a turn walks from intent to terminal message. * **Stage lattice** — The four-stage agent graph topology — `anchor-plan | tool-loop | synthesize | post-turn`. See [full entry](#s). * **Anchor-plan** — The first stage — intent detection, plan generation, anchor messages. See [full entry](#a). * **Tool-loop** — The second stage — iterative LLM-decision and tool-execution that may self-loop until the plan resolves. See [full entry](#t). * **Synthesize** — The user-facing final-answer call class, capped at one per turn by default. See [full entry](#s). * **SessionRuntime** — Per-session entry point owning the compiled graph, the channels, and the lifecycle. See [full entry](#s). ### Routing [#routing] How a call class binds to a model family at session start and stays bound. * **CallClass** — One of `utility`, `reasoning`, `converse`, or `synthesize` — discriminates internal calls from user-facing answer composition. See [full entry](#c). * **Family** — Semantic provider origin locking tokenizer, prompt-cache key, tool-call dialect, and refusal pattern at session start. See [full entry](#f). * **Family-strict cascade** — Provider fallback policy walking in-family rungs only; cross-family widening is consumer-initiated. See [full entry](#f). * **Matrix** — The `(ProviderFamily × CallClass)` resolution table — authoritative for what model fires for what call class. See [full entry](#m). * **ProviderSeam** — Per-`CallClass` seam contract for LLM invocation, exactly one per call class per runtime. See [full entry](#p). * **Seam** — Boundary between subsystems with a typed contract; the stream-observer contract lives at the seam boundary. See [full entry](#s). * **Native** — A provider transport that calls the provider's official SDK directly. See [full entry](#n). ### State [#state] The typed reactive slots and persistence backends carrying turn state. * **Channel** — Typed reactive state slot inside the compiled graph with per-kind concurrent-write semantics. See [full entry](#c). * **Checkpointer** — Swappable backend persisting graph state snapshots so the runtime can time-travel via `checkpoint()` / `restore()`. See [full entry](#c). * **Adapter** — Concrete implementation of an interface for a specific vendor — storage adapters, checkpoint savers, and provider transports. See [full entry](#a). * **Fork** — A new `SessionRuntime` sharing its parent's event log up to a cutoff and running independently after. See [full entry](#f). * **Event-granular fork** — Forking a session at any specific event, not only at checkpoint boundaries. See [full entry](#e). ### Audit & determinism [#audit--determinism] The append-only record and the contract that lets it replay byte-identically. * **Event log** — Append-only record of observable events in a session — messages, tool calls, interrupts, exports. See [full entry](#e). * **Determinism** — A recorded session replayed against the same package version and input produces a byte-identical fingerprint stream. See [full entry](#d). * **Replay** — Re-running a recorded turn against the same package version and input; `@pleach/replay` ships strict mode. See [full entry](#r). * **Strict mode** — `@pleach/replay`'s mode that fails on any divergence between recorded and replayed events. See [full entry](#s). * **Hash chain** — `prev_hash` + `row_hash` columns on `harness_event_log`; verification reports the first index where the chain breaks. See [full entry](#h). * **Attestation** — Signed manifest of a Pleach run's metadata — models, prompts, tools, event-chain hash. See [full entry](#a). * **Verification** — Running chain and determinism verifiers over a recorded session. See [full entry](#v). ### Extension [#extension] The consumer contracts for adding tier nodes, observers, contributors, and redaction. * **HarnessPlugin** — Consumer extension contract — registers tier nodes, stream observers, prompt contributors, and redaction gates without rewriting the lattice. See [full entry](#h). * **Stream observer** — Per-chunk dispatch on the inbound provider stream returning `continue`, `amend`, `emit`, or `halt`. See [full entry](#s). * **Redaction gate** — Contract for transforming or dropping data before provider calls, per-message granularity. See [full entry](#r). * **Scrubber** — Redaction-gating step at the `EventLogWriter` write site declaring an event-type allowlist. See [full entry](#s). * **KeyedRegex** — Parameterized regex scrubber consumers extend for custom regulated identifiers like MRN or EIN. See [full entry](#k). * **Facet** — Named accessor grouping related runtime methods — `runtime.tenant.id` replaces `runtime.getTenantId()`. See [full entry](#f). ### Observability [#observability] The spans, OTEL hook, and tenant attribute the substrate emits for trace queries. * **OTEL** — OpenTelemetry — Pleach exposes an OTEL adapter hook so spans cover session boundaries and per-call seams. See [full entry](#o). * **OTel span types** — The four spans emitted — `session.turn`, `llm.invocation`, `graph.stage`, `tool.execution`. See [full entry](#o). * **`pleach.tenant_id`** — OTel attribute set on every emitted span when `runtime.tenant` is configured. See [full entry](#p). * **Tenant** — Scoping unit carried as `organizationId` on every event and every audit row for single-predicate ledger queries. See [full entry](#t). ## A [#a] **Adapter** — A concrete implementation of an interface for a specific vendor. Storage adapters, checkpoint savers, and provider transports are all examples. Adapters live in the package that owns their interface. **Anchor-plan** — The first stage of the four-stage lattice. The turn boots here: intent detection, plan generation, anchor messages. **Attestation** — A signed manifest of a Pleach run's metadata (models used, prompts, tools, event-chain hash). Issued by `@pleach/compliance`. ## B [#b] **BYOK** — Bring Your Own Key. Provider credentials supplied by the consumer rather than by Pleach. Available transports include `byok-native` and `byok-openrouter`. ## C [#c] **Cache backend** — Runtime-side memoization of prepared LLM inputs via the `CacheBackend` contract. Default is `memoryCacheBackend` (1000 entries / 64 MB) since substrate adopted it as always-on. Distinct from provider-side prompt caching. See [Cache](/docs/cache). **Cache key** — The composite key used to look up cached LLM and tool responses. Includes the family, model, call class, and content hashes of the prompt and tool arguments. The fingerprint module (`computeFingerprint`) is the deterministic, platform-uniform hasher that produces the content-hash component — identical inputs on Node, Browser, Edge, and Cloudflare runtimes produce identical fingerprints, which is what lets a recorded turn replay against a different runtime and still hit the same cache entries. **CallClass** — One of `utility`, `reasoning`, `converse`, or `synthesize`. Discriminates internal classification calls from user-facing answer composition. The literal is restricted by lint to seam factories, so it can't be passed around as a runtime string. **Channel** — A typed reactive state slot inside the compiled graph (`LastValue`, `BinaryOperatorAggregate`, `Topic`, `EphemeralValue`, `NamedBarrier`, `DataChannel`). Nodes fire when a subscribed channel advances; concurrent-write semantics are defined per channel kind. **Checkpointer** — A swappable backend that persists graph state snapshots so the runtime can time-travel via `checkpoint()` / `restore()`. Memory, IndexedDB, and Supabase implementations ship in `@pleach/core`. **Content-hash cache** — A cache keyed on the hash of the prompt plus tool arguments, so identical inputs hit identical entries across runs and across streaming / non-streaming invocations. **Content delta** — `content.delta` event type — one chunk of streaming model output. `messageProjection` folds these into the terminal message at end-of-turn. See [Event log projections](/docs/event-log-projections) and [Stream events](/docs/stream-events). ## D [#d] **Determinism** — The property that a recorded Pleach session, replayed against the same package version + the same input, produces a byte-identical fingerprint stream. The contract behind `@pleach/eval` and `@pleach/replay`. ## E [#e] **Event log** — The append-only record of observable events in a session: messages sent, tool calls dispatched and completed, interrupts raised and resolved, exports queued. Distinct from the [audit ledger](#a) — the event log captures *what happened*, the ledger captures *every decision*. **Event-granular fork** — Forking a session at any specific event, not only at checkpoint boundaries. Enables targeted replay against historical runs. ## F [#f] **Facet** — A named accessor that groups related runtime methods. `runtime.tenant.id` replaces `runtime.getTenantId()`. Facets are the canonical accessor pattern; flat methods stay shipped with `@deprecated` JSDoc. See [Facets](/docs/facets). **Family** — Semantic provider origin. One of `anthropic`, `openai`, `google`, `deepseek`, `moonshot`, or `mistral`. Locks tokenizer, prompt-cache key, tool-call dialect, and refusal pattern at session start. **Family-strict cascade** — The provider fallback policy: when a primary model fails, the cascade walks in-family rungs only. Cross- family widening is explicit and consumer-initiated — never silent. **Fork** — A new `SessionRuntime` that shares its parent's event log up to a specified cutoff and runs independently after. **FSL-1.1-Apache-2.0** — The Functional Source License (1.1) with Apache 2.0 as the future license — the current license posture for every shipping `@pleach/*` package. Source-available, usable in production, free of charge during the FSL window; the only restriction is on competing offerings. Auto-transitions to permissive Apache 2.0 two years after first stable publish. See [Pricing](/pricing) for commercial-use guidance and [Versioning](/docs/versioning) for the per-SKU license lock. **Apache-2.0** — The OSI-approved permissive license that `@pleach/*` packages auto-convert to two years after first stable publish under the FSL future-license clause. Permits commercial use, modification, and redistribution with attribution. See the [FSL-1.1-Apache-2.0](#f) entry above for the current license posture. ## G [#g] **GraphProjection** — The fold contract over the event log: an initial state, a reducer `(state, row) → state`, and an optional selector. `runtime.events.fold(projection, opts)` runs it to completion. See [Event log projections](/docs/event-log-projections). ## H [#h] **Hash chain** — `prev_hash` + `row_hash` columns on `harness_event_log`. Each row's `prev_hash` is the previous row's `row_hash`; verification reports the first index where the chain breaks. The `TamperEvidence` plug-point in `@pleach/core/audit` ships a no-op default; the live verifier ships in `@pleach/core/eventLog` as `verifyChainForChat` + `generateProof`. The writer-side stamping lives in `@pleach/core/eventLog` (`chainStep`, `computeRowHash`) behind the `c9PhaseBEnabled` flag. See [Hash chain](/docs/hash-chain). **Harness** — A generic agent runtime that runs the turn loop. `@pleach/core` is a harness — it executes turns but doesn't dictate the topology of your application around it. **HarnessPlugin** — The consumer extension contract. A plugin can register tier nodes, stream observers, prompt contributors, and redaction gates; it cannot rewrite the lattice or bypass the synthesize seam. ## I [#i] **Interrupt** — A pause in the agent loop where the user must approve or modify a pending action. Per-tool granularity, recorded in the event log. The interrupt envelope follows the LangGraph-shape contract documented under [Interrupts](/docs/interrupts), so a host that already speaks the LangGraph approval shape can wire an existing approval UI against the same payload. The ledger records the resolution as an `interruptDecision` row carrying the human's verdict, joinable back to the `turnId` that triggered the pause. ## K [#k] **KeyedRegex** — One of the four bundled scrubbers in `@pleach/compliance`. A parameterized regex scrubber consumers extend for custom regulated identifiers (MRN, EIN, etc.). See [Scrubbers](/docs/scrubbers) and [Compliance](/docs/compliance). ## L [#l] **Lattice** — See [Stage lattice](#s). ## M [#m] **Matrix** — The `(ProviderFamily × CallClass)` model resolution table. Authoritative for what model fires for what call class in what family. Locked at session start, never silently widened. **MCP** — Model Context Protocol. The Anthropic-driven standard for tool integration. `@pleach/mcp` ships an MCP client and server. ## N [#n] **Native** — A provider transport that calls the provider's official SDK directly (e.g. `@anthropic-ai/sdk`). Contrasts with the OpenRouter transport. ## O [#o] **OpenRouter** — A multi-provider routing service. Available as a Pleach transport, including in a BYOK variant. **OTEL** — OpenTelemetry. Pleach exposes an OTEL adapter hook so spans cover session boundaries and per-call seams without re- implementing observability in every consumer. **OTel span types** — The four spans the substrate emits: `session.turn`, `llm.invocation`, `graph.stage`, `tool.execution`. Parent-thread naturally per turn. `runtime.spans.snapshot()` reads them in-process. See [OTel observability](/docs/otel-observability). ## P [#p] **Pleach** — The harness's name. Pleaching is a centuries-old horticultural technique: weave the living branches of adjacent trees together so they grow into a single, structurally coherent hedge. That's the metaphor for the runtime — each LLM call, tool execution, plugin contribution, and channel write is a branch; the lattice + audit ledger + family lock + singleton synthesize seam are how those branches are *pleached* into a structure that carries weight a single `streamText` call cannot. **`pleach.tenant_id`** — OTel attribute set on every emitted span when `runtime.tenant` is configured (or `withTenantHeader` wraps outbound HTTP). The load-bearing field for per-tenant trace queries. See [OTel observability](/docs/otel-observability) and [Tenant facet](/docs/tenant-facet). **Projection** — Synonym for `GraphProjection`. See [GraphProjection](#g). **ProviderFamily** — See [Family](#f). **ProviderSeam** — The per-`CallClass` seam contract for LLM invocation. Exactly one seam per call class per runtime. ## R [#r] **Redaction gate** — The contract for transforming or dropping data before provider calls. Per-message granularity. Distinct from stream observers, which act per-chunk on the response. **Replay** — Re-running a recorded turn against the same package version + the same input. `@pleach/replay` ships a strict mode that fails on any divergence between recorded and replayed events. **RLS** — Row-Level Security. The Postgres feature Pleach's Supabase storage adapter relies on for tenant isolation. **`runtime.tenant`** — The facet exposing the read-only `id` and `subId` properties. Read tenant scope through the facet rather than constructor config. See [Tenant facet](/docs/tenant-facet). ## S [#s] **Sandbox** — A code-execution boundary. The `SandboxProvider` interface lives in `@pleach/coding-agent`; adapters cover hosted sandboxes (Modal, E2B, Daytona) and Vercel's sandboxing surface. **Scrubber** — A redaction-gating step at the `EventLogWriter` write site. Declares an event-type allowlist; redacts matched patterns before persistence. Four ship in `@pleach/compliance`. Hosts register more via `contributeScrubbers`. See [Scrubbers](/docs/scrubbers). **Seam** — A boundary between subsystems with a typed contract. `ProviderSeam` is the canonical example. The stream-observer contract lives at the seam boundary. **SessionRuntime** — The per-session entry point — owns the compiled graph, the channels, the seam-bound provider calls, and the lifecycle. **Singleton synthesize-seam invariant** — Exactly one `ProviderSeam<"synthesize">` per `SessionRuntime`, structurally enforced. The reason: if two synthesize calls fired and the runtime showed one, the audit ledger and the rendered UI would disagree on what the model said. **SKU** — A separately-installable npm package in the `@pleach/*` namespace. The current matrix is documented on the [Packages](/docs/packages) page. **Stage lattice** — The four-stage agent graph topology: `anchor-plan | tool-loop | synthesize | post-turn`. Out-of-lattice edges fail CI. See [Architecture](/docs/architecture) for the deep dive. **Stream observer** — A per-chunk dispatch on the inbound provider stream. Observer factories register through `HarnessPlugin`. Each `onChunk(chunk, ctx)` returns a verdict: `continue`, `amend`, `emit`, or `halt`. Sync-only — replay determinism armor. **Strict mode** — `@pleach/replay`'s mode that fails on any divergence between recorded and replayed events. Throws `ReplayDivergenceError`. **`subTenantId`** — The secondary scoping field on `SessionRuntimeConfig` (nested isolation: tenant → team, or org → sub-account). Flows through alongside `tenantId` to every downstream write site. See [Tenant facet](/docs/tenant-facet). **Synthesize** — One of the four call classes — the user-facing final-answer LLM call. Capped at one per turn by default. ## T [#t] **Tenant** — A scoping unit for multi-tenant deployments. Carried as `organizationId` on the runtime config and as a required field on every event and every `AuditableCall` row, so a tenant-scoped query against the ledger or event log is a single `WHERE organization_id = $1` predicate against an indexed column rather than a join through a session table. The RLS template shipped with the schema bundle parameterizes on the same column for row-level isolation at the Postgres layer. **Tool-loop** — The second stage of the four-stage lattice. The iterative LLM-decision and tool-execution loop. May self-loop until the plan resolves. **Typed record** — One of the five typed, stage-correlated audit-record slots: `InterruptDecisionRecord`, `TokenCostRecord`, `ToolSelectionTrace`, `PlanGenerationRecord`, `SynthesisQualityRecord`. Narrow on the named slot's presence (correlated with `stageId`). See [Typed records](/docs/typed-records). ## V [#v] **Verification** — Running chain and determinism verifiers over a recorded session. Confirms hash-chain integrity and replay determinism. Shipped by `@pleach/compliance` and `@pleach/replay`. ## W [#w] **`withTenantHeader`** — Adapter that wraps a fetch client and stamps a tenant header on outbound HTTP. For hosts behind an upstream gateway that routes by tenant header. See [Tenant facet](/docs/tenant-facet). ## Z [#z] **Zod** — The TypeScript schema validation library. Pleach uses Zod for tool input/output schemas; JSON Schema is available as an escape hatch. ## Where to go next [#where-to-go-next] --- # Edge catalog (/docs/graph-edge-catalog) The default agent graph composes from four static chains plus two conditional routers. The chains carry the deterministic skeleton — START to END — and the routers carry the branching: post-LLM tool dispatch and post-hallucination retry. Every edge that compiles is matched against the nine-pattern `ALLOWED_EDGE_PATTERNS` table; the `audit:graph-stages` script refuses a build with any unmatched edge. The companion page is [Node catalog](/docs/graph-node-catalog). Conceptual framing lives in [Graph](/docs/graph); the structural-pin rationale lives in [Graph § lattice gate](/docs/graph#the-lattice-gate). ## The allowed-edge table [#the-allowed-edge-table] Every compiled-graph edge matches exactly one pattern in this list. The lint walks the compiled topology and reports a `[graph-stages] FORBIDDEN edge ...` violation for any edge whose `(from-stage, to-stage)` pair isn't here. | # | from-stage | to-stage | role | | - | ------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | `anchor-plan` | `anchor-plan` | Intra-stage chain — sessionAnchor → contextProjection → planReconciler, etc. | | 2 | `anchor-plan` | `tool-loop` | The pre-LLM chain enters the loop. | | 3 | `tool-loop` | `tool-loop` | Intra-loop chain — tools → guard → enrichment → … → llm. | | 4 | `tool-loop` | `synthesize` | Forced or natural escalation to synthesis. | | 5 | `tool-loop` | `post-turn` | Recovery dispatch — recovery shaping is post-turn work, so this edge sits in the allowed table (an earlier revision promoted it from the forbidden table). | | 6 | `synthesize` | `synthesize` | The synthesis retry self-loop, guarded by `messageId` equality. | | 7 | `synthesize` | `post-turn` | The terminal chain enters post-turn cleanup. | | 8 | `post-turn` | `post-turn` | The post-turn chain — citation → sessionMemoryWrite → costRollup → … | | 9 | `post-turn` | `anchor-plan` | Next-turn rollover. The only legal cross-turn edge. | ## The forbidden-edge table [#the-forbidden-edge-table] The forbidden patterns are enumerated exhaustively over the 4×4 stage cross-product so that lint failures point at the exact violation, not at "not in the allowed table." All seven rows fail CI on first match. (`tool-loop → post-turn` is *absent* here — it lives in the allowed table as the recovery-dispatch edge.) | # | from-stage | to-stage | failure mode | | - | ------------- | ------------- | --------------------------------------------------------- | | 1 | `anchor-plan` | `synthesize` | Skipping the tool loop. | | 2 | `anchor-plan` | `post-turn` | Skipping the loop and synthesis. | | 3 | `tool-loop` | `anchor-plan` | Re-planning mid-turn. | | 4 | `synthesize` | `tool-loop` | Post-synthesis tool dispatch — the headline risk surface. | | 5 | `synthesize` | `anchor-plan` | Post-synthesis re-planning. | | 6 | `post-turn` | `tool-loop` | Post-turn re-entering an active turn. | | 7 | `post-turn` | `synthesize` | Post-turn re-entering synthesis. | ## The chain skeleton [#the-chain-skeleton] The builder composes four static chains. Each is an inline array literal in `defaultAgentGraph.ts`; the audit script's `CHAIN_ARRAY_RE` regex parses the literals at lint time to synthesize the adjacency edges, so the array shape itself is load-bearing. ### Pre-LLM chain — `anchor-plan` interior [#pre-llm-chain--anchor-plan-interior] ``` START → intentDetector → fileIntentDetector → costRouter → skillActivation → mainLlmNode ``` Every member is `.filter(hasNode)`-gated, so an absent executor collapses the chain rather than breaking it. `mainLlmNode` is `"llmDecision"` when the host wires a streaming decision executor, `"llm"` otherwise. ### Enrich-route chain — `tool-loop` interior [#enrich-route-chain--tool-loop-interior] ``` garbledContentGuard → repetitionGuard → syntheticExpansionDetector → dsmlDetector → forceSynthesizer → hallucination ``` The post-stream detector chain. The `enrich` arm of the post-LLM `shouldContinue` router (below) routes into this chain's first member; the chain terminates at `hallucination`, which then routes via `afterHallucination`. ### Post-tool chain — `tool-loop` interior [#post-tool-chain--tool-loop-interior] ``` tools → dataSilo → guard → enrichment → safetyReview → quality → continuation → coupling → fabrication → diagnostics → asyncTaskDispatch → creditBudget → eventLogger → mainLlmNode ``` The sequential post-tool inspection chain. Each node operates on the accumulated `completedTools` channel and may decide to short-circuit by writing `shouldContinue: false` — `continuation`, `creditBudget`, `safetyReview`, and `quality` are the four nodes that exercise that short-circuit. ### Terminal chain — `synthesize` + `post-turn` interior [#terminal-chain--synthesize--post-turn-interior] ``` synthesizer → citation → contextSummarizer → memoryExtraction → consolidation → END ├─▶ sessionMemoryWrite ─┐ └─▶ costRollup ──────────┴─▶ (fan back in at contextSummarizer) ``` `synthesizer` is the lone stage-3 node; the `synthesize → post-turn` transition is the `citation` entry. `sessionMemoryWrite` and `costRollup` fan out as parallel siblings from `citation` and fan back in at `contextSummarizer` — their `subscribes`/`writes` are disjoint, so a failure in one doesn't cascade into the other. Each node is filter-gated; the chain composes cleanly when sub-segments are absent. The recovery shaping that earlier revisions placed in this chain — `refusalHint`, `retryNarration` — is gone. An earlier revision retired both graph nodes; they fire now as post-turn stream filters (`refusalHintFilter` at priority 100, `retryNarrationFilter` at 200), not as members of `terminalChainBase`. ## The conditional routers [#the-conditional-routers] Two route functions drive the branching topology. Each is wired through `.addConditionalEdges(from, routeFn, edgeMap)` and produces one edge per arm in the map — `wireStageTransitionEdges.ts` has exactly two `.addConditionalEdges(...)` call sites (`shouldContinue` on the LLM node and `afterHallucination` on the detector node). The maps are inline object literals — the audit script's `ADD_COND_RE` regex requires this so every conditional destination is visible to static topology analysis. Recovery dispatch is *not* a third router — it is a collection of post-stage stream filters (below), fired at stage completion through the `StreamObserverRegistry` rather than through a conditional edge. ### `shouldContinue` — post-LLM four-arm [#shouldcontinue--post-llm-four-arm] Source: `mainLlmNode`. The post-LLM router. An earlier revision retired the `shouldContinueWithGarbleRoute` wrapper that used to sit in front of it: once the `garbleRecovery` graph node retired, the wrapper's `garble` arm had no consumer and always short-circuited to bare `shouldContinue`. The conditional edge now consumes `shouldContinue` directly with a four-arm destination map. | arm | target | meaning | | ---------- | --------------------- | --------------------------------------------------------------- | | `tools` | `tools` | The decision call emitted pending tool calls; dispatch them. | | `subagent` | `subagent` | The decision call emitted a subagent delegation. | | `enrich` | `enrichRouteChain[0]` | Route through the post-stream detector chain. | | `retry` | `mainLlmNode` | Self-loop for provider cascade fallback — same node, new model. | Mid-stream body or prefix garble is no longer a graph arm. The `garbleRecoveryFilter` (priority 300) detects it at stage completion and dispatches recovery shaping through the `StreamObserverRegistry` — see [Recovery dispatch is a filter collection](#recovery-dispatch-is-a-filter-collection) below. ### Recovery dispatch is a filter collection [#recovery-dispatch-is-a-filter-collection] Earlier revisions dispatched recovery through a `recoveryDispatchPredicate` graph node and a four-arm conditional edge into a `recovery` node. Both retired. Recovery dispatch is now four post-stage **stream filters**, registered on the `StreamObserverRegistry` and fired at stage completion in priority order: | filter | priority | fires on | | ------------------------ | -------- | ------------------------------------------------------------------------- | | `refusalHintFilter` | 100 | A model refusal that should be reshaped before the user sees it. | | `retryNarrationFilter` | 200 | A provider retry or fallback model switch worth narrating. | | `garbleRecoveryFilter` | 300 | Body or prefix garble detected in the streamed output. | | `standardRecoveryFilter` | 400 | The cohort dispatch for zero-tool / missing-params / max-steps scenarios. | `standardRecoveryFilter` is the sole recovery-dispatch surface — the `audit:recovery-dispatch-single-surface` gate fails any build that re-introduces a graph-side recovery dispatch. The filters still consume the `recoveryDispatch` / `RecoveryDispatchArm` classifier the retired node used; only the dispatch mechanism moved (graph edge → stream filter), not the scenario taxonomy (zero-tool, missing-params, max-steps). ### `afterHallucination` — post-detector two-arm [#afterhallucination--post-detector-two-arm] Source: `hallucination`. The router decides whether to retry the LLM or proceed to synthesis based on the `synthesisState.exhausted` latch and the `turnFlags.hallucinationDetected` and `turnFlags.modelFallback` flags. | arm | target | meaning | | ---------- | ----------------- | ----------------------------------------------------------------------------------------- | | `llm` | `mainLlmNode` | Retry the decision — hallucination detected or model fallback pending. | | `citation` | `stage3EntryNode` | Proceed to synthesis; the entry point is `synthesizer` when active, `citation` otherwise. | The `synthesisState.exhausted` latch is set sticky by `multiInvocationTracker` after the same disjunctive predicate fires twice in a turn. The latch short-circuits both retry branches and routes directly to `citation`, terminating loops that would otherwise cycle indefinitely between `mainLlmNode` and `enrich → hallucination → llm`. ## Auxiliary edges [#auxiliary-edges] One static edge sits outside the four chains: | from | to | trigger | role | | ---------- | ------------------------------------------- | ------- | ---------------------------------------------------------------------------------------- | | `subagent` | `enrichment` (or `guard`, or `mainLlmNode`) | static | Subagent results enter the post-tool chain at the first available enrichment-stage node. | The `recovery` and `garbleRecovery` outgoing edges that earlier revisions listed here are gone — both graph nodes retired from the lattice. Their work happens in the recovery stream filters, which write through the channel set rather than routing a graph edge. ## Adding or removing edges — three paths [#adding-or-removing-edges--three-paths] Edge wiring is one degree more constrained than node registration. A node either wires or doesn't; an edge wires *and* picks one of the nine allowed `(from-stage, to-stage)` patterns. The three extension paths: | Path | What you own | What you can do | Lattice gate | | ------------------- | ----------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------- | | **Config omission** | The `config` passed to `buildDefaultAgentGraph` | Collapse chain segments by removing the nodes they link | Enforced — chains skip absent nodes silently | | **Plugin path** | A `HarnessPlugin` with `extraGraphNodes()` | Add edges around plugin-registered nodes | Enforced — every edge must match an `ALLOWED_EDGE_PATTERNS` row | | **Custom builder** | A raw `StateGraph` you compose from scratch | Wire any edge whose `(from-stage, to-stage)` matches an allowed row | Enforced — but you control which rows fire | The lattice gate is non-negotiable on every path. The nine allowed and seven forbidden rows are a property of the substrate, not of the host's build choices. ### Config omission — collapse a chain segment [#config-omission--collapse-a-chain-segment] The four static chains (`preLlmChain`, `enrichRouteChain`, `postToolChain`, `terminalChain`) all run through `.filter(hasNode)` before adjacency wiring. Drop a gated node and the chain composes around its absence; the edges that would have entered and exited it become a single edge from its predecessor to its successor. ```typescript const graph = buildDefaultAgentGraph({ llmExecutor, toolExecutor, // intentDetector omitted — the pre-LLM chain collapses: // START → fileIntentDetector → costRouter → skillActivation → llm // (the edge START → intentDetector becomes START → fileIntentDetector; // the edge intentDetector → fileIntentDetector is dropped.) }) ``` This is the most common form of edge "removal." You can't directly suppress an edge, but you can suppress the node it terminates at, and the chain wiring follows. A conditional router's arm is a different case: the `addConditionalEdges` map is an inline object literal the audit script reads, so every listed arm is visible to static analysis. The builder keeps these maps tight — an arm whose destination node can be absent is populated conditionally rather than left dangling. The `afterHallucination` router adds its `citation` arm only when `stage3EntryNode` is registered; the map is `{ llm }` otherwise. The retired `garble` arm of the old `shouldContinueWithGarbleRoute` wrapper was the cautionary case — it named `garbleRecovery` even in compiles that never registered the node, so the wrapper short-circuited to bare `shouldContinue` before the arm could fire. That wrapper and its dangling arm are both retired. ### Plugin path — edges around a plugin-registered node [#plugin-path--edges-around-a-plugin-registered-node] A plugin-registered node has the same edge story as a canonical node: the builder wires it through metadata-declared `subscribes` and `writes`, and the reactive engine schedules it when a subscribed channel advances. The chain-relative wiring happens automatically — the plugin doesn't need to add static edges for a node that's reading and writing channels other registered nodes consume. When the plugin's node needs a *static* edge — typically because it should fire at a specific point in a chain rather than reactively — the plugin registers the node in `extraGraphNodes()` and the builder's chain-extension hook places it. ```typescript import type { HarnessPlugin } from "@pleach/core" const metadata = { stageId: "post-turn", acceptsSeam: null, // Reactive: schedule whenever messages advances and write costAudit subscribes: ["messages"], writes: ["costAudit"], } export const myPlugin: HarnessPlugin = { name: "cost-audit-writer", version: "0.1.0", extraGraphNodes() { return [ { name: "costAuditWriter", factory: (ctx) => async (state) => ({ costAudit: await audit(state.messages) }), metadata, }, ] }, } ``` Three rules hold for any edge involving a plugin node: * **The edge's `(from-stage, to-stage)` must match `ALLOWED_EDGE_PATTERNS`.** A plugin node declaring `stageId: "tool-loop"` cannot directly hand off to a `synthesize` node if the chain it inserts into doesn't already make that transition. `audit:graph-stages` refuses the build. * **The plugin cannot add a `synthesize → tool-loop` edge** — the M-04 risk surface. This sits in `FORBIDDEN_EDGE_PATTERNS` and no extension path can re-add it. * **The plugin cannot wire its node into the singleton synthesize seam path.** Only `synthesizer` may consume `acceptsSeam: "synthesize"`. A plugin node that declares it fails the singleton invariant at compile time. See [Plugin contract](/docs/plugin-contract) for the full `extraGraphNodes` contract and the constraint set the substrate enforces against plugin registrations. ### Custom builder — full edge control [#custom-builder--full-edge-control] A custom `StateGraph` is the only path where you compose every edge directly. The lattice gate still runs — the audit script can be pointed at any compiled graph — but you choose which `ALLOWED_EDGE_PATTERNS` rows your topology exercises. ```typescript const graph = new StateGraph(StateSchema) .addNode("anchor", anchorFn, { stageId: "anchor-plan", acceptsSeam: null }) .addNode("llm", llmFn, { stageId: "tool-loop", acceptsSeam: "converse" }) .addNode("synth", synthFn, { stageId: "synthesize", acceptsSeam: "synthesize" }) .addEdge(START, "anchor") .addEdge("anchor", "llm") // anchor-plan → tool-loop ✓ row 2 .addConditionalEdges("llm", route, { tools: "llm", // tool-loop → tool-loop ✓ row 3 done: "synth", // tool-loop → synthesize ✓ row 4 }) .addEdge("synth", END) .compile() ``` The custom path is the only one where you can omit an entire allowed pattern. A topology that never makes the `synthesize → synthesize` retry transition simply never wires an edge with that `(from, to)` stage pair — the gate accepts the build because no edge violates the table. The trade-off is the loss of every canonical-builder construction: the pre-LLM chain, the post-tool detector chain, the recovery stream filters, the singleton synthesize seam wiring. You're back to authoring the topology from the lattice up. ## Removing an allowed pattern [#removing-an-allowed-pattern] Removing a pattern from `ALLOWED_EDGE_PATTERNS` is structural — it tightens the lattice and breaks every topology that exercised the row. The only path is upstream contribution to the substrate, with the same four-step ratchet as adding one: 1. Remove the row from `ALLOWED_EDGE_PATTERNS` in `topology.ts`. 2. Add the corresponding row to `FORBIDDEN_EDGE_PATTERNS` so the two tables stay symmetric. 3. Update the four-stage diagram in [Graph](/docs/graph), [Nodes](/docs/nodes), and this page. 4. Append a `Changed` row to the changelog naming the row and the topologies it breaks. The audit script will then fail every PR whose compiled graph exercises the removed row — the regression-detection surface for the lattice change. ## Adding an edge to the upstream canonical builder [#adding-an-edge-to-the-upstream-canonical-builder] When the new edge belongs in the substrate itself, the contributor path applies. Two cases: * **Edge within an existing allowed pattern.** Add the `.addEdge(...)` or `.addConditionalEdges(...)` call; the audit's per-config byte-identity check picks up the count change and the PR documents the ratchet. * **Edge requiring a new allowed pattern.** Add the row to `ALLOWED_EDGE_PATTERNS`, remove the symmetric row from `FORBIDDEN_EDGE_PATTERNS`, update the four-stage diagram, and append a `Changed` row to the changelog. Every existing compile now passes a broader gate; document why the broader lattice is the right call. See [Audit gates](/docs/audit-gates) for the gate invocation and the rest of the pre-merge gate set. ## Where to go next [#where-to-go-next] --- # Node catalog (/docs/graph-node-catalog) The default agent graph registers nodes through `buildDefaultAgentGraph(config)`. Which nodes wire in for a given compile depends on the executors and hooks the host supplies; the *registry* — every name the audit script will accept — is fixed at 44 entries spread across the four lattice stages. This page enumerates that registry. The companion page is [Edge catalog](/docs/graph-edge-catalog). Conceptual framing lives in [Graph](/docs/graph); the node-shape contract lives in [Nodes](/docs/nodes). ## Reading the table [#reading-the-table] Each row is a node name the host may register. The columns: | Column | Meaning | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | The string passed to `graph.addNode("...")`. Stable public identifier — renamed only via a `Changed` changelog row. | | `acceptsSeam` | The `CallClass` the node consumes — `utility`, `reasoning`, `converse`, or `synthesize` — or `null` for pure state transforms. | | `gated` | `always` if the node wires unconditionally; `config.` if registration requires the host to supply that executor or hook. An absent executor means an absent node — the graph still compiles. | | `purpose` | One-line summary of what the node does. | The `gated` column is the load-bearing one. The 44-name registry is the *upper bound*; a real compile under a minimal config registers a much smaller subset. The `audit:graph-stages` script asserts the compiled count is byte-identical PR-to-PR *for the same config*, not that every PR compiles 44 nodes. ## Stage 1 — `anchor-plan` [#stage-1--anchor-plan] Ten registered names. Pre-LLM context shaping: anchor strings, intent classification, plan generation, model routing, skill activation. No tool dispatch and no user-visible content at this stage. | name | acceptsSeam | gated | purpose | | -------------------------- | ----------- | ----------------------------------- | ---------------------------------------------------------------------------------- | | `sessionAnchor` | `reasoning` | always | Builds the session-scoped anchor string consumed by synthesis. | | `contextProjection` | `null` | always | Projects the data channel into the system prompt as a relevance-ranked summary. | | `planReconciler` | `reasoning` | always | Reconciles the active plan against completed tools; writes the next planning step. | | `planGenerator` | `utility` | `config.planGenerator` | Generates a multi-step tool-execution plan. | | `intentDetector` | `utility` | `config.intentDetectorExecutor` | Classifies the user's intent for the turn. | | `fileIntentDetector` | `utility` | `config.fileIntentDetectorExecutor` | Detects file-upload intent and extracts file context. | | `costRouter` | `utility` | `config.costRouterExecutor` | Routes to the cost-optimal model given the classified intent. | | `skillActivation` | `utility` | `config.skillActivationExecutor` | Activates the skill modules relevant to the turn. | | `structurePrefetchEmitter` | `null` | always | Emits a structural prefetch hint into the channel set. | | `depthZeroNudge` | `utility` | always | Nudges the model when the previous turn produced zero tool calls despite a plan. | ## Stage 2 — `tool-loop` [#stage-2--tool-loop] Twenty-six registered names. The iterative decide → dispatch → inspect loop. The *decision* call here is `utility`-class and runs on a separate utility seam — it decides whether to keep looping, never owns the final user-visible synthesis, and can't reach the synthesize seam or its per-turn counter. | name | acceptsSeam | gated | purpose | | ------------------------- | ----------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `llm` | `converse` | always | The per-turn LLM decision call. Decides whether to dispatch tools or terminate the loop. | | `llmDecision` | `utility` | `config.llmDecisionExecutor` | Streaming variant of the decision call with a `utility` seam threaded through — separate from the synthesize seam, so it can't bump the synthesize counter. | | `tools` | `null` | always | Dispatches pending tool calls in parallel. | | `subagent` | `null` | always | Delegates to a subagent runtime; routes results back into the parent turn. | | `guard` | `null` | always | Guards against invalid tool execution chains. | | `hallucination` | `null` | always | Detects hallucinated tool calls — zero-tool loops, narration-as-execution. | | `dataSilo` | `null` | always | Silos large tool results into the data channel and replaces them with refs. | | `enrichment` | `utility` | `config.enrichmentExecutor` | Enriches tool results with host-supplied context. | | `safetyReview` | `utility` | `config.safetyReviewExecutor` | Pre-synthesis safety review of accumulated tool results. | | `quality` | `utility` | `config.qualityEvaluator` | Scores tool result quality. | | `continuation` | `converse` | `config.continuationResolver` | Decides whether to continue looping or escalate to synthesis. | | `coupling` | `utility` | `config.couplingResolver` | Suggests coupled tool invocations the host can pre-bundle. | | `fabrication` | `null` | `config.fabricationChecker` | Detects and remediates fabricated tool outputs. | | `diagnostics` | `utility` | `config.diagnosticsAnalyzer` | Logs per-turn diagnostic info. | | `asyncTaskDispatch` | `utility` | `config.asyncTaskDispatchExecutor` | Dispatches long-running tasks for out-of-band execution. | | `creditBudget` | `null` | `config.creditBudgetExecutor` | Enforces a per-turn credit or cost budget. | | `eventLogger` | `null` | `config.eventLogger` | Logs completed tool execution events. | | `jobSilo` | `null` | always | Silos pending job state into a side channel. | | `workspaceIndex` | `null` | always | Maintains a workspace-index channel for downstream nodes. | | `forceSynthesizer` | `null` | always | Routes a forced synthesis request — the `tool-loop → synthesize` transition. | | `toolLoopStream` | `null` | `config.toolLoopStreamExecutor` | Provider-call streaming node for decision generation. | | `garbledContentGuard` | `null` | always | Detects coherence failures in the streamed decision output. | | `repetitionGuard` | `null` | always | Detects phrase-repetition loops in streamed output. | | `dsmlDetector` | `null` | always | Detects and redacts domain-specific markup artifacts. | | `multiInvocationTracker` | `null` | always | Tracks repeated synthesis invocations per message. | | `loopTerminatedAnnotator` | `null` | always | Annotates the channel state when the tool loop terminates. | ## Stage 3 — `synthesize` [#stage-3--synthesize] One registered name. The synthesis stage is a true singleton: `synthesizer` is the only node the lattice admits here, enforced by `SINGLETON_NODE_NAMES`. A PR that registers a second synthesize-stage node fails `audit:graph-stages` structurally. | name | acceptsSeam | gated | purpose | | ------------- | ------------ | -------------------- | ------------------------------------------------------------------------- | | `synthesizer` | `synthesize` | `config.synthesizer` | The singleton synthesizer seam call. Owns the final user-visible content. | Earlier revisions registered four recovery siblings in this stage — `recovery`, `refusalHint`, `retryNarration`, `garbleRecovery`. An earlier revision reclassified recovery shaping as *post-turn* work, not final-content synthesis, and retired all four graph nodes. Recovery dispatch is now a collection of post-stage **stream filters** — `refusalHintFilter` (priority 100), `retryNarrationFilter` (200), `garbleRecoveryFilter` (300), and `standardRecoveryFilter` (400) — that fire at stage completion via the `StreamObserverRegistry` rather than as nodes on the lattice. The `audit:recovery-dispatch-single-surface` gate locks that single dispatch surface; restoring the synthesize-stage singleton property is what lets a sixth synthesize sibling fail the lint structurally. See [Stream observers](/docs/plugins/stream-observers) for the filter substrate they now run on. ## Stage 4 — `post-turn` [#stage-4--post-turn] Seven registered names. Read-only side effects after the user has seen the answer: memory writes, cost rollups, citation finalization, context summary, plus the post-synthesis answer-sufficiency judge. | name | acceptsSeam | gated | purpose | | -------------------- | ----------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `citation` | `null` | `config.citationExtractor` | Extracts citations from the final content back to source data. | | `contextSummarizer` | `null` | `config.contextSummarizer` | Summarizes the turn for next-turn insertion. | | `memoryExtraction` | `utility` | `config.memoryExtractionExecutor` | Extracts facts for long-term memory storage. | | `consolidation` | `null` | `config.consolidationExecutor` | Consolidates extracted facts before the memory write. | | `sessionMemoryWrite` | `null` | always | Async write of the turn record to session memory. | | `costRollup` | `null` | always | Summarizes per-turn token and cost totals. | | `answerSufficiency` | `utility` | always | Post-synthesis judge (D-NC-6) — scores whether the final answer covers the turn's pending steps on a `utility` seam. | ## The singleton invariant [#the-singleton-invariant] `synthesizer` is the only entry in `SINGLETON_NODE_NAMES`, and — since the recovery siblings relocated to post-turn stream filters — the only node the lattice admits in the `synthesize` stage at all. The graph linter (gate G-13) refuses a compile that registers it more than once, and the `seamWireCheck` test asserts the synthesizer observes the singleton `ProviderSeam<"synthesize">` reference at runtime while the `llmDecision` node observes a *separate* `ProviderSeam<"utility">` — the singleton is per call class, so the decision node can't reach the synthesize seam. The stage is exactly-one by two independent locks: the `SINGLETON_NODE_NAMES` membership and the now-empty rest of the stage. See [Graph § singleton synthesize seam](/docs/graph#singleton-synthesize-seam) for the seam contract. ## Adding or removing nodes — three paths [#adding-or-removing-nodes--three-paths] A developer extending the graph picks the path that matches what they own. The three paths from least to most invasive: | Path | What you own | What you can do | Edge wiring | | ------------------- | ------------------------------------------------------ | --------------------------------------------------- | ---------------------------------------------------- | | **Config omission** | The `config` object passed to `buildDefaultAgentGraph` | Remove any gated node by not supplying its executor | Auto — chains `.filter(hasNode)` | | **Plugin path** | A `HarnessPlugin` with `extraGraphNodes()` | Add new nodes alongside the canonical set | Auto for chain-relative wiring; manual for new edges | | **Custom builder** | A raw `StateGraph` you compose from scratch | Anything within the lattice — add, remove, rewire | Manual everywhere | ### Config omission — remove a gated node [#config-omission--remove-a-gated-node] The `gated` column in the stage tables above tells you which nodes you can remove without forking. Any row whose `gated` cell reads `config.` is opt-in — leave the field unset and the builder skips the `addNode(...)` call. The chain literals (`preLlmChain`, `postToolChain`, `terminalChain`) all run through `.filter(hasNode)`, so the skip is silent. ```typescript const graph = buildDefaultAgentGraph({ llmExecutor, toolExecutor, // intentDetector omitted — the anchor-plan chain // collapses START → costRouter → skillActivation → llm // safetyReview omitted — the post-tool chain skips it }) ``` A row whose `gated` cell reads `always` is structural — removing it requires the custom-builder path. The builder hardcodes the `addNode(...)` call for those nodes; there is no config field to suppress. ### Plugin path — add a node alongside the canonical set [#plugin-path--add-a-node-alongside-the-canonical-set] `HarnessPlugin.extraGraphNodes()` returns `PluginGraphNodeRegistration[]`. The builder calls each registration's `factory(ctx)` once at compile time and registers the returned node under the plugin's namespace. This is the path for domain-specific nodes that shouldn't live in the substrate's generic builder — a retrieval node bound to a particular vector store, a tenant-specific safety pass, a post-turn writer that targets a specific event-log destination. ```typescript import type { HarnessPlugin } from "@pleach/core" import type { PluginGraphNodeRegistration, PluginGraphNodeContext, } from "@pleach/core/plugins" const myCustomNodeMetadata = { stageId: "post-turn", acceptsSeam: null, subscribes: ["messages", "completedTools"], writes: ["customSideTable"], } export const myPlugin: HarnessPlugin = { name: "my-custom-writer", version: "0.1.0", extraGraphNodes(): PluginGraphNodeRegistration[] { return [ { name: "customSideTableWriter", factory: (ctx: PluginGraphNodeContext) => async (state) => { await writeSideTable(state.completedTools, ctx.dataChannel) return { customSideTable: { written: true } } }, metadata: myCustomNodeMetadata, }, ] }, } ``` Three constraints hold for every plugin-registered node: * **`stageId` must be one of the four lattice stages.** A node declaring a stage that's not in `ALLOWED_EDGE_PATTERNS` fails `audit:graph-stages` before the registration takes effect. * **`name` must be unique** across all registrations — including the canonical 44-name registry. A collision throws at compile time. * **`acceptsSeam` is either `null` or one of `utility` / `reasoning` / `converse` / `synthesize`.** When non-null, the seam binding routes through the existing holder + cap machinery — no plugin-level seam construction. The plugin cannot bypass the singleton synthesize seam, add an out-of-lattice edge, or replace a registered node — the registration is additive. See [Plugin contract](/docs/plugin-contract) for the full `HarnessPlugin` surface and the `contributePostToolTier` slot specifically for post-tool enrichment nodes. ### Custom builder — full control [#custom-builder--full-control] When the canonical builder's shape doesn't fit — a fundamentally different lattice traversal, a topology that registers neither `llm` nor `llmDecision`, an agent that needs to skip the tool loop entirely — compose a `StateGraph` directly. The lattice gate still applies (every node's `stageId` must be one of the four, every edge must match an `ALLOWED_EDGE_PATTERNS` row), but you own every `addNode` and `addEdge` call. ```typescript import { StateGraph, START, END, AnnotationRoot, Annotation } from "@pleach/core/graph" const StateSchema = AnnotationRoot({ messages: Annotation({ reducer: messagesReducer, default: () => [] }), }) // Author each node's metadata as a standalone const — the same shape the // `*_NODE_METADATA` exports in @pleach/core's graph nodes use. const sessionAnchorMeta = { stageId: "anchor-plan", acceptsSeam: "reasoning", subscribes: ["messages"], writes: ["messages"], } const llmMeta = { stageId: "tool-loop", acceptsSeam: "converse", subscribes: ["messages"], writes: ["messages"], } const graph = new StateGraph(StateSchema) .addNode("sessionAnchor", sessionAnchorFn, sessionAnchorMeta) .addNode("llm", llmFn, llmMeta) .addEdge(START, "sessionAnchor") .addEdge("sessionAnchor", "llm") .addEdge("llm", END) .compile() ``` The custom path is the only one that lets you *remove* a non-gated node from the canonical set. The trade-off is that you give up the canonical builder's chain-relative wiring, the post-tool-tier enrichment slots, and every gated node — the four-stage lattice is the only thing you inherit. ## How the substrate source is organized [#how-the-substrate-source-is-organized] The canonical builder is split so a contributor can find any node — or any wiring decision — in one file. `buildDefaultAgentGraph` is a thin orchestrator (`defaultAgentGraph.ts`, kept under 1000 LoC by the `audit:graph-wiring-file-loc-ceiling` gate); the bodies live in concern-sorted directories under [`src/graph/`](https://github.com/pleachhq/core/tree/main/src/graph): | Directory / file | Holds | Shape | | ------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `nodes/` | Every node factory | One file per node — 59 files (`createCitationNode.ts`, `ContextProjectionNode.ts`, …) | | `nodes/shared/` | Cross-node helpers | 8 files (dedup, tool predicates, completed-tool hints, post-turn fan-out, …) | | `wiring/` | The `addNode` / `addEdge` registration, split by stage | 8 files — `registerAnchorPlanNodes.ts`, `registerToolLoopNodes.ts`, `registerSynthesizeNodes.ts`, `registerPostTurnNodes.ts`, `wireStageTransitionEdges.ts`, … | | `topology.ts` | `NODE_STAGE_MAP` + the allowed/forbidden edge tables | The lattice layer — readable on its own | | `seams/` | Per-call-class seam holders + registry | 17 files | | `predicates/` | Route functions (`shouldContinue`, `afterHallucination`, …) | One file per predicate + barrel — 7 files | | `strategies/` | Module-state holders + per-strategy triads | 6 files | | `state/reducers.ts` | The library reducers channels share | One file | The split means `grep addNode src/graph/wiring/` returns every registration site in one sitting, and a node's logic, metadata, and tests sit together rather than buried in a multi-thousand-line builder. A few substrate helpers a node author reaches directly: `src/engine/loopBounds.ts` (named superstep-bound constants instead of magic numbers), `src/engine/timers.ts` (`withTimer` / `withInterval`), `src/instrumentation/graphLog.ts` (the prefix taxonomy for graph console output), and `src/plugins/namespaces.ts` (the nine [contribution namespaces](/docs/plugins/namespaces)). ## Adding a node to the upstream canonical builder [#adding-a-node-to-the-upstream-canonical-builder] When the new node belongs in the substrate itself — not a plugin, not a custom build — the upstream-contributor path applies. Four places land in the same change: 1. The node factory in its own file under `src/graph/nodes/` — one node per file, exporting the factory and its `*_NODE_METADATA` constant. 2. The `NODE_STAGE_MAP` entry in `topology.ts` — pairing the name with its `stageId`. 3. The `addNode(...)` call in the matching `wiring/registerNodes.ts` registrar — registration lives in `wiring/`, not in the `defaultAgentGraph.ts` orchestrator. 4. A wire-check entry in `seamWireCheck.test.ts` when `acceptsSeam !== null`. `npm run audit:graph-stages` reports `missing-stage` distinctly from `forbidden-edge`, so an unmapped node fails clearly. The full new-node checklist lives at [Nodes § new-node checklist](/docs/nodes#new-node-checklist). ## Where to go next [#where-to-go-next] --- # Graph (/docs/graph) The runtime substrate is a declarative graph. Nodes consume from typed channels and write to typed channels; the engine schedules nodes reactively based on what advanced since the last superstep. `compile()` returns the runner that `SessionRuntime.executeMessage` drives. The six-pieces diagram in [Architecture](/docs/architecture#tldr--six-pieces-one-substrate) names the graph as the second piece; this page documents the API surface. Graph is one of three concepts in the execution-graph cluster — graph, nodes, channels. See [Architecture → the execution-graph cluster](/docs/architecture#the-execution-graph-cluster) for the cluster framing; the other two pages cover the same substrate from the node and channel sides. Channel kinds (the typed state slots nodes read and write) live at [Channels](/docs/channels). The node-level metadata shape — stage membership, seam declaration, authoring a custom node — lives at [Nodes](/docs/nodes). This page is about the graph as a whole. ```typescript import { StateGraph, START, END, CompiledGraph, Annotation, AnnotationRoot, Send, isSend, DefaultAgentState, buildDefaultAgentGraph, DEFAULT_TURN_FLAGS, } from "@pleach/core/graph" import type { StreamEvent, StateGraphNodeMetadata, AnnotationSchema, InferAnnotationState, } from "@pleach/core/graph" ``` ## Three pieces [#three-pieces] ### `Annotation` — channel schema [#annotation--channel-schema] `Annotation` declares one channel's `default` and `reducer`. `AnnotationRoot` collects them into a typed schema. The engine reads the schema at compile time to instantiate the underlying `Channel` instances — the same six kinds documented at [Channels](/docs/channels). ```typescript import { Annotation, AnnotationRoot, messagesReducer } from "@pleach/core" import type { Message } from "@pleach/core" const StateSchema = AnnotationRoot({ messages: Annotation({ reducer: messagesReducer, default: () => [], }), intent: Annotation({ default: () => "unknown", }), }) type State = InferAnnotationState ``` The schema is the typed contract every node reads against. A node that declares `inputs: ["messages"]` in its metadata sees `State` narrowed to `{ messages: Message[] }` at the call site, and writing to a channel not in the schema is a compile error. ### `StateGraph` — declarative builder [#stategraph--declarative-builder] `StateGraph` is the builder. `.addNode(name, fn, metadata)` registers a node; `.addEdge(from, to)` wires a static edge; `.addConditionalEdges(from, routeFn, edgeMap)` wires a branching edge; `.compile()` returns a `CompiledGraph`. ```typescript const graph = new StateGraph(StateSchema) .addNode("agent", agentNode, { stageId: "tool-loop", acceptsSeam: "reasoning", inputs: ["messages"], outputs: ["messages"], }) .addNode("tools", toolsNode, { stageId: "tool-loop", acceptsSeam: null, inputs: ["messages"], outputs: ["messages"], }) .addEdge(START, "agent") .addConditionalEdges("agent", routeAfterAgent, { call_tools: "tools", done: END, }) .addEdge("tools", "agent") .compile() ``` The two-node loop above is the canonical agent-plus-tools shape. The `agent` node decides; the route function reads the latest message and returns `call_tools` or `done`; the `tools` node executes pending tool calls and writes results back; control returns to `agent`. The loop exits when the agent emits a final message with no tool calls. ### `Send` — conditional fan-out [#send--conditional-fan-out] `Send` is the return value from a conditional-edge function that dispatches the same target node multiple times with different state slices. It's the fan-out primitive: tool-batch execution, parallel subagent spawn, map-reduce over a doc set. ```typescript import { Send, isSend } from "@pleach/core" function dispatchTools(state: State): Send[] { const pending = state.messages.at(-1)?.tool_calls ?? [] return pending.map((call) => new Send("tool_runner", { call })) } graph.addConditionalEdges("agent", dispatchTools) ``` The engine schedules each `Send` as a concurrent invocation of the target node with its own state slice. Writes land through the channel reducers — `messagesReducer` for the message accumulator, `appendReducer` for a `Topic`, and so on — so fan-out is deterministic when the reducers are commutative. `isSend(value)` is the runtime guard. Use it when a route function returns a heterogeneous union (a string for static targets, a `Send` array for fan-out) and downstream code needs to discriminate. ## Edges [#edges] Three edge kinds wire the topology together: | Edge kind | Builder call | Picks the next node by | | ----------- | -------------------------------------------------------- | ---------------------------------- | | Static | `.addEdge(from, to)` | Always `to` after `from` completes | | Conditional | `.addConditionalEdges(from, routeFn, edgeMap?)` | The route function's return value | | Fan-out | `.addConditionalEdges(from, routeFn)` returning `Send[]` | One target invocation per `Send` | `START` and `END` are the implicit endpoints. `addEdge(START, "agent")` declares the entry node; `addEdge("done", END)` (or returning `END` from a route function) terminates the graph for that turn. The route function signature is `(state) => string | Send | Send[]`. When it returns a string, the optional `edgeMap` translates the label to a node name — that indirection keeps route functions testable without holding the graph reference. ```typescript graph .addEdge(START, "anchor") .addEdge("anchor", "agent") .addConditionalEdges("agent", (state) => state.messages.at(-1)?.tool_calls ? "tools" : "synth", ) .addEdge("tools", "agent") .addEdge("synth", "post") .addEdge("post", END) ``` The lattice admits nine `(from-stage, to-stage)` edge patterns: the four happy-path transitions (`anchor-plan → tool-loop`, `tool-loop → synthesize`, `synthesize → post-turn`, and the `post-turn → anchor-plan` next-turn rollover), the `tool-loop → post-turn` recovery-dispatch edge, and the four intra-stage chains — `anchor-plan`, `tool-loop`, `post-turn`, plus the `synthesize → synthesize` retry self-loop. Every other pair is forbidden. See [Architecture § stage lattice](/docs/architecture#1-stage-lattice) for the structural constraint and the audit gate that enforces it. ## `CompiledGraph` [#compiledgraph] `.compile()` returns a `CompiledGraph` — the runner. It carries the schedule (which node fires when which channel advances) plus the instantiated channel set, accepts a per-turn input, streams `StreamEvent`s, and returns when a terminal node edges to `END`. You usually don't invoke a `CompiledGraph` directly. `SessionRuntime.executeMessage` owns it and drives the iteration — see [Turn lifecycle](/docs/turn-lifecycle) for the call arc. The compiled graph is exposed on the runtime for tooling that inspects the topology (devtools, the `audit:graph-stages` script, replay harnesses). ## The default agent graph [#the-default-agent-graph] `buildDefaultAgentGraph(config)` returns the pre-wired topology that covers the four-stage lattice end to end: intent detection, planning, the tool-loop, synthesis, and post-turn cleanup. The matching state shape is `DefaultAgentState`; the frozen per-turn flag baseline is `DEFAULT_TURN_FLAGS`. Pass the factory's return value to `SessionRuntime` and you get a working agent without authoring the graph yourself. The factory's contract is dependency injection. You bring the executors — `LlmExecutor` for seam-bound LLM calls, `ToolExecutor` for tool dispatch, `SubagentExecutor` for spawned sub-runtimes, and the enrichment hooks for plan generation, intent detection, and quality scoring. The factory wires each executor into the right node with the right seam. Omit an executor and the node short-circuits to its default pass-through — the graph still runs, that stage's enrichment is just absent. The post-tool tier nodes (`enrichment`, `safetyReview`, `quality`, `citation`) are **agnostic-by-injection**. The node bodies are domain-free; host runtimes supply the domain logic through `config.{enrichmentExecutor, safetyReviewExecutor, qualityEvaluator, citationExtractor}`. The factory only registers the node when the matching executor is provided, so absent executors mean absent nodes — pure dependency inversion, no hardcoded host logic in the graph layer. ```typescript import { buildDefaultAgentGraph, DefaultAgentState } from "@pleach/core/graph" const graph = buildDefaultAgentGraph({ llmExecutor, toolExecutor, subagentExecutor, // intentDetector, planGenerator, qualityScorer all optional }) ``` The node-level shape — what each node's metadata declares, how the seam binding works, what an authored node looks like — is at [Nodes](/docs/nodes). ## The lattice gate [#the-lattice-gate] Every node declares `stageId: "anchor-plan" | "tool-loop" | "synthesize" | "post-turn"` in its metadata. `npm run audit:graph-stages` parses the default agent graph at CI time and fails on out-of-lattice edges. The lattice is what lets per-stage cost rollup, observability slicing, and time-travel be structural rather than convention — a node that fires without a stage can't ship. See [Architecture § stage lattice](/docs/architecture#1-stage-lattice) for the four legal cross-stage transitions and the `SELECT stage_id, SUM(token_cost) FROM harness_auditable_calls` rollup shape that depends on the gate. See [Audit gates](/docs/audit-gates) for the script's invocation and the rest of the pre-merge gate set. ### Structural pins [#structural-pins] The lattice (D-36) is one-way; the only backward edge is the `messageId`-guarded `synthesize → synthesize` retry: ``` anchor-plan → tool-loop → synthesize → post-turn → (next turn) anchor-plan ↘ post-turn (recovery dispatch) ``` `ALLOWED_EDGE_PATTERNS` enumerates **nine** legal `(from-stage, to-stage)` pairs: five cross-stage transitions (`anchor-plan → tool-loop`, `tool-loop → synthesize`, `synthesize → post-turn`, the `tool-loop → post-turn` recovery-dispatch edge, and the `post-turn → anchor-plan` rollover) and four intra-stage chains (`anchor-plan`, `tool-loop`, `post-turn`, plus the `synthesize → synthesize` retry). The remaining **seven** pairs of the 4×4 cross-product sit in `FORBIDDEN_EDGE_PATTERNS`; the audit reports the exact violation pattern. The `NODE_STAGE_MAP` registry in `topology.ts` carries 44 names — the upper bound on what a canonical graph can register. A real compile under a given `buildDefaultAgentGraph(config)` wires the subset whose executor or hook the host supplied; an absent executor means an absent node. `npm run audit:graph-stages` asserts the compiled node and edge counts are byte-identical pre- and post-change *for the same config* — a structural pin that catches both silent node additions and edge drift. New nodes ship with a paired `NODE_STAGE_MAP` entry in `topology.ts` and a documented edge-count ratchet; the audit is the regression-detection surface. The two reference catalogs — [Node catalog](/docs/graph-node-catalog) and [Edge catalog](/docs/graph-edge-catalog) — enumerate the 44-name registry and the nine-pattern allowed edge table the gate enforces. The forbidden set is enumerated, not implicit. Skipping the tool-loop (`anchor-plan → synthesize`, `anchor-plan → post-turn`), re-planning mid-turn (`tool-loop → anchor-plan`), post-synthesis tool dispatch or re-planning (`synthesize → tool-loop`, `synthesize → anchor-plan`), and post-turn re-entering an active turn (`post-turn → tool-loop`, `post-turn → synthesize`) all fail at CI. `tool-loop → post-turn` is *not* forbidden — it's the recovery-dispatch edge in the allowed table. ### `acceptsSeam` reservation [#acceptsseam-reservation] Every node declares `acceptsSeam: CallClass | null` in its metadata. The literal is the seam the node reserves — the `utility`, `reasoning`, `converse`, or `synthesize` call class the node consumes at invocation time. `null` is for pure state transforms: anchor builders, context projectors, deterministic reducers, post-stream detectors. The reservation is what lets future LLM-bearing growth attach without re-typing the lattice. A pure transform that later wants to call a model flips its `acceptsSeam: null` to a `CallClass` literal, and the seam binding routes through the existing holder + cap machinery — no edge surgery, no audit drift. ### Singleton synthesize seam [#singleton-synthesize-seam] Exactly one `ProviderSeam<"synthesize">` per `SessionRuntime`, served by `SynthesizeSeamHolder` (D-38, D-50). The holder is **per-runtime**, not module-global — a single Node.js process runs many `SessionRuntime` instances concurrently, and module-global state would either throw on a second runtime's init or clobber the first runtime's adapter/counter binding and mis-attribute audit rows. `createSynthesizerNode` — and the recovery path that stands in for it — consume the synthesize **seam identity** through the holder. The tool-loop's `createLlmDecisionNode` is `utility`-class and consumes a separate seam, so the singleton is per call class (D-72): only the synthesizer reaches the synthesize seam. The cap (`TurnSynthesizeCounter`) is idempotent on `messageId` (D-37) — the synthesize-self-loop in the lattice is guarded by message-id equality so a retry produces one row, not two. The wire-check enforces both invariants from the test layer: ```bash npm run test:graphnoderef-wire-check ``` It compiles the default graph, asserts the holder is initialized exactly once per runtime, and asserts the synthesizer + the decision node observe the same `ProviderSeam<"synthesize">` reference. ## Determinism [#determinism] The graph engine is reactive but deterministic. Same channel versions feeding the same superstep produce the same firing order; two nodes that race on a channel resolve through the channel's reducer, which is required to be commutative and associative. Stream observers are sync-only — no `Promise` overload — so replay matches record on the first observation. Annotations expose `checkpoint()` and `restore()` per channel, so a session can rewind to any prior superstep and re-fire the graph from that point. See [Determinism](/docs/determinism) for the full property set and [Checkpointing](/docs/checkpointing) for the per-channel snapshot contract. ## Plugins and extra nodes [#plugins-and-extra-nodes] `HarnessPlugin.extraGraphNodes()` returns `PluginGraphNodeRegistration[]` — `{ name, factory, metadata? }` entries. The graph builder calls each factory once at compile time and adds the returned node to the topology under the plugin's namespace. Domain-specific nodes (a job-silo dispatcher, a sandbox workspace index, a custom enrichment pass) register here rather than living in the substrate's generic builder. The plugin can't add an out-of-lattice edge or bypass the singleton synthesize seam — both fail `audit:graph-stages` at CI before the registration takes effect. See [Plugin contract](/docs/plugin-contract) for the full extension surface. ## Where to go next [#where-to-go-next] --- # Tamper-evident hash chain (/docs/hash-chain) The hash chain is one of three concepts in the [audit-ledger cluster](/docs/audit-ledger#the-audit-ledger-cluster) — the [AuditableCall row](/docs/auditable-call-row) is what it protects; the [ProviderDecisionLedger](/docs/audit-ledger) is the write path; this page covers the tamper-evidence layer that links every persisted row to its predecessor. For read-side observability (OTel spans, lineage, Datadog wiring) see [Observability](/docs/observability). The hash chain protects the event log against three after-the-fact tampers: a silent backfill that inserts a row at an earlier index, a reorder that swaps two adjacent rows, and a removal that drops a row from the middle of the stream. Each of those mutations breaks the linkage between a row's `prev_hash` and the previous row's `row_hash`, and verification reports the first index where the chain breaks. The chain doesn't protect against a compromised writer mutating rows in real time — a writer that owns both the row contents and the hash stamp can produce a self-consistent chain on top of any state it likes. Real-time write integrity is a runtime-attestation problem and out of scope for this page. ## The two columns [#the-two-columns] The chain rides on two columns added to `harness_event_log`: | Column | Type | Meaning | | ----------- | ------------------ | ------------------------------------------------------------------------------------------ | | `prev_hash` | `BYTEA` (nullable) | the previous row's `row_hash`, copied verbatim (not re-hashed) | | `row_hash` | `BYTEA` (nullable) | raw 32-byte sha256 of a canonical encoding of the current row, excluding `row_hash` itself | Both columns store the **raw 32-byte digest** as `BYTEA` (not text) — the verifier keeps storage compact and the walk O(rows); it exports hex only for display (`encode(row_hash, 'hex')`). Both are nullable for back-compat: rows written before the migration landed carry `NULL` in both, and stamped rows written after carry both. The migration shape: ```sql ALTER TABLE harness_event_log ADD COLUMN IF NOT EXISTS prev_hash BYTEA, ADD COLUMN IF NOT EXISTS row_hash BYTEA; CREATE INDEX IF NOT EXISTS harness_event_log_chat_row_hash_idx ON harness_event_log (chat_id, sequence_number DESC) INCLUDE (row_hash) WHERE chat_id IS NOT NULL AND row_hash IS NOT NULL; ``` The partial index keys the verification walk: a chat's stamped slice is read in `(chat_id, sequence_number)` order without a sequential scan over pre-hash rows. One caveat — `sequence_number` is a per-chat write *ordinal*, not a uniqueness guarantee. Under concurrent writers a slice can carry a repeated ordinal, so a verifier should treat `sequence_number` as a hint and resolve ties by a monotonic insertion identity (`created_at, id`); ordering the walk on `sequence_number` alone can report false breaks when two rows share an ordinal. ## What "canonical encoding" means [#what-canonical-encoding-means] Two writers that observed the same row state need to compute the same `row_hash`, otherwise the chain isn't verifiable across writer restarts or across writers running in parallel. The encoding is a deterministic byte serialization of the row's columns in a fixed order, with JSONB normalized to sorted-key form and timestamps serialized as their ISO-8601 string. The exact column order and the JSONB normalization rules live in the source — see the linked `src/event-log/` directory. Don't reimplement the encoding from a guess; consume the helper the writer uses so a future column addition propagates through both the stamp and the verifier in one place. ## Lifecycle status [#lifecycle-status] Be explicit about where the chain sits today. **Today.** The `prev_hash` and `row_hash` columns ship in the `@pleach/core` schema bundle (`003_harness_event_log.sql`), so a bare `npx pleach init` install has columns to stamp into — no host-only migration required. Writer-side stamping is **on by default** (`EventLogWriter`'s `c9PhaseBEnabled`, a single-character rollback if you need to disable it): the writer reads the most recent `row_hash` for the `(tenant_id, chat_id)` slice, computes `row_hash` over the canonical encoding of the row it's about to insert, and writes both columns as part of the insert. Rows written before the columns existed carry `NULL` in both and are skipped by the verifier. In a reference deployment, every row over a rolling 24-hour window carried both columns. **Concurrency note.** The per-chat `sequence_number` is assigned by reading the current max and incrementing; parallel writers can therefore collide on an ordinal (see the verification-walk caveat above). The chain links themselves are unaffected — `prev_hash` still copies the prior `row_hash` verbatim — but a verifier must order by insertion identity, not `sequence_number`, to walk it. **Shipping.** The verifier ships in code on `@pleach/core/eventLog`: `verifyChainForChat(...)` walks the chain and reports the first index where it breaks, and `generateProof(...)` produces a portable `ChainProofV1` over a window of the chain. `@pleach/replay` consumes `verifyChainForChat` to gate deterministic re-execution against an untampered log. Because the same `chainStep`, `computeRowHash`, `computeGenesisSeed`, `canonicalizeRowForChain`, and `PLEACH_C9_CANONICALIZATION_VERSION` helpers the writer uses are exported from `@pleach/core/eventLog`, verifier and writer share canonical encoding by construction. The SQL pattern below remains the fallback for ad-hoc audits or hosts that haven't wired the in-code verifier. ## C9 probes — proof the writer is reaching prod [#c9-probes--proof-the-writer-is-reaching-prod] Two `[UXParity:c9-hash-chain-*]` probes are wired into `EventLogWriter` and `hashChain.ts`. They activate the dormant `audit:c9-hash-chain-integrity` soak ledger so the 3-batch clean gate can become load-bearing for the verifier-CLI cutover. Both probes fire whether or not stamping is enabled — a missing emission means a misconfigured rollout, not a passing one. ### `[UXParity:c9-hash-chain-row-stamp]` (PE-1) [#uxparityc9-hash-chain-row-stamp-pe-1] Fires once per chat-bearing flush row in `EventLogWriter.flushBatchWithRetry`. Two phases: * `phase: "active"` — `c9PhaseBEnabled === true`, `chainStep` stamps `prev_hash` + `row_hash`; the probe carries the first 16 hex chars of each. * `phase: "disabled"` — operator opt-out path; `prevHashPrefix` and `rowHashPrefix` are `null` so canvas-grep cohorts can split active-vs-disabled fire counts without rerunning the cohort. Payload: ```ts interface C9HashChainRowStampInput { phase: "active" | "disabled"; chatId: string; tenantId: string; prevHashPrefix: string | null; // 16 hex chars when active; null when disabled rowHashPrefix: string | null; // emitted with a tsMs timestamp } ``` ### `[UXParity:c9-hash-chain-verify]` (PE-2) [#uxparityc9-hash-chain-verify-pe-2] Fires once per `verifyChainForChat` call at the function epilogue — the verifier walks the iterator collecting counters, then emits one structured line with the totals + verdict. Locked at every call, no sampling. Payload: ```ts interface C9HashChainVerifyInput { chatId: string; tenantId: string; chainValid: boolean; rowCount: number; // total rows examined (legacy + non-legacy) nonLegacyRowCount: number; // chain-participating subset failedIndex?: number; // present iff chainValid: false warnOnly: boolean; } ``` `chainValid: false` is the structured JSON complement to the existing `[UXParity:c9-chain-verify-warn-only]` `console.warn`. ### Audit gate clean condition [#audit-gate-clean-condition] The `audit:c9-hash-chain-integrity` gate evaluates a batch as clean when three conditions hold over a three-batch window: * `c9-hash-chain-row-stamp` count `> 0` (any write-shadow emission proves the probe is reaching production) * Every `c9-hash-chain-verify` emit carries `chainValid: true` * Zero `[C9:legacy-prefix]` boundary-disagreement diagnostics A single failing batch doesn't fail the deploy — the three-batch aggregation tolerates a transient sink delay. The activation gate flipping green is what promotes the writer from shadow to enabled. ## Pure hash module + verification [#pure-hash-module--verification] The chain's hashing and verification logic ships today as a pure module — no substrate imports, no writer wiring, no database access. It builds on top of `node:crypto` and nothing else, so verifiers, tests, and external auditors can consume the same canonicalization the writer will use without pulling in the rest of the runtime. ```typescript import { PLEACH_C9_CANONICALIZATION_VERSION, // "pleach.c9.v1" computeGenesisSeed, // (tenantId, chatId) → Buffer canonicalizeRowForChain, // (CanonicalRowFields) → Buffer computeRowHash, // (prevHash, canonical) → Buffer chainStep, // genesis-aware single advance verifyChain, // walk + row-precise diagnostic isLegacyRow, // null-rowHash detector } from "@pleach/core/eventLog"; ``` The genesis seed is derived per `(tenant_id, chat_id)` and carries the version prefix into the hash, so a chain rooted at one `(tenant, chat)` pair can't be grafted onto another. `verifyChain` walks a slice top-to-bottom and returns either `{ ok: true }` or a row-precise diagnostic — `{ ok: false, failedIndex, expected, actual, reason }` — pinpointing the first index where the stored chain diverges from a recomputed one. `isLegacyRow` separates pre-stamping rows from chain breaks so the verifier can emit a distinct legacy signal rather than reporting a false tamper. The next phases — writer-side stamping wiring at insert time, and the `@pleach/replay` verification CLI surface previewed below — build on this module. The canonicalization contract lives here so both directions read from a single source. ## What gets hashed, what doesn't [#what-gets-hashed-what-doesnt] The chain hashes the persisted shape of the row — the bytes that actually live in `harness_event_log`. Scrubber-redacted payloads hash to the redacted form, not the pre-redaction form. That's deliberate: the chain protects against tampering with what was written, not against losing information to redaction. | Field | In the hash? | Why | | --------------------------------------- | ------------ | --------------------------------------- | | `record_id`, `session_id`, `event_type` | Yes | Identity columns — load-bearing | | `payload` (post-scrubber) | Yes | Persisted shape only | | `payload` (pre-scrubber) | No | Never persisted | | `sequence_number` | Yes | Ordering signal | | `prev_hash` | Yes | Locks the chain back-link | | `row_hash` | No | Hashing its own field would be circular | See [Scrubbers](/docs/scrubbers) for the redaction layer that shapes the payload before it reaches the writer. ## Back-compat for pre-hash rows [#back-compat-for-pre-hash-rows] A consumer that started its chain mid-history doesn't need to backfill old rows. Verification skips any row whose `row_hash` is `NULL` and resumes at the next stamped row. The first stamped row in a tenant's history is the chain root for that tenant; everything before it is unverified by construction. In practice: a tenant who upgraded after the stamping flag flipped has a verifiable chain from the upgrade row forward. Older rows remain queryable; they just aren't covered by the chain. ## Verification (preview) [#verification-preview] When the verification CLI ships in `@pleach/replay`, the surface will look roughly like: ```bash npx pleach-replay verify-chain \ --session \ --from # → ok through 12,847 rows # → break at record_id 01JCXY... (prev_hash mismatch) ``` Until then, verification is a recursive CTE that walks the chain and reports the first row where `prev_hash` doesn't match the previous `row_hash`: ```sql WITH RECURSIVE chain AS ( SELECT record_id, prev_hash, row_hash, 1 AS idx FROM harness_event_log WHERE session_id = $1 AND row_hash IS NOT NULL ORDER BY record_id ASC LIMIT 1 ), walked AS ( SELECT c.record_id, c.prev_hash, c.row_hash, c.idx FROM chain c UNION ALL SELECT e.record_id, e.prev_hash, e.row_hash, w.idx + 1 FROM walked w JOIN harness_event_log e ON e.session_id = $1 AND e.record_id > w.record_id AND e.row_hash IS NOT NULL AND e.prev_hash = w.row_hash ORDER BY e.record_id ASC LIMIT 1 ) SELECT idx, record_id FROM walked ORDER BY idx DESC LIMIT 1; ``` The last `idx` returned is the chain length. If it's shorter than the count of stamped rows for the session, the chain breaks at the next `record_id` after the returned one. The CLI will do this walk plus a contents re-hash (recomputing `row_hash` from the row's columns and comparing it to the stored value) — the SQL pattern catches link breaks but not contents tampering on a single row. ## Relationship to `@pleach/replay` [#relationship-to-pleachreplay] Replay reads the chain to assert deterministic re-execution against an untampered event log. A replay pass that walks a slice whose chain doesn't verify can't claim its result reproduces the original session — the inputs to the replay aren't trusted. The verification step is what lets replay diff results stand as evidence. See [Eval and replay](/docs/eval-and-replay) for the replay surface itself. ## What the hash chain doesn't replace [#what-the-hash-chain-doesnt-replace] The chain is an after-the-fact tamper detector. It's not access control. Specifically, it does not replace: * **RLS.** Row-level security gates who can read or write rows. The chain runs over whatever rows actually got written; it doesn't decide who got to write them. * **Auth.** A signed JWT proves the requester is who they say they are. The chain says nothing about identity — it says only that the row sequence hasn't been mutated since it was written. * **In-flight encryption.** TLS protects bytes between the writer and the database. The chain protects bytes after they land. Layer the chain on top of those controls, not in place of them. ## Where to go next [#where-to-go-next] --- # Host adapter (/docs/host-adapter) `new SessionRuntime()` constructs with zero config. But `runtime.executeMessage(...)` reaches back into the host application for capabilities the substrate doesn't ship absorbed yet — streaming utilities, fabrication guards, intent detectors, citation enrichers, tool registries. The seam is `setHarnessModuleLoader`. This page is for hosts mid-migration. If you're building a new agent on `@pleach/core`, prefer the typed config surfaces (`provider`, `tools`, `plugins`, `metaToolNames`) — the loader seam exists for hosts that grew up with the substrate inside a larger app and are unwinding the integration gradually. ```typescript import { setHarnessModuleLoader, type HarnessModuleKey, type HarnessModuleLoader, } from "@pleach/core/runtime"; ``` ## What happens without it [#what-happens-without-it] Calling `executeMessage` without registering a loader produces: ``` HarnessModuleLoaderUnregisteredError: [harness] Module loader is not registered. Tried to load key="orchestrator.intentDetector". The host application must call setHarnessModuleLoader(...) at startup … ``` The error names the first key the runtime actually reaches for on a turn — an early turn-start key like `orchestrator.intentDetector`, not a later stream-phase key. Wire the loader at startup and the error goes away. ## The 30-second contract [#the-30-second-contract] ```typescript import { setHarnessModuleLoader, type HarnessModuleKey } from "@pleach/core/runtime"; setHarnessModuleLoader((key: HarnessModuleKey) => { switch (key) { case "orchestrator.streamDegenerationGuards": return import("@your-app/streamDegenerationGuards"); case "orchestrator.fabricationGuard": return import("@your-app/fabricationGuard"); case "orchestrator.intentDetector": return import("@your-app/intentDetector"); // ... ~40 more keys ... default: throw new Error(`Unhandled harness module key: ${key}`); } }); ``` `HarnessModuleKey` is a string-literal union. TypeScript will tell you when a new key gets added — the `default` arm catches anything your switch doesn't cover. ## The retirement direction [#the-retirement-direction] The loader surface is **shrinking**, not growing. Each `@pleach/core` release retires keys as the underlying capability moves into the substrate as typed config: | Era | Capability sat in | Today | | ------- | -------------------------------------------------------------- | ------------------------------ | | `0.x` | Loader seam | Loader seam | | `1.0.0` | Loader seam | Loader seam (R-1 started) | | `1.1.x` | Loader seam | Some keys retired; rest stable | | Future | Typed `SessionRuntimeConfig` strategies, `HarnessPlugin` hooks | Loader seam minimal residual | Keys retired so far: | Retired key | Replaced by | | ------------------------------- | ----------------------------------------------------------------------- | | `orchestrator.streamHelpers` | `SessionRuntimeConfig.metaToolNames` (typed config) | | `orchestrator.fabricationGuard` | `setOrchestratorHotpathModules({ fabricationGuard })` (registered shim) | Re-adding a retired key is a regression. The runtime's substrate test suite catches the regression at build time, not first turn. ## The R-tracks [#the-r-tracks] The retirement work is tracked as three named tracks. Hosts can opt into each independently. | Track | Scope | What lands | | ----- | --------------- | ------------------------------------------------------------------------------------- | | R-1 | Absorption | Capability moves from loader seam into the substrate, no surface change for consumers | | R-2 | Typed config | Loader-resolved value becomes a typed field on `SessionRuntimeConfig` | | R-3 | Plugin contract | Loader-resolved value becomes a `HarnessPlugin` contribution hook | The reference adapter at `examples/host-adapter/reference.ts` in the package marks every R-1-retired arm with a `RETIRED YYYY-MM-DD (R-1.X, commit )` comment. Hosts cribbing from that file should delete the marked arms rather than wire them. ## `CreatePleachRuntimeConfig.host.{strategies, modules, raw}` [#createpleachruntimeconfighoststrategies-modules-raw] `createPleachRuntime()` accepts a typed `host` carveout that groups the host-private substrate dependencies under three named fields. The carveout is the destination loader-bag keys absorb into as they retire — once a key lands here, the loader stops being asked for it on that runtime. ```typescript import { createPleachRuntime, type CreatePleachRuntimeConfig, } from "@pleach/core"; ``` | Field | Holds | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `host.strategies` | Host-private strategy injection slots — the cohort marked `@strategy.host-private` on `SessionRuntimeConfig`. Typed as `Partial>` over a 15-key list (see below). | | `host.modules` | Operator-supplied module shims the runtime composes against — `eventLogWriter`, `store`, `interrupt`. These are substrate dependencies, not plugin contributions. | | `host.raw` | Uncategorized escape hatch. Accepts any `Partial`. Use only when neither the typed groups above nor the top-level options expose the field. | The 15 keys `host.strategies` accepts (from `HostExtensionBundle`): `orchestratorConfig`, `lineageTracker`, `agentRegistry`, `planManager`, `learningAuditor`, `preserveDataRefFields`, `getDataHandlerFactory`, `dataChannelRefetch`, `continuationShadowResolver`, `entityNameCounter`, `structurePrefetcher`, `artifactCacheReader`, `guestDeniedTools`, `fabricationDetectorRules`, `recordGarbledOutput`. The 3 keys `host.modules` accepts: `eventLogWriter`, `store`, `interrupt`. ```typescript const config: CreatePleachRuntimeConfig = { plugins: [compliancePlugin, gatewayPlugin], host: { strategies: { lineageTracker: myLineageTracker, planManager: myPlanManager, guestDeniedTools: GUEST_DENIED, }, modules: { eventLogWriter: mySupabaseEventLogWriter, store: myMultiNamespaceKV, }, // raw: { ... } — only when a field is neither classified above nor a top-level option }, userId: "user_123", }; const runtime = createPleachRuntime(config); ``` The three fields are independent. A host can fill `strategies` without touching `modules`; a host with no operator-supplied module shims can omit `modules` entirely. `raw` is the safety valve — prefer `strategies`/`modules` when applicable so the autocomplete surface stays meaningful. ### Merge precedence [#merge-precedence] Top-level fields → `host.strategies` → `host.modules` → `host.raw` → (deprecated) `advanced`. Higher precedence wins on key collision. `advanced?: Partial` stays in the shape for 1.x back-compat with the pre-1.0 surface; it retires in 2.0. New hosts shouldn't reach for `advanced`. ### Bag-entry retirement readiness [#bag-entry-retirement-readiness] A per-bag-entry status report tracks the migration off the `orchestratorHotpath` bag toward the typed surface + plugin-routed `runtime.plugins.collect*` cohort. The bag is transitional, not terminal — every entry carries an inline retirement-path comment on the `OrchestratorHotpathModules` interface declaration. | Entry | Status | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `streamHelpers` | **RETIRED**. Bag field deleted; the lone production reader now calls `this._getMetaToolNames()` directly. `SessionRuntimeConfig.metaToolNames` is canonical. | | `toolCoupling` | **RETIRE-READY**. Typed `HarnessPlugin.contributeToolCouplingHints` is canonical; hosts adopting the plugin-routed path read through `runtime.plugins.collectToolCouplingHints()`. The bag entry is retained as CLI/raw-path fallback and retires fully once non-host consumers no longer reach it. | | `fabricationGuard` | **NEAR-READY**. Surface widened from 1 → 9 host-side functions (regex tables bake in domain rules); the typed `contributeFabricationGuard` hook is the canonical path, and the H-3.3 runtime probe `recordFabricationGuardResolverPath` emits per-invocation `via:"bundle"\|"bag-fallback"\|"unwired"` telemetry to drive the 3-batch soak before the bag-fallback retires. | The canonical routing layer is `runtime.plugins.collect*` — the bag is the legacy fallback. Hosts adopting facets should read through the plugins facet, not the bag. See [Facets](/docs/facets). ### Audit gates enforcing the contract [#audit-gates-enforcing-the-contract] Four gates run in CI on the upstream core repo. Their job is to catch "we typed the hook but the bag read is still load-bearing" regressions at build time, rather than at first turn in production. | Gate | Enforces | | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `audit:bag-entries-have-retirement-path` | Every property on `OrchestratorHotpathModules` must have an inline retirement-path comment (`// TRANSITIONAL —`, `// D-PA-N`, or `// retir`) immediately above it. New entries cannot land without a documented retirement vehicle. | | `audit:bag-readers` | Inventory of `getOrchestratorHotpathModules().{fabricationGuard,toolCoupling}` read callsites. G3 routing assertion: any file reading `bag.toolCoupling` MUST also reference `collectToolCouplingHints` (plugin-routed canonical path) OR carry a `// FALLBACK-OK: ` marker. Companion to the entry-side gate. | | `audit:fabrication-guard-bag` | Source-text regression locking R-1.C Variant A′ post-audit state. Fails on novel `dynamicImportApp("orchestrator.fabricationGuard")` call sites beyond the baselined documentation-comment hit at `appRegistries.ts`. | | `audit:fabrication-guard-resolver-clean` | Runtime soak ledger — accumulates per-canvas-batch counts of the `[UXParity:fabrication-guard-resolver-path]` probe grouped by `via` discriminator. `:strict` fails until the last 3 batches show auth `via:"bundle"` > 0 AND `via:"bag-fallback"` == 0. Replaces calendar bake with runtime evidence. | ### `pleach-plugin-modernize` codemod [#pleach-plugin-modernize-codemod] The codemod rewrites the four soft-deprecated bare properties into their typed `contribute*` method form. It does not migrate to `definePleachPlugin` — it's a narrower rewrite that produces method-form plugins which `definePleachPlugin` can then accept. ```text tools: [a, b] → contributeTools: () => [a, b] events: { foo: ... } → contributeEventTypes: () => ({ foo: ... }) customEventTypes: [...] → contributeEventTypes: () => [...] batchingHints: [a, b] → contributeBatchingHints: () => [a, b] ``` The codemod is an internal repo script in the upstream core repo, not a published npm binary: ```bash node scripts/codemods/pleach-plugin-modernize.mjs [--dry-run] [--json] ``` It only rewrites bare properties inside object literals containing a `name: ""` field, so arbitrary objects aren't touched. Collision-safe — if a `contribute*` method already exists alongside the bare property in the same plugin object, the codemod warns to stderr and skips. `customEventTypes` folds into `contributeEventTypes` only when no `events` / `contributeEventTypes` already exists in the same literal; otherwise it's left for manual merge. It's the prep step for the upcoming bare-property cut, after which those forms become type errors. The output composes with [`definePleachPlugin`](/docs/plugin-contract#definepleachplugin--the-typed-factory) without further edits. ## `setOrchestratorHotpathModules` [#setorchestratorhotpathmodules] A second registration helper for hot-path modules — capabilities called synchronously inside the per-call seam loop, where dynamic import overhead would matter. ```typescript import { setOrchestratorHotpathModules } from "@pleach/core/runtime"; setOrchestratorHotpathModules({ fabricationGuard: fabricationGuardModule as never, // ... other hotpath modules }); ``` Used for capabilities that are domain-coupled in a way the substrate doesn't want to absorb. The fabrication guard, for example, carries host-specific regex tables (tool aliases, phantom tool replacements, identifier patterns) — registering it synchronously keeps host-domain rules in host code without introducing dynamic-import latency on the per-call path. ## `metaToolNames` (the canonical R-1.A migration) [#metatoolnames-the-canonical-r-1a-migration] Hosts that previously resolved `CONTINUATION_META_TOOL_NAMES` through the loader should now pass them as typed config: ```typescript import { SessionRuntime } from "@pleach/core"; const runtime = new SessionRuntime({ metaToolNames: new Set([ "set_step_complete", "wait_for_jobs", // ... your meta-tool names ]), // ...other config }); ``` Without the field, the runtime fires a one-shot probe **lazily on the first read** of the meta-tool set (not at construction) — the `_getMetaToolNames()` accessor emits it the first time a guard reaches for the set and finds it undefined: ``` [UXParity:metaToolNames-config-missing] { reason: "metaToolNames-undefined-at-read", ... } ``` The runtime then falls back to a shared empty set, which silently disables continuation and fabrication guards that key off the set. (`setOrchestratorAdapter` is unrelated — it's a deprecated delegating stub that forwards to the `adapter` facet and emits no metaToolNames warning.) Fix by passing the field; never ship past the probe in production. ## The reference adapter [#the-reference-adapter] The package ships two reference adapter files at `examples/host-adapter/`: | File | Purpose | | -------------- | ----------------------------------------------------------------------------- | | `index.mjs` | Minimal no-op adapter that throws a doc-pointer error for every key | | `reference.ts` | Full production-shaped adapter with vendor paths neutralized to `@your-app/*` | Start from `index.mjs` for a brand-new host — implement one key at a time as you need it. Crib from `reference.ts` to see the full set of keys a mature integration wires. ## What gets called when [#what-gets-called-when] Different keys resolve at different turn phases. A rough map: | Phase | Keys resolved | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | Runtime construction | `supabase`, `stores.*`, registry constants | | Turn start | `orchestrator.intentDetector`, `orchestrator.fileIntentDetector` | | Stream phase | `orchestrator.streamSingleTurn`, `orchestrator.streamDegenerationGuards` | | Tool dispatch | `orchestrator.tools.*` (registry, dataflow, guards, execution) | | Synthesis | `orchestrator.synthesis.*`, `orchestrator.finalization.finalizeContent` | | Quality / post-turn | `orchestrator.quality.evaluator`, `orchestrator.workflow.workflowHintResolver` | | Middleware | `orchestrator.middleware.creditBudget`, `orchestrator.middleware.safetyReview`, `orchestrator.middleware.enrichmentCitations` | | Provider fallback | `orchestrator.providers.circuitBreaker`, `orchestrator.providers.fallbackExecutor`, `orchestrator.providers.modelAvailabilityChecker` | The runtime does not cache resolved modules itself — `dynamicImportApp` re-invokes the registered loader on every call. Deduplication comes from the process-wide `import()` module cache the loader delegates to, so a key resolves cheaply on repeat reaches but the loader still runs each time (scope is process-wide, not per-runtime-lifetime). ## When you don't need the loader [#when-you-dont-need-the-loader] For a brand-new host that's not bringing a legacy orchestrator into the substrate: * Use the typed `provider` config field for LLM execution. * Use the `plugins` config field for domain extensions. * Use `metaToolNames` for continuation flow control. * Register tools through plugin `contributeTools` or the legacy `setOrchestratorRegistry` shim. You'll still hit some loader-required keys (the substrate is mid- migration), but the minimum set is small. The no-op adapter at `examples/host-adapter/index.mjs` implements just the keys required for a first-turn execution to land. ## Where to go next [#where-to-go-next] --- # Pleach (/docs) Pleach is a TypeScript agent runtime. Every LLM call, tool dispatch, and subagent spawn writes one row keyed by `turnId` — joinable to your billing or compliance schema in a single `GROUP BY`. Use it as the substrate under your product's agent loop, or as the backbone an agent harness builds on. The name is horticultural. **Pleaching** weaves living branches into one load-bearing hedge. ai-sdk and LangChain hand you a pot and a few stakes — enough to keep one branch upright. Pleach hands you the lattice: every addressable event the agent produces — each LLM call, tool dispatch, subagent spawn — lands as a branch, woven into a structure you can query, prune, and replay. The four stages are the trellis; the audit row is the weave. Full entry in the [glossary](/docs/glossary#p). ## The three concepts [#the-three-concepts] Everything else on this site reduces to these. * **Session.** Long-lived state for one user × one product surface. Channels, storage, family-lock, and the event log all hang off it. Mint one with `runtime.sessions.create()`; resume by id across processes and clients via version-vector sync. See [SessionRuntime](/docs/session-runtime). * **Turn.** One user-message-in / one-answer-out cycle. Walks the four-stage lattice — `anchor-plan` → `tool-loop` ⇄ `synthesize` → `post-turn` — under singleton-synthesize discipline so exactly one final answer fires per turn. See [Turn lifecycle](/docs/turn-lifecycle). * **AuditableCall row.** One append-only typed row per LLM call, keyed by `turnId`. Carries `tenantId`, `toolName`, `subagentDepth`, `modelId`, and `tokenUsage`. The grain every cost rollup, compliance query, and replay reads from. See [The AuditableCall row](/docs/auditable-call-row). Sessions hold turns; turns produce rows. From those three: per-tenant cost in one `GROUP BY turn_id`, compliance review in SQL, and replay-deterministic regression against fingerprints. The full concept map — seven cluster triplets at four depths — is at [Concept clusters](/docs/concept-clusters). ## The shape, in one paragraph [#the-shape-in-one-paragraph] A session holds the state. A turn drives one user-message-in / one-answer-out cycle through the four stages — `anchor-plan` sets intent, `tool-loop` ⇄ `synthesize` iterates until the answer is ready, `post-turn` finalizes. Every addressable decision in that loop — a tool call, a subagent spawn, a model invocation, an interrupt — writes one row to the ledger, keyed by the same `turnId`. See [overview](/docs/overview) for the diagrams and [turn-lifecycle](/docs/turn-lifecycle) for the walk-through. ## What ships today [#what-ships-today] All `@pleach/*` packages ship under **FSL-1.1-Apache-2.0** (Functional Source License with Apache 2.0 as the future license, auto-transitions to Apache 2.0 two years after first stable publish). The first-wave publish ceremony cuts 14 packages at `0.1.0` uniformly: `@pleach/core`, `@pleach/tools`, `@pleach/react`, `@pleach/base-tools`, `@pleach/replay`, `@pleach/sandbox`, `@pleach/langchain`, `@pleach/compliance`, `@pleach/compliance-contract`, `@pleach/eval`, `@pleach/gateway`, `@pleach/mcp`, `@pleach/coding-agent`, and `@pleach/recipes`. Pin exactly — `^0.1.0` will not pick up later `0.x` releases. Two names aren't in the wave yet: `@pleach/observe` is scoped at `0.1.0-alpha.0` (not on npm; next publish), and `@pleach/trust-pack` is a reserved name at `0.0.1 · UNLICENSED`. See [Packages](/docs/packages) for the canonical per-SKU status table. ## Start here [#start-here] Pick the closest; the sidebar carries the rest. ## Already on Anthropic Enterprise or OpenAI Enterprise? [#already-on-anthropic-enterprise-or-openai-enterprise] Pleach composes underneath the vendor contract — it does not replace it. SSO/SAML, ZDR, Workspaces or Projects, the Admin / Usage API, dedicated capacity, prompt caching, and snapshot pinning stay where they are. The runtime adds per-axis cost rollup inside one Workspace or Project (your end customers in a SaaS, or your employees / teams / cost centers when the lab is used internally), a hash-chained `AuditableCall` row in your own Postgres, and replay-determinism across snapshots. No new vendor, no new SOC 2 boundary. ## Read next [#read-next] ## What this site is not [#what-this-site-is-not] * Not where the package source lives. `@pleach/core` is published from the upstream repository at `github.com/pleachhq/core` and consumed here as a documentation target — the version of `@pleach/core` rendered against this site is whatever the package manifest pins. * Not the canonical reference for any individual package — each package's README on npm is. If this site disagrees with a published README (a row in the [model resolution matrix](/docs/model-resolution-matrix), a field name in the [AuditableCall row](/docs/auditable-call-row), a verdict in the [stream-observer ladder](/docs/architecture#3-seams)), the README wins. --- # Install (/docs/install) The soil layer before the trellis goes up. Install paths organized by what you already use: pick a package manager, pick a framework, pick a provider. The wizard wraps all three; the manual snippets are if you want to see what gets generated. ## The wizard (recommended) [#the-wizard-recommended] `npx pleach init` detects your framework, asks four questions (template, provider, plugin stub, schema scaffold), and writes a starter project. Per-framework template matrix lives at [CLI → `pleach init`](/docs/cli#pleach-init--the-wizard). ```bash npx pleach init ``` ```bash pnpm dlx pleach init ``` ```bash yarn dlx pleach init ``` ```bash bunx pleach init ``` Run `npm run dev`. On Next.js App Router the scaffold runs a keyless demo with no key set — the real graph, base-tools, and audit ledger run with canned model text, and the bundled `/audit` view shows real rows from your first message. Set a provider key to go live. \~60 seconds wall-clock. ## Manual: install `@pleach/core` [#manual-install-pleachcore] ```bash npm install @pleach/core ``` ```bash pnpm add @pleach/core ``` ```bash yarn add @pleach/core ``` ```bash bun add @pleach/core ``` ## Manual: set a provider key [#manual-set-a-provider-key] The runtime auto-detects whichever key is set. Detection priority matches the tab order above. To pin a provider explicitly (skipping detection), pass `provider: "anthropic"` to `createPleachRoute()`. ## Manual: wire a route handler [#manual-wire-a-route-handler] The same `createPleachRoute()` factory works in any fetch-based runtime — App Router, Pages Router, Astro, SvelteKit, Remix, Hono, Workers, Bun. Pick the framework you're on; the body is identical. ```typescript // app/api/chat/route.ts import { createPleachRoute } from "@pleach/core/quickstart"; export const POST = createPleachRoute(); ``` ```typescript // pages/api/chat.ts import { createPleachRoute } from "@pleach/core/quickstart"; const handler = createPleachRoute(); export const config = { api: { bodyParser: false } }; export default async function POST(req, res) { const response = await handler(new Request(`http://x${req.url}`, { method: "POST", body: req as unknown as BodyInit, })); res.status(response.status); response.headers.forEach((v, k) => res.setHeader(k, v)); if (response.body) { const reader = response.body.getReader(); while (true) { const { value, done } = await reader.read(); if (done) break; res.write(value); } } res.end(); } ``` ```typescript // src/pages/api/chat.ts import type { APIRoute } from "astro"; import { createPleachRoute } from "@pleach/core/quickstart"; const handler = createPleachRoute(); export const POST: APIRoute = ({ request }) => handler(request); ``` ```typescript // src/routes/api/chat/+server.ts import { createPleachRoute } from "@pleach/core/quickstart"; const handler = createPleachRoute(); export const POST = ({ request }) => handler(request); ``` ```typescript // app/routes/api.chat.ts import { createPleachRoute } from "@pleach/core/quickstart"; const handler = createPleachRoute(); export const action = ({ request }) => handler(request); ``` ```typescript // src/index.ts import { Hono } from "hono"; import { createPleachRoute } from "@pleach/core/quickstart"; const app = new Hono(); const handler = createPleachRoute(); app.post("/api/chat", (c) => handler(c.req.raw)); export default app; ``` ```typescript // src/index.ts import { createPleachRoute } from "@pleach/core/quickstart"; const handler = createPleachRoute(); export default { async fetch(req: Request): Promise { const url = new URL(req.url); if (url.pathname === "/api/chat" && req.method === "POST") { return handler(req); } return new Response("Not found", { status: 404 }); }, }; ``` ```typescript // server.ts import { createPleachRoute } from "@pleach/core/quickstart"; const handler = createPleachRoute(); Bun.serve({ port: 3000, fetch(req) { const url = new URL(req.url); if (url.pathname === "/api/chat" && req.method === "POST") { return handler(req); } return new Response("Not found", { status: 404 }); }, }); ``` ## What this gives you [#what-this-gives-you] A streaming chat handler at `POST /api/chat` that accepts `{ sessionId?, message }` JSON and streams `StreamEvent` NDJSON back. No persistence, no plugins, no domain customization — the runtime returns a substrate placeholder until you adopt a [storage adapter](/docs/storage) and (optionally) a [plugin](/docs/plugin-contract). For keyless local dev — no env var, no 503 — pass `demo: process.env.NODE_ENV !== "production"` to `createPleachRoute()`. With no key it drives a real graph turn over canned model text and writes real audit rows (response header `x-pleach-mode: demo`); a resolved key always wins, and it stays off in a production build. It's the mode the wizard scaffolds, with an `/audit` view to read the rows. For the React side — `useChat()`, `` — see [Getting started](/docs/getting-started). For a production-shape handler with custom storage, plugins, and provider pinning, see [Getting started → Custom storage, plugins, or provider pinning](/docs/getting-started#custom-storage-plugins-or-provider-pinning). ## Where to go next [#where-to-go-next] --- # Internal knowledge agent (/docs/internal-knowledge-agent) An internal knowledge agent is the use case that exposes the hidden cost of un-attributed answers. A model that's "pretty sure" is a model that gets cited by an employee in a Slack thread, which becomes a process document, which becomes wrong. Pleach's answer: every chunk the retrieval tool returned lives in the [audit row](/docs/auditable-call-row) for that turn. Every answer is replayable against the same chunks. And a safety policy can refuse to answer when no chunk crossed the confidence threshold. **Related shapes.** [Regulated-domain agent](/docs/regulated-domain-agent) if the corpus contains PHI, PII, or other regulated content. [Multi-tenant SaaS agent](/docs/multi-tenant-saas-agent) if one runtime indexes per-tenant corpora. [Customer support agent](/docs/customer-support-agent) if the retrieved answer feeds a multi-turn support thread. ## What you're building [#what-youre-building] An agent that answers questions about your internal documentation — onboarding wikis, runbooks, design docs. It: * Retrieves relevant chunks from a vector store. * Cites each claim with a chunk id and document URL. * Refuses to answer when retrieval confidence is below threshold. The retrieval tool's full input and output land in the [audit ledger](/docs/audit-ledger). The answer is reproducible under [determinism](/docs/determinism): same question + same chunks * same model + same seed = same answer. ## The retrieval tool [#the-retrieval-tool] One [tool](/docs/tools) that the agent calls before composing any answer. The output schema makes provenance non-optional — the model can't return an answer without surfacing the chunks it used. ```typescript // lib/tools/searchInternalDocs.ts import { defineTool } from "@pleach/core"; import { z } from "zod"; const Chunk = z.object({ chunkId: z.string(), docUrl: z.string().url(), docTitle: z.string(), text: z.string(), score: z.number().min(0).max(1), }); export const searchInternalDocs = defineTool({ name: "search_internal_docs", description: "Retrieve up to 8 chunks from internal documentation. Always call this before composing an answer.", input: z.object({ query: z.string().min(4), topK: z.number().int().min(1).max(8).default(5), }), output: z.object({ chunks: z.array(Chunk), maxScore: z.number().min(0).max(1), queryEmbedded: z.array(z.number()), }), async handler({ query, topK }) { const embedding = await embed(query); // Tenant isolation is enforced at the storage/RLS layer (the runtime's // `tenantId` scope) — it is NOT threaded through the tool context. const chunks = await vectorStore.search(embedding, { topK }); return { chunks, maxScore: Math.max(0, ...chunks.map(c => c.score)), queryEmbedded: embedding, }; }, }); ``` `queryEmbedded` is on the output deliberately — it lands in the audit row, so a later reproduction can use the exact embedding vector instead of re-embedding (which would drift if the embedding model is updated). ## The "no chunk, no answer" safety policy [#the-no-chunk-no-answer-safety-policy] A safety policy that gates the final synthesis on retrieval confidence. If the top chunk's score is below threshold, the runtime forces the model to return the standard "I don't know" template instead of synthesizing. ```typescript // lib/safety/noChunkNoAnswer.ts import { defineSafetyPolicy, safetyPolicyId } from "@pleach/core/safety"; export const noChunkNoAnswer = defineSafetyPolicy({ id: safetyPolicyId("knowledge-agent.no-chunk-no-answer"), version: "1.0.0", enforcement: "refusal", scope: { callClass: "synthesize" }, content: ` [Retrieval confidence policy] If the retrieval tool returns no chunks with a score >= 0.6, do not synthesize an answer. Reply with the standard template: "I don't have enough confidence in the internal documentation to answer this. Try rephrasing, or ask a human." Cite the empty-result set as the reason. `.trim(), }); ``` The policy is **capability-subtracting**: it surfaces the operator's stated refusal posture (composed LAST into the system prompt) and lands on the audit row by `id` + `version` so a later review can ask "which turns ran under this rule". See [Safety](/docs/safety) for the contribution shape. ## Runtime construction [#runtime-construction] ```typescript // lib/runtime.ts import { SessionRuntime, AiSdkProvider, definePleachPlugin, appendPrompt } from "@pleach/core"; import { SupabaseAdapter } from "@pleach/core/sessions"; import { SupabaseSaver } from "@pleach/core/checkpointing"; import { createOpenRouter } from "@openrouter/ai-sdk-provider"; const openrouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY! }); const KNOWLEDGE_SYSTEM_PROMPT = `You answer questions about internal documentation. Rules: - Always call search_internal_docs before answering. - Cite each claim with the chunk_id and doc URL it came from. - If the tool returns no chunks above 0.6 score, say you don't know.`; export function buildKnowledgeRuntime(req: AuthedRequest) { return new SessionRuntime({ provider: new AiSdkProvider({ model: openrouter("anthropic/claude-sonnet-4-5"), maxSteps: 5, }), storage: new SupabaseAdapter({ client: supabase }), checkpointer: new SupabaseSaver({ client: supabase }), plugins: [definePleachPlugin("knowledge-tools", { tools: [searchInternalDocs], safetyPolicies: [noChunkNoAnswer, piiRedaction], prompts: [appendPrompt("knowledge-agent.system", KNOWLEDGE_SYSTEM_PROMPT)], })], tenantId: req.tenantId, userId: req.userId, }); } ``` ## What the ledger sees [#what-the-ledger-sees] A single turn writes (at minimum) three audit rows: | Row | call\_kind | What it carries | | --- | ---------- | ------------------------------------------------------------------------------------------ | | 1 | `llm` | The initial planning call — system prompt, user message, model response with the tool call | | 2 | `tool` | `search_internal_docs` input + full output (chunks, scores, embedding) | | 3 | `llm` | The synthesis call — input now includes the chunks; output is the cited answer | Reproducing the answer is row 2's output replayed into row 3's input. The [`runtimeMode: "replay"`](/docs/eval-and-replay) constructor does this end-to-end. ## Provenance query [#provenance-query] "Show me every answer this week that cited doc X." ```sql select turn_id, user_id, created_at, payload->'output'->'finalText' as answer from harness_auditable_calls where call_kind = 'llm' and created_at >= now() - interval '7 days' and exists ( select 1 from harness_auditable_calls t where t.turn_id = harness_auditable_calls.turn_id and t.call_kind = 'tool' and t.tool_name = 'search_internal_docs' and t.payload->'output'->'chunks' @> jsonb_build_array( jsonb_build_object('docUrl', $1) ) ); ``` If doc X was wrong and got rewritten, this query is the list of answers to revisit. ## Eval: lock the chunks, vary the prompt [#eval-lock-the-chunks-vary-the-prompt] The retrieval output is recorded. Build a fresh [session runtime](/docs/session-runtime) with a new system prompt, replay the recorded turn — the diff tells you whether the prompt change improved synthesis without re-running embedding. ```typescript import { createReplayRuntime } from "@pleach/replay"; const NEW_SYSTEM_PROMPT = `…revised instructions…`; const challenger = new SessionRuntime({ provider: new AiSdkProvider({ model: openrouter("anthropic/claude-sonnet-4-5"), maxSteps: 5, }), storage: new SupabaseAdapter({ client: supabase }), plugins: [definePleachPlugin("knowledge-tools", { tools: [searchInternalDocs], safetyPolicies: [noChunkNoAnswer, piiRedaction], prompts: [appendPrompt("knowledge-agent.system", NEW_SYSTEM_PROMPT)], })], }); const replayRuntime = createReplayRuntime({ sessionRuntime: challenger, tenantId: req.tenantId, }); const replay = await replayRuntime.replayTurn({ chatId: sessionId, tenantId: req.tenantId, messageId: goldenTurnId, }); // `replay.state` is the reconstructed HydratedHarnessState (typed `unknown`): // the re-synthesized answer plus the tool calls, whose chunks came from the ledger. const state = replay.state; console.log(state); ``` ## Project layout [#project-layout] Three adds on top of the [baseline](/docs/project-layout#a-layout-that-works): an `index/` module the retrieval tool calls into, a `safety/` directory for the "no chunk, no answer" rule, and a SQL file for the provenance query QA reads. ``` my-app/ src/ pleach/ runtime.ts # SessionRuntime + tools + safety + storage tools/ search-internal-docs.ts # defineTool — returns chunks with chunkId + sourceUri index/ client.ts # the vector / lexical index the tool calls into ingest.ts # offline corpus ingestion (separate process) safety/ no-chunk-no-answer.ts # defineSafetyPolicy — refuses synthesis without chunk evidence pii-redaction.ts # → /docs/scrubbers app/ api/agents/[id]/route.ts qa/ provenance.sql # "which chunks did turn T cite?" ``` What changes from the baseline: * **`index/` is separate from `tools/`.** The tool is a thin wrapper that turns a query into a chunk list; the index is the store. They have different release cadences — the index gets re-ingested on a corpus refresh schedule; the tool only changes when the chunk shape changes. * **`index/ingest.ts` is a separate process.** Ingestion is long-running and offline; it doesn't share lifetime with the agent runtime. Keep it in `src/pleach/index/` so the chunk shape stays consistent between writer and reader, but run it via its own entry point (cron, queue worker, CLI). * **`safety/no-chunk-no-answer.ts` is the load-bearing file.** This is what prevents [fabricated citations](/docs/fabrication-detection) — a capability-subtracting [safety policy](/docs/safety) that refuses synthesis if the tool returned zero chunks. A prompt instruction would not be enforceable; a policy is. * **`qa/provenance.sql` lives in the repo.** The [provenance query](#provenance-query) is what answers "what did the agent cite?" weeks after the turn. Same discipline as the customer-support rollup: the SQL ships next to the code that produces the rows it reads. ## Where to go next [#where-to-go-next] --- # Interrupts (/docs/interrupts) An interrupt is a turn-level pause: the runtime stops mid-execution, emits a structured envelope to the consumer, and waits for a human to respond. The shape is LangGraph-compatible so external tooling (inspectors, dashboards) interops without translation. See [Stream events](/docs/stream-events) for `interrupt.requested` and `interrupt.resolved` on the wire, and [Checkpointing](/docs/checkpointing) for how a paused turn survives reloads. Typical uses: approving a destructive tool call before it fires, clarifying ambiguous user intent, gating expensive operations behind a confirm step. ## Five rules for safe interrupts [#five-rules-for-safe-interrupts] The rules below apply to both pleach interrupts and LangGraph interrupts — they're properties of the underlying replay model, not framework-specific. Violating any of the five produces a non-replayable turn that may behave differently across pause / resume. 1. **Don't `try` / `catch` the interrupt path.** The runtime uses a thrown exception to unwind the stack at the interrupt point. Wrapping in `try` / `catch` swallows the exception and turns the interrupt into a no-op, leaving the turn in an inconsistent state. If you need cleanup, register it on `runtime.onAbort`, not in a try-block around the interrupt call site. 2. **Payloads must be JSON-serializable.** Interrupt envelopes survive serialization to the checkpoint store and back. `Date`, `Map`, `Set`, `BigInt`, `Symbol`, functions, and non-finite numbers either drop information or fail outright. Stick to JSON-safe primitives + plain objects + arrays. 3. **Pre-interrupt side effects must be idempotent.** A resume re-runs the node body up to the interrupt point. If the body sent an email, charged a card, or wrote a file before hitting the interrupt, that side effect fires twice. Move side effects to **after** the interrupt resolves, or use `runtime.cache` to memoize. 4. **Interrupt ordering must be stable across resume.** If a node body fires three interrupts in a loop, the same loop on resume must fire them in the same order. Random iteration order on an unordered collection (a `Set`, a `Record` with non-deterministic key order) breaks this. Sort the collection before the loop. 5. **`interruptBefore` / `interruptAfter` are debug-only.** Static interrupt points configured at graph-compile time don't compose with mid-node `interrupt()` calls and can't carry payloads. Use them for stepping through a graph during dev; use `interrupt()` for production human-in-the-loop. The same rules surface in LangGraph's docs; pleach inherits them by being LangGraph-shape-compatible at the envelope level. ```typescript import { InterruptManager, InterruptScratchpad, GraphInterrupt, } from "@pleach/core"; import type { HumanInterrupt, HumanResponse, PerToolApproval, } from "@pleach/core"; ``` ## Enabling interrupts [#enabling-interrupts] Pass `InterruptConfig` at runtime construction. The config declares *which* tool calls pause the turn — by name (`interruptBefore` / `interruptAfter`) or by a runtime predicate (`interruptOn`): ```typescript import { SessionRuntime } from "@pleach/core"; const runtime = new SessionRuntime({ storage: supabaseAdapter, userId: "user_123", interrupt: { // Pause BEFORE these tools dispatch, for approval: interruptBefore: ["delete_file", "send_email", "execute_sql"], // Pause AFTER these tools complete, to review the result: interruptAfter: ["run_migration"], // Or gate dynamically on the tool call itself: interruptOn: (toolCall) => toolCall.name.startsWith("prod_"), }, }); ``` | `InterruptConfig` field | Type | Purpose | | ----------------------- | --------------------------- | ------------------------------------------------- | | `interruptBefore` | `string[]?` | Tool names that require approval before execution | | `interruptAfter` | `string[]?` | Tool names that pause execution after completion | | `interruptOn` | `(toolCall) => boolean` `?` | Runtime predicate; pause when it returns `true` | ## The `HumanInterrupt` envelope [#the-humaninterrupt-envelope] What the runtime emits when it pauses. The shape mirrors LangGraph's `interrupt()` payload exactly. ```typescript interface HumanInterrupt { action_request: { action: string; // e.g. "approve_tool_call" args: Record; // tool args, context, justification }; config: { allow_ignore: boolean; // skip + continue allow_respond: boolean; // freeform response back to the LLM allow_edit: boolean; // user edits args before dispatch allow_accept: boolean; // approve as-is }; description?: string; // human-readable why } ``` `config` declares which response types are valid for this interrupt. A confirm-or-deny gate sets `allow_accept: true, allow_ignore: true` and the others false; a "review and edit" flow sets `allow_edit: true` instead. A UI that renders an Approve button against `allow_accept: false` is a bug — the manager will reject the submitted `HumanResponse` and the interrupt stays pending. Read the `config` block and disable buttons whose corresponding flag is `false`. ## Receiving an interrupt in the stream [#receiving-an-interrupt-in-the-stream] ```typescript for await (const event of runtime.executeMessage(sessionId, prompt)) { if (event.type === "interrupt.requested") { const decision = await showApprovalUI(event.interrupt); const receipt = runtime.interrupts.resolve(event.interrupt.id, decision); if (!receipt.handled) { // No matching pending interrupt (already resolved / expired / unknown id). } } } ``` The stream pauses on `interrupt.requested`. The next event after the consumer calls `runtime.interrupts.resolve` is `interrupt.resolved`, then the turn resumes from where it paused. `runtime.interrupts.resolve` is the canonical surface; the flat `runtime.resolveInterrupt` method remains callable but is `@deprecated`. See [Facets](/docs/facets) for the broader facet migration and `runtime.interrupts.manager` for direct access to the `InterruptManager`. ## Responding [#responding] `runtime.interrupts.resolve` (and the underlying `InterruptManager.resume` it delegates to) consume an `ApprovalDecision` — the runtime branches on `decision.approved`: ```typescript interface ApprovalDecision { approved: boolean; // approve or deny the paused call note?: string; // optional freeform note modifiedArguments?: Record; // edited args, applied on approve } ``` | Intent | `ApprovalDecision` | Effect | | ------------------ | ---------------------------------------------- | ---------------------------------------------- | | Approve as-is | `{ approved: true }` | Dispatch the action with its original args | | Approve with edits | `{ approved: true, modifiedArguments: {...} }` | Dispatch with the modified args | | Deny | `{ approved: false }` | Skip the action; the turn continues without it | An **approval-with-edit** is `approved: true` plus `modifiedArguments` — *not* a separate response type. A payload that omits `approved` (for example an `{ type: "edit", args }`-shaped object) leaves `decision.approved === undefined`, which the manager treats as a **rejection**. ```typescript const receipt = runtime.interrupts.resolve(interruptId, { approved: true, modifiedArguments: { ...originalArgs, dryRun: true }, }); // receipt.handled → true when the pending interrupt was found and resolved // receipt.resolvedDecision → echoes the decision payload on success // receipt.interruptId → always echoes the caller's argument ``` ### The resolve receipt [#the-resolve-receipt] `runtime.interrupts.resolve` returns an `InterruptResolveReceipt` rather than a bare boolean — the receipt echoes the decision that was filed so audit logs, optimistic UI confirmations, and plugin hooks can read the resolution without re-querying the manager. ```typescript interface InterruptResolveReceipt { handled: boolean; // strict parity with the legacy boolean resolvedDecision?: ApprovalDecision; // present when handled === true interruptId: string; // always echoes the argument } ``` The receipt is always a truthy object — callers previously branching on the bare boolean (`if (result) ...`, `Boolean(result)`) continue to behave correctly. Switch on `receipt.handled` for the original boolean semantics. The companion `InterruptCancelReceipt` (from `runtime.interrupts.manager?.cancel(id)`) and `InterruptDestroyReceipt` (from `manager.destroy()`) follow the same shape — see the `InterruptManager` section below. ## Per-tool approval [#per-tool-approval] The most common interrupt pattern. The runtime tracks every pending tool call as a `PerToolApproval` and gates dispatch on the user's decision. ```typescript interface PerToolApproval { toolCallId: string; toolName: string; args: Record; decision: "pending" | "approved" | "rejected" | "edited"; editedArgs?: Record; } ``` A typical UI iterates the pending approvals and exposes accept / edit / reject buttons per tool: ```tsx function ApprovalQueue({ interrupt }: { interrupt: HumanInterrupt }) { const approvals = interrupt.action_request.args.approvals as PerToolApproval[]; return approvals.map((a) => (

{a.toolName}

{JSON.stringify(a.args, null, 2)}
)); } ``` ## React: `useInterruptUI` [#react-useinterruptui] `@pleach/core/react` ships `useInterruptUI` — it subscribes to the runtime's `interrupt.{requested,resolved,timeout}` events, tracks the pending interrupts as React state, and routes each one to a caller-supplied handler component via `renderActive()`. Handlers are matched by `interruptType` (defaulting to the paused tool's name); the matched component receives the `PendingInterrupt` plus typed `onResolve` / `onCancel` callbacks. ```tsx import { useInterruptUI } from "@pleach/core/react"; import type { PendingInterrupt, SessionRuntime } from "@pleach/core"; // A handler renders one interrupt type. The plugin contract types // `interrupt` as `unknown` (core stays React- and domain-free), so // narrow it to `PendingInterrupt` to read the paused tool call. function ApprovalHandler({ interrupt, onResolve, onCancel, }: { interrupt: unknown; onResolve: (decision: unknown) => void; onCancel: () => void; }) { const { toolCall } = interrupt as PendingInterrupt; return (

Approve {toolCall.name}?

{JSON.stringify(toolCall.arguments, null, 2)}
{/* onResolve files an ApprovalDecision — approve as-is: */} {/* approve with edited args: */} {/* onCancel rejects — shorthand for `{ approved: false }`: */}
); } function ApprovalSurface({ runtime }: { runtime: SessionRuntime | null }) { const { renderActive } = useInterruptUI({ runtime, handlers: [{ interruptType: "approvalRequired", component: ApprovalHandler }], }); // Renders nothing until an interrupt arrives; accepts `runtime: null`. return <>{renderActive()}; } ``` `useInterruptUI` owns the event wiring and resolve plumbing — `onResolve` forwards an `ApprovalDecision` to `runtime.interrupts.resolve`, and `onCancel` rejects the interrupt (`{ approved: false }`). You just render the approval UI. ## Per-tool dispatch configuration [#per-tool-dispatch-configuration] When a batch of tool calls returns and some require approval, the runtime splits the batch so each tool is its own dispatch unit. The config that drives the split: ```typescript interface PerToolDispatchConfig { alwaysInterrupt: string[]; // names always paused neverInterrupt: string[]; // names always bypassed defaultBehavior: "interrupt" | "auto-approve"; } ``` A tool name in both lists resolves to `neverInterrupt`. Tools not named in either list follow `defaultBehavior`. A single-tool batch short-circuits the dispatcher and runs as-is. ## Programmatic envelope: `InterruptRequest` / `InterruptDecision` [#programmatic-envelope-interruptrequest--interruptdecision] The lower-level envelope the runtime publishes to the guest interrupt bus and that observer plugins consume. `HumanInterrupt` is the UI-shaped wrapper; `InterruptRequest` is the wire shape. ```typescript interface InterruptRequest { interruptId: string; toolCall: { id: string; name: string; parameters?: Record; arguments?: Record; }; riskLevel: "low" | "medium" | "high"; } interface InterruptDecision { approved: boolean; note?: string; // "always_allow" = session bypass modifiedArguments?: Record; // edit action's new args } ``` Both shapes carry an index signature — orchestrator-side variants with extra fields pass through unchanged. ## Guest interrupt bus [#guest-interrupt-bus] A chat-scoped queue + decision store for sessions that pause on a server (e.g. an edge function) and resolve on a browser. The package ships the bus interface and polling helpers; you wire the KV implementation. ```typescript import { setGuestInterruptStore, publishInterruptRequest, popInterruptRequest, recordInterruptDecision, waitForInterruptDecision, } from "@pleach/core/guestInterruptBus"; setGuestInterruptStore(myRedisBackedStore); // Server stage: await publishInterruptRequest(chatId, request); const decision = await waitForInterruptDecision(request.interruptId, { timeoutMs: 5 * 60_000, intervalMs: 750, }); // SSE endpoint: const queued = await popInterruptRequest(chatId); // Decision sink: await recordInterruptDecision(interruptId, decision); ``` The `GuestInterruptStore` contract is `get / setex / del / lpush / rpop / expire` — any Redis-shaped KV satisfies it. Keys TTL at 10 minutes. `waitForInterruptDecision` returns `null` on timeout or abort; the caller picks the fail-safe. The split between the two failure modes matters: `null` from a timeout means "the user never decided in the window" and the typical action is to fail the turn closed (deny the gated action); `null` from an abort means "the parent turn was cancelled by the user" and the action is to let the abort propagate without surfacing a denial in the UI. ## `InterruptManager` programmatic surface [#interruptmanager-programmatic-surface] The manager owns pending interrupts and the resolve-or-cancel plumbing. Public methods: | Method | Signature | Use | | -------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `shouldInterruptBefore(toolCall)` | `boolean` | Honors `interruptBefore` list + `interruptOn` predicate | | `shouldInterruptAfter(toolCall)` | `boolean` | Honors `interruptAfter` list | | `requestApproval(sessionId, toolCall, timeoutMs?)` | `Promise` | Pause + emit `interrupt.requested`; resolves on resume | | `resume(interruptId, decision)` | `InterruptResolveReceipt` | Submit a decision; `handled: false` when the id is unknown | | `cancel(interruptId)` | `InterruptCancelReceipt` | Reject the pending promise with `"Interrupt cancelled"`; receipt echoes `cancelled` + `toolCall` + `sessionId` + `state` | | `destroy()` | `InterruptDestroyReceipt` | Tear down the manager; receipt carries `cancelledCount` + `cancelledIds` | | `getPendingInterrupts()` | `PendingInterrupt[]` | Snapshot of every outstanding pause | | `getPendingInterrupt(id)` | `PendingInterrupt \| null` | Single-lookup variant | Each receipt is a typed audit record — the `interrupt.requested` → `interrupt.resolved` boundary is captured by `InterruptDecisionRecord` on the audit ledger as well. See [Typed records](/docs/typed-records) for the persisted shape. The manager also emits `interrupt.timeout` when `defaultTimeoutMs` fires — a separate event from `interrupt.resolved`, so observability can distinguish a user no-op from a denial. Walk-through: a per-tool approval for `execute_sql` raises an interrupt at `T+0`. The user is mid-meeting and the modal sits open for `defaultTimeoutMs` (say, 5 minutes). At `T+5min`, the manager fires `interrupt.timeout` with the same `interruptId`, the pending promise from `requestApproval` rejects, and the turn lands as `subagent.failed` (if inside a subagent) or `error` (root turn) with code carrying the timeout origin. Compare against the user actively clicking "Reject": that path fires `interrupt.resolved` with `decision.approved === false` and a real human response. The two shapes look similar in a dashboard — the timeout signal is what tells you to surface a different remediation ("approval expired, re-request" vs "approval denied, revise the plan"). ## Terminal writes to the event log [#terminal-writes-to-the-event-log] When the interrupt manager resolves an interrupt — approve, deny, or edit — it writes a terminal event log row carrying the resolution payload. The row closes the pending interrupt and records the decision verbatim, including any edited args. That terminal row is the deterministic boundary replay tools depend on. Replaying an interrupted turn from the event log re-establishes the same post-interrupt state without re-prompting the human. For the event log shape and the projections that fold these rows, see [Event log](/docs/event-log) and [Event log projections](/docs/event-log-projections). ## Where to go next [#where-to-go-next] --- # Landscape (/docs/landscape) Most of what an agent runtime can do, some other tool already does. This page says which is which — honestly, in both directions. We ran the field (Helicone, Langfuse, LangSmith, Portkey, LiteLLM, OpenMeter, MLflow, Logfire, Braintrust, Datadog, Arize Phoenix, LangGraph, Temporal, Inngest, CrewAI, the OpenTelemetry GenAI conventions) against twenty capabilities Pleach claims, and graded each one. Three tiers, ordered by how much of the capability is structural to `@pleach/core` versus available elsewhere: * **Structural** — the shape is the audit row, and we didn't find it shipping as a first-class concept in the tools above. Reach for Pleach *for these*. * **Assembled here** — the pieces exist across two or three separate products; Pleach is the one place they're one primitive. The wedge is the combination, not the invention. * **Table stakes** — commodity. Everyone ships it. Don't switch runtimes for these; if you already have one of the tools below, you already have the capability. A note on the verdicts: where a tier says "we didn't find it," that is absence of evidence across the public docs we read, not a proof that nothing exists. Each row names the mechanism, so you can check it against your own stack rather than take the grade on faith. ## Tier 1 — Structural to the audit row [#tier-1--structural-to-the-audit-row] The capabilities where the substrate's row shape is the differentiator. Each row: what's different here, and where the rest of the field lands. | # | Capability | Where the field lands | What's different in Pleach | | - | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | **Tamper-evident hash chain over the LLM log** | No LLM-observability or gateway tool we read ships a hash chain over the call log. The mechanism is proven elsewhere — AWS CloudTrail digest chaining, `immudb`, Sigstore Rekor — but none are LLM-call-shaped. | Every event-log row carries `prev_hash` + `row_hash`. A backfill or a removed row breaks the chain at the verifier pass (`verifyChainForChat`, `generateProof` in `@pleach/core/eventLog`). See [hash chain](/docs/hash-chain). | | 2 | **Deterministic replay of the agent path** | Three things get called "replay." Durable-execution platforms (Temporal, Inngest) journal the model output as an opaque step result. LangGraph's own docs say replay re-fires LLM calls and "may return different results." Eval tools re-generate and score. | A recorded turn replays byte-identical against the same version + fingerprint, or strict mode throws `ReplayDivergenceError` naming the first diverging event. See [determinism](/docs/determinism). | | 3 | **Family-lock as a replay-integrity guard** | No framework names "this replay is only valid against the same model family" as an enforced constraint. The adjacent idea — Temporal's non-determinism guard, eval-tool `seed` pinning — is about code version or sampling, not provider family. | At session start the lock freezes tokenizer, prompt-cache key, tool-call dialect, and refusal pattern; the cascade walks the same family and never silently widens. See [family-lock](/docs/family-lock). | | 4 | **Subagent cost rolled up to the parent turn** | Observability tools capture child-span tokens, then reconstruct cost by walking the trace tree at query time. The gap is documented in agent runtimes' own trackers, where a parent turn can under-report child spend. | Each subagent's `AuditableCall` rows carry the parent `turnId`, so child-session token spend rolls up as a stored field — per-customer billing is one `GROUP BY`, not a tree walk. See [subagents](/docs/subagents). | | 5 | **One typed row per provider call** | The market emits span *trees* into an analytical store, with sampling expected. MLflow is the closest neighbor — one row per trace — but it's per-trace, not per-decision, and not framed to join billing. | Every model invocation writes exactly one `AuditableCall` row, no sampling, keyed `(sessionId, turnId, stageId, seqWithinTurn)`. See [the AuditableCall row](/docs/auditable-call-row). | | 6 | **Event-granular session fork** | LangGraph forks at the super-step / node boundary — the finest grain in the field. Mastra suspends and resumes at a step id. Nobody forks mid-step at an arbitrary event. | `fork(opts)` splices the original event stream at `forkAtSequenceNumber`, an event-keyed row, not a node boundary. See [time-travel](/docs/time-travel). | | 7 | **Same primitives, two adoption paths** | The brownfield hook (wrap an existing loop) and the greenfield runtime both exist — but as different products with different value shapes. Inngest's `step.ai.wrap` adds durability; LangSmith adds tracing; neither writes the *same* audit primitive the full runtime does. | The hook over an existing loop and the full runtime write the same `AuditableCall` row, so the hook is an on-ramp into the runtime, not a separate tool. See [adoption paths](/docs/adoption-paths). | | 8 | **Trust posture as a stated contract** | Telemetry-on-by-default is the norm in agent frameworks. No framework we read packages the four promises into one named commitment. | No telemetry phone-home, no remote license check, no account to run, no retroactive paywall via the license clock — stated, and scoped to the runtime package. See [ownership](/docs/ownership). | | 9 | **Language-agnostic wire contract** | Multi-language tooling means either N parallel SDKs over an inter-agent protocol (Google ADK + A2A) or N clients of one central server (Temporal). Neither is two reference implementations holding one contract honest. | The contract pins HTTP+SSE shapes, fingerprint canonicalization, and row layout; a Go implementation round-trips the same recorded turns as the TypeScript reference. See [language-agnostic contract](/docs/language-agnostic-contract). | ## Tier 2 — Assembled here [#tier-2--assembled-here] Each of these exists in the field, split across separate products. Pleach's claim is that they're one primitive in the audit row, not that no one else has the pieces. | # | Capability | Where the pieces live | What the combination buys | | -- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | 10 | **Cost joinable to a billing schema in your own DB** | LiteLLM owns the spend row in your Postgres; OpenMeter does the billing join and Stripe sync. Neither carries agent-aware shape (turn, subagent depth, tool name). | One row you own *and* can join — agent-shaped — without a second metering pipeline. | | 11 | **Per-turn cost as a queryable field** | Per-*request* cost is a first-class inline field (OpenRouter `usage.cost`, Cloudflare, Vercel). A turn that fans out into many calls is a query-time `GROUP BY`, not a column. | `tokenUsage` rolls to `turnId` on the row, so the turn — not the request — is the indexed unit. | | 12 | **Ledger written to your destination** | "Ship OTLP spans to a trace backend" is commodity (OpenLLMetry, the OTel collector). Writing the audit *ledger* into your existing operational Postgres / Supabase — with Memory as a peer for tests — is not the shipped shape. | BYO-DB adapters write the row where the rest of your business data already lives. See [storage](/docs/storage). | | 13 | **SQL-queryable ownership** | Logfire gives you full SQL — in their cloud. MLflow gives you SQL on your own Postgres. Langfuse / LangSmith / Braintrust self-host means your infra, but a proprietary store, not joinable rows. | The row is in your database, queryable with ordinary SQL, joinable to the tables next to it. See [the audit ledger](/docs/audit-ledger). | | 14 | **Erasure that coexists with the chain** | "Delete rows by user id" is common. Erasure that preserves a tamper-evident chain — crypto-shred the subject, keep the hash — is unsolved in the products we read; the tension is real. | `GDPRSoftDelete` is a plug-point designed to erase the subject without breaking the chain verifier. See [GDPR erasure](/docs/compliance/gdpr-erasure). | | 15 | **Sessions, graph, and channels together** | Sessions are universal. A graph runtime is common. The three as co-equal, surfaced primitives is essentially only LangGraph today, and there "channels" is an internal compilation detail. | All three are documented developer primitives on one substrate. See [graph](/docs/graph) and [channels](/docs/channels). | ## Tier 3 — Table stakes [#tier-3--table-stakes] Commodity. If you already run one of the tools named, you already have the capability — these are not a reason to change runtimes. Pleach ships them because the substrate needs them, not because they're differentiated. | # | Capability | Already shipped by | | -- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | 16 | **Per-tenant cost allocation** | Helicone (`Helicone-User-Id`), Portkey metadata, LiteLLM (`x-litellm-customer-id`), Vercel AI Gateway, Cloudflare, Datadog, OpenMeter. A metadata tag and a `GROUP BY`. | | 17 | **Tool-call audit capture** | The OpenTelemetry GenAI conventions and OpenLLMetry made tool-call spans table stakes. Every tracing tool captures them. | | 18 | **Checkpointing / resumable state** | LangGraph is the reference; Temporal / Inngest / Restate do it as durable execution; Mastra snapshots on suspend. | | 19 | **Regression diff engine** | Braintrust, LangSmith, Langfuse, Promptfoo, DeepEval all diff scores across runs. (Diffing two *deterministic* runs event-for-event is Tier 1, row 2 — different claim.) | | 20 | **PII redaction at the boundary** | Microsoft Presidio is the detection engine; Langfuse, LangSmith, LiteLLM, Portkey are hooks that mostly delegate to it. `@pleach/compliance` `Scrubber`s do the same and can delegate too. | ## The short version [#the-short-version] If you want four sentences to carry to a buyer: 1. Per-tenant cost is table stakes — don't sell it as new. 2. The differentiated shape is the **row**: one typed, hash-chained `AuditableCall` per call, in your own database, with subagent spend already rolled to the turn. 3. **Deterministic replay** plus **family-lock** is the pair the rest of the field admits it doesn't ship — replay you can trust because the lock keeps it honest. 4. Everything else here is real, but it's a combination of parts you could assemble yourself — Pleach is the assembly, not the only source of the parts. --- # @pleach/langchain (preview) (/docs/langchain) A vine bridging your trellis to the LangChain one. `@pleach/langchain` wires LangChain primitives into a `@pleach/core` session. LangChain `Tool` instances become Pleach tool definitions, `BaseMessage` arrays become event log rows, and an `AgentExecutor` can run inside a Pleach session with its calls flowing through the audit ledger. > **Pre-1.0 — pin a tight range.** The package is published below > `1.0.0` and the surface will move before it cuts. Treat every > minor as potentially breaking and pin via `~0.x.y` (patch only). > The warning is in the package itself; it is repeated here because > the migration cost of an unintended major-jump on this adapter is > the highest of any sibling. ## This page vs the migration guide [#this-page-vs-the-migration-guide] This page is the reference for the **active adapter** — for hosts keeping a LangChain tool, agent, or history backend in production and running it alongside Pleach primitives. The adapter is the bridge. If you're moving wholesale off LangChain, the right page is [Migrating from LangChain](/docs/migrating-from-langchain). That guide is one-time work: rewrite chains as runtime calls, retire the LangChain dependency, end with no adapter in the tree. This page is the opposite shape — long-lived integration, both libraries in `package.json`, deliberate boundary between them. ## Install [#install] ```bash npm install @pleach/langchain # peer deps: npm install langchain @langchain/core ``` The `langchain` and `@langchain/core` ranges are declared as peer dependencies so the adapter doesn't pin a LangChain version on top of the one the host already uses. Mismatches surface as peer-dep warnings at install time — don't ignore them; the converters key off LangChain type shapes that vary across majors. ```typescript import { fromLangChainTool, fromLangChainTools, toLangChainTool, LangChainProvider, LangGraphCheckpointerAdapter, LangGraphStoreAdapter, HarnessCheckpointSaver, HarnessEventLogCallback, } from "@pleach/langchain"; ``` The adapter exposes converters in both directions — LangChain tools become Pleach `ToolDefinition`s and vice versa — plus a `LangChainProvider` that exposes a LangChain chat model as a Pleach provider, plus LangGraph-shaped checkpointer/store adapters in each direction. The package README on npm carries the version-current constructor options. ## Bridging a LangChain tool into a Pleach session [#bridging-a-langchain-tool-into-a-pleach-session] A LangChain `Tool` (or `StructuredTool`) instance wraps a callable plus a schema. The adapter converts it into a `ToolDefinition` usable on the Pleach side — registrable through `setOrchestratorRegistry` or a plugin's `contributeTools`. ```typescript import { fromLangChainTool } from "@pleach/langchain"; import { definePleachPlugin } from "@pleach/core"; import { DynamicStructuredTool } from "@langchain/core/tools"; const lcSearch = new DynamicStructuredTool({ name: "search_db", description: "Search the product catalog.", schema: searchSchema, func: async (input) => productDb.query(input), }); const pleachSearch = fromLangChainTool(lcSearch); // Register through a plugin's `contributeTools` — the ergonomic path. // Pass the plugin to `SessionRuntime({ plugins: [...] })`. const langchainTools = definePleachPlugin("langchain-tools", { tools: [pleachSearch], }); ``` For bulk conversion use `fromLangChainTools(tools)` — same converter, applied across an array. Conversion notes documented in the package README cover the LangChain features without a Pleach equivalent — callbacks scoped to the tool, the `returnDirect` flag, the `tags`/`metadata` pass- through. Anything the adapter can't carry across becomes a no-op on the Pleach side; the converter doesn't silently change semantics. The reverse — Pleach `ToolDefinition` → LangChain `Tool` — ships as `toLangChainTool(def)`. Use it when a LangChain-driven agent needs to call a Pleach-native tool: ```typescript import { toLangChainTool } from "@pleach/langchain"; const lcTool = toLangChainTool(myPleachToolDef); // pass `lcTool` into a LangChain AgentExecutor's tools array. ``` ## Calling a LangChain chat model as a Pleach provider [#calling-a-langchain-chat-model-as-a-pleach-provider] `LangChainProvider` wraps any LangChain `BaseChatModel` and exposes it through the Pleach `AgentAdapter` provider contract. Use this when the host already owns a configured LangChain model (custom provider, LCEL chain composed upstream of the chat call, callback wiring) and wants the Pleach runtime to drive the agent loop against that model. ```typescript import { LangChainProvider } from "@pleach/langchain"; import { ChatOpenAI } from "@langchain/openai"; import { createPleachRuntime } from "@pleach/core/runtime"; const lcModel = new ChatOpenAI({ model: "gpt-4o-mini" }); // The constructor is positional: `new LangChainProvider(model, maxSteps=10)`. const provider = new LangChainProvider(lcModel); const runtime = createPleachRuntime({ // `provider` is a top-level config slot on createPleachRuntime. provider, }); ``` Tool calls round-trip via the `fromLangChainTool` / `toLangChainTool` converters, so LangChain-native streaming `tool_call_chunks` surface to Pleach as the same final `tool_calls` shape the runtime expects. ## LangGraph-shaped storage adapters [#langgraph-shaped-storage-adapters] Two adapter pairs let LangGraph storage co-exist with Pleach's checkpointer + store contracts: * **`LangGraphCheckpointerAdapter`** — wraps a LangGraph `BaseCheckpointSaver` so Pleach's `SessionRuntime` uses LangGraph storage as its checkpointer backend. * **`LangGraphStoreAdapter`** — wraps a LangGraph `BaseStore` so Pleach's memory contract reads/writes against LangGraph's store. * **`HarnessCheckpointSaver`** — the reverse: a LangGraph-shaped `BaseCheckpointSaver` backed by a Pleach checkpointer. * **`HarnessEventLogCallback`** — a LangGraph `BaseCallbackHandler` that writes LangGraph callback events into the Pleach event log. ```typescript import { LangGraphCheckpointerAdapter, LangGraphStoreAdapter, } from "@pleach/langchain"; import { MemorySaver, InMemoryStore } from "@langchain/langgraph"; const checkpointer = new LangGraphCheckpointerAdapter(new MemorySaver()); const store = new LangGraphStoreAdapter(new InMemoryStore()); const runtime = createPleachRuntime({ checkpointer, store, /* ... */ }); ``` The reverse direction (Pleach storage exposed as LangGraph storage) is useful for a host running a LangGraph agent that wants the Pleach event log as its callback sink and the Pleach checkpointer as its state store — without rewriting the agent body. ## Not yet shipping [#not-yet-shipping] The 0.x surface is intentionally narrow. Two converters described in early scoping notes are NOT in the published dist: * **`fromLangChainMessages` / `BaseMessage[]` → event-log rows.** No bulk message-history importer ships. Hosts cutting over from a LangChain `ChatMessageHistory` backend write the row-shape mapping themselves against `runtime.eventLog.append(...)` (see the [Event log](/docs/event-log) reference). On the roadmap; not present. * **`wrapLangChainExecutor` / `AgentExecutor` wrap.** No executor wrapper ships. The migration story uses `LangChainProvider` plus `fromLangChainTools` to run the Pleach loop against the LangChain model + tools, rather than wrapping LangChain's agent loop. If you need to keep an `AgentExecutor` running, instrument it with `HarnessEventLogCallback` so its callbacks land in the Pleach event log alongside the Pleach loop's rows. ## Back-compat boundary [#back-compat-boundary] The package sits below `1.0.0` because the converter signatures are still settling. Pin patch-only: ```json { "dependencies": { "@pleach/langchain": "~0.3.2" } } ``` Kinds of changes consumers should expect across minors before 1.0: * **Export renames.** Function names may shift as the surface consolidates pre-1.0; pin patch-only against the npm README. * **Converter signature tweaks.** Optional-argument shapes for the three converters are still narrowing — expect new required options as edge cases get formalized. * **Peer-dep range tightening.** Supported `langchain` and `@langchain/core` ranges may narrow as LangChain itself moves through majors. * **Roadmap for `AgentExecutor` wrap + message-history import.** Neither converter is in the 0.x dist today (see "Not yet shipping" above). If they land, the API will be additive — none of the converters currently shipping change shape. Read the package CHANGELOG before each upgrade. The release notes flag any breaking change in the minor that introduces it. ## Not in scope [#not-in-scope] * **LangSmith parity.** Distributed-tracing parity with LangSmith is out of scope for this adapter. For tracing, use OTel via [Observability](/docs/observability) — the audit ledger plus an OTel exporter covers the same trace store role. * **LangGraph node-level bridging.** The adapter operates at the tool / message / executor boundary, not at the LangGraph state- graph node level. A LangGraph migration is the [migration guide's](/docs/migrating-from-langchain) territory. * **Vector store / retriever wrapping.** LangChain's retrieval surface stays inside LangChain; expose it to Pleach as a `defineTool` call, the same way the migration guide describes. ## Where to go next [#where-to-go-next] --- # Language-agnostic contract (/docs/language-agnostic-contract) `@pleach/core` is a TypeScript reference implementation of a contract that doesn't depend on TypeScript. The runtime substrate's load-bearing primitives are wire shapes: SSE event frames, checkpoint envelopes, audit ledger rows, sync version vectors, HTTP routes. An independent client in another language that implements those shapes correctly is a conforming runtime. A Go implementation has been built against the same contract and round-trips a shared corpus of recorded turns — that's the test that catches anything TypeScript-flavored leaking into the wire. Both implementations write `AuditableCall` rows into the same `harness_auditable_calls` table shape and consume the same `Checkpoint` envelope JSON, and a session started under one and resumed under the other hydrates to a byte-identical `SessionState`. The Go implementation isn't published as a SKU yet — an official `@pleach` Go runtime is the next planned published implementation. This page documents which shapes are contract — and which are implementation details a non-TypeScript consumer can ignore. ## What's in the contract [#whats-in-the-contract] Five shapes are load-bearing: | Shape | Where it lives | Why it's contract | | ------------------------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------- | | **HTTP + SSE wire** | `/api/harness/*` routes | A native client speaks these to interop with a server-hosted runtime | | **`StreamEvent` discriminated union** | One SSE `data:` frame each | A non-TS client deserializes these into its own types | | **`AuditableCall` row** | `harness_auditable_calls` table | Audit consumers (eval, SOC2 evidence, billing) query this shape | | **Checkpoint envelope** | `harness_checkpoints` rows + the in-memory shape | A native runtime that round-trips through this shape can interop with the TS runtime's checkpoints | | **Version vector** | `session_state.versionVector` JSON | Sync conflict detection works across clients with matching vector math | Each shape is documented in its own page on this site; this page walks the *contract status* of each. ## HTTP + SSE wire [#http--sse-wire] The 8 routes documented in [API routes](/docs/api-routes) are the canonical wire protocol. Path prefix is convention; relative shapes are contract: | Route shape | Contract status | | ----------------------------------------------- | --------------------- | | `GET /sessions` query params, response shape | Contract | | `POST /sessions` body, response | Contract | | `GET/PUT/DELETE /sessions/[id]` | Contract | | `POST /sessions/[id]/sync` body + response | Contract | | `POST /sessions/[id]/execute` body + SSE stream | Contract | | `GET /health` response shape | Contract | | The literal path prefix `/api/harness/` | Convention | | Authorization header parsing | Implementation detail | An independent client mounts the same shapes under a different path and conforms. ## `StreamEvent` discriminated union [#streamevent-discriminated-union] The full event union documented in [Stream events](/docs/stream-events) is contract. Specifically: * The `type` field's literal values (`message.delta`, `tool.completed`, `sync.conflict`, etc.). * The payload shape for each `type` value. * The `namespace?: string[]` field that tags subagent-emitted events. Implementation details (not contract): * TypeScript-specific representations (which `as const` literals, which `&` intersections). A Go client implements the same union with its own type system. * Internal field ordering inside the JSON. Canonicalized ordering matters for the fingerprint, not for the wire. Adding a new `type` is non-breaking — clients with a `default`-arm switch keep working. Removing a `type` is a breaking change. A concrete example: adding `subagent.spawned` in a minor cycle is fine because a Go consumer that switches on the existing union falls through to its default arm and the SSE stream keeps decoding; removing `tool.completed` is a major because every consumer that relied on the lifecycle pair (`tool.started` / `tool.completed`) suddenly has half a lifecycle. ## `AuditableCall` row [#auditablecall-row] The row shape documented in [AuditableCall row](/docs/auditable-call-row) is the contract for audit consumers. Both the SQL column shape and the in-memory JS shape: | Column / field | Contract | | ------------------------------------------------------ | -------------------------------------------------------- | | `record_id` (ULID, Crockford Base-32, 26 chars) | Yes | | `audit_record_version` | Yes; bumps coordinate per [Versioning](/docs/versioning) | | `session_id`, `turn_id`, `stage_id`, `seq_within_turn` | Yes; idempotency key | | `created_at`, `actor_kind`, `session_auth_method` | Yes | | `call_class`, `provider`, `model`, `transport` | Yes | | `status`, `latency_ms`, `finish_reason`, `http_status` | Yes | | `payload` JSONB shape (typed sub-objects) | Yes — keyed on `payload.kind` | | Underlying storage choice (Supabase, IndexedDB, S3) | Implementation detail | A Go consumer that reads `harness_auditable_calls` via direct SQL gets the same data the TS `ProviderDecisionLedger` adapter returns. Both are conforming consumers. ### The append-only invariant is contract [#the-append-only-invariant-is-contract] `ProviderDecisionLedger.recordCall` has no update or delete primitive. Adapters in any language MUST be append-only — an adapter that exposes mutation is non-conforming and a wire-format break. Bumps to `audit_record_version` are the only sanctioned way to change the row shape; that's why [`AUDIT_RECORD_VERSION_HISTORY`](/docs/audit-ledger) is part of the public API. ## Checkpoint envelope [#checkpoint-envelope] A checkpoint is a typed envelope carrying a session state plus per-channel snapshots. The envelope shape is contract; the storage adapter (`MemorySaver`, `SupabaseSaver`, custom) is not. ```typescript interface Checkpoint { id: string; // ULID sessionId: string; stageId: "anchor-plan" | "tool-loop" | "synthesize" | "post-turn"; createdAt: string; // ISO 8601 schemaVersion: number; channels: Record; parentCheckpointId?: string; } interface ChannelSnapshot { kind: "last_value" | "binary_op_aggregate" | "topic" | "ephemeral_value" | "named_barrier" | "data_channel"; version: number; value: unknown; // shape depends on `kind` } ``` A non-TS runtime that consumes a checkpoint table populated by the TS runtime, restores its own state to match the snapshot shape, and continues a turn is a conforming consumer. Specifically not contract: * Which channel kinds you implement. A subset is fine if your runtime doesn't expose the un-implemented kinds. * The wire representation of channel values when the kind's shape is opaque (e.g. `DataChannel`'s internal LRU layout). Round-trip through `value` is what matters. ## Version vector [#version-vector] The version vector format is `Record` — a JSON object keyed by client id with monotonic-incrementing integer values. The comparison semantics documented in [Sync](/docs/sync) are contract: | Operation | Behavior | | ---------------------- | --------------------------------------------------------- | | `mergeVectors(a, b)` | Element-wise max | | `compareVectors(a, b)` | One of `equal` / `ancestor` / `descendant` / `concurrent` | | `hasSeen(v, change)` | `v[change.clientId] >= change.version` | | Increment | A client increments its own entry on every write | Two clients with conforming vector math detect concurrent writes correctly regardless of language. A client that uses different semantics (last-write-wins, lamport timestamps, etc.) is not conforming. ## What's NOT in the contract [#whats-not-in-the-contract] | Surface | Why not contract | | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | `SessionRuntimeConfig` field set | TS-specific construction; equivalent fields exist in Go but the constructor shape isn't shared | | `defineTool` / `HarnessPlugin` interfaces | TS-side authoring contracts; Go has its own equivalents | | Channel internals (LRU eviction policy, reducer implementations) | Per-runtime; only the snapshot round-trip is contract | | Specific transport types in providers (AI SDK / Anthropic SDK wrappers) | TS-side conveniences | | React hooks | Browser-specific; not part of the runtime contract | | `setHarnessModuleLoader` | TS-side migration scaffold; Go runtimes don't have this concept | These surfaces exist because they're how TypeScript consumers work. A Go consumer never sees them — it interacts with the runtime through the wire shapes. ## How the contract stays honest [#how-the-contract-stays-honest] Four load-bearing mechanisms keep the TS reference and the Go implementation from drifting: 1. **Shared test fixtures.** A corpus of recorded turns (`AuditableCall` rows, checkpoint envelopes, event sequences) that any conforming runtime round-trips identically. The fixtures are framework-agnostic JSON; the TS runtime authors them. 2. **Cross-runtime replay.** A turn recorded against the TS runtime replays byte-identical against the Go implementation when both are pointed at the same provider, same model id, same fingerprint inputs. 3. **Schema gate.** The schema bundle is the canonical source for the wire-table shapes. Any runtime that drifts from the bundle is wrong by definition. 4. **Domain-string purity gate.** `audit:domain-string-purity` scans `packages/core/src/**` for \~50 forbidden literal patterns across five families (host vocabulary, vendor backend names, sandbox tool prefixes, identity discriminators, domain phrasing). A leak fails CI. The TS reference can't accidentally embed a consumer's domain vocabulary that the Go runtime would then have to mirror — the substrate stays consumer-agnostic by structure, not by review discipline. Plugins remain the only legal channel for consumer-specific content. The Go implementation makes the "wire is language-agnostic" claim falsifiable rather than aspirational. The acceptance test: a recorded turn against the TS runtime produces an `AuditableCall` row sequence ordered by `(turn_id, seq_within_turn)`; replaying that turn against the Go implementation with the same fingerprint inputs produces the same row sequence under the same key, or the schema-gate CI flags the runtime that diverged. The Go implementation isn't published as a SKU yet — an official `@pleach` Go runtime is the next planned published implementation. ## Implementing a conforming client [#implementing-a-conforming-client] Minimum viable conformance for an independent client: 1. **Speak the HTTP + SSE wire** for the routes you implement. You can implement a subset — e.g. just `execute` and `health` — and conform partially. 2. **Round-trip the `StreamEvent` union** for the variants you produce or consume. Unknown variants in your input should be ignored, not error. 3. **Append-only writes to `harness_auditable_calls`** with the row shape above. 4. **Version-vector semantics** matching the operations above if you implement sync. 5. **Checkpoint envelope round-trip** for the channel kinds you support, if you implement checkpointing. A client that does only (1) and (2) is a streaming-only implementation — like an SSE-consuming dashboard. A client that does all five is a full runtime peer. ## Where to go next [#where-to-go-next] --- # Lineage (/docs/lineage) Lineage is one surface in the **observability** [thematic island](/docs/concept-clusters#what-lives-outside-the-cluster-pattern) — siblings of [observability](/docs/observability) (the orientation page), [OTel spans](/docs/otel-observability), and [runtime inspector](/docs/runtime-inspector). It's read-side, after the turn. For the write-side audit ledger (the per-call row that joins to lineage nodes on `sessionId`), see [Audit ledger](/docs/audit-ledger). A session rarely exists in isolation. A user continues a previous conversation. A time-travel branch forks from a checkpoint. A subagent's output flows into a parent message. A long-running batch job's result imports into a fresh thread. `@pleach/core/lineage` records those relationships explicitly: typed edges between sessions, plus optional artifact provenance, queryable as a graph. ```typescript import { LineageTracker, HARNESS_NATIVE_LINEAGE_ARTIFACT_TYPES, } from "@pleach/core"; import type { SessionNode, LineageEdge, LineageRelation, LineageArtifact, SessionMetrics, HarnessNativeLineageArtifactType, } from "@pleach/core"; ``` ## `LineageTracker` [#lineagetracker] Producer + consumer in one surface. Lineage is **opt-in**: pass a `LineageTracker` as `config.lineageTracker` and the runtime records the session node at creation and a `branched_from` edge when a fork materializes a new session. The remaining relations (`continued_from`, `used_result_of`, `delegated_from`, `planned_by`) are recorded by the host at their trigger points — the tracker is a plain `BaseStore`-backed surface you call directly. Without an injected tracker, nothing is recorded. Consumers then walk the graph for "every session that depends on this one," "where did this artifact come from," "what forked off this turn." ```typescript const tracker = new LineageTracker(store, orgId, userId); await tracker.registerSession({ sessionId, chatId, intent: "research", createdAt: new Date().toISOString(), }); await tracker.addEdge({ fromSessionId: parentSessionId, toSessionId: childSessionId, relation: "branched_from", artifact: { type: "checkpoint", sourceId: checkpointId, label: "Forked from checkpoint" }, createdAt: new Date().toISOString(), }); ``` The tracker is constructed with a `BaseStore`, an `orgId`, and a `userId`. Edges and nodes persist under the tenant-scoped key path the store enforces. ## Edge relations [#edge-relations] `LineageRelation` is a closed union. Each value carries semantic weight the runtime and consumers can rely on. `branched_from` is recorded by the runtime on the fork path; the other four are recorded by the host at the trigger point shown. | Relation | Recorded by | Trigger | Consumer use | | ---------------- | ----------- | ------------------------------------------------------------- | -------------------------------------------------------------- | | `continued_from` | host | User explicitly continues prior work | "Show me the conversation history including the previous chat" | | `branched_from` | **runtime** | Fork / time-travel branch materializes a new session | "What experiments forked off the same starting point" | | `used_result_of` | host | Session B consumed a canvas card or job result from session A | Job → chat provenance | | `delegated_from` | host | Subagent spawned across a session boundary | Subagent provenance UI | | `planned_by` | host | Session was created to fulfill a plan step | Plan → execution provenance | ## `SessionNode` [#sessionnode] The graph's vertex shape, persisted at session creation via `registerSession()`. ```typescript interface SessionNode { sessionId: string; chatId: string; intent?: string; // primary intent detected at session start agentSpec?: string; // agent spec used (if subagent session) summary?: string; // first ~200 chars of the opening message createdAt: string; // ISO-8601 completedAt?: string; // ISO-8601, set by completeSession() metrics?: SessionMetrics; } ``` Call `completeSession(sessionId, metrics)` at end-of-session to stamp `completedAt` and attach the aggregate metrics. This is a host call — the runtime does not invoke `completeSession` on your behalf, so `completedAt`/`metrics` stay unset until you record them. ## Querying the graph [#querying-the-graph] ```typescript // All ancestors of a session (BFS, default depth 10): const ancestors = await tracker.getAncestors(sessionId); // All descendants: const descendants = await tracker.getDescendants(sessionId); // Self + ancestors + descendants + the edges between them: const { nodes, edges } = await tracker.getChain(sessionId); // Sessions that consumed a specific artifact: const consumers = await tracker.findByArtifact("canvas_card", cardId); ``` `getAncestors` and `getDescendants` walk the graph breadth-first with a `maxDepth` parameter (default 10). `getChain` returns the full neighborhood plus the edges that connect it. `findByArtifact` filters edges where `artifact.type` and `artifact.sourceId` match — the read path for "show every session that used this card." ## Artifact provenance [#artifact-provenance] Artifacts ride on the edge — `LineageArtifact` names what was carried forward when one session feeds another. ```typescript interface LineageArtifact { type: string; // see HARNESS_NATIVE_LINEAGE_ARTIFACT_TYPES sourceId: string; // identifier in the source session label: string; // human-readable label } ``` The native artifact types ship as a `readonly` constant: ```typescript import { HARNESS_NATIVE_LINEAGE_ARTIFACT_TYPES } from "@pleach/core"; HARNESS_NATIVE_LINEAGE_ARTIFACT_TYPES; // → ["canvas_card", "job_result", "plan_step", "file", "checkpoint"] ``` The `type` field is widened to `string` so host plugins can register domain-specific values. Consumers MUST NOT switch exhaustively on `type` — the union is open by design. ## `SessionMetrics` [#sessionmetrics] Per-session aggregates set by `completeSession()`. The shape is fixed: ```typescript interface SessionMetrics { toolCalls: number; tokenUsage: number; duration: number; // ms subagentsSpawned: number; } ``` `toolCalls`, `subagentsSpawned`, and `tokenUsage` are the variable surface — they're what the lineage node carries that no structural invariant can derive. ## Persistence [#persistence] The tracker writes nodes and edges through `BaseStore` keyed on `[orgId, userId, "lineage", "nodes" | "edges"]`. Edges key on `"${fromSessionId}→${toSessionId}"` — re-writing the same edge overwrites in place; the tracker doesn't validate idempotency beyond key equality. ## Use cases [#use-cases] | Scenario | What lineage gives you | | ------------------------------------------------ | ------------------------------------------------------------------------------------- | | User asks "what did we talk about last time?" | `continued_from` parents — `getAncestors` walks the chain | | Stuck-session debugging fork | `branched_from` from a known-good checkpoint — `getDescendants` lists the experiments | | Subagent transparency | `delegated_from` — show which parent spawned this session | | Plan → execution provenance | `planned_by` — every session created to fulfill a plan step | | Compliance audit "where did this card come from" | `findByArtifact("canvas_card", cardId)` — list every session that consumed it | ## Where to go next [#where-to-go-next] --- # MCP integration (/docs/mcp-integration) This page is the consumer-side pattern for wrapping MCP server calls as `@pleach/core` tools — the integration you write yourself today, using `defineTool` over `@modelcontextprotocol/sdk`. For the `@pleach/mcp` package itself — the SKU that projects a runtime's tool registry onto the MCP wire — see [`@pleach/mcp`](/docs/mcp). `@pleach/core` integrates with the Model Context Protocol in both directions: exposing its own tool registry as an MCP server, and consuming tools from external MCP servers through a `defineTool` wrapper. The dedicated `@pleach/mcp` SKU collapses the server-side projection into a typed package; the consumer-side wrapper documented here remains the supported path for pulling external MCP tools into a runtime. The Model Context Protocol is a standard for exposing tools, resources, and prompts to AI agents. MCP servers expose capabilities (filesystem access, GitHub queries, database introspection); MCP clients connect and call them. The two integration directions are independent: | Direction | What ships in `@pleach/core` today | | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | Pleach **exposes** its tools to an external MCP client (Claude Code, an IDE) | `@pleach/core` root barrel — `createHarnessMCPServer`, `createMCPSDKServer` | | Pleach **consumes** tools from an external MCP server | Hand-rolled `defineTool` wrapper over `@modelcontextprotocol/sdk`. The dedicated `@pleach/mcp` SKU is reserved on npm and will collapse the boilerplate. | This page documents both directions. ```typescript import { defineTool } from "@pleach/core"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; ``` ## Pleach as MCP server (`@pleach/core`) [#pleach-as-mcp-server-pleachcore] `createHarnessMCPServer` builds a typed `MCPServer` from a `ToolRegistryWrapper`. The in-process projection — calling `listTools` / `callTool` on the returned object directly — is the stable, recommended path and is what the substrate's own integration tests exercise. Wiring it onto a live stdio transport so external MCP clients can call your registered tools is the early-stage (beta) arm; lock against the in-process shape first and adopt the stdio wiring as it matures. The MCP server symbols ship from the `@pleach/core` root barrel — there is no `@pleach/core/mcp` subpath: ```typescript import { createHarnessMCPServer, createMCPSDKServer, ToolRegistryWrapper, } from "@pleach/core"; const server = createHarnessMCPServer({ registry: new ToolRegistryWrapper(), name: "my-pleach-host", version: "1.0.0", toolFilter: (tool) => tool.category !== "internal", toolExecutor: async (name, args) => myExecutor(name, args), maxTools: 100, }); // Or wire through the official SDK: const sdkServer = await createMCPSDKServer({ registry, toolExecutor }); ``` `HarnessMCPServerOptions` carries the registry, an optional filter, the executor (`(name, args) => Promise`), and a `maxTools` cap. Without `toolExecutor`, `callTool` reports "No tool executor configured" — wire it explicitly. The returned `MCPServer` exposes: ```typescript interface MCPServer { name: string; version: string; capabilities: { tools: boolean; resources: boolean }; listTools: () => Promise; callTool: (name: string, args: Record) => Promise; listResources: () => Promise; readResource: (uri: string) => Promise<{ contents: string; mimeType: string }>; } ``` `generateToolsMarkdown(registry)` and `generateToolsManifest(registry)` ship as helpers for static exports of the catalog. ## Pleach as MCP client [#pleach-as-mcp-client] An MCP server exposes three resource kinds: | Kind | What it carries | Pleach equivalent | | --------- | ---------------------------------------------- | ------------------- | | Tools | Named functions with JSON-schema args + return | `defineTool` | | Resources | Read-only data (files, URIs) | Tool that fetches | | Prompts | Reusable prompt templates | Prompt contribution | The substrate's primitives map cleanly. The integration is one translation layer. ## Pattern: wrap an MCP server as `defineTool` calls [#pattern-wrap-an-mcp-server-as-definetool-calls] A function that introspects an MCP server's tool list and generates a `ToolDefinition` for each: ```typescript // lib/mcp/mcpTools.ts import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { defineTool, type ToolDefinition } from "@pleach/core"; import { z } from "zod"; export async function mcpTools(opts: { serverCommand: string; serverArgs?: string[]; toolPrefix?: string; }): Promise { const transport = new StdioClientTransport({ command: opts.serverCommand, args: opts.serverArgs ?? [], }); const client = new Client( { name: "@pleach/core-consumer", version: "1.0.0" }, { capabilities: {} }, ); await client.connect(transport); const { tools } = await client.listTools(); return tools.map((mcpTool) => defineTool({ name: `${opts.toolPrefix ?? "mcp_"}${mcpTool.name}`, description: mcpTool.description ?? "(MCP-sourced tool)", // Convert JSON Schema → Zod. Use a small adapter or the // `zod-from-json-schema` library; pass-through for unknown // shapes (the MCP server validates server-side anyway). inputSchema: zodFromJsonSchema(mcpTool.inputSchema) as z.ZodType, async execute(input, ctx) { const result = await client.callTool({ name: mcpTool.name, arguments: input as Record, }); if (result.isError) { throw new Error(`MCP tool ${mcpTool.name} failed: ${JSON.stringify(result.content)}`); } return result.content; }, }), ); } ``` Register the proxied tools with the runtime: ```typescript const mcpToolDefs = await mcpTools({ serverCommand: "uvx", serverArgs: ["mcp-server-filesystem", "--root", "/data"], toolPrefix: "fs_", }); setOrchestratorRegistry({ tools: mcpToolDefs }); const runtime = new SessionRuntime({ /* ... */ }); const session = await runtime.createSession({ tools: { enabled: mcpToolDefs.map((t) => t.name) }, }); ``` The MCP server runs as a subprocess; the client talks to it over stdio. The runtime sees ordinary `defineTool`-shaped tools. ### What the wrapper handles [#what-the-wrapper-handles] * **Schema translation.** MCP servers publish JSON Schema; the runtime expects Zod. A small adapter library handles the common cases; complex schemas can fall back to `z.unknown()` with server-side validation. * **Transport.** Stdio is the simplest; HTTP / SSE transports are also supported by the MCP SDK. * **Error propagation.** MCP errors become tool failures — surfacing as `tool.failed` stream events with the structured error. * **Lifecycle.** The client connection is per-runtime. Close it in your cleanup path; the substrate doesn't manage subprocess lifecycles. ### What the wrapper doesn't handle [#what-the-wrapper-doesnt-handle] * **Connection pooling.** Multiple runtimes sharing one MCP server need a shared client. Wrap the client construction in a singleton. * **Resource subscriptions.** The MCP `subscribe`/`unsubscribe` surface for resource updates doesn't map cleanly to `defineTool`. Use the lower-level client directly for those. * **Server-pushed prompts.** MCP server-provided prompts could be threaded as static prompt contributions; the wrapper above doesn't do that automatically. ## Pattern: MCP as a prompt source [#pattern-mcp-as-a-prompt-source] For MCP servers that publish prompts, register them as `PromptContribution` entries: ```typescript import type { HarnessPlugin } from "@pleach/core"; async function mcpPromptPlugin(client: Client): Promise { const { prompts } = await client.listPrompts(); return { name: "mcp-prompts", contributePrompts: () => prompts.map((mcpPrompt) => ({ id: `mcp-prompts.${mcpPrompt.name}`, mode: "append" as const, scope: undefined, content: async (ctx) => { const resolved = await client.getPrompt({ name: mcpPrompt.name, arguments: { callClass: ctx.callClass }, }); return resolved.messages.map((m) => typeof m.content === "string" ? m.content : m.content.text, ).join("\n\n"); }, })), }; } ``` The `content` function runs at prompt-composition time, so it re-fetches per call. For static prompts, cache the result at plugin construction. The runtime-aware contribution hook is the right home for prompts that depend on session state — MCP doesn't model that directly, but a wrapper plugin can. ## Pattern: MCP resources as retrieval [#pattern-mcp-resources-as-retrieval] For MCP servers that publish resources (files, URIs, database schemas), the cleanest integration is a single retrieval tool that proxies to `resources/list` + `resources/read`: ```typescript // lib/mcp/mcpRetrievalTool.ts import { defineTool } from "@pleach/core"; export function mcpRetrievalTool(client: Client) { return defineTool({ name: "mcp_retrieve", description: "Retrieve a resource from the MCP server by URI.", inputSchema: z.object({ uri: z.string(), limit: z.number().int().optional(), }), async execute(input, ctx) { const result = await client.readResource({ uri: input.uri }); return { contents: result.contents }; }, }); } ``` For more sophisticated retrieval (RAG over MCP resources), pair this with a vector store — embed the resource list, retrieve top-k URIs, and read them via the tool. ## Connecting to a remote MCP server [#connecting-to-a-remote-mcp-server] The MCP SDK supports HTTP and SSE transports as well as stdio. For a remote MCP server: ```typescript import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; const transport = new SSEClientTransport( new URL("https://mcp.example.com/sse"), { /* auth headers */ }, ); const client = new Client(/* ... */); await client.connect(transport); ``` For production deployments, the SSE transport pairs naturally with the substrate's own SSE wire — the same long-lived stream shape on both ends. ## Health checks [#health-checks] MCP servers can fail (subprocess died, network blip, auth expired). Add a periodic ping to the connection management layer: ```typescript async function pingMcp(client: Client): Promise { try { await client.ping(); return true; } catch { return false; } } setInterval(async () => { if (!(await pingMcp(client))) { log.warn("MCP server unreachable; reconnecting"); await reconnect(); } }, 30_000); ``` Surface the MCP server's health through the substrate's `/api/harness/health` route by adding a custom component check. ## What `@pleach/mcp` adds today [#what-pleachmcp-adds-today] `@pleach/mcp@0.1.0` bundles the server primitive (`MCPServer`), the harness-tools-to-MCP projection (`adaptHarnessTools`, `projectToolDefinitionToMCP`), and the stdio transport end-to-end. The SSE and WebSocket arms ship in the transport union and on `MCPServerStartOptions` so consumer code can lock against the final shape today; `server.start({ transport: "sse" })` and `"websocket"` throw `NotImplementedError("D-PA-181")` until the next slice. `registerSession()` throws `NotImplementedError("D-PA-184")` until `@pleach/gateway` C3 multi-tenant routing lands — see [`@pleach/mcp`](/docs/mcp) for the per-arm status table. To serve over stdio today, hand the pluggable transport to `start()`. `createPluggableStdioTransport()` owns the JSON-RPC `initialize` handshake natively — prefer it over the legacy `start({ transport: "stdio" })` options-bag form, which builds the handlers but does not bind a transport: ```typescript import { MCPServer, createPluggableStdioTransport, type HarnessToolExecutor, } from "@pleach/mcp"; const executor: HarnessToolExecutor = async (name, args) => { // Bind `name` to a dispatch path of your choice. return { success: true, output: { name, args } }; }; const server = new MCPServer( { name: "my-mcp-server", version: "1.0.0", runtime, autoAdaptTools: true }, executor, ); await server.start(createPluggableStdioTransport()); // Server now answers tools/list + tools/call on stdio. ``` For the consumer side (running the runtime as an MCP client into other servers), the hand-rolled wrapper patterns above remain the supported path — `@pleach/mcp` Phase A focuses on the runtime-as-server projection. The substrate's own integration tests still use the hand-rolled patterns above against reference MCP servers. ## Where to go next [#where-to-go-next] --- # @pleach/mcp (/docs/mcp) Grafting your tools onto another agent's branch. `@pleach/mcp` is the Model Context Protocol server SKU for the Pleach ecosystem. It wraps a `SessionRuntime` from [`@pleach/core`](/docs/core) and projects its tool registry onto the MCP `tools/list` + `tools/call` wire so that stdio-based MCP clients — Claude Desktop, IDE extensions, and similar — can call into a Pleach host without the host writing the JSON-RPC plumbing. This page is the **SKU reference** — what the package exposes, how to install it, and what works today versus what ships in the API but throws. If you're integrating with an MCP server from `@pleach/core` today without adopting this SKU, read [MCP integration](/docs/mcp-integration) — it documents the [`defineTool`](/docs/tools) wrapper pattern for consuming external MCP servers as runtime tools. ## Install [#install] ```bash npm install @pleach/mcp @pleach/core @modelcontextprotocol/sdk ``` ```bash pnpm add @pleach/mcp @pleach/core @modelcontextprotocol/sdk ``` The `@modelcontextprotocol/sdk` dependency is declared on the package but loaded **dynamically** inside `start({ transport: "stdio" })`. Type-checking, DTS emission, and the package's unit tests all compile without the SDK installed; the import only resolves at the moment a host actually starts the server. If the SDK is missing when `start()` runs, the load throws with an actionable message naming the install command. That arrangement is deliberate. Consumers who only use the in-process surface — projecting harness tools, dispatching via `callTool`, inspecting `listTools()` — never need the SDK on disk. ```typescript import { MCPServer, NotImplementedError, adaptHarnessTools, projectToolDefinitionToMCP, createMcpRuntime, createPluggableStdioTransport, createSSETransport, createWebSocketTransport, } from "@pleach/mcp"; import type { McpServerConfig, McpTransport, MCPToolDefinition, MCPToolContext, MCPToolResult, SessionRuntimeLike, MCPTransport, McpRuntime, McpRuntimeConfig, } from "@pleach/mcp"; ``` ## `MCPServer` [#mcpserver] The package consolidates the two factory functions of the reference implementation (`createHarnessMCPServer` and `createMCPSDKServer`) into a single class. One construction call, one start call, one stop call. ```typescript import { MCPServer, createPluggableStdioTransport, type HarnessToolExecutor, } from "@pleach/mcp"; import { createPleachRuntime } from "@pleach/core"; const runtime = createPleachRuntime({ /* ... */ }); // SessionRuntimeLike deliberately does NOT declare an executeTool surface. // Wire the executor against whichever tool registry your runtime uses // (a domain-specific registry on `runtime.toolRegistry`, an HTTP shim, // a direct function map, etc.). The closure below shows the shape. const executor: HarnessToolExecutor = async (name, args) => { // your dispatch — return { success: true, output: ... } on success // or { success: false, error: ... } on failure. throw new Error(`tool '${name}' not wired — see Quickstart`); }; const server = new MCPServer( { name: "my-mcp-server", version: "1.0.0", runtime, autoAdaptTools: true, maxTools: 100, }, executor, ); await server.start(createPluggableStdioTransport()); ``` The constructor accepts an `McpServerConfig` and an optional `HarnessToolExecutor`. The executor is the closure the server calls when it dispatches a `tools/call` request; without an executor the server still responds to `tools/list` (so external clients can discover the tool surface) but every `callTool` returns a structured "no executor configured" error. The recommended transport for new code is `createPluggableStdioTransport()` — it is SDK-free (no runtime dependency on `@modelcontextprotocol/sdk` for the transport itself) and is the Phase B §4 production path Claude Desktop and stdio-based IDE clients use. The legacy `start({ transport: "stdio" })` form continues to work for back-compat and dynamically loads `@modelcontextprotocol/sdk/server`; reach for it only if you specifically want the SDK's own server object. The pluggable dispatcher implements the `initialize` handshake itself (`dispatchPluggableMethod` routes `initialize` to `handleInitialize`), so the SDK is no longer required for the handshake. ### Constructor options [#constructor-options] | Field | Default | Purpose | | ---------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `name` | `"pleach-mcp"` | Server name surfaced to the MCP client. | | `version` | `"0.2.0"` | Semver string the client sees. | | `runtime` | `undefined` | The `SessionRuntime`-shaped object whose tool registry backs the server. | | `autoAdaptTools` | `true` | Seed the tool list from `adaptHarnessTools(runtime)` at construction. Set `false` to register tools manually via `register()`. | | `maxTools` | `100` | Soft cap on tools projected from the registry. | ### `register(tool)` [#registertool] Registers a tool against the server's internal table. Tools registered explicitly win against tools projected from the runtime — a name collision overwrites the adapted projection with the explicit entry. That ordering is the load-bearing piece for hosts that want to ship a hand-rolled tool alongside the auto-adapted surface. ```typescript server.register({ name: "ping", description: "Health check — returns 'pong'.", inputSchema: { type: "object", properties: {} }, handler: async () => ({ success: true, output: "pong" }), }); ``` `registerTool(tool)` is preserved as a Phase 0 back-compat alias and delegates to `register`. ### `listTools()` and `callTool(name, args, signal?)` [#listtools-and-calltoolname-args-signal] `listTools()` returns the full `MCPToolDefinition[]` — the same shape the wire `tools/list` method serves. `callTool(name, args, signal?)` dispatches against the bound runtime via the tool's handler closure. Both return immediately if no tool matches; `callTool` also returns a structured error (rather than throwing) if no runtime is bound. ```typescript const result = await server.callTool("math", { mode: "rpn", expr: "3 4 +" }); // { success: true, output: { result: 7 } } ``` ### `start({ transport })` and `stop()` [#start-transport--and-stop] `start()` wires the configured transport and begins serving. Phase A supports `transport: "stdio"` only — the SSE and WebSocket arms exist in the `McpTransport` union and on `MCPServerStartOptions`, but the implementation throws `NotImplementedError` until Phase B lands. See [Phase A status](#phase-a-status) below. `stop()` is idempotent. Calling it on an unstarted server is a no-op. Calling `start()` twice on the same instance throws — `MCPServer` is not a connection pool, and a host that wants two servers should construct two instances. ```typescript await server.start({ transport: "stdio" }); // ... server runs over stdin/stdout ... await server.stop(); ``` ### `registerSession(sessionId, runtime)` — live [#registersessionsessionid-runtime--live] The server ships a live in-memory session registry: `registerSession(sessionId, runtime, { tenantId? })` stores the runtime (last-wins on a duplicate `sessionId`), and the companion `getSession(sessionId)`, `listSessions()`, and `unregisterSession(sessionId)` methods round out the surface. When no sessions are registered, the constructor-bound runtime (from `config.runtime`) continues to drive all tool/resource/prompt dispatch, so single-runtime hosts need not touch the registry at all. The broader multi-tenant *routing* story — resolving which registered runtime serves an inbound request — lands with [`@pleach/gateway`](/docs/packages). ## `McpTransport` [#mcptransport] The transport union enumerates what the MCP spec allows. Phase A ships only `stdio` end-to-end; the other arms exist in the union and on `MCPServerStartOptions` so consumer code can lock against the final shape today: | Variant | Phase A status | | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `"stdio"` | Live. Line-delimited JSON over stdin/stdout — Claude Desktop's default. | | `"sse"` | Throws `NotImplementedError("D-PA-181")` from `server.start({ transport: "sse" })`. Server-Sent Events transport; Phase B candidate. | | `"websocket"` | Throws `NotImplementedError("D-PA-181")` from `server.start({ transport: "websocket" })`. Bidirectional transport some IDE clients require; Phase B candidate. | The point of shipping the throwing arms now is to let consumer code lock against the final shape today. A host that writes `server.start({ transport: "sse" })` against the current cut catches a typed error with a `decision` field naming the deferral; the same call site picks up the real transport transparently when Phase B publishes. ### The `MCPTransport` abstraction [#the-mcptransport-abstraction] `@pleach/mcp` also exports a pluggable `MCPTransport` interface alongside three factory functions: `createPluggableStdioTransport`, `createSSETransport`, and `createWebSocketTransport`. This is the production path the `MCPServer` start switch dispatches through for new code; today it's also the seam for hosts that want to drive a custom transport directly. | Factory | What ships | | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `createPluggableStdioTransport(opts)` | Live. SDK-free stdio transport conforming to the pluggable `MCPTransport` interface — no runtime dependency on `@modelcontextprotocol/sdk`. Recommended for new code. | | `createSSETransport(opts)` | Scaffold landed; the factory's `start()` throws a generic `Error` naming the missing SDK-side supervised-session execution (the typed `NotImplementedError("D-PA-181")` is the *options-bag* `server.start({ transport: "sse" })` path, not the factory). The contract shape and `cors` / port wiring are stable. | | `createWebSocketTransport(opts)` | Deferred-stub landed; throws at `start()` with a message naming the missing supervised-session execution. Same stable shape. | A consumer that needs a transport before Phase B lands can hand-roll an object conforming to the structural `MCPTransport` contract and pass it where the factory result would go — the interface is intentionally narrow (`start(handler)` boots, the handler dispatches inbound requests, `stop()` tears down). ## Harness-tool projection — `adapter.ts` [#harness-tool-projection--adapterts] The projection layer converts a Pleach harness tool definition into the minimal MCP wire shape. Two exports: * `adaptHarnessTools(runtime, executor?, maxTools = 100)` — reads `runtime.toolRegistry`, projects each entry, wraps it with a handler that delegates to the executor. Returns `MCPToolDefinition[]`. If the runtime has no tool registry, returns an empty array. * `projectToolDefinitionToMCP(tool)` — single-tool variant. Returns an `MCPToolDefinition` whose handler is a placeholder; the caller binds a real handler. This is the building block when a consumer wants to project a single tool without going through the full registry. ```typescript import { adaptHarnessTools, type HarnessToolExecutor } from "@pleach/mcp"; // SessionRuntimeLike does NOT carry an executeTool surface — wire your // own dispatch closure against the runtime's tool registry of choice. const executor: HarnessToolExecutor = async (name, args) => { // your dispatch here return { success: true, output: null }; }; const projected = adaptHarnessTools(runtime, executor); // projected[i] is now a wire-shaped MCPToolDefinition ready to // register with an MCPServer, or to surface via tools/list. ``` Phase A consumes the tool registry through the legacy `ToolRegistryWrapper`-shaped surface on `SessionRuntimeLike.toolRegistry`. The `runtime.tools.*` facet accessor on `SessionRuntime` is the Phase B target; the adapter switches to it once the facet stabilizes, with no change required at the call site. ### Silent metadata drop at the wire boundary [#silent-metadata-drop-at-the-wire-boundary] The harness tool definition carries fields that aren't part of the MCP spec — they're Pleach-internal classifications the runtime uses for routing, budget gating, and prompt assembly. These fields are **dropped silently** at the projection boundary: | Dropped field | Why it doesn't ship on the MCP wire | | -------------- | ----------------------------------------------------------------------------------------- | | `category` | Internal classification used by Pleach's intent detector. No counterpart in the MCP spec. | | `tier` | Pleach budget gating signal. Specific to the runtime's call-class ladder. | | `costCategory` | Cost-attribution metadata. Routed through the audit ledger, not the tool surface. | | `requiresGPU` | Sandbox-execution hint. Meaningful inside the runtime, not at the MCP client. | | `entryPoint` | Pleach-side entry-tool flag — orders the tool catalog. No MCP semantics. | | `role` | File-frontmatter convention from the harness. Not a tool-surface field. | The MCP spec is intentionally minimal — `name`, `description`, `inputSchema` — and extensions break interop with stock clients. The adapter retains the original harness tool reference internally via the handler closure, so the runtime can still dispatch correctly with all its metadata; only the wire `tools/list` response is stripped. If you need a field on the wire, the right move is the standard MCP extension path (a separate JSON-RPC method or a tool whose output carries the structured data), not bending the `tools/list` shape. ## Registering custom capabilities — `McpRuntime.registerCapability` [#registering-custom-capabilities--mcpruntimeregistercapability] The harness-tool projection above is the automatic path: every tool already registered with the runtime surfaces as an MCP tool without code. The explicit path is `McpRuntime.registerCapability` — for MCP-shaped tools, resources, and prompts that *don't* come from the harness tool registry. The API takes a discriminated union: ```typescript import type { McpRuntime, McpToolRegistration, McpResourceRegistration, McpPromptRegistration, } from "@pleach/mcp" ``` ### Custom tools — works today [#custom-tools--works-today] `McpToolRegistration` registers a tool with its JSONSchema input shape and an async handler. The runtime routes the registration through the Phase A `registerTool` surface; the wire-level behavior is identical to a harness-projected tool. ```typescript const myTool: McpToolRegistration = { kind: "tool", name: "search-knowledge-base", description: "Searches the knowledge base by free-text query.", inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"], }, handler: async (input: unknown) => { const { query } = input as { query: string } return await searchKnowledgeBase(query) }, } await mcpRuntime.registerCapability(myTool) ``` Use the explicit path when the tool doesn't fit the harness tool contract — a wire-only tool that exists for the MCP surface but doesn't make sense as a Pleach agent tool, or a tool whose handler needs MCP-specific context the harness adapter doesn't thread. ### Resources and prompts [#resources-and-prompts] `McpResourceRegistration` and `McpPromptRegistration` register the same way tools do. `registerCapability` handles all three kinds uniformly — each registration lands in the runtime's last-wins ledger keyed by `name`, with no runtime throw for resource or prompt kinds. ```typescript const myResource: McpResourceRegistration = { kind: "resource", name: "agent-runbook", uri: "file:///workspace/runbooks/agent.md", mimeType: "text/markdown", } // Lands in the resource ledger (last-wins on a duplicate `name`), // same as a tool registration. await mcpRuntime.registerCapability(myResource) ``` A duplicate `name` within a kind replaces the prior entry rather than appending; the choice is regression-locked in the runtime tests. ## `NotImplementedError` [#notimplementederror] Tagged error class for the deferred surfaces. Carries a `decision` field naming the decision record that documents the deferral, so callers can branch on the cause rather than parsing message text. ```typescript import { MCPServer, NotImplementedError } from "@pleach/mcp"; try { await server.start({ transport: "sse" }); } catch (error) { if (error instanceof NotImplementedError) { console.warn(`Deferred surface: ${error.decision}`); // Fall back to stdio, queue for retry once Phase B ships, etc. } throw error; } ``` The class is exported from the package barrel so consumer code can narrow against it. Today's instances carry `D-PA-181` — the legacy options-bag transport arms (`server.start({ transport: "sse" | "websocket" })`). ## Phase A status [#phase-a-status] What works today, in one place: | Surface | Phase A status | | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `new MCPServer(config, executor?)` | Live. | | `server.register(tool)` / `registerTool(tool)` | Live. | | `server.listTools()` | Live. | | `server.callTool(name, args, signal?)` | Live. | | `server.start({ transport: "stdio" })` | Live. Loads the MCP SDK dynamically. | | `server.start({ transport: "sse" })` | Throws `NotImplementedError("D-PA-181")`. | | `server.start({ transport: "websocket" })` | Throws `NotImplementedError("D-PA-181")`. | | `server.stop()` | Live, idempotent. | | `server.registerSession(sessionId, runtime, { tenantId? })` / `getSession` / `listSessions` / `unregisterSession` | Live. In-memory session registry, last-wins on duplicate `sessionId`. | | `adaptHarnessTools(...)` / `projectToolDefinitionToMCP(...)` | Live. | | `MCPTransport` interface + `createPluggableStdioTransport()` | Live. SDK-free pluggable transport substrate. | | `createSSETransport()` / `createWebSocketTransport()` | Scaffold + deferred-stub. Contract shape stable; `start()` throws naming the deferred substrate. | | `createMcpRuntime()` + `McpRuntime.registerCapability(input)` | Live. Tool / resource / prompt registrations land in the runtime ledger with last-wins semantics. | | Resources (`resources/list`, `resources/read`) | Live. Dispatched on the `MCPServer` wire — both the SDK-backed stdio path (`setRequestHandler`) and the pluggable dispatcher (`dispatchPluggableMethod`). | | Prompts (`prompts/list`, `prompts/get`) | Live. Dispatched on the `MCPServer` wire — both the SDK-backed stdio path and the pluggable dispatcher. | | `runtime.tools.*` facet adoption | Deferred to Phase B. Phase A reads `runtime.toolRegistry`. | The `0.1.0` cut ships the contract, the stdio transport, the harness-tool projection, and the `McpRuntime` registration ledger. SSE and WebSocket transports continue to throw typed sentinels until the next phase wires the supervised-session execution. The resource and prompt surfaces are already dispatched on the `MCPServer` wire, and the session registry ships live. Phase B adds the SSE and WebSocket transports and the `runtime.tools.*` facet adoption. Phase C wires multi-tenant *routing* via `@pleach/gateway` so an inbound request resolves to the right registered runtime. The reserved API points — the throwing arms of the options-bag `start()` — are not a placeholder for a breaking change. The shape that ships today is the shape later phases land against; only the throw goes away. ## Position vs `mcp-integration` [#position-vs-mcp-integration] The two pages cover adjacent territory: | Page | What it documents | | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `@pleach/mcp` (this page) | The shipping SKU's public surface — `MCPServer`, the adapter, transport status, the metadata-drop rules. | | [MCP integration](/docs/mcp-integration) | The integration story across both directions — exposing harness tools to external MCP clients (this SKU's job) **and** consuming external MCP servers as `defineTool` entries (a hand-rolled wrapper today). | Reach for this page when you want the reference for what `@pleach/mcp` ships. Reach for [MCP integration](/docs/mcp-integration) when you want the broader integration patterns, including consumer-side wrapping and the futures around external resource and prompt forwarding. ## Where to go next [#where-to-go-next] --- # Memory (/docs/memory) Memory is one half of the **cross-session state** [thematic island](/docs/concept-clusters#what-lives-outside-the-cluster-pattern) — paired with [plans](/docs/plans). Memory spans sessions; plans span turns. Both outlive a single session arc; neither fits the runtime-lifecycle cluster. The memory layer is the variable surface for what the runtime has learned about a user across sessions: typed facts with categories and confidence scores, the per-agent vs global namespace they live in, the exponential-decay curve that erodes unverified confidence over time, and the conflict groups that surface when two agents disagree. The substrate ships the extraction pipeline and the auditor; the application picks the LLM, the policy, and what gets stored. See [Agents](/docs/agents) for the per-agent namespace this shares, and [Audit ledger](/docs/audit-ledger) for the LLM-call row that records the extraction. Ships from `@pleach/core/memory`. ```typescript import { memoryExtractionQueue, memoryExtractionHook, extractAndStoreFacts, loadExtractedFacts, setFactExtractorStore, consolidateAgentFindings, LearningAuditor, type ExtractedFact, type ExtractionPayload, type LearnedFact, type ConflictGroup, type PruneResult, } from "@pleach/core/memory"; ``` ## Pipeline [#pipeline] Production extraction runs as a graph node. When a turn terminates (`shouldContinue: false`), the `memoryExtraction` node calls the configured executor — `extractAndStoreFacts` — directly. No queue, no debounce. ``` turn terminates (shouldContinue: false) │ ▼ memoryExtraction graph node │ ▼ extractAndStoreFacts │ ┌─────────────┴─────────────┐ ▼ ▼ per-user / per-agent LearningAuditor fact store (correct, prune, detect conflicts) ▲ │ consolidateAgentFindings (cross-agent rollup after subagent fan-in) ``` Every stage is fire-and-forget — extraction never blocks a turn, consolidation never blocks a response, and a failed write logs and moves on. The point of memory is to be cheap and recoverable, not to be transactional. The queue path — `memoryExtractionHook` (a `PostModelHook`) feeding `memoryExtractionQueue` with a 5s debounce — ships in `@pleach/core/memory` as a standalone option for hosts that drive extraction off post-model hooks instead of the graph. It is not registered by default; wire it yourself with `memoryExtractionQueue.setExtractFn`. On the quickstart path (`createPleachRoute`) the memory binders are **wired by default, with fact extraction opt-in**. The default registers a no-op extract fn and leaves the fact store unwired — the graph node's direct `extractAndStoreFacts` call no-ops, so a bare install incurs no per-turn model cost. Turn extraction on with `memory: { extract: true }` (which registers the real `extractAndStoreFacts` extractor plus a default in-memory store), and pass `memory.store` to back facts with a durable store instead of the in-memory default. Wiring the binders by hand — as the rest of this page shows — is for hosts that don't go through `createPleachRoute`. ## `memoryExtractionHook` [#memoryextractionhook] A `PostModelHook` that queues the last 20 messages of the turn for background extraction. Runs server-side only, skips guests, and waits for at least three user turns of conversation before firing. | Guard | Condition | | ----------- | --------------------------------------------- | | Environment | `typeof window === "undefined"` — server only | | Substance | At least 3 user-role messages | | Identity | `userId` and `orgId` present in hook state | The hook never returns `retry`, `interrupt`, or a modified response. It pushes a payload into the queue and returns `{}`. ## `memoryExtractionQueue` [#memoryextractionqueue] | Property | Value | | --------- | ----------------------------------------------------------------------------- | | Debounce | 5\_000ms | | Semantics | Latest-wins per `sessionId` | | Grouping | Payloads grouped by `orgId:userId` for a single extraction per user per flush | | Lifecycle | `add(sessionId, payload)`, `forceFlush()`, `destroy()`, `size` | The queue is a process-wide singleton — the same instance is shared across every runtime in the process. Hook up the extractor once at startup with `memoryExtractionQueue.setExtractFn(fn)` and the queue routes drained batches into it. `ExtractionPayload` carries `messages`, `userId`, `orgId`, `sessionId`, and optional `agentName`. `agentName` decides which namespace the extracted facts land in. ## `extractAndStoreFacts` [#extractandstorefacts] The default extractor calls a lightweight model via OpenRouter (`google/gemini-2.0-flash-001`, `temperature: 0.1`, `max_tokens: 1024`) with a prompt that includes existing facts for deduplication context. Returns parsed `ExtractedFact[]`. | Filter | Effect | | ------------------- | ----------------------------------------------------------------------------------------- | | `confidence >= 0.5` | Low-confidence outputs dropped | | Cap at 5 | Hard limit per extraction | | Content-hash dedup | Existing facts get a `+0.1` confidence bump (capped at 1.0) and a fresh `lastConfirmedAt` | `setFactExtractorStore(store)` binds the store the extractor writes to. Without it, the extractor logs and returns `[]`. Fact namespace: | Scope | Key path | | ----------------------- | ---------------------------------------------------------- | | Global (no `agentName`) | `[orgId, userId, "memories", "fact"]` | | Per-agent | `[orgId, userId, "agents", agentName, "memories", "fact"]` | ## `loadExtractedFacts` [#loadextractedfacts] Reads facts back for system-prompt injection. Runs decay in the same pass: | Age | Action | | ------------------------------- | ---------------------------- | | ≤ 30 days | Untouched | | > 30 days | Confidence multiplied by 0.9 | | > 90 days and confidence \< 0.5 | Deleted | Returns the top 20 facts at `confidence >= 0.7`, sorted descending. ## `consolidateAgentFindings` [#consolidateagentfindings] Runs after a multi-subagent task. Pulls existing global facts and per-agent facts, asks the lightweight model for cross-agent insights (max 5), and writes them to the global namespace with `source: "consolidation"` and `sourceAgents` attribution. ```typescript await consolidateAgentFindings({ parentSessionId, results, // SubAgentResult[] store, orgId, userId, }).catch((err) => log.warn("consolidation failed", err)); ``` Returns `{ factsExtracted, factsUpdated }`. Existing keys get the same `+0.1` confidence bump as single-agent extraction. ## `LearningAuditor` [#learningauditor] The post-batch surface for inspecting, correcting, and pruning the fact store. | Method | Returns | Effect | | ------------------------------------------ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `listFacts(options)` | `{ key, fact }[]` | Decay-adjusted; filters on `category`, `minConfidence`, `maxAge`, `agentName` | | `correctFact(key, newContent, agentName?)` | void | Preserves the old text in `priorContent`, sets `confidence: 1.0`, `userVerified: true` | | `deleteFact(key, agentName?)` | void | Hard delete | | `detectConflicts()` | `ConflictGroup[]` | Jaccard-overlap heuristic; flags `global_vs_agent` and `agent_vs_agent` pairs with negation or numeric divergence | | `prune(options)` | `PruneResult` | Drops facts older than `maxAgeDays` or below `minConfidence`; `dryRun` reports without deleting; never prunes `userVerified` facts | Confidence decay is exponential: `c * e^(-λt)` with `λ = 0.003` (roughly 10% per 30 days). `userVerified` facts skip the decay entirely. ## Auditor in practice [#auditor-in-practice] The block shows a `dryRun` prune followed by a targeted correction. ```typescript import { LearningAuditor } from "@pleach/core/memory"; const auditor = new LearningAuditor(store, orgId, userId); const preview = await auditor.prune({ maxAgeDays: 180, minConfidence: 0.4, dryRun: true, }); for (const row of preview.details) { console.log(row.key, row.reason, "—", row.content); } // Pin a fact the model keeps drifting on. confidence flips to 1.0, // userVerified becomes true, the prior text lands in `priorContent`. await auditor.correctFact( "fact_2a9k", "Default citation style is numbered footnotes, not parenthetical.", ); ``` `prune` skips `userVerified` facts unconditionally — a correction is the way to say "this one is durable, stop decaying it." ## Loading facts into the prompt [#loading-facts-into-the-prompt] The reader sees how high-confidence facts get pulled in at turn start and rendered for system-prompt injection. ```typescript import { loadExtractedFacts } from "@pleach/core/memory"; const facts = await loadExtractedFacts(store, orgId, userId); const memoryBlock = facts.length ? "## What we know about this user\n" + facts.map((f) => `- ${f.content}`).join("\n") : ""; ``` The call also runs decay in the same pass — stale low-confidence rows are deleted, 30-day-old rows get their confidence multiplied by 0.9. The returned list is already filtered to `confidence >= 0.7` and capped at 20. ## `LearnedFact` shape [#learnedfact-shape] | Field | Type | Notes | | ----------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `category` | enum | `preference` / `knowledge` / `context` / `behavior` / `goal` / `correction` | | `content` | string | One-sentence fact | | `confidence` | number | 0.0–1.0 | | `source` | object \| string | Provenance. The extractor and `consolidateAgentFindings` persist a **string** — `"auto"` / `"consolidation"` / `"explicit"`. The auditor also reads an **object** form — `sessionId`, `chatId`, `timestamp`, optional `triggerExcerpt`. | | `createdAt` | ISO-8601 \| undefined | Extractor write-shape timestamp | | `lastConfirmedAt` | ISO-8601 \| undefined | Extractor dedup-bump timestamp | | `lastReinforced` | ISO-8601 \| undefined | Set by dedup bumps and corrections | | `userVerified` | boolean \| undefined | True once corrected through the auditor | | `priorContent` | string \| undefined | Previous text after a `correctFact` | The substrate does not opinionate on what's safe to extract. Consent gating, PII stripping, and tenant-specific policy belong in the extractor implementation or the storage adapter. ## Where to go next [#where-to-go-next] --- # Migrate to Pleach (/docs/migrate) Pleach composes underneath what you have today. The four migration guides below cover the most common starting points; each is structured the same way: 1. **When to migrate, when not to** — concrete signals. 2. **What maps cleanly** — the parts that translate 1:1. 3. **What doesn't** — the parts that change shape. 4. **The minimum-viable migration** — smallest diff that gets you to a working `SessionRuntime`. 5. **What you keep** — provider, enterprise contract, custom tools, existing UI. You can stop at any step. The runtime works with a single provider, no plugins, no audit adapter, no checkpointer. Add capabilities as you need them. ## From an LLM SDK or framework [#from-an-llm-sdk-or-framework] ## From an enterprise vendor contract [#from-an-enterprise-vendor-contract] You don't replace the contract — Pleach runs underneath it. SSO, ZDR, Workspaces / Projects, dedicated capacity, prompt caching, and snapshot pinning stay where they are. What Pleach adds: a hash-chained `AuditableCall` row in your own Postgres, per-axis cost rollup inside one Workspace, and replay determinism across snapshots. ## Compose, don't migrate [#compose-dont-migrate] Sometimes the right call is to bridge, not migrate. The pages below cover the major composition patterns: ## Version migrations (`0.x` → future `1.0`) [#version-migrations-0x--future-10] Every shipping `@pleach/*` package is at `0.1.0` today under FSL-1.1-Apache-2.0. The `0.1.x → 1.0.0` jump is non-caretable — pin exactly. When the `1.0` cut lands, this page will host the codemod + per-SKU upgrade guide. See [versioning](/docs/versioning) for the current policy. ## Not seeing your starting point? [#not-seeing-your-starting-point] The migration guides above are the most-asked patterns. If you're coming from a less common shape — CrewAI, AutoGen, LlamaIndex Workflows, OpenHands, Goose — the [comparison page](/docs/comparison) maps the capability overlaps row by row, and the [getting-started](/docs/getting-started) path takes \~5 minutes regardless of starting point. If a missing migration guide would actually move the needle for your project, open an issue on the [`@pleach/core` repo](https://github.com/pleachhq/core) — migration content is operator-prioritized, not speculatively written. --- # Migrating from the Vercel AI SDK (/docs/migrating-from-ai-sdk) The Vercel AI SDK is the most common starting point for chat applications. `@pleach/core` doesn't replace it — the AI SDK remains an excellent provider integration, and `AiSdkProvider` keeps it in the stack. What changes is what wraps the provider: a typed runtime with sessions, per-call audit, family lock, checkpointing, and replay. This guide walks the migration. You can stop at any step — the runtime works with a single provider, no plugins, no audit adapter, no checkpointer. Add capabilities as you need them. ## When to migrate, when not to [#when-to-migrate-when-not-to] * The app is a single-shot chat with tools and no audit needs. * Streaming, `useChat`, and provider switching cover your shape. * You don't need per-tenant cost allocation or per-call audit. Migrate to `@pleach/core` when one of these lands on your roadmap: * A regulator, customer, or finance team will eventually ask "show me which tools this session invoked, which subagents it spawned, and what each cost — attributed to the turn the user typed." The lattice stages are a structural invariant; the ledger is where the variable surface lives. * Multi-tenant cost allocation requires `(tenantId, turnId, toolName, subagentDepth, modelId, tokens)` joined cleanly. * You need to replay a recorded turn deterministically for eval or regression testing. * You need to swap providers mid-product without breaking tool-call dialect or refusal handling. The migration cost is real but bounded. The runtime contract is the new mental model; everything else is configuration. ## Keep your provider; add a runtime [#keep-your-provider-add-a-runtime] Today (AI SDK alone): ```typescript import { streamText } from "ai"; import { anthropic } from "@ai-sdk/anthropic"; const result = await streamText({ model: anthropic("claude-sonnet-4-5"), messages, tools, }); for await (const part of result.fullStream) { // ... } ``` After migration: ```typescript import { SessionRuntime, AiSdkProvider } from "@pleach/core"; import { anthropic } from "@ai-sdk/anthropic"; const runtime = new SessionRuntime({ provider: new AiSdkProvider({ model: anthropic("claude-sonnet-4-5"), maxSteps: 5, }), userId: req.user.id, }); const session = await runtime.createSession({ tools: { enabled: Object.keys(tools) }, }); for await (const event of runtime.executeMessage(session.id, prompt)) { // ... } ``` What changed: * A `SessionRuntime` constructed once at startup (or per request, depending on your architecture). * A `Session` instead of a raw message list — the runtime owns the conversation state. * An `AsyncGenerator` instead of a `fullStream` — the events are typed and richer (tool lifecycle, sync, interrupts, subagents, checkpoints). What stayed the same: the provider. `AiSdkProvider` wraps `streamText` exactly; your model factory, tool defs, and stream mechanics are unchanged. ## Convert your tools [#convert-your-tools] AI SDK tools translate to `defineTool` 1:1: ```typescript // Before import { tool } from "ai"; import { z } from "zod"; const searchCorpus = tool({ description: "Search the corpus", inputSchema: z.object({ query: z.string() }), execute: async ({ query }) => fetchCorpus(query), }); // After import { defineTool } from "@pleach/core"; const searchCorpus = defineTool({ name: "search_corpus", description: "Search the corpus", inputSchema: z.object({ query: z.string() }), async execute(input, ctx) { return fetchCorpus(input.query, { signal: ctx.signal }); }, }); ``` Two real differences: 1. `name` is explicit — the AI SDK derives it from the object key; the runtime requires it on the definition. 2. The second arg is `ToolContext` with an `AbortSignal`. Thread it through every fetch or spawn — tools that ignore the signal keep burning resources after the user hits stop. Register tools through a plugin or the legacy `setOrchestratorRegistry` shim. See [Tools](/docs/tools) for the options. ## Convert the stream consumer [#convert-the-stream-consumer] The AI SDK's `fullStream` carries typed chunks (`text-delta` / `tool-call` / `tool-result` / `finish`). The runtime's `StreamEvent` is a richer union with the same shapes plus lifecycle, sync, and interrupt events. ```typescript // Before for await (const part of result.fullStream) { switch (part.type) { case "text-delta": onTextDelta(part.textDelta); break; case "tool-call": onToolCall(part); break; case "tool-result": onToolResult(part); break; case "finish": onFinish(part); break; } } // After for await (const event of runtime.executeMessage(session.id, prompt)) { switch (event.type) { case "message.delta": onTextDelta(event.delta); break; case "tool.started": onToolCall(event.toolCall); break; case "tool.completed": onToolResult(event.toolCall, event.result); break; case "message.complete": onFinish(event.message); break; } } ``` The mapping is mostly mechanical. See [Stream events](/docs/stream-events) for the full catalog — there's more you can react to, but porting only the four above gets your existing UI working. ## Convert the React surface [#convert-the-react-surface] `useChat` and `useHarness` cover overlapping ground; the runtime's hook is shaped for the richer event stream. ```tsx // Before import { useChat } from "ai/react"; function Chat() { const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({ api: "/api/chat", }); return <>...; } // After import { HarnessProvider, useHarness } from "@pleach/core/react"; function App() { return ( ); } function Chat() { const { messages, sendMessage, isLoading } = useHarness(); return <>...; } ``` Two structural differences: 1. `HarnessProvider` owns the runtime — single source of truth across components. `useChat` is per-component. 2. `sendMessage(text)` instead of `handleSubmit(e)` — the runtime handles event wiring; the form submit is yours to compose. The `messages` array carries the same shape (`role`, `content`, `id`), so existing message-rendering components transfer. ## Pick storage and a checkpointer [#pick-storage-and-a-checkpointer] The AI SDK doesn't persist sessions. The runtime expects you to pick a storage adapter even if it's in-memory: ```typescript import { SessionRuntime } from "@pleach/core"; import { MemoryAdapter } from "@pleach/core/sessions"; import { MemorySaver } from "@pleach/core/checkpointing"; const runtime = new SessionRuntime({ storage: new MemoryAdapter(), checkpointer: new MemorySaver(), provider: new AiSdkProvider({ model }), userId: "user_123", }); ``` For production, swap `MemoryAdapter`/`MemorySaver` for `SupabaseAdapter`/`SupabaseSaver` and apply the schema bundle — see [Storage](/docs/storage) and [CLI](/docs/cli). ## Add the API route (optional) [#add-the-api-route-optional] If you were using the AI SDK's pattern with a Next.js API route, swap the handler: ```typescript // Before (Next.js app router) import { streamText } from "ai"; export async function POST(req: Request) { const { messages } = await req.json(); const result = streamText({ model, messages, tools }); return result.toDataStreamResponse(); } // After export async function POST(req: Request) { const { sessionId, content } = await req.json(); const stream = new ReadableStream({ async start(controller) { for await (const event of runtime.executeMessage(sessionId, content)) { controller.enqueue(`data: ${JSON.stringify(event)}\n\n`); } controller.close(); }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream" }, }); } ``` Or use the shipped `createPleachRoute` from `@pleach/core/quickstart` for a one-line POST handler — see [API routes](/docs/api-routes) for the full wire contract. ## What you gain after the migration [#what-you-gain-after-the-migration] | Capability | Before | After | | ------------------------ | ---------------------------------- | ---------------------------------------------------------------- | | Per-call audit row | None | `AuditableCall` ledger row per LLM call | | Per-turn cost allocation | Manual log aggregation | `GROUP BY turn_id` against the ledger | | Tool lifecycle | `tool-call` / `tool-result` chunks | `tool.started` / `tool.delta` / `tool.completed` / `tool.failed` | | Human-in-the-loop | DIY | `interrupt.requested` / `interrupt.resolved` | | Time travel | None | `runtime.checkpoints.rollback(sessionId, checkpointId)` | | Replay determinism | None | Fingerprint-based reproducibility | | Multi-tenant isolation | DIY | `tenantId` in the fingerprint key + RLS in the schema | | Sync across devices | DIY | Version-vector outbox | ## What you keep paying [#what-you-keep-paying] * The runtime contract is a model you have to learn. The `SessionRuntimeConfig` surface is small but real. * Storage and checkpointing add a database dependency. Mock mode works for dev; production needs schema migrations. * Plugin authoring is a new abstraction. You don't need it on day one — the substrate stands on its own. ## Common migration pitfalls [#common-migration-pitfalls] | Symptom | Likely cause | | ------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | Tools fire but don't appear in the stream | Tool registered but not added to `createSession({ tools: { enabled: [...] } })` | | `executeMessage` throws on first call | Missing `setHarnessModuleLoader` if your host has legacy orchestrator integration | | `[UXParity:metaToolNames-config-missing]` warning | Pass `metaToolNames` on the runtime config — see [SessionRuntime](/docs/session-runtime) | | Stream silently disconnects in production | SSE response wasn't flushed; check `Cache-Control: no-cache` and disable response buffering | | React hooks return stale data | `HarnessProvider` re-mounted because `runtime` was re-created on every render — wrap in `useMemo` | See [Troubleshooting](/docs/troubleshooting) for the longer list. ## Where to go next [#where-to-go-next] --- # Migrating from Anthropic Enterprise (/docs/migrating-from-anthropic-enterprise) If you have an Anthropic Enterprise (or Anthropic for Work) contract, you've already been through procurement, a security questionnaire, and the vendor-side SSO/SAML and Zero Data Retention review. You should not casually add a new vendor on top of that work. Pleach is explicitly designed not to be one — it ships as an npm package and writes to your existing Postgres. There is no Pleach SaaS to add to your vendor list. This page is for the team that built its first one to three LLM features on the existing Anthropic Enterprise contract and is now hitting one of three downstream walls: finance can't attribute cost to a meaningful axis **inside a Workspace** — your end customers in a SaaS product, or your employees, teams, and cost centers when the Workspace is an internal-use deployment; compliance can't produce a tamper-evident audit row from **their own database**; or eval/regression can't replay a turn byte-identically against a specific model snapshot. Pleach sits underneath the existing contract and closes those three gaps without changing what Anthropic does for you. A note on shape: this page reads naturally for the multi-tenant SaaS case (Acme Corp is your customer). The same row, the same hash chain, and the same fingerprint cover internal-use deployments — set `tenantId` to the employee, team, or cost-center identifier you want to chargeback or audit against. The vendor contract still rolls up to the Workspace; Pleach rolls up to whichever axis your finance and audit teams actually report on. ## The architecture [#the-architecture] ``` ┌──────────────────────────────────────────────────────────────┐ │ Anthropic Enterprise (your existing contract) │ │ - SSO / SAML │ │ - Zero Data Retention │ │ - Workspaces (per-workspace API keys) │ │ - Admin API (per-workspace usage rollup) │ │ - Dedicated capacity / fine-tuned snapshots │ │ - Prompt caching, batches API, files API │ │ │ │ ┌────────────────────────────────────────────────────────┐ │ │ │ Pleach SessionRuntime (npm + your Postgres) │ │ │ │ - AuditableCall row → your Postgres │ │ │ │ keyed (sessionId, turnId, stageId, seqWithinTurn) │ │ │ │ carrying tenantId, modelId, tokenUsage │ │ │ │ - Family-lock at session start (snapshot pinned) │ │ │ │ - Replay-deterministic StreamEvent via fingerprint │ │ │ │ - prev_hash + row_hash chain │ │ │ │ - Subagent rollup to parent turnId │ │ │ │ │ │ │ │ AnthropicSdkProvider │ │ │ │ (wraps @anthropic-ai/sdk; uses your workspace key) │ │ │ └────────────────────────────────────────────────────────┘ │ │ │ └──────────────────────────────────────────────────────────────┘ ``` Anthropic's Admin API is the **vendor-facing** rollup — what each Workspace spent against your contract. Pleach's `AuditableCall` ledger is the **downstream** rollup — which of *your* end customers within that Workspace caused the spend, which turn they typed, what the agent did with it, and what each tool call cost. The two ladders compose: the Admin API tells you what the Workspace owed Anthropic; Pleach tells you which of your customers inside that Workspace owes you. ## The three walls [#the-three-walls] The Anthropic Enterprise contract is comprehensive at the *vendor* layer. The three walls below are the places it stops — not because the contract is incomplete, but because each one is a downstream concern about your product's behavior that only your own infrastructure can answer. ### Wall 1: Per-end-customer cost attribution inside a Workspace [#wall-1-per-end-customer-cost-attribution-inside-a-workspace] What the contract gives you: the Admin API exposes per-Workspace usage and per-API-key usage. If you provisioned one Workspace per end customer, you already have rollup at end-customer granularity. The gap: most enterprise deployments don't carve one Workspace per end customer. A single Workspace serves N tenants in your product because that's how your auth, your rate limits, and your prompt-caching prefixes are organized. The Admin API reports the Workspace's total — it does not know which of your tenants inside that Workspace caused which turn, which subagent spawned, or which tool called the LLM again. What Pleach closes: every LLM call writes an `AuditableCall` row carrying `tenantId`, `turnId`, `subagentDepth`, `modelId`, and `tokenUsage`, keyed `(sessionId, turnId, stageId, seqWithinTurn)`. A single SQL query against your Postgres gives you per-end-customer rollup; the Admin API gives you the matching Workspace total to reconcile against. ### Wall 2: A tamper-evident audit row in your own database [#wall-2-a-tamper-evident-audit-row-in-your-own-database] What the contract gives you: Zero Data Retention means Anthropic does not retain your inputs or outputs past the inference window. The contract documents this and is auditable through Anthropic's SOC 2 / ISO process. The gap: ZDR is a statement about what the *vendor* stores. It is not a statement about what *you* store, and it is not the row your downstream auditor will ask for when they want to see what your product told a specific customer on a specific day. If your compliance program needs a tamper-evident record of decisions *your product* made — which tool the agent invoked, which model snapshot answered, which subagent spawned — that record has to live in your database, not the vendor's. What Pleach closes: the `AuditableCall` row in your Postgres carries a `prev_hash` + `row_hash` chain. Any reordering or deletion of a row breaks the chain at the next verifier pass. The ZDR contract continues to govern what Anthropic stores; Pleach governs what you store and how you defend the chain when asked. ### Wall 3: Replay determinism across model snapshots [#wall-3-replay-determinism-across-model-snapshots] What the contract gives you: dedicated capacity and pinned model snapshots (`claude-sonnet-4-5`, `claude-opus-4-7`) let you control which model version serves your traffic. You can hold a snapshot for the length of a contract term. The gap: holding a snapshot doesn't make a turn replayable. A recorded turn replayed two months later against the same snapshot can still drift if any input changed — prompt cache invalidation, tool definition reordering, a system-prompt edit. The vendor contract gives you the snapshot; it does not give you a cache key that says "this exact input produced this exact output." What Pleach closes: every turn produces a `Fingerprint` covering the prompt, tool schemas, family, snapshot id, and runtime config. A recorded turn replays byte-identically when the fingerprint matches; when it diverges, the diff tells you which input changed. This is the substrate eval and regression testing run against before a new snapshot rolls out to production. ## The code shape [#the-code-shape] ```typescript import { createPleachRuntime, AnthropicSdkProvider, type StreamEvent, } from "@pleach/core"; import { SupabaseAdapter } from "@pleach/core/sessions"; // Your existing Anthropic Enterprise workspace key. SSO, ZDR, and // the rest of the vendor contract continue to govern this key // exactly as before — Pleach reads it, doesn't replace it. const WORKSPACE_API_KEY = process.env.ANTHROPIC_WORKSPACE_API_KEY!; const runtime = createPleachRuntime({ // tenantId is YOUR end customer inside the Workspace. The Admin // API doesn't see this field; Pleach stamps it on every row. tenantId: "acme-corp", userId: "alice@example.com", storage: new SupabaseAdapter({ client: supabase }), provider: new AnthropicSdkProvider({ apiKey: WORKSPACE_API_KEY, model: "claude-sonnet-4-5", maxTokens: 4096, }), }); // Family-lock pins tokenizer, prompt-cache key, tool-call dialect, // and refusal pattern to the snapshot at session start. A // long-running session never silently widens to a different // snapshot mid-conversation — important when you run sonnet-4-5 // for one tier of customer and opus-4-7 for another. const session = await runtime.sessions.create({ provider: { type: "anthropic" }, model: { id: "claude-sonnet-4-5" }, }); for await (const event of runtime.executeMessage( session.id, "Summarize last quarter's filings.", )) { // event: StreamEvent — paired with an AuditableCall row in your // Postgres carrying tenantId, turnId, modelId, tokenUsage. } ``` The per-end-customer rollup falls out of one SQL query against your own database: ```sql -- Per-end-customer cost rollup inside a single Anthropic Workspace. -- Reconcile the SUM against the Admin API's per-workspace total for -- the same window. SELECT tenant_id, COUNT(DISTINCT turn_id) AS turns, SUM((token_usage->>'input_tokens')::int) AS input_tokens, SUM((token_usage->>'output_tokens')::int) AS output_tokens FROM harness_auditable_calls WHERE created_at >= now() - interval '30 days' AND model_id = 'claude-sonnet-4-5' GROUP BY tenant_id ORDER BY output_tokens DESC; ``` Three things this pattern gives you that the Anthropic Enterprise contract alone doesn't: 1. **End-customer cost rollup inside a shared Workspace.** The Admin API tells you what the Workspace spent. Pleach's `(workspace_key, tenant_id, turn_id, token_usage)` row tells you which of your customers inside that Workspace spent it, joinable to your billing pipeline today. 2. **A hash-chained audit row in your own Postgres.** ZDR continues to govern what Anthropic retains. Pleach's `prev_hash` / `row_hash` chain governs what your downstream auditor sees when they ask what your product told a customer on a specific day. 3. **Replay-deterministic eval across snapshots.** Before promoting `claude-sonnet-4-5` to `claude-opus-4-7` for a regulated tier, you can replay last quarter's recorded turns against both snapshots and read the divergence diff. The fingerprint is the cache key; the `StreamEvent` log is the replay record. A note on prompt caching: Anthropic's `cache_control: { type: "ephemeral" }` breakpoints on system prompts and tool definitions **keep working unchanged** — the SDK speaks to the API, Pleach does not intercept the request body. Pleach's own [fingerprint](/docs/fingerprint) is the cache key for *replay*, a different concern from vendor-side prefix reuse. See [Prompt caching](/docs/prompt-caching) for the distinction. ## What stays the same [#what-stays-the-same] Pleach does not replace any of these. Your existing contract and the vendor primitives it governs continue to work unchanged: * **SSO / SAML** for Console access. * **Zero Data Retention** as negotiated. * **Workspaces** and per-Workspace API keys. * **Admin API** for per-Workspace usage rollup. * **Dedicated capacity** and **fine-tuned snapshots**. * **Prompt-caching breakpoints** (`cache_control: { type: "ephemeral" }`). * **Batches API**, **files API**, **tool-use beta flags**. * **Extended thinking** (surfaced through Pleach as `thinking.delta` `StreamEvent`s, content unchanged). ## What changes [#what-changes] | | Before Pleach | With Pleach | | ------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------ | | End-customer cost rollup inside a Workspace | Admin API gives Workspace total; per-tenant split is your code's problem | `SELECT … GROUP BY tenant_id` against your Postgres | | Audit row for what your product decided | Anthropic's request log (vendor-shaped) | `AuditableCall` row in your DB with `prev_hash` + `row_hash` | | Replay a recorded turn against a new snapshot | Re-run and eyeball | Fingerprint match → byte-identical; divergence → diff | | Multi-snapshot deployment without silent fallback | Manual discipline | locked at `runtime.sessions.create({ provider, model })` | | Subagent cost attribution to the parent turn | Manual | `SpawnTreeState` rolls up to parent `turnId` | ## No new vendor [#no-new-vendor] Pleach is `npm install @pleach/core` plus the Postgres you already run. There is no Pleach SaaS, no new ZDR contract to negotiate, no new security questionnaire, no new SOC 2 review to wait on, and nothing for your procurement team to onboard. The license is [FSL-1.1-Apache-2.0](/docs/versioning) — source-available, usable in production, auto-transitions to permissive Apache 2.0 two years after first stable publish. ## Where each is load-bearing [#where-each-is-load-bearing] | Concern | Anthropic Enterprise's slot | Pleach's slot | | -------------------------------------------- | -------------------------------- | -------------------------------------------------------- | | SSO / SAML for Console access | yes (vendor primitive) | None — out of scope | | Zero Data Retention contract | yes (vendor primitive) | None — governs vendor, not your DB | | Per-Workspace usage rollup | Admin API | None — composes with Admin API | | Per-end-customer rollup inside a Workspace | None | `AuditableCall` row keyed on `tenantId` | | Dedicated capacity / fine-tuned snapshots | yes (vendor primitive) | None — referenced by `modelId` on the row | | Prompt-caching breakpoints | yes (`cache_control: ephemeral`) | None — passes through unchanged | | Tools API, batches, files, extended thinking | yes (vendor primitive) | None — surfaced as `StreamEvent`s | | Tamper-evident audit row in YOUR database | None | `prev_hash` + `row_hash` chain | | Family-locked, snapshot-pinned routing | Single-vendor by construction | locked at `runtime.sessions.create({ provider, model })` | | Replay-deterministic eval | None (snapshot only) | Fingerprint replays byte-identical `StreamEvent`s | | Subagent cost rollup to parent `turnId` | None | `SpawnTreeState` | | OTel spans threaded by `tenantId` | None | `runtime.spans` facet | The two coverage maps barely overlap. The Anthropic Enterprise contract is the right tool for everything the vendor owes you; Pleach is the right tool for everything your product owes its own downstream auditors and finance team. Neither does the other's job. ## When you don't need Pleach [#when-you-dont-need-pleach] * Single-tenant enterprise app where finance doesn't need a per-end-customer rollup, the Admin API's per-Workspace total is the answer they want, and there's no downstream auditor asking for a row in your own database. The Anthropic Enterprise contract is sufficient. * One Workspace per end customer, no subagents, no replay requirement, eval is "we eyeball the latest snapshot." The vendor-side primitives carry the load. * Internal tooling where the SDK's request log is a good-enough audit trail and you're not going to run regression replay across snapshots. ## Sibling SKUs that ride alongside the contract [#sibling-skus-that-ride-alongside-the-contract] Two SKUs reach the same Enterprise-contract reader through adjacent doors. Neither is required by the migration above; both get raised by the same buying conversation. * **`@pleach/coding-agent`** — the sandboxed coding-agent surface for the internal dev-tools team that often sits inside the same Enterprise contract footprint. The typed `CodingAgentRuntime` contract ships today at `0.2.0-alpha.0` on the `/runtime` subpath; method bodies and sandbox-provider wiring land in the next phase. The audit row is the same row, the `tenantId` axis is the same axis — an internal coding-agent's per-employee or per-team spend rolls up under the same `GROUP BY` as the rest of your Workspace traffic. See [Coding agent](/docs/coding-agent). * **Language-agnostic contract.** The runtime substrate is wire shapes (HTTP+SSE, `StreamEvent`, `AuditableCall`, checkpoint envelope, version vector), not a TypeScript surface. A Go implementation round-trips a shared corpus of recorded turns against the contract today; the official `@pleach` Go runtime SKU is the next planned published implementation. This is the procurement-visible answer when Enterprise IT asks "is this TypeScript-only?" — the same contract supports a Java or Python runtime built against the same wire shapes. See [Language-agnostic contract](/docs/language-agnostic-contract). ## Where to go next [#where-to-go-next] --- # Migrating from LangChain (/docs/migrating-from-langchain) LangChain and `@pleach/core` overlap structurally — both treat agent execution as composable primitives with explicit control flow — but they make different load-bearing decisions. LangChain optimizes for *integration breadth*: hundreds of pre-built loaders, retrievers, agents, and tools. `@pleach/core` optimizes for *structural guarantees*: a 4-stage lattice, family-locked routing, append-only audit, replay determinism. This guide walks the migration when you've decided the structural guarantees are what you need. If your LangChain code is mostly using the integration catalog (loaders, vector stores, retrievers), keep using LangChain for that — the migration target is the agent + chain surface, not the data layer. ## When to migrate, when not to [#when-to-migrate-when-not-to] * The integration catalog (document loaders, vector stores, retrievers) is the value. * LangGraph's per-node control flow already covers your shape and you don't need the lattice / audit / family-lock guarantees. * You're shipping a RAG pipeline first, an agent second. Migrate to `@pleach/core` when: * You need per-call audit rows, joinable to sessions and turns. * You need replay determinism for eval or regression testing. * You need family-locked provider routing (no silent cross-family widening when a provider fails). * You need the singleton synthesize seam (one user-facing answer per turn, structurally enforced). You can run both — keep LangChain for the retrieval side, point the runtime at it through a `defineTool` wrapper, and let the runtime own the agent execution. ## The mental-model shift [#the-mental-model-shift] | LangChain | `@pleach/core` | What's different | | ------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `Runnable` | (no direct equivalent) | The substrate doesn't model individual steps as composable runnables. The graph topology is declarative; the per-call surface is the seam. | | Chains (`RunnableSequence`, `RunnableParallel`) | Stage lattice + channels | Topology is constrained to the 4 stages; concurrency is a channel concern, not a chain primitive. | | LangGraph | The substrate's graph + channels | Same family of primitives; the lattice + call-class typing are the additions. | | Agents (ReAct, OpenAI Functions) | `tool-loop` stage + seams | The agent loop is built in. You provide tools; the runtime drives the loop. | | Callbacks (`callbacks`, `BaseCallbackHandler`) | Stream events + audit ledger | Audit is structural, not optional. Events are typed. | | Memory (`BaseMemory`, `ConversationBufferMemory`) | `SessionState.messages` + `@pleach/core/store` | Memory is session state; cross-session memory is a separate subpath. | | Document loaders / vector stores | (out of scope) | The substrate doesn't ship retrieval. Keep LangChain or another retrieval library for that. | | LangSmith tracing | Audit ledger + custom adapters | The ledger is the trace store. Plug an OTel adapter for LangSmith parity. | ## Keep your retrieval; wrap it as a tool [#keep-your-retrieval-wrap-it-as-a-tool] The most common LangChain investment is in document loaders + retrievers. Don't migrate that — wrap it. ```typescript // Before — LangChain import { ChatPromptTemplate } from "@langchain/core/prompts"; import { RunnableSequence } from "@langchain/core/runnables"; const chain = RunnableSequence.from([ { context: retriever, question: (input) => input.question }, promptTemplate, llm, ]); // After — wrap the retriever as a defineTool import { defineTool } from "@pleach/core"; export const retrieveDocs = defineTool({ name: "retrieve_docs", description: "Retrieve relevant documents for a query.", inputSchema: z.object({ query: z.string().min(1), k: z.number().int().min(1).max(20).default(5), }), async execute(input, ctx) { const docs = await retriever.invoke(input.query, { configurable: { k: input.k }, signal: ctx.signal, }); return { docs: docs.map((d) => ({ pageContent: d.pageContent, metadata: d.metadata, })) }; }, }); ``` The retriever's full configuration (vector store, embedding model, reranker, MMR settings) stays inside the tool. The runtime just sees a typed tool with a Zod schema. ## Replace the chain with a SessionRuntime [#replace-the-chain-with-a-sessionruntime] ```typescript // Before — agent with chain import { createOpenAIFunctionsAgent, AgentExecutor } from "langchain/agents"; const agent = await createOpenAIFunctionsAgent({ llm, tools: [retrieveDocs, searchDb], prompt: agentPromptTemplate, }); const executor = new AgentExecutor({ agent, tools: [...] }); const result = await executor.invoke({ input: "What is X?" }); // After import { SessionRuntime, AiSdkProvider } from "@pleach/core"; const runtime = new SessionRuntime({ provider: new AiSdkProvider({ model: openai("gpt-4o") }), storage, userId, }); const session = await runtime.createSession({ tools: { enabled: ["retrieve_docs", "search_db"] }, }); for await (const event of runtime.executeMessage(session.id, "What is X?")) { // stream events } ``` The agent loop is built into the `tool-loop` stage. You don't write a `createXAgent` call — the runtime drives the LLM-decision ↔ tool-execution cycle until the plan resolves. ## Convert callbacks to stream events + plugins [#convert-callbacks-to-stream-events--plugins] LangChain's callback handlers are an event subscription surface; the runtime's equivalent is the stream + plugin contract. ```typescript // Before — BaseCallbackHandler class MyHandler extends BaseCallbackHandler { name = "my-handler"; async handleLLMStart(llm, prompts) { metrics.increment("llm.starts"); } async handleLLMEnd(output) { metrics.timing("llm.tokens", output.llmOutput?.tokenUsage?.totalTokens); } async handleToolStart(tool, input) { metrics.increment("tool.starts", { name: tool.name }); } } // After — tool starts arrive as `tool.started` StreamEvents on the // executeMessage() iterable (they are stream events, not emitter events) for await (const event of runtime.executeMessage(sessionId, input)) { if (event.type === "tool.started") { metrics.increment("tool.starts", { name: event.toolCall.name }); } } // The audit ledger is the per-LLM-call surface. Wrap your ledger // adapter to also emit metrics: class MetricsAwareLedger implements ProviderDecisionLedger { constructor(private primary: ProviderDecisionLedger) {} async recordCall(call: AuditableCall) { metrics.increment("llm.calls", { model: call.call.model }); metrics.timing("llm.latency", call.outcome.latencyMs); return this.primary.recordCall(call); } } ``` The audit ledger is the per-LLM-call observability surface — every call writes a row with `model`, `family`, `tokenUsage`, `latencyMs`. That's what LangChain's `handleLLMEnd` gave you, made structural. ## Convert memory to session state [#convert-memory-to-session-state] LangChain's `ConversationBufferMemory` (and variants) maps to `SessionState.messages` directly. The runtime owns the conversation history; you don't construct a memory class. ```typescript // Before const memory = new ConversationBufferMemory({ returnMessages: true }); await memory.saveContext({ input: userInput }, { output: assistantOutput }); const { history } = await memory.loadMemoryVariables({}); // After // The runtime persists messages automatically via the storage adapter. // Read history via: const session = await runtime.sessions.find(sessionId); const history = session?.state.messages ?? []; ``` For cross-session memory (LangChain's `VectorStoreRetrieverMemory`, `ConversationSummaryMemory` with persistence), use the `@pleach/core/store` cross-session memory primitives or wrap an external store as a tool. ## Convert LangGraph nodes to the lattice [#convert-langgraph-nodes-to-the-lattice] LangGraph users will find the closest mental match in the substrate's graph + channels. The migration shape: | LangGraph | `@pleach/core` | | ---------------------------- | ----------------------------------------------------------------------------- | | `StateGraph` | The compiled graph (declarative; lattice-constrained) | | Node | A graph node belonging to one of 4 stages | | Edge | Constrained to the 4-stage lattice transitions | | `Annotation.Root` / channels | Same idea — `LastValue`, `Topic`, `BinaryOperatorAggregate`, etc. | | `interrupt()` | `HumanInterrupt` envelope (LangGraph-compatible shape) | | Conditional edges | Channel-driven scheduling — a node fires when its subscribed channel advances | The `HumanInterrupt` shape is intentionally LangGraph-compatible — external tooling (LangGraph Inspector, dashboards) interops without translation. See [Interrupts](/docs/interrupts). What changes: the lattice constrains where nodes live. A node that "doesn't fit" any of the 4 stages is a signal that it's two nodes (often a planner that should be in `anchor-plan` plus a quality scorer that should be in `post-turn`). The substrate's graph topology is more opinionated than LangGraph's free-form state machine. ## Tool ecosystem [#tool-ecosystem] The substrate doesn't ship LangChain's tool catalog. Three paths for the tools you depended on: 1. **Wrap the LangChain tool as a `defineTool` call.** The tool's `_call` becomes your `execute`; the tool's schema becomes your Zod schema. Quick; preserves the implementation. 2. **Rewrite using `@pleach/tools`.** The sibling SKU ships filesystem / HTTP / shell / structured-parse primitives with Zod schemas and consistent error handling. Use for common tools. 3. **Build native `defineTool` implementations.** For domain-specific tools, the explicit Zod schema + named batching strategy is worth the rewrite. ## What you gain after the migration [#what-you-gain-after-the-migration] | Capability | Before (LangChain) | After | | --------------------- | ------------------------------- | --------------------------------------- | | Per-call audit | Callbacks, manual aggregation | `AuditableCall` ledger row per LLM call | | Family-locked routing | Per-chain provider choice | Session-scoped family + transport lock | | Singleton synthesis | DIY | Structurally enforced | | Replay determinism | Partial via LangSmith record | Fingerprint-based, byte-identical | | Time travel | Partial (LangGraph checkpoints) | Built-in `runtime.checkpoints.rollback` | | Plugin contract | Modular but unconstrained | Bounded; can't break the lattice | ## What you keep paying [#what-you-keep-paying] * LangChain's integration breadth is gone. You bring the loaders and retrievers; the substrate doesn't ship them. * The lattice is opinionated. Code that fits the LangGraph free-form state-machine model has to be re-shaped to fit the 4 stages. * The runtime adds a storage dependency. Mock mode works for dev; production wants a real database with the schema bundle applied. ## Common migration pitfalls [#common-migration-pitfalls] | Symptom | Likely cause | | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | Agent loop runs once and exits | `maxSteps` not passed on `AiSdkProvider`; default is 1 | | Tool fires but the LLM ignores the result | Tool name doesn't match what the model emits — Zod-validated names are stricter than LangChain's tolerance | | LangGraph node doesn't fit any stage | Almost always 2 nodes — split into a planner (`anchor-plan`) and a scorer (`post-turn`) | | Callback handler doesn't fire | LangChain callbacks have no analogue for stream-level events — use `runtime.on` for that | | Memory class missing | Memory IS session state; no separate class needed | ## Where to go next [#where-to-go-next] --- # Migrating from OpenAI Enterprise (/docs/migrating-from-openai-enterprise) If you're on an OpenAI Enterprise contract, the question isn't whether to replace it. SSO/SAML, ZDR, dedicated capacity, Projects with per-project API keys, the Admin API, evals, fine-tuning, prompt caching, structured outputs — those are the vendor's slot and they keep doing what they're good at. The question is what sits **underneath** the OpenAI call inside your own infrastructure, and that's where most enterprise customers hit one of three walls after the first 1-3 features ship. The walls are: (1) finance can't allocate cost to a meaningful axis **within a Project** because the Usage API reports per-project, period — your end customers in a SaaS product, or your employees, teams, and cost centers when the Project is an internal deployment, all get flattened into one Project total; (2) compliance can't show a tamper-evident audit row in **your own database** because the only durable audit trail lives at OpenAI; (3) eval/regression can't replay byte-identically because OpenAI doesn't guarantee determinism across model snapshots. Pleach is an npm dependency and a Postgres table. It runs inside your existing infrastructure, doesn't add a vendor, and closes each of those three walls without touching what your OpenAI contract already covers. A note on shape: this page reads naturally for the multi-tenant SaaS case (Acme Corp is your customer). The same row covers internal-use deployments — set `tenantId` to the employee, team, or cost-center identifier you want to chargeback or audit against. The Usage API still rolls up to the Project; Pleach rolls up to whichever axis your finance and audit teams actually report on. ## The architecture [#the-architecture] ``` ┌─────────────────────────────────────────────────────────────┐ │ OpenAI Enterprise (your existing contract) │ │ - SSO / SAML, SCIM │ │ - Zero Data Retention │ │ - Projects + per-project API keys │ │ - Admin API, Usage API │ │ - Scale tier / dedicated capacity │ │ - Evals, fine-tuning, prompt caching │ │ │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ Your API route / serverless function │ │ │ │ │ │ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ │ │ Pleach SessionRuntime (npm dep + your DB) │ │ │ │ │ │ - AuditableCall row → your Postgres │ │ │ │ │ │ - tenantId stamped per end customer │ │ │ │ │ │ - Family-lock at session start │ │ │ │ │ │ - Replay fingerprint per turn │ │ │ │ │ │ - Subagent rollup to parent turnId │ │ │ │ │ │ │ │ │ │ │ │ ┌───────────────────────────────────────────┐ │ │ │ │ │ │ │ AiSdkProvider({ │ │ │ │ │ │ │ │ model: openai("gpt-4o", { │ │ │ │ │ │ │ │ apiKey: PROJECT_API_KEY │ │ │ │ │ │ │ │ }) │ │ │ │ │ │ │ │ }) │ │ │ │ │ │ │ └───────────────────────────────────────────┘ │ │ │ │ │ └─────────────────────────────────────────────────┘ │ │ │ └───────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` OpenAI's Usage API and run history are the **vendor** trail — what the project spent, which key fired, what the model returned. Pleach's `AuditableCall` ledger is the **downstream** trail — which of your end customers caused the call, what tools the agent invoked, which subagents spawned, attributed to the turn the user typed, in a row keyed `(sessionId, turnId, stageId, seqWithinTurn)` that your finance and compliance teams can query directly in your own DB. The two ladders compose. OpenAI tells you what the Project spent; Pleach tells you which of *your* customers within that Project spent it. Neither side is doing the other's job badly. ## The three walls [#the-three-walls] ### 1. Per-end-customer cost attribution within a Project [#1-per-end-customer-cost-attribution-within-a-project] **What OpenAI Enterprise gives you.** Projects partition usage and keys. The Usage API and Admin API report token spend per project, per API key, per model, per minute. Per-project budgets and rate limits enforce ceilings. **The gap.** Inside a single Project, the Usage API has no notion of *your* end customer. If your SaaS app serves a thousand tenants out of one Project (the common shape — Projects are expensive to provision and operationally scoped to your team, not your customers), the OpenAI dashboard tells you the Project spent $8,400 last month. It cannot tell you that Acme Corp was $3,200 of it. **What Pleach closes.** `runtime.tenant` stamps `tenantId` on every `AuditableCall` row at write time, before the stream finishes. The row also carries `turnId`, `toolName`, `subagentDepth`, and `tokenUsage`. The per-end-customer rollup falls out of a single SQL query against your Postgres: ```sql SELECT tenant_id, SUM((token_usage->>'totalTokens')::bigint) AS tokens, SUM((token_usage->>'inputTokens')::bigint) AS input, SUM((token_usage->>'outputTokens')::bigint) AS output, COUNT(DISTINCT turn_id) AS turns FROM harness_auditable_calls WHERE created_at >= date_trunc('month', now()) AND model_id LIKE 'gpt-4o%' GROUP BY tenant_id ORDER BY tokens DESC; ``` Reconcile that against the Project's Usage API total at month close — the sums match because Pleach is recording the same calls your OpenAI key is making, just with the tenant attribution attached. ### 2. Tamper-evident audit row in your own DB [#2-tamper-evident-audit-row-in-your-own-db] **What OpenAI Enterprise gives you.** ZDR ensures OpenAI does not retain prompt or completion data for training. The Admin API exposes audit logs of organizational events (key creation, role changes, project membership). Your security questionnaire likely covers SOC 2, ISO 27001, and the vendor's penetration testing. **The gap.** ZDR is about what OpenAI doesn't keep. It doesn't give you a row in your own database that says "on 2026-06-07 at 14:23:11, session `sess_abc` turn `turn_42` invoked tool `compound_lookup` for tenant `acme-corp`, model `gpt-4o`, spent 2,193 tokens, fingerprint `f3a9...`." That row is what a downstream auditor — your customer's compliance team, an SEC inquiry, a FedRAMP review six months from now — asks for. The vendor contract doesn't produce it because the vendor doesn't know about the decisions *your* product made. **What Pleach closes.** Every LLM call writes an `AuditableCall` row before the stream completes. The row is hash-chained: `prev_hash` references the previous row's `row_hash` keyed by session, so any backfill or mutation breaks the chain detectably. The schema lives in your Postgres, under your backup and retention policy, joinable against your customer and billing tables. ### 3. Replay determinism for evals and regression [#3-replay-determinism-for-evals-and-regression] **What OpenAI Enterprise gives you.** The Evals product runs a graded test set against a model snapshot and reports pass rate. Fine-tuning produces a frozen snapshot you can pin a Project to. Dedicated capacity reduces latency variance. **The gap.** OpenAI does not guarantee that the same prompt against the same snapshot returns the same stream. Sampling parameters, model serving variance, prompt-cache hit/miss state, and silent rolling updates within a snapshot family can all shift output. When your eval suite regresses, you can't replay the offending production turn byte-identically to bisect what changed. **What Pleach closes.** The `Fingerprint` primitive records `(messages, tools, model, params, family, transport)` per turn. Replay against the same fingerprint reproduces the `StreamEvent` sequence the production turn emitted, against the same family-locked transport. Drift between two snapshots surfaces as a fingerprint-keyed delta in the replay output — you catch the regression in your CI eval suite before the customer does. ## The code shape [#the-code-shape] ```typescript import { createPleachRuntime, AiSdkProvider, type StreamEvent, } from "@pleach/core"; import { SupabaseAdapter } from "@pleach/core/sessions"; import { openai, createOpenAI } from "@ai-sdk/openai"; // Per-project API keys keep working. You read the same env you // already read; OpenAI's Usage API still rolls up against this // key inside the Project. const openaiClient = createOpenAI({ apiKey: process.env.OPENAI_PROJECT_API_KEY, }); // createPleachRuntime is the zero-config bootstrap. The tenantId // here is YOUR end customer, not OpenAI's Project — that's how // the two attribution ladders compose. const runtime = createPleachRuntime({ tenantId: endCustomerId, // e.g. "acme-corp" userId: endUserId, storage: new SupabaseAdapter({ client: supabase }), provider: new AiSdkProvider({ model: openaiClient("gpt-4o"), }), }); // Family-lock fires here. The session pins: // - tokenizer (OpenAI cl100k) // - prompt-cache key (so OpenAI's automatic prefix caching // stays stable per session — Pleach doesn't interfere) // - tool-call dialect (OpenAI JSON Schema) // - refusal pattern (OpenAI's shape) // If you run multiple snapshots (gpt-4o, gpt-4o-mini, o1) the // session-start lock prevents a transient provider error from // silently falling back to a different snapshot mid-conversation. const session = await runtime.sessions.create({ provider: { type: "openai" }, model: { id: "gpt-4o" }, }); // executeMessage writes the AuditableCall row at call time, with // tenantId = endCustomerId already stamped by runtime.tenant. const events: StreamEvent[] = []; for await (const evt of runtime.executeMessage(session.id, prompt)) { events.push(evt); } ``` Three things this pattern gives you that the OpenAI Enterprise contract alone doesn't: 1. **End-customer cost rollup falls out of one SQL query** against `harness_auditable_calls` keyed by `tenant_id`. Reconcile it against the Project's Usage API total at month close — the sums match because Pleach records the same calls your per-project key is making, with tenant attribution attached. 2. **The audit row lives in your DB before the response returns**, hash-chained `prev_hash` → `row_hash`, joinable to your customer, billing, and compliance tables. A downstream auditor asking "show me every LLM call that touched tenant Acme last quarter" answers from your Postgres without an OpenAI dashboard export. 3. **Replay is byte-identical against the recorded fingerprint.** Your CI eval suite replays last week's production turns against the current snapshot and surfaces drift before the customer does. When OpenAI rolls a silent update inside a snapshot family, the fingerprint-keyed delta is your early warning. ## What stays the same [#what-stays-the-same] Pleach is downstream of the LLM call. None of the following change: * **SSO/SAML and SCIM provisioning** through your IdP — Pleach doesn't touch identity. * **Zero Data Retention** on prompts and completions — the vendor contract governs what OpenAI stores; Pleach governs what *you* store. * **Projects and per-project API keys** — Pleach reads the same key and stamps your downstream `tenantId` on the audit row. * **Scale tier / dedicated capacity** — capacity allocation is vendor-side; Pleach doesn't see it. * **Fine-tuned snapshots** — pin the model in `AiSdkProvider({ model: openaiClient("ft:gpt-4o:acme:...") })` exactly as before. * **Automatic prompt caching** — OpenAI's server-side prefix cache is keyed by your prompt shape, which Pleach doesn't rewrite. Cache hit rates are preserved. * **Structured outputs** (JSON Schema response format) and function/tool calling — these are transport-side features that pass through `AiSdkProvider`. * **Batch API** for offline jobs — Pleach captures per-batch audit rows around the batch submission and result fetch. * **Evals product** — keep running it for graded test sets; Pleach's replay determinism is the orthogonal CI-side primitive. * **Vector stores** and file search — Pleach doesn't replace these; they remain the vendor's slot. * **Admin API** — organizational audit logs and key management are unchanged. ## What changes [#what-changes] | Before Pleach | With Pleach | | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | Project-level usage in OpenAI dashboard | Per-end-customer rollup via `SELECT ... GROUP BY tenant_id` in your Postgres | | Audit trail at OpenAI (per ZDR contract) | Hash-chained `AuditableCall` row in your DB, joinable to billing/compliance tables | | Eval runs against current snapshot only | Replay any recorded turn byte-identically against its fingerprint | | Multi-snapshot deployments rely on the SDK not falling back | Family-locked at session start — provider error surfaces as an explicit halt, not silent snapshot switch | ## The Assistants API caveat [#the-assistants-api-caveat] If you built on the Assistants API, thread state lives at OpenAI. Pleach can still capture per-call `AuditableCall` rows around each `runs.create` or `runs.createAndPoll`, with `tenantId` and `tokenUsage` attributed, and the hash chain still holds. What doesn't transfer is the conversation primitive — the durable record of "what did this conversation contain" lives at OpenAI, not in your DB, and replay determinism is weaker because the next-call inputs depend on what OpenAI stored. For new code, prefer Chat Completions or the Responses API with Pleach owning the session. The [Pleach + OpenAI SDK page](/docs/with-openai-sdk) has the longer treatment and the per-shape recommendation. ## No new vendor [#no-new-vendor] Pleach is `npm install @pleach/core` and a table in the Postgres you already run. There is no Pleach-hosted service, no new ZDR contract to negotiate, no new security questionnaire, no new procurement cycle, no new SOC 2 boundary. The npm package ships under FSL-1.1-Apache-2.0 — source-available, usable in production, auto-transitions to permissive Apache 2.0 two years after first stable publish. See [Versioning](/docs/versioning) for the license-posture detail your legal team will want. The database table lives under your existing backup, retention, and encryption-at-rest policies. The only new surface is a row schema your DBA already knows how to operate. This is the property most enterprise customers care about most. The OpenAI contract is the expensive one to get through procurement; adding a second vendor underneath it would neutralize the point. Pleach is structured so that doesn't happen. ## Where each is load-bearing [#where-each-is-load-bearing] | Concern | OpenAI Enterprise's slot | Pleach's slot | | -------------------------------------------------- | ---------------------------------------------- | ---------------------------------------------------- | | Sending tokens to the model | yes | none — delegates to provider | | ZDR on prompts/completions | vendor contract | none — Pleach governs YOUR DB only | | SSO / SAML / SCIM | yes | none | | Per-project API keys, budgets, rate limits | yes | none — Pleach reads the same key | | Project-level usage reporting | Usage API | none | | Per-end-customer cost rollup inside a Project | none | `tenant_id` on every `AuditableCall` row | | Tamper-evident downstream audit row in YOUR DB | none | `prev_hash` + `row_hash` hash chain | | Family + transport lock across snapshots | none | `runtime.sessions.create()` freezes at session start | | Replay-deterministic LLM stream | none — same prompt may return different stream | `Fingerprint` replays byte-identical `StreamEvent`s | | Subagent cost rollup to parent `turnId` | none | `SpawnTreeState` | | Prompt caching (server-side prefix reuse) | yes (OpenAI's automatic caching) | preserved; Pleach doesn't interfere | | Structured outputs / function calling | yes | preserved through `AiSdkProvider` | | Fine-tuning, evals, vector stores, batch API | yes | preserved | | Admin API (org events, key management) | yes | none | | Scale tier / dedicated capacity | yes | none | | Operational dashboard (usage, errors, rate limits) | OpenAI dashboard | none — Pleach doesn't replace it | ## When you don't need Pleach [#when-you-dont-need-pleach] * A single-tenant enterprise app where finance doesn't need per-end-customer rollup, there's no downstream audit obligation beyond ZDR, and eval is "we eyeball the latest snapshot." The OpenAI Enterprise contract is sufficient; adding Pleach underneath buys you optionality you may not exercise. * An internal-only tool where the OpenAI request id in your logs is enough audit trail. Pleach's overhead doesn't pay back until a finance, compliance, or eval question forces a query you can't answer from the vendor dashboard. * A team that's already built a per-customer cost rollup, hash-chained audit row, and replay harness in-house and is happy operating them. The Pleach value is that you don't have to; if you already do, the [comparison page](/docs/comparison) covers the trade. ## Sibling SKUs that ride alongside the contract [#sibling-skus-that-ride-alongside-the-contract] Two SKUs reach the same Enterprise-contract reader through adjacent doors. Neither is required by the migration above; both get raised by the same buying conversation. * **`@pleach/coding-agent`** — the sandboxed coding-agent surface for the internal dev-tools team that often sits inside the same Enterprise contract footprint. The typed `CodingAgentRuntime` contract ships today at `0.2.0-alpha.0` on the `/runtime` subpath; method bodies and sandbox-provider wiring land in the next phase. The audit row is the same row, the `tenantId` axis is the same axis — an internal coding-agent's per-employee or per-team spend rolls up under the same `GROUP BY` as the rest of your Project traffic. See [Coding agent](/docs/coding-agent). * **Language-agnostic contract.** The runtime substrate is wire shapes (HTTP+SSE, `StreamEvent`, `AuditableCall`, checkpoint envelope, version vector), not a TypeScript surface. A Go implementation round-trips a shared corpus of recorded turns against the contract today; the official `@pleach` Go runtime SKU is the next planned published implementation. This is the procurement-visible answer when Enterprise IT asks "is this TypeScript-only?" — the same contract supports a Java or Python runtime built against the same wire shapes. See [Language-agnostic contract](/docs/language-agnostic-contract). ## Where to go next [#where-to-go-next] --- # Model resolution matrix (/docs/model-resolution-matrix) Every call into the runtime resolves to a concrete model through a `(ProviderFamily × CallClass)` matrix. The **family** is locked at session start (it picks tokenizer, prompt-cache key, tool-call dialect, and refusal pattern); the **call class** is declared per-call by the seam that's invoking the LLM. This page is the reference table for the routing cluster. The three concepts — [CallClass](/docs/call-classes), [Seam](/docs/seams), [family-lock](/docs/family-lock) — that decide which model fires for which kind of call are framed in [Family-lock → the routing cluster](/docs/family-lock#the-routing-cluster). ## The matrix [#the-matrix] | | `anthropic` | `openai` | `google` | `deepseek` | `moonshot` | `mistral` ‡ | `xai` | | ------------ | --------------------- | -------------- | ------------------------ | ------------------- | ------------------------ | ------------------------ | ------------------------------ | | `synthesize` | `claude-sonnet-4-6` | `gpt-5.4` | `gemini-3-pro` | `deepseek-v4-pro` | `moonshotai/kimi-k2.5` | `mistral-large-latest` | `grok-4.3` | | `reasoning` | `claude-sonnet-4-6` † | `gpt-5.4-mini` | `gemini-3-pro` † | `deepseek-v4-pro` † | `moonshotai/kimi-k2.5` † | `mistral-large-latest` † | `grok-4.20-0309-reasoning` | | `converse` | `claude-haiku-4-5` | `gpt-5.4-mini` | `gemini-3-flash-preview` | `deepseek-v4-flash` | `moonshotai/kimi-k2.5` † | `mistral-small-latest` | `grok-4.20-0309-non-reasoning` | | `utility` | `claude-haiku-4-5` | `gpt-5.4-nano` | `gemini-3-flash-preview` | `deepseek-v4-flash` | `moonshotai/kimi-k2.5` † | `mistral-small-latest` | `grok-4.20-0309-non-reasoning` | † Aliased up to that family's `synthesize` rung because no distinct mid-rung model exists. Moonshot is single-rung today — every call class resolves to the same model id. ‡ `mistral` is **not currently exposed for routing.** Its matrix entries are placeholder model ids (`mistral-small-latest` / `mistral-large-latest`) that don't resolve at any transport, so the family is excluded from `EXPOSED_FAMILIES` and a session cannot lock to it today. The row is shown for matrix completeness only. The live matrix is the source of truth — it lives in `@pleach/core` and is the only place edited when a model is added or rotated. A future build of this page will regenerate the table directly from the published matrix; until then, treat this rendering as a snapshot suitable for orientation, not for procurement decisions. ## Call classes [#call-classes] | Class | When the runtime picks it | Why it has its own row | | ------------ | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | `synthesize` | The single final answer for a turn. | Highest-quality model per family; exactly one of these fires per turn. | | `reasoning` | An intermediate generator whose output feeds the next call. | Quality matters, but it's not user-visible prose — a smaller model is acceptable. | | `converse` | Short user-facing prose (clarifying questions, refusal hints, retry narration). | Latency-sensitive and conversational — typically the cheapest model that can hold the voice. | | `utility` | Internal classification or extraction (intent detection, refusal repair). | Pure machine-consumed; cheapest model the family ships. | The classes are a downgrade ladder: `synthesize → reasoning → converse → utility` is the cost-quality direction. ## Transports [#transports] Family choice fixes the tokenizer; transport choice fixes how the request gets to the provider. Both lock at session start. | Family | Available transports | | ----------- | --------------------------------------------------------------------------- | | `anthropic` | `native` · `openrouter` · `byok-native` · `byok-openrouter` | | `openai` | `native` · `openrouter` · `byok-native` · `byok-openrouter` | | `google` | `openrouter` · `byok-openrouter` (native pending) | | `deepseek` | `openrouter` · `byok-openrouter` (native pending) | | `moonshot` | `openrouter` · `byok-openrouter` | | `mistral` ‡ | not exposed for routing — placeholder model ids (see the matrix note above) | | `xai` | `openrouter` · `byok-openrouter` (native pending) | Families without a native provider collapse `native` and `byok-native` requests to their OpenRouter equivalents at resolve time, rather than dispatching to a transport that doesn't exist. The resulting `transport` is recorded on the audit row, so the collapse is visible after the fact. `native` reaches the provider directly. `openrouter` routes through OpenRouter — useful for evaluation and for families without a native transport. `byok-*` variants delegate credentials to the end user's BYOK key rather than the deployment's. Transport is session-scoped, so mid-session escalation across transports doesn't silently happen. A session that starts on `anthropic` + `byok-native` and exhausts the user's key surfaces the exhaustion through the ledger as a `providerCascade` row with `outcome.status: "exhausted"`, not a silent flip to the deployment's own credentials. ## Cross-family fallback policy [#cross-family-fallback-policy] The runtime does **not** silently widen across families when the session's locked family is exhausted. Once every in-family rung is tried, the runtime surfaces a `family-exhausted` state to the host and lets the user pick another family explicitly. The pivot exists because cross-family widening produced a measurable regression class: a session that began on `anthropic`'s tool-call dialect would silently cross to `openai`'s on cascade, and tools whose argument schemas relied on Anthropic's `input_schema` shape would stop being called correctly — the same agent code, the same session id, two incompatible tool dialects within one conversation. The family lock makes that crossing visible to the host instead of invisible to the runtime. A narrow legacy carve-out preserves cross-family fallback for models that don't resolve through the matrix at all — BYOK rigs, modal-LLM endpoints, and unrecognized model slugs — because those sessions never had a family lock to honor in the first place. ## Where to go next [#where-to-go-next] --- # Multi-tenant SaaS agent (/docs/multi-tenant-saas-agent) A multi-tenant SaaS agent is the use case the `tenantId` field on the [audit row](/docs/auditable-call-row) was added for. One runtime serves dozens to thousands of customers; every LLM call and every tool call has to trace back to the customer whose user message triggered it. This page describes the use-case shape. For the primitive reference (the `runtime.tenant` accessors), see [runtime.tenant facet](/docs/tenant-facet). For the ops guide (RLS templates, deployment checklist), see [Multi-tenant deployments](/docs/multi-tenant). **Related shapes.** [Regulated-domain agent](/docs/regulated-domain-agent) if the SaaS serves a regulated vertical (HIPAA, SOC 2, FedRAMP). [Internal knowledge agent](/docs/internal-knowledge-agent) if each tenant has its own retrieval corpus. [Research agent](/docs/research-agent) if turns inside a tenant fan out into multi-subagent investigations. The pattern shows up four places: an internal platform team exposing "AI for everything" across business units, an AI consultancy whose tenants are client engagements, an ISV embedding an agent in a product whose own customers nest below it, and a team running Pleach under one Anthropic Workspace or OpenAI Project on an Enterprise contract — where `tenantId` partitions employees, teams, or cost centers instead of external customers. All four need the same answer: which axis spent what, and on what. ## What you're building [#what-youre-building] A single runtime construction path, parameterized per request by the authenticated tenant. The runtime: * Routes to the tenant's BYOK credentials when present; falls back to the platform pool when not. * Stamps `tenantId` on every audit row, including the rows written by spawned [subagents](/docs/subagents). * Rolls subagent token spend back to the parent turn, so nested fan-out doesn't get stranded under a child `sessionId`. Finance runs one `GROUP BY tenant_id` against `harness_auditable_calls` (the [audit ledger](/docs/audit-ledger)) and produces the month's invoice. No parallel cost pipeline. ## Per-tenant runtime construction [#per-tenant-runtime-construction] The [runtime](/docs/session-runtime) is built per request. The tenant resolver runs first; its output is the input to provider selection and [storage](/docs/storage) scoping. The `runtime.tenant` facet is the load-bearing piece. Set `tenantId` (and optionally `subTenantId`) on `SessionRuntimeConfig`, and the runtime stamps `tenant_id` on every write site — audit rows, [harness event log](/docs/event-log), [checkpoints](/docs/checkpointing) — automatically. See [Tenant facet](/docs/tenant-facet) and [Facets](/docs/facets) for the broader facet model. ```typescript // lib/runtime.ts import { SessionRuntime, AnthropicSdkProvider, AiSdkProvider, definePleachPlugin } from "@pleach/core"; import { SupabaseAdapter } from "@pleach/core/sessions"; import { SupabaseSaver } from "@pleach/core/checkpointing"; import { createOpenRouter } from "@openrouter/ai-sdk-provider"; const platformOpenRouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY! }); interface TenantConfig { tenantId: string; subTenantId?: string; // e.g. workspace under the org providerType: "anthropic" | "openai" | "platform-default"; apiKey?: string; // BYOK — tenant-supplied model?: string; monthlyCapUsd?: number; } export async function buildTenantRuntime(req: AuthedRequest) { const tenant = await loadTenant(req.user.orgId); return new SessionRuntime({ storage: new SupabaseAdapter({ client: supabase }), checkpointer: new SupabaseSaver({ client: supabase }), provider: pickProvider(tenant), plugins: [definePleachPlugin("shared-tools", { tools: sharedTools })], tenant: { tenantId: tenant.tenantId, subTenantId: req.user.workspaceId, // optional second axis }, context: { userId: req.user.id, organizationId: req.user.orgId, }, }); } function pickProvider(tenant: TenantConfig) { if (tenant.providerType === "anthropic" && tenant.apiKey) { return new AnthropicSdkProvider({ apiKey: tenant.apiKey, // tenant's key model: tenant.model ?? "claude-sonnet-4-5", }); } if (tenant.providerType === "openai" && tenant.apiKey) { return new AiSdkProvider({ model: openai("gpt-4o", { apiKey: tenant.apiKey }), }); } return new AiSdkProvider({ // platform pool model: platformOpenRouter("anthropic/claude-sonnet-4-5"), maxSteps: 5, }); } ``` The tenant's API key never appears in the audit ledger. The row records `family`, `modelId`, and `transport` — not the credential. And it never reaches the browser bundle: runtime construction is server-side. ## Outbound HTTP through a tenant-routing gateway [#outbound-http-through-a-tenant-routing-gateway] If outbound tool calls ride through an upstream gateway that routes by a tenant header, the runtime ships `withTenantHeader` to thread the facet's `tenantId` onto every request without hand-plumbing. ```typescript import { withTenantHeader } from "@pleach/core"; // inside a tool handler — `ctx.tenantId` comes from runtime construction const fetchForTenant = withTenantHeader(fetch, { header: "x-tenant-id", // whatever the gateway expects tenantId: ctx.tenantId, }); await fetchForTenant("https://gateway.internal/v1/lookup", { method: "POST", body: JSON.stringify({ query }), }); ``` The header value is bound once at wrapper construction; the host threads `ctx.tenantId` from the per-request runtime into each tool handler. A tool that forgets to use the wrapper is the only way for a request to leave without the header — that single coupling site (one closure per tool) is the audit anchor. ## What the audit row carries [#what-the-audit-row-carries] Every row in `harness_auditable_calls` carries the six fields finance and compliance both need: | Field | Source | Why finance cares | | ---------------- | ---------------------- | --------------------------- | | `tenant_id` | runtime context | invoice partition | | `turn_id` | runtime, per user turn | unit of work | | `tool_name` | runtime, per tool call | what the model actually did | | `subagent_depth` | runtime, per spawn | nested fan-out attribution | | `model_id` | resolved at call time | rate card lookup | | `token_usage` | provider response | cost calculation | See [Auditable call row](/docs/auditable-call-row) for the full column list. ## OTel spans inherit the tenant [#otel-spans-inherit-the-tenant] Once `runtime.tenant` is set, every emitted span carries a `pleach.tenant_id` attribute. A per-tenant trace query in your OTel backend is a single attribute filter — no join effort, no custom processor, no log correlation pass. If `subTenantId` is set, it rides alongside as `pleach.sub_tenant_id`. The two attributes are stable across versions; treat them as part of the observability contract. See [OTel observability](/docs/otel-observability) for the full attribute schema. ## Cross-tenant cache pollution is prevented by construction [#cross-tenant-cache-pollution-is-prevented-by-construction] The cache [fingerprint](/docs/fingerprint) has four gaps, and `tenantId` is one of them. Two tenants asking the literally identical question fingerprint to different cache keys. A leak from tenant A's cache into tenant B's response is structurally impossible — there's no shared key to read from. See [Cache](/docs/cache) for the fingerprint shape and the other three gaps (model identity, prompt, tool surface). ## Inherited audit gates [#inherited-audit-gates] Adopting `@pleach/compliance` brings two CI gates that catch the hand-rolled multi-tenancy mistakes: * `audit:tenant-scoping` — flags storage reads and writes that don't carry a tenant predicate. * `audit:harness-event-log-tenant-id-required` — flags emits to `harness_event_log` that don't stamp `tenant_id`. Both gates run in CI; both fail the build on violation. The runtime's facet plumbing is what makes the gates green by default — but the gates are the proof that no code path slipped around the facet. ## The per-tenant cost rollup [#the-per-tenant-cost-rollup] The month's invoice is one query. ```sql select tenant_id, count(*) filter (where call_kind = 'llm') as llm_calls, count(*) filter (where call_kind = 'tool') as tool_calls, sum(input_tokens) as input_tokens, sum(output_tokens) as output_tokens from harness_auditable_calls where created_at >= date_trunc('month', now()) group by tenant_id order by input_tokens + output_tokens desc; ``` For a per-engagement consultancy or a per-customer ISV, the exact same shape — different `tenant_id` semantics, same query. ## Subagent fan-out, attributed to the parent turn [#subagent-fan-out-attributed-to-the-parent-turn] A typed `SpawnTreeState` keeps nested-spawn cost on the parent's books. Without it, a deep tree of work would be charged to the child `sessionId` and the parent would look free. ```sql -- "Whose user turn caused this token spend?" select parent_turn_id, sum(input_tokens) as input_tokens, sum(output_tokens) as output_tokens, max(subagent_depth) as deepest_spawn from harness_auditable_calls where tenant_id = $1 and created_at >= date_trunc('month', now()) group by parent_turn_id; ``` `parent_turn_id` resolves to the originating user message. A five-level fan-out still rolls back to one row in this output. See [Subagents](/docs/subagents) for the spawn lifecycle. ## Tenant nesting for ISVs [#tenant-nesting-for-isvs] ISVs embedding the runtime have two tenant axes: their direct customer (an org) and a sub-tenant inside it (a workspace, a project, a customer's own end-user). Both ride on the facet. ```typescript tenant: { tenantId: customer.orgId, // ISV's customer subTenantId: endUser.workspaceId, // customer's own sub-tenant }, context: { organizationId: customer.orgId, // mirror, for storage RLS userId: endUser.id, }, ``` A customer asking "how much did my workspace W cost?" is one `where sub_tenant_id = $1` predicate — no JSON extraction, no metadata fishing. See [Multi-tenant](/docs/multi-tenant) for the broader isolation pattern and [Recipes](/docs/recipes) (recipe 10\) for the per-sub-tenant rollup query. ## Cap enforcement: in the runtime, not in finance [#cap-enforcement-in-the-runtime-not-in-finance] A monthly cap is enforced before the call, not after. A safety policy that reads the current month's spend from the ledger and refuses dispatch is one of the canonical capability-subtracting policy shapes. ```typescript // lib/safety/monthlyCap.ts import { defineSafetyPolicy, safetyPolicyId } from "@pleach/core/safety"; export const monthlyCapPolicy = defineSafetyPolicy({ id: safetyPolicyId("saas.monthly-cap"), version: "1.0.0", enforcement: "refusal", content: ` [Monthly spend cap policy] This workspace runs under a hard monthly AI budget. When the operator's pre-dispatch hook flags "monthly_cap_exceeded" on the runtime context, refuse to invoke any model or tool. Reply with: "This workspace has hit its monthly AI budget. Contact your administrator." Do not paraphrase. Do not offer a workaround. `.trim(), }); ``` `defineSafetyPolicy` carries the operator's stated REFUSAL posture into the system prompt and the audit row. The actual cap-vs-spend check runs in the host's pre-dispatch hook (one ledger query per turn) and short-circuits the model invocation when the cap is hit. The audit row records the policy `id` + `version`, so a later review can ask "which turns refused under the monthly cap" in one query. See [Safety](/docs/safety) for the policy contract. ## Project layout [#project-layout] The biggest delta from the [baseline](/docs/project-layout#a-layout-that-works): `pleach/runtime.ts` no longer exports a singleton instance. It exports a `buildTenantRuntime(req)` factory. Everything else hangs off that change. ``` my-app/ src/ pleach/ runtime.ts # exports buildTenantRuntime(req) — NOT a singleton tenant.ts # loadTenant(orgId) — resolver the factory calls first providers/ pick.ts # pickProvider(tenant) — BYOK vs platform pool gateway/ with-tenant-header.ts # withTenantHeader wiring for outbound HTTP safety/ monthly-cap.ts # defineSafetyPolicy — refuses on cap exceeded otel/ setup.ts # span attributes including pleach.tenant_id app/ api/agents/[id]/route.ts # imports buildTenantRuntime, calls it per request ops/ rollups/ by-tenant.sql # the per-tenant cost GROUP BY cap-enforcement.sql # what the safety policy reads ``` What changes from the baseline: * **`runtime.ts` exports a factory, not an instance.** Every request resolves a tenant and builds a `SessionRuntime` with that tenant's facet, provider key, and storage scope wired in. This is the load-bearing structural change — it's also why cross-tenant cache pollution is [prevented by construction](#cross-tenant-cache-pollution-is-prevented-by-construction) rather than by a runtime check. * **`tenant.ts` runs first.** The factory's first step is the tenant resolver; its output drives every subsequent decision (provider, key, storage scope, cap). Splitting it out keeps the resolver swappable for tests. * **`providers/pick.ts` is the BYOK seam.** Anthropic-BYOK, OpenAI-BYOK, and platform-pool selection live in one file so the auth-key paths are auditable in a single read. * **`gateway/with-tenant-header.ts` threads the facet onto outbound HTTP.** Hand-plumbing tenant headers across tools invites the one tool that forgets; centralizing the wiring is the failure-mode fix. * **`ops/rollups/*.sql` ship in the repo.** Per-tenant cost rollups and cap-enforcement reads are the same SQL the product runs and the same SQL finance reviews. Keeping them in the repo means the column shape stays in sync with the [audit row](#what-the-audit-row-carries). The [`audit:tenant-scoping`](#inherited-audit-gates) gate makes the directory layout enforceable — a storage read without a tenant predicate fails CI, not production. ## Where to go next [#where-to-go-next] --- # Multi-tenant deployments (/docs/multi-tenant) A multi-tenant deployment of `@pleach/core` is where the [audit ledger](/docs/audit-ledger) pays for itself. The runtime carries `tenantId` through the [fingerprint](/docs/fingerprint) key, the [audit row](/docs/auditable-call-row), and the [storage](/docs/storage) schema — the tenant-isolation RLS policies enforce isolation *once the host arms the tenant context* (sets the active tenant on the database connection / forwards the tenant header; the `withTenantHeader` adapter below is provided for the forwarding leg), billing rollups are one query, and a regulator's "show me tenant X's usage" question has a one-line answer. This page is the operations guide. For the primitive reference (the `runtime.tenant` accessors and the type-level surface), see [runtime.tenant facet](/docs/tenant-facet). For the SaaS use-case shape, see [Multi-tenant SaaS agent](/docs/multi-tenant-saas-agent). Use this page as a checklist when standing up a customer-facing deployment. ## `tenantId` vs `organizationId` [#tenantid-vs-organizationid] Two fields, two layers, both required for a production multi-tenant deployment. | Field | Layer | What it does | | ---------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tenantId` | Substrate | Partition key the [event log](/docs/event-log), RLS policies, audit row, fingerprint, and OTEL attribute all read. The runtime's structural isolation primitive. | | `subTenantId` | Substrate | Optional second scope for per-team / per-workspace attribution under the same tenant. Stamped on every event log row; `NULL` when omitted. | | `organizationId` | Application | Your application-layer org id (Clerk org, Supabase row id). Stays on the runtime for product-level queries; doesn't drive substrate isolation. | `tenantId` is opaque to the substrate. It partitions whichever axis you're billing or attributing on — your end customers in a SaaS, or your own employees, teams, or cost centers when Pleach sits inside one Anthropic Workspace or OpenAI Project on an Enterprise contract. The rollup query is the same; the field carries `customer-acme` or `cost-center-eng-platform` interchangeably. See [Migrating from Anthropic Enterprise](/docs/migrating-from-anthropic-enterprise) and [Migrating from OpenAI Enterprise](/docs/migrating-from-openai-enterprise) for the contract-composition walk-through. `tenantId` defaults to `"default"` when omitted — single-tenant deploys, local dev, and tests work without per-runtime wiring. Production multi-tenant deploys must pass a concrete value. **Empty string is rejected** at construction with `TenantIdEmptyError`: the silent-isolation case (an unset env var interpolated as `""`) becomes a load-bearing throw at init, not a months-later billing incident. ```typescript new SessionRuntime({ tenantId: "" }); // → throws TenantIdEmptyError ``` ## Four places tenancy lives [#four-places-tenancy-lives] Get all four right — and arm the tenant context on the database connection — and isolation is enforced by the RLS policies rather than policed by application code. | Scope | Field | Lives in | | -------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------- | | **[Cache](/docs/cache) / dedup** | `tenantId` | Fingerprint key — one tenant never sees another's cached call. | | **Event log** | `tenant_id`, `sub_tenant_id` | Stamped by `EventLogWriter` on every `harness_event_log` row at write. | | **Storage RLS** | `tenant_id` | `harness_event_log_tenant_isolation` policy refuses cross-tenant reads at the database. | | **Audit + telemetry** | `payload.tenantId`, OTEL `pleach.tenant_id` | Per-tenant ledger queries; cost rollups by tenant; OTEL spans carry the attribute for dashboards. | A leak in any of these is a real incident. The substrate stamps all four from the single `tenantId` field — no separate wiring per scope. ## The `runtime.tenant` facet [#the-runtimetenant-facet] Read the substrate's tenant identity from the runtime itself, not from your own request context: ```typescript runtime.tenant.id; // "acme" — the partition key runtime.tenant.subId; // "team-7" | undefined ``` `id` and `subId` are properties, not methods — the facet is built once in the constructor and `Object.freeze`d, so reads are pure dereferences and `runtime.tenant === runtime.tenant` always holds (safe to put in `useMemo` deps). Use this in plugins, tool handlers, and gateway adapters that need the tenant scope without re-threading it through their own config. The facet is the stable accessor; reading constructor config directly (`config.tenantId`) is deprecated because the facet is the only path guaranteed to stay aligned with the event log stamping and OTel attribute. See [facets](/docs/facets) for the wider facet model and [tenant facet](/docs/tenant-facet) for the deeper runtime API write-up. ## Construction pattern [#construction-pattern] Per-request runtime, scoped to the requesting tenant. ```typescript // lib/runtime.ts import { createClient } from "@supabase/supabase-js"; import { SessionRuntime } from "@pleach/core"; import { SupabaseAdapter } from "@pleach/core/sessions"; import { SupabaseSaver } from "@pleach/core/checkpointing"; export function buildRuntime(req: AuthedRequest) { // Service-role client is fine here — the runtime threads tenant // scoping through `userId` + `organizationId` and the audit row. const supabase = createClient( process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, ); return new SessionRuntime({ storage: new SupabaseAdapter({ client: supabase }), checkpointer: new SupabaseSaver({ client: supabase }), userId: req.user.id, tenantId: req.user.orgId, // substrate partition key subTenantId: req.user.workspaceId, // optional second scope organizationId: req.user.orgId, // application-layer org row plugins: [compliancePlugin, gatewayPlugin], enabledSafetyPolicies: req.user.org.enabledPolicies, }); } ``` `tenantId` is the field every substrate scope reads — fingerprint, event log writer, RLS, OTEL. Pass it on every runtime construction; never default it in production. `organizationId` stays around for application-layer joins (it's the row id in your `organizations` table); keep them in sync until your product model needs them to diverge. For a Next.js route handler, the JWT is the source of truth — read it once at the top of the handler, then thread the claims straight into the runtime: ```typescript // app/api/chat/route.ts import { jwtVerify } from "jose"; export async function POST(req: Request) { const token = req.headers.get("authorization")?.replace("Bearer ", ""); const { payload } = await jwtVerify(token!, secret); const runtime = new SessionRuntime({ storage: new SupabaseAdapter({ client: supabase }), checkpointer: new SupabaseSaver({ client: supabase }), userId: payload.sub as string, // "user-7" organizationId: payload.org_id as string, // "org-acme" }); // ... handle the request } ``` `tenantId` rejects the empty string at construction; the canonical foot-gun is the *mismatch* — passing a non-empty `organizationId` while leaving `tenantId` to default to `"default"`. The application thinks it's scoped; the substrate pools every tenant's events into one partition. Mitigation is to thread the same value into both at the construction seam, or assert equality: ```typescript if ( config.organizationId && config.organizationId !== runtime.tenant.id ) { throw new Error("organizationId and tenantId must match"); } ``` ## Event log stamping at write [#event-log-stamping-at-write] Every row the substrate writes to `harness_event_log` carries `tenant_id` and `sub_tenant_id` stamped at write time by `EventLogWriter`. The stamping is structural: there's no application-layer code path that writes an event log row without the tenant scope attached. ```sql -- A representative row: SELECT id, tenant_id, sub_tenant_id, event_type, created_at FROM harness_event_log WHERE tenant_id = 'acme' ORDER BY created_at DESC LIMIT 5; ``` A `NULL` in either column is a bug — runtime construction either produced a concrete value or threw. Wire an audit query into your post-deploy smoke test: ```sql SELECT COUNT(*) AS unscoped_rows FROM harness_event_log WHERE tenant_id IS NULL; -- Expect: 0 ``` ## Storage scoping [#storage-scoping] The schema bundle ships RLS policies that filter on `user_id = auth.uid()` for anon clients. For service-role clients (the typical server-side path), RLS is bypassed — the runtime itself enforces the scope by always writing rows with the correct `user_id`, `organization_id`, and `tenant_id`. The predicates that ship: | Table | Predicate | | ------------------------- | ---------------------------------------------------------------------------------------- | | `harness_sessions` | `user_id = auth.uid()::text` | | `harness_checkpoints` | `user_id = auth.uid()::text` | | `harness_event_log` | `tenant_id = current_tenant()` (the `harness_event_log_tenant_isolation` policy) | | `harness_auditable_calls` | `tenant_id = current_tenant()` (plus a Clerk `user_id = auth.jwt()->>'sub'` owner scope) | `current_tenant()` is the shipped resolver: it reads the active tenant from the `app.tenant_id` GUC (`SET LOCAL`, for adapters on a persistent connection) or the forwarded `x-tenant-id` request header (the Supabase-JS / PostgREST path), falling back to `'default'`. The cross-tenant refusal only engages once one of those is armed. Two queries against the same event log row from two different tenants return one row for the matching tenant and zero for the other — *provided the connection carries the active tenant the policy reads*. The `harness_event_log_tenant_isolation` policy filters on `tenant_id = current_tenant()`, and `current_tenant()` resolves the active tenant from `app.tenant_id` (a `SET LOCAL "app.tenant_id"` GUC, for adapters holding a persistent connection) or the forwarded `x-tenant-id` request header (the PostgREST / Supabase-JS path), falling back to `'default'`. The host must arm one of those for the database to refuse the cross-tenant read — the `withTenantHeader` adapter below stamps the `x-tenant-id` leg. Once armed, the policy enforces the substrate scope independently of any application-layer org check; the runtime never gets the chance to filter in application code, because the database already did. For shared sessions (multi-user within a tenant), extend the default policies to include membership lookup via `harness_session_members`: ```sql DROP POLICY harness_sessions_owner_select ON harness_sessions; CREATE POLICY harness_sessions_tenant_select ON harness_sessions FOR SELECT USING ( user_id = auth.uid()::text OR id IN ( SELECT session_id FROM harness_session_members WHERE user_id = auth.uid()::text ) ); ``` Apply analogous policies on `harness_checkpoints`, `harness_event_log`, and `harness_auditable_calls`. The audit table additionally needs a `tenant_id` column read filter — ledger rows must never leak across tenants regardless of membership. ## Cache isolation [#cache-isolation] The fingerprint includes `tenantId`. Two identical calls from two tenants produce different fingerprints, hit different cache buckets, and never share a cached result. This is the property that lets you safely deploy a shared cache layer (Redis, an in-memory LRU, a CDN edge cache) without an isolation review per layer. The cache primitive is the fingerprint hash; the isolation primitive is what's in the hash. Don't add `tenantId` to a cache key by hand outside the fingerprint flow — the runtime already does it, and a second write site is a chance to forget. A worked case: `org-acme` and `org-globex` both ask the same question against `search_corpus` ("indexing strategies"). The fingerprint inputs are byte-identical *except* for `tenantId`, so the two hashes differ in every bit (sha-256 is avalanche-shaped). The cache layer's `GET fp.hash` reads return `null` for whichever tenant hits second, the provider call fires fresh, and the ledger row for `org-globex` lands with `cacheHit: false` — even though the prompt was identical. Zero risk of `org-globex`'s answer appearing in `org-acme`'s transcript by way of a shared cache. ## Per-tenant ledger queries [#per-tenant-ledger-queries] The audit ledger row carries `tenantId` in the payload. Cost rollups are one query: ```sql SELECT payload->>'tenantId' AS tenant_id, date_trunc('day', created_at) AS day, SUM((payload->'tokenUsage'->>'in')::int) AS input_tokens, SUM((payload->'tokenUsage'->>'out')::int) AS output_tokens, COUNT(*) AS call_count FROM harness_auditable_calls WHERE created_at >= now() - interval '30 days' GROUP BY 1, 2 ORDER BY 1, 2; ``` The same query keyed on `turn_id` instead of `tenant_id` gives you per-turn cost — useful when a tenant wants to see "how much did the AI cost for this specific conversation." When the billing report needs the session title alongside the cost, join through `harness_sessions`: ```sql SELECT s.organization_id AS tenant_id, s.id AS session_id, s.title, SUM((c.payload->'tokenUsage'->>'in')::int ) AS input_tokens, SUM((c.payload->'tokenUsage'->>'out')::int ) AS output_tokens, COUNT(*) AS call_count FROM harness_auditable_calls c JOIN harness_sessions s ON s.id = c.payload->>'sessionId' WHERE s.organization_id = 'org-acme' AND c.created_at >= now() - interval '30 days' GROUP BY 1, 2, 3 ORDER BY input_tokens + output_tokens DESC LIMIT 50; ``` The `@pleach/core/query` server-side helper (`getAggregateUsage(client, store, filter)`) wraps this with typed filters when you'd rather not write SQL. It scopes to one `userId` and groups by `session`, `agent`, `model`, `tool`, `day`, or `week`. ## OTel `pleach.tenant_id` attribute [#otel-pleachtenant_id-attribute] Once `runtime.tenant` is configured, every emitted OTel span carries `pleach.tenant_id` automatically — provider calls, tool runs, [subagent](/docs/subagents) spawns, [checkpoint](/docs/checkpointing) writes. No opt-in. See [OTel observability](/docs/otel-observability) for the full attribute surface and collector wiring. ## Per-tenant safety policies [#per-tenant-safety-policies] Different tenants in the same deployment can have different active safety policies — a regulated tenant enables sector-specific disclaimers, a non-regulated tenant doesn't. Both run the same plugin set; only the active list changes. ```typescript const runtime = new SessionRuntime({ // ... enabledSafetyPolicies: tenantConfig.safetyPolicies, }); ``` `enabledSafetyPolicies` participates in the fingerprint key, so enabling a policy invalidates the cache for that tenant automatically. No manual cache busting; the structural property takes care of it. ## Provider isolation per tenant [#provider-isolation-per-tenant] Two common patterns for letting tenants bring their own provider credentials: ### Pattern A — BYOK via runtime construction [#pattern-a--byok-via-runtime-construction] Construct the provider with tenant-supplied credentials at runtime build: ```typescript const provider = req.user.org.byokKey ? new AnthropicSdkProvider({ apiKey: req.user.org.byokKey }) // tenant BYOK (Anthropic-native) : new AiSdkProvider({ // platform pool via OpenRouter model: createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY! })("anthropic/claude-sonnet-4-5"), maxSteps: 5, }); const runtime = new SessionRuntime({ provider, /* ... */ }); ``` Simplest pattern. Works when one tenant = one provider. ### Pattern B — Gateway plugin [#pattern-b--gateway-plugin] Install `@pleach/gateway` and route every call through a tenant-aware gateway that picks the right credentials per call: ```typescript const runtime = new SessionRuntime({ plugins: [gatewayPlugin], // ... the gateway plugin reads tenantId off the runtime and // routes per its own config }); ``` Use when tenants share a provider but have different rate limits, different fallback chains, or different observability sinks. ## React: per-tenant `HarnessProvider` [#react-per-tenant-harnessprovider] In a multi-tenant React UI, the provider construction must be keyed on the current tenant — otherwise a tenant switch leaks the previous tenant's runtime: ```tsx function App({ tenantId, userId }: { tenantId: string; userId: string }) { const runtime = useMemo( () => new SessionRuntime({ storage: indexedDbAdapter(`pleach-${tenantId}`), userId, organizationId: tenantId, }), [tenantId, userId], ); return ( ); } ``` The `useMemo` keyed on `[tenantId, userId]` is load-bearing — re-renders within the same tenant keep the runtime; tenant switches rebuild it. Without the dep array, every render builds a new runtime and the chat resets. ## Cross-tenant analytics (admin) [#cross-tenant-analytics-admin] For internal dashboards that need to see *across* tenants, query the ledger directly with a service-role client. There is no `"tenantId"` groupBy — `getAggregateUsage` scopes to a single `userId` and its `groupBy` union is `session | agent | model | tool | day | week`. For a rollup keyed on tenant, use the raw SQL above; use the typed helper for a single user's usage: ```typescript import { getAggregateUsage } from "@pleach/core/query"; const usage = await getAggregateUsage(adminClient, store, { userId: "user-123", groupBy: "model", dateRange: { from: "2026-06-01", to: "2026-07-01" }, }); ``` Never expose these queries to tenant-facing code paths. The service-role client bypasses RLS; one accidental import in a browser bundle is a tenant-isolation incident. ## Checklist before going to production [#checklist-before-going-to-production] * [ ] `tenantId` passed on every runtime construction (and never `""`) * [ ] `organizationId` matches `tenantId` (or the divergence is intentional) * [ ] `subTenantId` wired where you need per-team / per-workspace attribution * [ ] Service-role keys never reach the browser bundle * [ ] Schema bundle applied; `harness_event_log_tenant_isolation` policy live * [ ] RLS policies extended for membership where shared sessions are needed * [ ] `enabledSafetyPolicies` resolved per-tenant * [ ] BYOK / gateway pattern chosen and wired * [ ] React `HarnessProvider` `useMemo` keyed on `[tenantId, userId]` * [ ] Per-tenant cost dashboards built off `getAggregateUsage` and `pleach.tenant_id` OTEL attribute * [ ] Post-deploy smoke query: `SELECT COUNT(*) FROM harness_event_log WHERE tenant_id IS NULL` → 0 * [ ] Tenant-isolation test: two tenants, identical input — verify different fingerprints, different event log rows, no cache crossover ## `withTenantHeader` adapter [#withtenantheader-adapter] `withTenantHeader` wraps a fetch client (or any outbound HTTP layer) and stamps a tenant header onto every outbound request. The header value comes from `runtime.tenant.id`, so the adapter and the substrate stay aligned. ```typescript import { withTenantHeader } from "@pleach/core/tenant"; const tenantFetch = withTenantHeader(fetch, { header: "x-tenant-id", tenantId: runtime.tenant.id, }); ``` Useful for hosts running behind an upstream gateway that routes by tenant header. Replaces the manual header-threading pattern consumers previously rolled themselves — that pattern is deprecated because it drifted from the facet whenever a tenant switch missed a call site. See [tenant facet](/docs/tenant-facet) for the deeper write-up. ## Audit gates that ride alongside tenancy [#audit-gates-that-ride-alongside-tenancy] Four CI gates ship enabled when a host adopts [`@pleach/compliance`](/docs/compliance). Two enforce tenant scoping directly; two ride alongside on the event-log allowlist surface that tenant rows persist through. **`audit:tenant-scoping`** scans every `tenant_id`-bearing migration for a matching RLS policy referencing `current_tenant()`. A failure means a table accepts tenant-scoped writes without the database enforcing the partition. **`audit:harness-event-log-tenant-id-required`** scans every write site under `packages/core/src/eventLog/**` for a `tenant_id` reference in the row literal, the canonical `eventToRow` stamping path, or an explicit `// tenant-id: ` opt-out marker. Catches code paths that would write `NULL` into the partition column. **`audit:c8-event-type-allowlist-coverage`** verifies every `EventLogInput` discriminated-union member has an entry in `SCRUBBABLE_FIELDS` (the per-event-type allowlist that `@pleach/compliance` scrubbers and the C8 redaction substrate consume). Tenant-bearing rows are no exception — a new event type that forgets its allowlist row would land unscrubbed. **`audit:c8-union-member-has-producer`** is the reverse-invariant companion — every union member must have at least one `writer.write({ type: "", ... })` producer in the substrate. A union member with no producer is dead substrate that the allowlist gate still demands an entry for. Together the four gates close the loop: tenancy must be stamped at the row, enforced at the database, scrubbed before persist, and the union surface stays free of orphan members on either side. ## Where to go next [#where-to-go-next] --- # Nodes (/docs/nodes) A node is an async function plus typed metadata. The function takes the current state, returns a partial state update, and the metadata tells the runtime three things: which lattice stage the node belongs to, which LLM seam (if any) it consumes, and which channels it reads and writes. The reactive engine reads that metadata to schedule nodes; the lattice gate reads it to refuse out-of-stage edges. Nodes are one of three concepts in the execution-graph cluster — graph, nodes, channels. See [Architecture → the execution-graph cluster](/docs/architecture#the-execution-graph-cluster) for the cluster framing. This page covers the node-level surface. See [Graph](/docs/graph) for how nodes compose into a `StateGraph`, and [Channels](/docs/channels) for the reactive state slots nodes flow through. ## The node shape [#the-node-shape] ```typescript import type { CallClass } from "@pleach/core/modelfamily" // The node-metadata shape you author for each node. The channel-wiring // fields mirror the `*_NODE_METADATA` consts in @pleach/core's graph nodes. interface NodeMetadataShape { stageId: "anchor-plan" | "tool-loop" | "synthesize" | "post-turn" acceptsSeam: CallClass | null subscribes?: string[] writes?: string[] } ``` | Field | Purpose | | ------------- | ---------------------------------------------------------------------------------------------------------- | | `stageId` | Lattice membership. One of the four stages. `audit:graph-stages` enforces it. | | `acceptsSeam` | `CallClass` literal if the node consumes an LLM seam; `null` if seam-free state transform. | | `subscribes` | Channels the node READS. When one of these advances, the engine schedules this node in the next superstep. | | `writes` | Channels the node UPDATES. The engine uses this to track downstream subscribers. | A minimal registration: ```typescript graph.addNode( "intentDetector", async (state) => ({ intent: classify(state.messages) }), { stageId: "anchor-plan", acceptsSeam: "utility", subscribes: ["messages"], writes: ["intent"], }, ) ``` LangGraph nodes typically return `new Command({ update, goto, graph: Command.PARENT })` — a sum type that collapses "edge decision + state update + cross-graph routing" into one return value. Pleach moves all three concerns to **declaration time**, not return time: * **State updates** — return a partial state object (LangGraph: `update` field). * **Edge routing** — declare with `subscribes` / `writes` metadata. The reactive engine schedules subscribers automatically (LangGraph: `goto` field). * **Cross-stage transitions** — lattice-gated by `stageId`. The four-stage lattice admits nine `(from-stage, to-stage)` edge patterns (five cross-stage transitions + four intra-stage chains); `audit:graph-stages` refuses out-of-stage edges at lint time (LangGraph: `graph: Command.PARENT`). * **Cross-subgraph routing** — subagents flow back to the parent turn via `SpawnTreeState` rather than a routing primitive. The two approaches converge: pleach trades runtime flexibility for static-analyzability (a graph linter can see the topology without running the nodes). If you need LangGraph's runtime routing for a specific pattern, return a state field the next stage's nodes subscribe to. ## Stage membership [#stage-membership] Every node declares `stageId`. The lattice is one-way; the only backward edge is the `messageId`-guarded `synthesize → synthesize` retry: ``` anchor-plan → tool-loop → synthesize → post-turn ↘ post-turn (recovery dispatch) ``` A node whose declared stage doesn't appear in this lattice — or whose outgoing edges cross it — fails `npm run audit:graph-stages` before CI green. The four stages are structural: they pin cost allocation, time-travel rollback points, and per-stage budget gates. See [Architecture — Stage lattice](/docs/architecture#1-stage-lattice) for the lattice's role in the broader substrate. ## Seam membership [#seam-membership] `acceptsSeam` is `null` for seam-free nodes and a `CallClass` literal (`utility`, `reasoning`, `converse`, `synthesize`) for nodes that consume an LLM seam. The literal is lint-restricted to the seam factories; outside the seams, a node resolves its model through `AgentAdapter.resolveModel()` and the locked call class threads through statically. Use `acceptsSeam: null` for pure state transforms, anchor builders, context projectors, and any node whose work is computation over existing state. Use a `CallClass` literal when the node fires a seam. See [Architecture — Seams](/docs/architecture#3-seams). ## Reactive scheduling [#reactive-scheduling] `subscribes` is what triggers the node. When a channel a node subscribes to advances its version, the engine schedules that node in the next superstep. `writes` is the inverse — the engine uses it to compute downstream subscribers for the channels this node updates. Without explicit metadata, the engine falls back to subscribing the node to **every** channel in the schema (and marking it as writing every channel). That conservative default keeps a metadata-less node correct but over-triggers it; declare explicitly when the read/write set matters for documentation, plugin-registered nodes, or any node whose subscription contract is load-bearing. See [Channels](/docs/channels) for the six channel kinds and their concurrent-write semantics. ## The default node catalogue [#the-default-node-catalogue] `buildDefaultAgentGraph` registers up to 44 nodes across the four stages — the [Node catalog](/docs/graph-node-catalog) enumerates every name and its `gated` condition. A representative slice per stage: ### `anchor-plan` (10 names) [#anchor-plan-10-names] * `sessionAnchor` — boots the turn with system primer + session-level context * `intentDetector` — classifies user intent * `planGenerator` — emits the per-turn plan * `planReconciler` — handles plan drift mid-turn * `skillActivation` — picks active skills for the turn * `costRouter` — model resolution + cost-aware routing * `contextProjection` — projects relevant history into the active context ### `tool-loop` (26 names) [#tool-loop-26-names] * `llm` — the per-turn LLM decision * `tools` — dispatches tool calls * `subagent` — spawns subagent runtimes * `safetyReview` — pre-synthesis safety gate * `creditBudget` — per-turn budget gate * `eventLogger` — logs completed tool-execution events * `repetitionGuard` — detects loops in tool selection * `hallucination` — narration-vs-execution analyzer * `fabrication` — unsourced-claim guard * `forceSynthesizer` — escape hatch when the loop won't converge — the `tool-loop → synthesize` transition * `continuation` — depth-zero continuation gate ### `synthesize` (1 name) [#synthesize-1-name] * `synthesizer` — the singleton synthesizer seam call; owns the final user-visible content `synthesize` is a true singleton — `synthesizer` is the only node the lattice admits in this stage, enforced by `SINGLETON_NODE_NAMES`. The recovery siblings earlier revisions placed here (`refusalHint`, `retryNarration`, `garbleRecovery`, `recovery`) retired from the lattice; they fire now as post-turn stream filters, not graph nodes. ### `post-turn` (7 names) [#post-turn-7-names] * `citation` — citation injection back to source data * `contextSummarizer` — context-window summarizer for next-turn insertion * `memoryExtraction` — long-term memory extraction * `consolidation` — turn consolidation before the memory write * `sessionMemoryWrite` — session-level memory persistence * `costRollup` — per-turn token/cost rollup * `answerSufficiency` — post-synthesis utility-seam judge verdict (D-NC-6) See [`src/graph/nodes/`](https://github.com/pleachhq/core/tree/main/src/graph/nodes) for the full set, and the [Node catalog](/docs/graph-node-catalog) for the audit-gated registry. See [`src/graph/nodes/`](https://github.com/pleachhq/core/tree/main/src/graph/nodes) for the full set. ## New-node checklist [#new-node-checklist] When adding a graph node to the canonical builder (or to a plugin's `extraGraphNodes()`), the following five steps are required for the audit gate to pass: 1. Declare `stageId` in the node-level `*_NODE_METADATA` constant the node file exports. 2. Declare `acceptsSeam: CallClass | null` — the literal for nodes that reserve a seam, `null` for pure state transforms. 3. Extend `NODE_STAGE_MAP` in [`src/graph/topology.ts`](https://github.com/pleachhq/core/tree/main/src/graph/topology.ts) with `: ""`. The audit reports `missing-stage` distinctly from `forbidden-edge`, so an unmapped node fails clearly. 4. If the node consumes a seam (`acceptsSeam !== null`), add a wire-check entry in `seamWireCheck.test.ts` asserting the binding resolves to the expected `ProviderSeam`. 5. Run `npm run audit:graph-stages` — the canonical graph has 28 nodes / 61 edges today; the count must update in the same PR with a documented rationale (or stay byte-identical when the node is plugin-registered). See [Audit gates](/docs/audit-gates) for the full pre-merge gate set and the `ci:graphnoderef` bundle the canonical graph runs under. ## Post-tool tier — agnostic by injection [#post-tool-tier--agnostic-by-injection] The post-tool nodes (`enrichment`, `safetyReview`, `quality`, `citation`) are wired into the lattice but their bodies are **domain-free**. Host runtimes supply the domain logic at runtime through `buildDefaultAgentGraph(config)`: | Config field | Node it wires | | ---------------------- | -------------- | | `enrichmentExecutor` | `enrichment` | | `safetyReviewExecutor` | `safetyReview` | | `qualityEvaluator` | `quality` | | `citationExtractor` | `citation` | The factory only registers the matching node when the executor is provided; absent executor means absent node. This is pure dependency inversion — the graph layer carries no hardcoded host logic for biosecurity scanning, citation extraction, domain quality thresholds, or enrichment passes. A host runtime stays in control of every domain decision through the executor it supplies. ## Authoring a custom node [#authoring-a-custom-node] A custom node is an async function returning `Partial`, registered with metadata. The example below summarises completed tool calls into a single string — no seam consumed, so `acceptsSeam` is `null`. ```typescript import type { ToolResult } from "@pleach/core" const toolSummaryMetadata = { stageId: "tool-loop", acceptsSeam: null, subscribes: ["completedTools"], writes: ["toolSummary"], } async function toolSummary(state: { completedTools: ToolResult[] }) { const lines = state.completedTools.map( (t) => `${t.success ? "ok" : "error"}: ${t.message ?? ""}`, ) return { toolSummary: lines.join("\n") } } graph.addNode("toolSummary", toolSummary, toolSummaryMetadata) ``` The metadata is the contract. The runtime reads `subscribes` to know when to schedule the node, `writes` to know who's downstream, `stageId` to refuse an out-of-lattice edge at compile time, and `acceptsSeam` to know the node doesn't consume an LLM. ## Plugin-registered nodes [#plugin-registered-nodes] A plugin contributes nodes via `HarnessPlugin.extraGraphNodes()`, which returns `PluginGraphNodeRegistration[]`: ```typescript interface PluginGraphNodeRegistration { name: string factory: (ctx: PluginGraphNodeContext) => GraphNode metadata?: StateGraphNodeMetadata } ``` The graph builder calls each factory once at compile time, passing a `PluginGraphNodeContext` with shared resources (storage adapter, event log writer, registries). This is the path for domain-specific nodes that shouldn't live in the substrate's generic builder — a retrieval node bound to a particular vector store, a tenant-specific safety pass, an integration-specific post-turn writer. See [Plugin contract](/docs/plugin-contract) for the full surface. ## Determinism contract [#determinism-contract] A node function MUST be deterministic given the same state input. The function is `async` because real nodes do I/O — a tool call, a storage read, a seam invocation — but the OUTPUT must be a function of the input state. No module-level RNG. No wall-clock reads outside the captured-at-turn-start values the runtime threads through. No reads from singletons that mutate between turns. Replay determinism depends on it. A node that captures `Date.now()` mid-body produces a different partial state on replay than on record, the engine schedules a different next superstep, and the diff harness flags the node as the divergence source. See [Determinism](/docs/determinism) for the five substrate-level contracts the node-level rule sits inside. ## What CI checks when you add a node [#what-ci-checks-when-you-add-a-node] A node you add — to the canonical builder or to a plugin's `extraGraphNodes()` — has to satisfy two gates before CI goes green: * `audit:graph-stages` — the node has a stage and its edges stay in the lattice. An unstaged node fails with the node named: `[graph-stages] Node "" missing stageId in NODE_STAGE_MAP`. * `audit:edge-inventory-completeness` — every cross-stage edge resolves to the documented edge inventory. Run both, plus the graph tests, with `npm run ci:graphnoderef`. The node is one row in the [extension map](/docs/extending), and the failure strings map to fixes on [Gate failures → fixes](/docs/gate-failures#you-added-a-graph-node). ## Where to go next [#where-to-go-next] --- # Observability (/docs/observability) Standing back from the hedge to see what's actually growing where. This page is the read-side observability surface. For the write-side audit ledger that these decorators wrap, see [Audit ledger](/docs/audit-ledger) and [AuditableCall row](/docs/auditable-call-row). The substrate emits two structured write streams ready for an observability stack: every `StreamEvent` fires on the runtime's event emitter, and every LLM call writes one `AuditableCall` row to the ledger. Both are typed; both are the seams to wire into your telemetry pipeline. This page documents the patterns that hold up in production — without breaking determinism, without blocking the turn, and without inventing a second write site for data the substrate is already writing. ```typescript import type { ProviderDecisionLedger, AuditableCall } from "@pleach/core/audit"; import type { StreamEvent } from "@pleach/core"; ``` > **Observability is a thematic island.** Four distinct surfaces > (`lineage`, `observability`, `otel-observability`, > `runtime-inspector`) — not a three-concept cluster. Readers > usually arrive already knowing which one they need. See > [What lives outside the cluster pattern](/docs/concept-clusters#what-lives-outside-the-cluster-pattern). ## The two write streams [#the-two-write-streams] | Stream | Where it fires | What's in it | | -------------------- | ----------------------------------- | ----------------------------------------------------------------------------- | | **Lifecycle events** | `runtime.on(eventType, handler)` | Every `StreamEvent` — sessions, messages, tools, sync, interrupts, subagents | | **Audit ledger** | `ProviderDecisionLedger.recordCall` | One typed row per LLM call — model, family, tokens, latency, decision payload | Wire both. The lifecycle stream covers turn-level shape (when did this turn start, how many tool calls fired, did it interrupt); the audit ledger covers per-call detail (which model, which family, how many tokens, how long, what was the decision). ## OpenTelemetry pattern [#opentelemetry-pattern] The canonical OTel integration wraps the audit ledger in a span tracer and subscribes turn-level events to a span builder. ```typescript // lib/observability/tracedLedger.ts import { trace, SpanKind, SpanStatusCode } from "@opentelemetry/api"; import type { ProviderDecisionLedger, AuditableCall } from "@pleach/core/audit"; const tracer = trace.getTracer("@pleach/core"); export class TracedLedger implements ProviderDecisionLedger { constructor(private primary: ProviderDecisionLedger) {} async recordCall(call: AuditableCall): Promise { const span = tracer.startSpan(`llm.${call.call.callClass}`, { kind: SpanKind.CLIENT, attributes: { "llm.model": call.call.model, "llm.family": call.call.provider, "llm.transport": call.call.transport, "llm.call_class": call.call.callClass, "llm.session_id": call.sessionId, "llm.turn_id": call.turnId, "llm.stage_id": call.stageId, "llm.tokens.input": call.tokenCost?.inputTokens, "llm.tokens.output": call.tokenCost?.outputTokens, "llm.latency_ms": call.outcome.latencyMs, "llm.fallback": call.decision.selectedReason, "llm.cache_hit": (call.cacheBreakpoint?.cacheReadTokens ?? 0) > 0, }, }); if (call.outcome.status === "failed") { span.setStatus({ code: SpanStatusCode.ERROR, message: call.outcome.finishReason ?? undefined }); } span.end(); return this.primary.recordCall(call); } } ``` Wire it as the active ledger: ```typescript import { setProviderDecisionLedgerFactory } from "@pleach/core/runtime"; setProviderDecisionLedgerFactory({ fromSupabase: (client) => new TracedLedger(new SupabaseProviderDecisionLedger(client)), }); ``` Per-call spans are the load-bearing telemetry. Don't roll your own span at the seam call site — the ledger is the seam the substrate already calls. A span emitted from a stream observer hook fires per chunk (potentially hundreds per turn) and breaks the sync-only observer contract; a span emitted from a custom provider wrapper double-counts against the ledger's own span and makes the trace tree confusing. One span per `recordCall` is the right cardinality — one per LLM call, attributed by `turnId` and `stageId`. ### Turn-level spans [#turn-level-spans] Wrap each turn in an outer span via the runtime event stream: ```typescript const activeSpans = new Map>(); runtime.on("step.start", (event) => { if (event.step === "anchor-plan") { const span = tracer.startSpan("agent.turn", { attributes: { "session.id": event.sessionId }, }); activeSpans.set(event.sessionId, span); } }); runtime.on("step.end", (event) => { if (event.step === "post-turn") { activeSpans.get(event.sessionId)?.end(); activeSpans.delete(event.sessionId); } }); ``` Turn-level spans + per-call child spans give you a complete trace tree per turn — what stages fired, what models were called, where the latency went. ### OTEL spans (in-process) [#otel-spans-in-process] The substrate now emits four span types directly: `session.turn`, `llm.invocation`, `graph.stage`, and `tool.execution`. Parent-threading nests them naturally per turn — the turn span contains stage spans, which contain LLM and tool spans. A `runtime.spans` facet exposes the exporter lifecycle plus introspection reads. Its `snapshot()` returns `{ inFlightCount, isShutdown }` — exporter state, not the span records. To inspect the trace tree itself during debugging, wire the reference `CapturingOtelExporter` and read its `captured` array (no OTLP collector needed). See [`/docs/otel-observability`](/docs/otel-observability#runtimespans-facet). The ledger-decorator pattern above still works for per-call span emission. The four built-in spans complement it; they don't replace it. Pick the decorator when you want per-`recordCall` control; use the built-in spans when you want the full turn tree for free. For the auto-flush controls (`otelFlushIntervalTurns`), the OTLP wiring recipe, and the `pleach.tenant_id` attribute, see [`/docs/otel-observability`](/docs/otel-observability). ## Datadog pattern [#datadog-pattern] Datadog's `dd-trace` library auto-instruments many libraries. For the substrate, the integration shape is the same as OTel — a ledger decorator emits per-call metrics: ```typescript // lib/observability/datadogLedger.ts import StatsD from "hot-shots"; import type { ProviderDecisionLedger, AuditableCall } from "@pleach/core/audit"; const dogstatsd = new StatsD({ host: process.env.DD_AGENT_HOST }); export class DatadogLedger implements ProviderDecisionLedger { constructor(private primary: ProviderDecisionLedger) {} async recordCall(call: AuditableCall): Promise { const tags = [ `model:${call.call.model}`, `family:${call.call.provider}`, `call_class:${call.call.callClass}`, `outcome:${call.outcome.status}`, ]; dogstatsd.increment("llm.calls", 1, tags); dogstatsd.histogram("llm.latency_ms", call.outcome.latencyMs, tags); dogstatsd.histogram("llm.tokens.input", call.tokenCost?.inputTokens ?? 0, tags); dogstatsd.histogram("llm.tokens.output", call.tokenCost?.outputTokens ?? 0, tags); if (call.outcome.status === "failed") { dogstatsd.increment("llm.errors", 1, [...tags, `reason:${call.outcome.finishReason}`]); } return this.primary.recordCall(call); } } ``` For Datadog APM (distributed tracing), use the `@opentelemetry/exporter-trace-otlp-http` exporter pointed at Datadog's OTLP intake — the OTel pattern above lands in Datadog APM unchanged. ## Honeycomb pattern [#honeycomb-pattern] Honeycomb consumes OTLP directly. Configure the SDK to point at Honeycomb's OTLP endpoint: ```typescript import { NodeSDK } from "@opentelemetry/sdk-node"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; const sdk = new NodeSDK({ traceExporter: new OTLPTraceExporter({ url: "https://api.honeycomb.io/v1/traces", headers: { "x-honeycomb-team": process.env.HONEYCOMB_API_KEY! }, }), serviceName: "my-agent", }); sdk.start(); ``` Configuring the SDK is necessary but not sufficient: `@pleach/core` never reads the global tracer provider, so the SDK alone captures no pleach spans. Pass a bridge implementing `OtelExporter` to the runtime via `config.otelExporter` to forward the four substrate spans into this pipeline — see the [OTLP bridge recipe](/docs/otel-observability#exporter-substrate-and-a-minimal-otlp-recipe). Honeycomb's strength is high-cardinality field aggregation — the per-call ledger row's `model_id`, `turn_id`, `tenant_id` make it a natural fit. Don't pre-aggregate; let Honeycomb do it. ## Prometheus pattern [#prometheus-pattern] For self-hosted Prometheus, expose a metrics endpoint that reads from an in-memory counter the ledger increments: ```typescript // lib/observability/promLedger.ts import { Counter, Histogram, register } from "prom-client"; const llmCalls = new Counter({ name: "pleach_llm_calls_total", help: "Total LLM calls", labelNames: ["model", "family", "call_class", "outcome"], }); const llmLatency = new Histogram({ name: "pleach_llm_latency_ms", help: "LLM call latency in ms", labelNames: ["model", "family", "call_class"], buckets: [50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000], }); export class PromLedger implements ProviderDecisionLedger { constructor(private primary: ProviderDecisionLedger) {} async recordCall(call: AuditableCall): Promise { const labels = { model: call.call.model, family: call.call.provider, call_class: call.call.callClass, }; llmCalls.inc({ ...labels, outcome: call.outcome.status }); llmLatency.observe(labels, call.outcome.latencyMs); return this.primary.recordCall(call); } } ``` Mount the `/metrics` endpoint: ```typescript app.get("/metrics", async (req, res) => { res.set("Content-Type", register.contentType); res.end(await register.metrics()); }); ``` For high-throughput deployments, the Counter/Histogram update cost is amortized into the ledger write — both are microsecond-scale. ## Structured logging [#structured-logging] The substrate's default loggers write event types and ids, not payloads. To stream the full event log into a structured logger: ```typescript import pino from "pino"; import type { StreamEvent } from "@pleach/core"; const log = pino({ level: process.env.LOG_LEVEL ?? "info" }); const interestingEvents: StreamEvent["type"][] = [ "error", "tool.failed", "sync.conflict", "stream.truncated", "interrupt.requested", ]; for (const type of interestingEvents) { runtime.on(type, (event) => { log[type === "error" ? "error" : "info"]({ event_type: type, session_id: event.sessionId, ...event, }); }); } ``` Don't log every event variant — `message.delta` fires per chunk and floods logs. The audit ledger is the per-call surface; structured logs are for the lifecycle shape. ### Forwarding `tool.completed` to a log sink [#forwarding-toolcompleted-to-a-log-sink] Subscribe to the lifecycle stream and push completion events to Loki, Honeycomb, or any sink that takes JSON: ```typescript runtime.on("tool.completed", async (event) => { await fetch(process.env.LOKI_PUSH_URL!, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ streams: [{ stream: { app: "knowledge-base", tool: event.toolName }, values: [[String(Date.now() * 1e6), JSON.stringify({ sessionId: event.sessionId, turnId: event.turnId, toolName: event.toolName, durationMs: event.durationMs, status: event.status, })]], }], }), }).catch(() => {}); }); ``` The `.catch(() => {})` matters: telemetry failures must not break the turn. ## Per-tenant dashboards [#per-tenant-dashboards] Tenant attribution is in the ledger row payload. The query layer groups it: ```typescript import { getAggregateUsage } from "@pleach/core/query"; const usage = await getAggregateUsage(client, store, { userId: "tenant-service-account", groupBy: "model", dateRange: { from: "2026-06-01", to: "2026-07-01" }, }); ``` Feed this into your dashboarding tool (Grafana, Looker, Metabase). The ledger schema is stable; queries written against it survive substrate upgrades. ### Joining the ledger to billing [#joining-the-ledger-to-billing] The load-bearing join key is `payload->>'tenantId'` on `harness_auditable_calls` against your billing table's tenant column (typically `tenants.id` or `organizations.id`). A second join on `payload->>'turnId'` lets a per-conversation invoice line-item exist — the same `turnId` that `runtime.executeMessage` stamps onto the ledger row is the one the React layer surfaces in DevTools, so support requests like "why was this conversation expensive" map back to a single `WHERE payload->>'turnId' = $1` query. ```sql SELECT b.tenant_name, SUM((c.payload->'tokenUsage'->>'in')::int) AS input_tokens, SUM((c.payload->'tokenUsage'->>'out')::int) AS output_tokens FROM harness_auditable_calls c JOIN billing_tenants b ON b.id = c.payload->>'tenantId' WHERE c.created_at >= date_trunc('month', now()) GROUP BY 1; ``` ### Per-day fallback rate [#per-day-fallback-rate] The audit row's `payload.fallbackReason` is the seam. Rolled up by day, it answers "how often is the primary model failing": ```sql SELECT date_trunc('day', created_at) AS day, COUNT(*) AS total_calls, COUNT(*) FILTER (WHERE payload->>'fallbackReason' IS NOT NULL AND payload->>'fallbackReason' <> 'none') AS fallback_calls, ROUND( 100.0 * COUNT(*) FILTER (WHERE payload->>'fallbackReason' IS NOT NULL AND payload->>'fallbackReason' <> 'none') / NULLIF(COUNT(*), 0), 2 ) AS fallback_pct FROM harness_auditable_calls WHERE created_at >= now() - interval '30 days' GROUP BY 1 ORDER BY 1; ``` A rising `fallback_pct` is the early signal for a primary-provider incident; pair with the alert thresholds below. ## What to alert on [#what-to-alert-on] Five signals from the substrate that correlate to user-facing issues: | Signal | What it means | Threshold | | ---------------------------------------- | ----------------------------- | -------------------------------- | | `error` events with code 5xxx (provider) | Provider outage or rate limit | `>1%` of calls over 5 min | | `family-exhausted` events | Every in-family rung failed | `>0` is a problem | | `sync.conflict` rate | Multi-device write contention | Set baseline; alert on deviation | | `tool.failed` rate per tool | Tool-specific breakage | `>5%` per tool over 1 hour | | Audit-ledger write failures | Storage degradation | `>0` is a problem | Don't alert on `message.delta` rate or token counts — those are business metrics, not reliability signals. A rising input-token count on a tenant is usually "they wrote a longer prompt this week," not "the substrate broke." Wire those into the billing dashboard, not the on-call paging path. ## Extending observability — plugin hooks [#extending-observability--plugin-hooks] The patterns above show the read-side: subscribe to the runtime's existing streams, decorate the ledger, fold the event log. Sometimes the right move is to inject observability *into* the stream — capture a custom probe per chunk, sanitize the rendered output, attach a span at a specific node. Two plugin hooks are the supported path. ### `contributeStreamObservers` — per-chunk probes [#contributestreamobservers--per-chunk-probes] Stream observers fire per stream delta on a specific seam invocation. They're the hook for per-chunk telemetry — a redaction gate, a latency probe, a coherence-score sampler. The registration declares a `when` filter (which `callClass`, `stageId`, optional `nodeId` triggers the observer) and a `factory` that returns the `StreamIterationObserver` whose `onChunk` runs per delta. ```typescript import type { HarnessPlugin } from "@pleach/core" import type { StreamObserverRegistration } from "@pleach/core/plugins/stream" const latencyProbe: StreamObserverRegistration = { when: { callClass: "synthesize", stageId: "synthesize" }, factory: (ctx) => { let firstChunkAt: number | undefined return { onChunk: (chunk) => { if (firstChunkAt === undefined) { firstChunkAt = performance.now() recordTtft(ctx.nodeId, firstChunkAt) } return { kind: "continue" } }, } }, } export const myPlugin: HarnessPlugin = { name: "ttft-probe", version: "0.1.0", contributeStreamObservers: () => [latencyProbe], } ``` Three rules hold for every stream observer: * **`onChunk` is synchronous.** Returning a `Promise` is rejected for replay-determinism. The observer can hand work off to a background queue, but it can't await. * **The `when` filter is AND-composed.** Every set field must match. `callClass: "*"` matches any class; omitting `stageId` matches any stage. * **The factory must not throw at construction.** `PluginManager` pre-screens factories via `smokeValidateFactories` at register time; a throwing factory surfaces as `PluginValidationError` before any session uses it. ### `contributeFinalizationPasses` — output sanitization [#contributefinalizationpasses--output-sanitization] A `SanitizerPass` runs on the rendered synthesizer output before it lands in the audit row. The hook for redaction passes, citation post-processing, output-shape normalization. Each pass declares an `id` and an `order` (lower runs first; ties resolve by registration order); the `apply(content, ctx)` method returns the rewritten string. ```typescript import type { HarnessPlugin } from "@pleach/core" import type { SanitizerPass, FinalizationPassContext, } from "@pleach/core/plugins" const redactInternalIds: SanitizerPass = { id: "redact-internal-ids", order: 100, apply(content: string, ctx: FinalizationPassContext): string { return content.replace(/INTERNAL-\d{6}/g, "[REDACTED]") }, } export const redactionPlugin: HarnessPlugin = { name: "internal-id-redactor", version: "0.1.0", contributeFinalizationPasses: () => [redactInternalIds], } ``` Finalization passes run after the `FabricationDetector` hooks, so a detector that flagged a suspect span has already routed before sanitization runs. See [Fabrication detection](/docs/fabrication-detection#adding-a-custom-detector) for the pre-finalization guard surface and [Plugin contract](/docs/plugin-contract) for the full hook set. ## What not to do [#what-not-to-do] A few patterns that fight the substrate: * **Don't log raw `AuditableCall` payloads** without wiring `PIIRedactor`. The payload carries user input; raw logs are a PII risk. * **Don't block the turn on telemetry writes.** The ledger contract is fire-and-forget; a telemetry decorator that throws fails the call, which fails the turn. * **Don't add wall-clock timestamps to spans inside the chain.** The substrate's IDs (ULIDs) carry timing; spans get their own timing from the tracer. Two clocks disagree about subtle things. * **Don't roll your own per-call instrumentation in plugin observer hooks.** Observers are sync-only by contract; making them telemetry sinks risks breaking the sync property. ## Where to go next [#where-to-go-next] --- # @pleach/observe (/docs/observe) `@pleach/observe` is the **brownfield entry point** to the Pleach audit ledger. It is a small, destination-flexible SDK — `init`, `recordCall`, `subagent`, and four destination factories — that sits in front of whatever agent loop you already run (the Vercel AI SDK, LangChain, the Anthropic or OpenAI SDK called directly, an in-house orchestrator) and writes one auditable row per LLM call to a backend **you** pick. The row is a strict subset of [`@pleach/core`](/docs/core)'s `AuditableCall` v13 record plus the `TokenCostRecord` field set. A row written through the SDK is forward-compatible with the full runtime row, so a buyer who later adopts `@pleach/core` keeps the existing audit history without a migration. ## When @pleach/observe fits [#when-pleachobserve-fits] Two reader-shaped questions: * **Do you already have an agent loop you do not want to rewrite?** Then `observe` is the brownfield hook. You add roughly 15 lines around your existing LLM calls and get the audit row, per-subagent attribution, and the destination of your choice. * **Are you starting fresh, or do you need replay determinism, family-locked routing, reactive channels, or checkpoint/restore?** Then [`@pleach/core`](/docs/core) is the better fit — those are runtime properties, not row properties, and the SDK does not carry them by design. The two paths are documented as a pair on [Adoption paths](/docs/adoption-paths). `observe` is the brownfield SDK; `@pleach/core` is the greenfield substrate. ## Install [#install] ```bash npm install @pleach/observe ``` `@pleach/core` is a `peerDependency` (`^0.1.0` today; bumps to `^1` once `@pleach/core@1.0.0` ships). Only the `ProviderDecisionLedger` TypeScript interface is referenced — the SDK does not pull a runtime implementation of the substrate. **Zero `@opentelemetry/*` runtime dependencies.** The OTel destination is buyer-callback only: your OTel SDK stays on your side; the SDK hands you a GenAI-semantic-convention envelope and your callback ships it to the exporter you already configured. **Zero transport peer dependencies** for Postgres / Supabase. Pass a buyer-constructed `pg.Pool` / `pg.Client` or `SupabaseClient`; the SDK uses minimal structural types so it never has to import the upstream packages. ## Quickstart — paste and run [#quickstart--paste-and-run] The Memory destination has no external dependencies; ideal for tests, local development, and first-look demos. ```typescript import { init, recordCall } from "@pleach/observe"; import { memory } from "@pleach/observe/destinations"; const dest = memory(); init({ destination: dest }); recordCall({ turnId: "turn_001", providerId: "anthropic", callClass: "synthesize", family: "anthropic", model: "claude-opus-4-7", inputTokens: 1000, outputTokens: 250, costUSD: 0.0125, startedAt: Date.now(), completedAt: Date.now() + 1200, }); console.log(dest.rows.length); // 1 console.log(dest.rows[0].model); // "claude-opus-4-7" ``` That is the entire surface for a one-shot write: one `init`, one `recordCall`. Swap `memory()` for any of the three other destinations below to ship the same row to Postgres, Supabase, or your OTel collector. ## The module-level API [#the-module-level-api] Four module-level entry points. `init` is called once per process; the other three are bound to the module singleton that `init` creates. ```typescript init(config: ObserveConfig): void recordCall(row: ObserveRow): void getRecorder(): ObserveRecorder // throws if called before init() subagent(name: string): ObserveRecorder & { run: (cb: () => Promise) => Promise } ``` `init` picks the destination, caches the config on a module-level singleton, and is singleton-per-process — a second `init` call throws (rather than silently replacing the live recorder, which would orphan in-flight writes against the prior destination). Re-initialization in tests is supported via the internal `__resetForTesting` hook exported from the root barrel. `recordCall` is the load-bearing entry. One call produces one `ObserveRow` written to the configured destination. The destination decides whether the write is synchronous (memory) or asynchronous (Postgres / Supabase / OTel batching). `getRecorder` is an escape hatch for advanced wiring — returns the active recorder so you can pass it across module boundaries without re-importing `recordCall`. It throws if called before `init` (it never returns `undefined`). `subagent(name)` is the per-sub-agent attribution helper, covered below. ### `ObserveConfig` [#observeconfig] ```typescript interface ObserveConfig { readonly destination: ObserveDestination; readonly sampling?: { readonly rate: number }; // 0..1, FNV-1a per-turnId readonly redactor?: (row: ObserveRow) => ObserveRow; // pure, sync, pre-write readonly redact?: PIIRedactionConfig; // substrate-side policy readonly throwOnDestinationError?: boolean; // default false (silent-swallow) } ``` * `sampling.rate` is hash-deterministic per `turnId` (FNV-1a) — all `recordCall` invocations inside one turn agree (ship-together or drop-together). `rate === 0` drops everything; `rate === 1` (default when omitted) keeps everything. Validated at `init` time; throws on `NaN`, non-finite, `< 0`, or `> 1`. * `redactor` is the buyer-supplied PII hook. Applied to every row *before* `destination.write`. Pure function contract. If the callback throws, the SDK swallows, warns via the `[Observe:redactor-threw]` anchor, and drops the row (fail-closed — the raw pre-redaction row is never written). * `redact` is the substrate-side policy (see [PII redaction](#optional-pii-redaction) below) — when configured, the recorder applies it automatically per row. * `throwOnDestinationError` opts out of the v0.x silent-swallow contract. When `true`, `recordCall` re-throws synchronous destination errors and leaves async rejections unhandled so process-level handlers can observe them. Default `false`. ### The `ObserveRow` shape [#the-observerow-shape] The row is a strict subset of `@pleach/core`'s `AuditableCall` v13 record plus the `TokenCostRecord` field set. Forward-compatibility is the point: any row the SDK writes is a valid `AuditableCall` row, and any consumer that already reads the v13 schema (`@pleach/eval`, `@pleach/compliance`, downstream dashboards) reads SDK rows without a code change. ```typescript interface ObserveRow { readonly turnId: string; // ULID readonly providerId: string; // "anthropic" | "openai" | "google" | "deepseek" | … readonly callClass: CallClass; // "synthesize" | "reasoning" | "utility" | "converse" readonly family: ProviderFamily; readonly model: string; readonly inputTokens: number; readonly outputTokens: number; readonly costUSD: number; readonly startedAt: number; // epoch millis readonly completedAt: number; // epoch millis readonly subagent?: string; // set automatically by subagent(...) readonly subagentPath?: readonly string[]; // ALS-scope path when nested readonly tags?: Record; } ``` Fields the v13 record carries that `ObserveRow` does not populate (the full `RoutingDecision`, the cascade walk history, the fingerprint payload, attestation hashes) stay `undefined` on the row. If a downstream consumer needs them, reach for [`@pleach/core`](/docs/core) — the runtime populates the full v13. `tags` is the open extension hook. The SDK never inspects the map beyond the `cost.*` namespace (see [Cost attribution](#cost-attribution) below); it is passed through to the destination as-is. Conventional keys (`env`, `release`, `user.tier`) align with the existing audit-ledger conventions. ## The four destinations [#the-four-destinations] `init({ destination })` accepts any of four factories from the `./destinations` subpath. Each is a function that returns an `ObserveDestination`. | Destination | Factory | Use case | | ------------- | --------------------------------------------------------------------------- | ------------------------------------------------------- | | Memory | `memory({ maxRows? })` | Tests, local dev, demos | | Postgres | `postgres({ pgClient, tableName?, schemaName? })` or `postgres({ ledger })` | Self-hosted production; existing PG infrastructure | | Supabase | `supabase({ client, tableName? })` or `supabase({ ledger })` | Managed PG; RLS-friendly; quick start | | OpenTelemetry | `otel({ exportSpan, serviceName? })` or `otel({ export, serviceName? })` | Existing OTel collector / Honeycomb / Datadog / Grafana | ### Postgres [#postgres] ```typescript import { Pool } from "pg"; import { init, recordCall } from "@pleach/observe"; import { postgres } from "@pleach/observe/destinations"; const pgClient = new Pool({ connectionString: process.env.DATABASE_URL }); init({ destination: postgres({ pgClient, tableName: "pleach_observe_calls" }), }); recordCall({ turnId: "turn_001", providerId: "openai", callClass: "synthesize", family: "openai", model: "gpt-5", inputTokens: 850, outputTokens: 320, costUSD: 0.0091, startedAt: Date.now() - 900, completedAt: Date.now(), }); ``` Reuses a buyer-owned `pg`-shaped client. The SDK does **not** add `pg` as a dependency; the structural type is enough. The buyer-owned table must include the columns the parameterized `INSERT` targets — see the package's `docs/postgres.md` for the full `CREATE TABLE` and the optional ledger-injection mode (`postgres({ ledger })`) where you pass a `ProviderDecisionLedger` adapter from `@pleach/core` directly. Postgres is the production default. Hosts already running Postgres for their application database get cost attribution joinable to the billing schema with one `GROUP BY tenant_id`. ### Supabase [#supabase] ```typescript import { createClient } from "@supabase/supabase-js"; import { init, recordCall } from "@pleach/observe"; import { supabase } from "@pleach/observe/destinations"; const client = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!); init({ destination: supabase({ client, tableName: "pleach_observe_calls" }), }); recordCall({ turnId: "turn_supabase_001", providerId: "google", callClass: "reasoning", family: "google", model: "gemini-2.5-pro", inputTokens: 2100, outputTokens: 480, costUSD: 0.0185, startedAt: Date.now() - 1450, completedAt: Date.now(), }); ``` Accepts a buyer-constructed `SupabaseClient`. RLS, auth, and table schema stay under buyer control. An RLS template policy ships in the package's `docs/supabase.md`. The optional ledger-injection mode (`supabase({ ledger })`) is the same shape as Postgres above. The trade-off vs the direct Postgres destination is the round trip through PostgREST. At >100 rows/s sustained, switch to the Postgres destination and keep Supabase Auth + Storage as separate concerns. ### OpenTelemetry [#opentelemetry] ```typescript import { init, recordCall } from "@pleach/observe"; import { otel } from "@pleach/observe/destinations"; init({ destination: otel({ serviceName: "my-agent", exportSpan: (envelope) => { // Hand `envelope` to your @opentelemetry/sdk-trace-base exporter. // SDK builds attributes per OTel GenAI semantic conventions. myExporter.export(envelope); }, }), }); recordCall({ turnId: "turn_otel_001", providerId: "anthropic", callClass: "synthesize", family: "anthropic", model: "claude-sonnet-4-5", inputTokens: 1500, outputTokens: 600, costUSD: 0.0228, startedAt: Date.now() - 1700, completedAt: Date.now(), }); ``` The SDK builds the GenAI-semantic-convention envelope (span name `` `.` `` — e.g. `anthropic.synthesize`; OTel GenAI attributes `gen_ai.system`, `gen_ai.request.model`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `gen_ai.operation.name`, plus the SDK-additive `pleach.observe.turn_id`, `pleach.observe.provider_id`, `pleach.observe.cost_usd`, and `pleach.observe.tag.*`) and hands it to your callback. You own the OTel SDK on your side: construct a `BatchSpanProcessor` against an OTLP exporter pointed at Honeycomb, Datadog, Grafana Tempo, or any OTLP collector, and forward the envelope from `exportSpan`. Two callback modes ship: * `exportSpan(envelope)` — the SDK builds the GenAI envelope and hands it to you (the example above). * `export(row)` — pass-through; the SDK hands you the raw `ObserveRow` and you map it yourself. This is the *"I already have observability"* destination. No new database to provision, no new dashboard to wire — the rows land in your existing backend through your own OTel configuration. ### Memory [#memory] ```typescript import { init, recordCall } from "@pleach/observe"; import { memory } from "@pleach/observe/destinations"; const sink = memory(); init({ destination: sink }); // later, in a test: expect(sink.rows).toHaveLength(2); expect(sink.rows[0].costUSD).toBeCloseTo(0.0143, 4); sink.clear(); // explicit reset between tests ``` In-process buffer. Useful in unit tests and as a `@pleach/eval` fixture; not suitable for production. The buffer does not drain on init — your test owns the buffer's lifecycle, by design. ## Sub-agent attribution [#sub-agent-attribution] Attribute calls to named planner, critic, or tool-runner sub-agents in your UI. `subagent(name)` is arity-1: it returns a scoped recorder. Pick the mode that matches your loop. Grab the recorder once, call `.recordCall(...)` directly. Zero `AsyncLocalStorage` overhead; ideal when you already thread per-agent identity through your code. ```typescript import { init, subagent } from "@pleach/observe"; import { memory } from "@pleach/observe/destinations"; init({ destination: memory() }); const planner = subagent("planner"); const critic = subagent("critic"); planner.recordCall({ turnId: "t1", providerId: "anthropic", callClass: "reasoning", family: "anthropic", model: "claude-opus-4-7", inputTokens: 1200, outputTokens: 320, costUSD: 0.014, startedAt: Date.now() - 900, completedAt: Date.now(), }); critic.recordCall({ turnId: "t1", providerId: "openai", callClass: "utility", family: "openai", model: "gpt-5", inputTokens: 450, outputTokens: 80, costUSD: 0.003, startedAt: Date.now() - 350, completedAt: Date.now(), }); ``` Each row is automatically tagged `subagent: "planner"` or `subagent: "critic"`. Wrap your sub-agent invocation in `.run(async () => { ... })` and any `recordCall` issued inside the callback inherits a `subagentPath` automatically. Use this for nested planner/executor/critic loops where you do not want to thread the name through every call site. ```typescript import { init, recordCall, subagent } from "@pleach/observe"; import { memory } from "@pleach/observe/destinations"; init({ destination: memory() }); await subagent("planner").run(async () => { await subagent("executor").run(async () => { // Row written here is tagged subagent: "executor", // subagentPath: ["planner", "executor"]. recordCall({ turnId: "t1", providerId: "anthropic", callClass: "synthesize", family: "anthropic", model: "claude-opus-4-7", inputTokens: 900, outputTokens: 220, costUSD: 0.011, startedAt: Date.now() - 800, completedAt: Date.now(), }); }); }); ``` Backed by `AsyncLocalStorage`. The nested-scope shape falls back gracefully on runtimes that do not support `AsyncLocalStorage` — only closure-scope works there. ## Cost attribution [#cost-attribution] `@pleach/observe` ships a tag-convention helper that populates the `cost.*` namespace on `ObserveRow.tags` for cross-tenant aggregation in dashboards. ```typescript import { recordCall, costAttribution } from "@pleach/observe"; recordCall({ turnId: "t1", providerId: "anthropic", callClass: "synthesize", family: "anthropic", model: "claude-opus-4-7", inputTokens: 900, outputTokens: 220, costUSD: 0.011, startedAt: Date.now() - 800, completedAt: Date.now(), tags: costAttribution({ workflowId: "lead-enrichment", tenantId: "acme-corp", featureId: "draft-email", }), }); ``` Three canonical keys lock at `v1.0.0`: `cost.workflow_id`, `cost.tenant_id`, `cost.feature_id`. Other `cost.*` keys are reserved for future use; reach for `tags` directly for buyer-defined dimensions outside the namespace. ## Optional PII redaction [#optional-pii-redaction] `@pleach/observe` ships a built-in matcher catalog so you can opt in to substrate-side PII scrubbing without writing your own regexes. ```typescript import { init } from "@pleach/observe"; import { memory } from "@pleach/observe/destinations"; import { BUILT_IN_MATCHERS, DEFAULT_REPLACEMENT } from "@pleach/observe"; init({ destination: memory(), redact: { fields: ["subagent", "tags.user_email", "tags.user_phone"], matchers: [ ...BUILT_IN_MATCHERS, // email + phone + SSN // ...your custom matchers ], replacement: DEFAULT_REPLACEMENT, // "[REDACTED]" }, }); ``` Public surface for the redaction config: | Export | Shape | When to use | | --------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PIIRedactionConfig` | type | Type the `redact:` arg to `init`. | | `BUILT_IN_MATCHERS` | `readonly Matcher[]` | Spread alongside custom matchers. | | `DEFAULT_REPLACEMENT` | `string` | Replacement token used by built-ins (`"[REDACTED]"`). Override per matcher. | | `APPLY_REDACTION_POLICY_ID` | `string` | Stable policy identifier (`"observe.applyRedaction.v1"`). `applyRedaction` stamps it on every row it scrubs under the `APPLY_REDACTION_POLICY_TAG_KEY` (`redactionPolicyId`) tag, so downstream queries can attribute redactions (`WHERE tags->>'redactionPolicyId' = 'observe.applyRedaction.v1'`; surfaces in OTel as `pleach.observe.tag.redactionPolicyId`). | For full GDPR / HIPAA-grade redaction policy management — DSAR flows, consent revocation, regional residency rules — pair `@pleach/observe` with [`@pleach/compliance`](/docs/compliance). The `@pleach/recipes` `compliantChatbot` factory wires both together. The `redactor` / `redact` callbacks above stay buyer-controlled; `@pleach/observe` does not auto-apply any compliance policy. ## The `ObserveDestination` interface [#the-observedestination-interface] Custom destinations are first-class. The interface is intentionally minimal: ```typescript interface ObserveDestination { write(row: ObserveRow): Promise | void; flush?(): Promise; } ``` `write` accepts one row and is allowed to be sync (the memory destination is) or async (the Postgres destination is). `flush` is optional; if your destination buffers, drain on a process shutdown hook. A buyer wiring a custom destination — say, a Kafka topic for a streaming pipeline, or a Cloudflare Queues binding — implements the two methods and passes the result to `init`. No registration, no service locator; the destination is a value, not a name. ## API surface — what to import from where [#api-surface--what-to-import-from-where] Three publishable subpaths (explicit, not glob): | Subpath | Public exports | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------- | | `@pleach/observe` | `init`, `recordCall`, `getRecorder`, `subagent`, `costAttribution`, sampling + redaction surface, fingerprint helper | | `@pleach/observe/destinations` | `memory()`, `postgres()`, `supabase()`, `otel()` + their option types | | `@pleach/observe/types` | `ObserveRow`, `ObserveDestination`, `ObserveConfig`, `ObserveRecorder`, `CallClass`, `ProviderFamily` | `MemoryDestination` (with `.rows` getter + `.clear()`), `PostgresDestination` (with `.mode`), `SupabaseDestination` (with `.mode`), and `OtelSpanEnvelope` are exported from their respective destination modules; type re-export from `./destinations` is deferred to a follow-up consolidation. ## What is hookable, what is not [#what-is-hookable-what-is-not] The SDK exposes a deliberate subset of the substrate. Five primitives are hookable from a brownfield agent loop: | Primitive | Hookable from SDK | | -------------------------------- | -------------------------------------------------------- | | `AuditableCall` row construction | Yes — `recordCall(row)`. | | `turnId` propagation | Yes — set on the row directly. | | Sub-agent attribution | Yes — `subagent(name)` (closure + ALS-scope). | | Fingerprint compute | Yes — `computeObserveFingerprint` re-export. | | PII redaction | Yes — `redactor` callback or `redact:` substrate policy. | Five things are **not** hookable, by design — they are properties of the runtime, not of the row: * **Family-locked routing.** The cascade-on-503 walk and the cross-family non-widening invariant live in [`@pleach/core`'s routing decision](/docs/family-lock). The SDK observes calls; it does not make them. * **Reactive channels.** Channel subscription, fan-out, and back-pressure are [`@pleach/core`](/docs/channels)-internal. * **Replay determinism.** Replaying a recorded turn against a fresh runtime requires the [event log](/docs/event-log) — the SDK writes a subset of fields, not the full log. * **Checkpoint / restore.** `@pleach/core/checkpointing` is the surface; the SDK has no notion of session state. * **Time-travel.** [`@pleach/replay`](/docs/replay)'s event-granular forking consumes the canonical event log, which the SDK does not produce. The split is the load-bearing line between brownfield and greenfield: the row is the substrate; the runtime is the engine. A buyer who needs the engine adopts `@pleach/core`. A buyer who needs the row uses the SDK and writes to wherever they want. ## Composing with `@pleach/core` [#composing-with-pleachcore] When a buyer ends up running both — the SDK for legacy code paths, the runtime for new ones — the two compose cleanly: * The runtime can detect the SDK's `init` config at startup. If a destination is configured, runtime audit writes can route through the SDK's transport instead of opening a parallel sink. * Fingerprint compute is shared. Both surfaces import from the same `@pleach/core/fingerprint` module; the SDK does not carry a second implementation. * Sub-agent attribution survives the migration. Rows written through `subagent("planner").recordCall(...)` carry the same `subagent` field that runtime-written rows do. The brownfield-to-greenfield migration is therefore monotonic — existing rows keep their ids and join cleanly to runtime-written rows. The [adoption paths](/docs/adoption-paths) page walks the migration shape end-to-end. ## License + versioning [#license--versioning] Published under **FSL-1.1-Apache-2.0** (Functional Source License with Apache 2.0 as the future license) — same license posture as the rest of the `@pleach/*` substrate set. Source- available, usable in production, free of charge during the FSL window; auto-transitions to permissive Apache 2.0 two years after first stable publish. See [Versioning](/docs/versioning) for the release-cadence policy. The 0.x line is pre-1.0 — pin the exact `0.x.y` version you adopt. The `0.0.x → 0.1.0` jump and the `0.1.x → 1.0.0` jump are both non-caretable; `^0.1.0` will not pick up later 0.x releases. ## Roadmap [#roadmap] Highlights for `0.x` → `1.0`: * Sampling configuration (`init({ sampling: { rate: 0.1 } })`) — shipped. * Buyer-supplied `redactor` hook — shipped. * Substrate-side PII redaction (`init({ redact: { fields, matchers } })`) — shipped, matcher catalog locked. * First publish-rehearsal (`0.2.0`) — operator-attended. v1+ candidates: * Pleach-hosted Observe destination (one option among several; never the only option). A `pleachHosted` factory is already reserved on `@pleach/observe/destinations` and throws `HostedBackendNotYetGA` on first `write` until the receiver ships separately — wiring the call now is safe; the receiver flip will land as a patch release with no SDK-side change. * Query surface (`AuditableCallQuery` → `ObserveRow`). * Cost-attribution dashboard. * Richer sub-agent attribution (deeper nesting via `AsyncLocalStorage` where supported). ## Where to go next [#where-to-go-next] --- # TurnOrchestrator (formerly OrchestratorClient) (/docs/orchestrator-client) `TurnOrchestrator` is the per-turn handle. When a turn begins, the runtime constructs one, threads it through the stream body and the substrate's tool / model / prompt resolution paths, and disposes of it at turn end. The instance holds **what's in scope for this one turn** — the channel config, the message history, the per-turn context, the tools available, the resolved model, the composed prompt. It's the turn-scoped sibling of [`SessionRuntime`](/docs/session-runtime) inside the [runtime-lifecycle cluster](/docs/session-lifecycle#the-runtime-lifecycle-cluster) — `SessionRuntime` hosts the session arc; `TurnOrchestrator` hosts each turn arc inside it. Both `TurnOrchestrator` and the deprecated `OrchestratorClient` alias export from the `@pleach/core/runtime` barrel — the canonical import path for hosts that need a typed handle on the per-turn surface — for example, when a custom strategy needs to read `client.history` or query `client.model`. ```typescript import type { TurnOrchestrator } from "@pleach/core/runtime"; ``` ### Renamed from `OrchestratorClient` [#renamed-from-orchestratorclient] The class was renamed under D-PO-3. `OrchestratorClient` survives as a deprecated re-export from the same `@pleach/core/runtime` barrel so existing consumers keep compiling: ```typescript // Still resolves; deprecated; removed at @pleach/core@2.0.0. import { OrchestratorClient } from "@pleach/core/runtime"; ``` Throughout the rest of this page we use `client` as the variable name to match the field naming on `SessionRuntime` and the plugin-hook context objects. ## The six facets [#the-six-facets] `TurnOrchestrator` exposes a facet surface that mirrors the `SessionRuntime` facet pattern — domain-grouped accessors instead of a flat method dump. Six user-facing facets plus one `_internal` namespace that consumer code must not touch. | Facet | What it carries | | ------------------------ | ----------------------------------------------------------- | | `client.config` | Turn config — channel, model overrides, abort signal | | `client.history` | The message history this turn sees, post-trim | | `client.context` | Per-turn context object — variables, scratchpad | | `client.tools` | Tools registered and available this turn | | `client.model` | Resolved model + family info — provider, family, model id | | `client.prompts` | Prompt building, graph-config snapshot, system-notice drain | | `client._internal.graph` | INTERNAL graph-internal composite — substrate-only | `client.prompts` covers the surface that used to live as flat methods on the class. Use `client.prompts.buildSystemPrompt(...)` instead of the deprecated `client.buildSystemPrompt(...)`. Same shape applies to `client.prompts.getGraphConfig()` and `client.prompts.drainPendingSystemNotices()`. The flat methods remain callable with `@deprecated` JSDoc and are removed at `@pleach/core@2.0.0`. `client._internal.graph` absorbs the seven `*Public` / `*ForGraph` suffixed methods the graph nodes call into the client with (`executeTool`, `applyPreModelTransforms`, `runPostModelHooks`, `getAuthToken`, `getThinkingConfig`, `getFallbackConfig`, `recordToolOutcomes`). The leading underscore is the contract — the field is intentionally not part of the user-facing surface and is callable only from graph nodes inside the substrate. For the full facet inventory across `SessionRuntime` and `TurnOrchestrator` see [Facets](/docs/facets). The `audit:orchestrator-facet-coverage` CI gate asserts every new public field on the client lands under a facet (or under `_internal`). The migration is complete — today's baseline shows zero remaining call sites on the deprecated flat methods, and the gate runs strict (FAIL on any novel direct call). See [Facets § CI gates](/docs/facets#ci-gates-enforcing-facet-coverage). ## `_internal.graph` — do not use [#_internalgraph--do-not-use] `TurnOrchestrator._internal.graph` is a substrate-only composite the graph nodes call into for bookkeeping (`executeTool`, `applyPreModelTransforms`, `recordToolOutcomes`, and the configuration accessors named above). The leading underscore is the contract: the field can change shape without a deprecation cycle. Consumer code that reads `_internal.graph` will break the next time the snapshot shape changes. The supported surface for graph inspection is `runtime.graph.*` on the [`SessionRuntime`](/docs/session-runtime), not `_internal.graph` on the client. See [Facets § What's internal — don't use](/docs/facets#whats-internal--dont-use) for the broader rule. ## Where the client comes from [#where-the-client-comes-from] Hosts don't construct `TurnOrchestrator` directly. The runtime allocates one per turn during `executeMessage` and disposes it at turn end. A host receives the client in three places: * **Inside a custom strategy.** Strategy slots that the per-turn body consumes (see [Runtime strategies](/docs/runtime-strategies)) take the client as part of their typed input bundle when the substrate needs to thread per-turn state through to the host. * **Inside a plugin hook.** Plugin hooks that participate in the turn — `contributePrompts`, `contributeRuntimeAwarePrompts`, `contributeStreamObservers` — receive a context object that carries the relevant client facets. * **Inside a custom turn body.** Hosts that swap the `streamSingleTurn` body for their own strategy receive the client as an explicit argument. See [streamSingleTurn](/docs/stream-single-turn) for the body contract. The client itself is not part of the language-agnostic wire contract — it's a TypeScript-side convenience. A Go implementation threads equivalent per-turn state through its own types. See [Language-agnostic contract § What's NOT in the contract](/docs/language-agnostic-contract#whats-not-in-the-contract) for the broader split. ## Coexistence with the graph path [#coexistence-with-the-graph-path] The substrate runs two execution paths side by side: the imperative `TurnOrchestrator` path (the per-turn body calls seams directly through the client) and the declarative graph path (the [`CompiledGraph`](/docs/graph) walks the four-stage lattice). Both write to the same `AuditableCall` ledger; both honor the same family-lock and singleton synthesize seam. The choice is a host decision, not a runtime fork. A host picks the imperative path when its turn shape is linear — one tool loop, one synthesis, no plan stage. The declarative path is the choice when the turn needs the lattice's enrichment slots (intent detection, plan generation, quality scoring) or when multiple plugins contribute graph nodes. ## Minimal consumer usage [#minimal-consumer-usage] Most hosts never reach for `TurnOrchestrator` directly. The runtime threads it through the substrate; plugin hooks and strategies receive what they need via typed input bundles. A host that **does** need the typed handle — typically for a custom strategy implementing a `streamSingleTurn` swap — imports the class for the type, not the constructor: ```typescript import type { TurnOrchestrator } from "@pleach/core/runtime"; function myCustomTurnBody(client: TurnOrchestrator) { const config = client.config.get(); // OrchestratorConfig snapshot const selection = client.model.getLastSelection(); // ModelSelection | null const tools = client.tools.getResolved(); // ToolDefinition[] // ... drive the seams using the typed facets } ``` ## Where to go next [#where-to-go-next] --- # OTEL spans & exporter wiring (/docs/otel-observability) OTel spans are one surface in the **observability** [thematic island](/docs/concept-clusters#what-lives-outside-the-cluster-pattern) — siblings of [observability](/docs/observability) (the orientation page), [lineage](/docs/lineage), and [runtime inspector](/docs/runtime-inspector). This page is the read-side OTel surface. For the write-side audit ledger that the `llm.invocation` span pairs one-to-one with, see [Audit ledger](/docs/audit-ledger). This page covers the four span types the substrate emits, the parent-threading rules that nest them correctly, the auto-flush knob, the `runtime.spans` in-process introspection facet, and a minimal OTLP exporter wiring. For non-OTel observability — structured logs, Prometheus, Datadog statsd, lifecycle-event streaming — see [Observability](/docs/observability). ## The four span types [#the-four-span-types] The substrate emits exactly four span types. Every other span in your trace tree is either a parent (your HTTP handler) or a child that you wired yourself. | Span type | Where emitted | Key attributes | | ---------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `session.turn` | Around `runtime.executeMessage` for one user turn | `pleach.session_id`, `pleach.turn_id`, `pleach.tenant_id`, `pleach.actor_kind` | | `llm.invocation` | One per LLM call — same cardinality as the audit ledger row | `pleach.model_id`, `pleach.family`, `pleach.call_class`, `pleach.tokens.input`, `pleach.tokens.output`, `pleach.tenant_id` | | `graph.stage` | Around each orchestrator stage transition | `pleach.stage_id` (`anchor-plan` / `tool-loop` / `synthesize` / `post-turn`), `pleach.session_id`, `pleach.turn_id` | | `tool.execution` | Per tool dispatch — pairs with the `tool.completed` stream event | `pleach.tool_name`, `pleach.capability_id`, `pleach.status`, `pleach.duration_ms` | These four are the cardinality-correct seams. Adding your own per-call span at a custom seam double-counts against `llm.invocation` and clutters the trace tree. ## Parent-threading [#parent-threading] Spans nest naturally: a `session.turn` parents the `graph.stage`s fired during that turn; each `graph.stage` parents the `llm.invocation`s and `tool.execution`s emitted during that stage. The substrate threads the active span through the OTel context API (`context.with(setSpan(ctx, span), fn)`), so consumer spans created inside a runtime callback nest under the active stage without manual parenting. If your HTTP handler opens its own outer span, `session.turn` nests under that — the chain reads `http.request → session.turn → graph.stage → llm.invocation` end to end. ## Auto-flush [#auto-flush] The exporter buffer flushes on a turn cadence. The knob lives on `SessionRuntimeConfig`: ```typescript import { SessionRuntime } from "@pleach/core"; const runtime = new SessionRuntime({ storage: memoryAdapter, userId: "user-123", otelFlushIntervalTurns: 1, // default: flush every turn }); ``` Default is `1` — the buffer drains at the end of every `session.turn`. Raise it (`5`, `10`) to batch in high-throughput deployments where the per-turn flush cost shows up in tail latency. Set it to `0` to disable the runtime-driven flush entirely and let the SDK's batch processor drive cadence on its own timer. ## `runtime.spans` facet [#runtimespans-facet] The `runtime.spans` facet is the in-process handle on the wired exporter. It exposes the exporter lifecycle — `start(input)` to open a host span, `flush()` to drain buffered snapshots, `shutdown()` to close the exporter — plus three introspection reads: ```typescript import { SessionRuntime } from "@pleach/core"; const runtime = new SessionRuntime({ /* ... */ }); await runtime.executeMessage(sessionId, "hello"); runtime.spans.inFlightCount(); // number — spans started but not yet ended runtime.spans.isShutdown(); // boolean — has the exporter been shut down? runtime.spans.snapshot(); // → { inFlightCount: number; isShutdown: boolean } ``` `snapshot()` returns the exporter's lifecycle state, **not** the span records — it's `{ inFlightCount, isShutdown }`, a single read of the two introspection counters. Calling `.find(...)` on it throws; it is not an array. The default `NoopOtelExporter` tracks both counters accurately even when no sink is wired. To READ the emitted span records in-process — for test assertions or a DevTools trace-tree panel — wire the reference `CapturingOtelExporter` and read its `captured` array directly. It captures every span snapshot in memory instead of forwarding to an external collector: ```typescript import { SessionRuntime } from "@pleach/core"; import { CapturingOtelExporter } from "@pleach/core/otel"; const exporter = new CapturingOtelExporter(); const runtime = new SessionRuntime({ /* ... */, otelExporter: exporter }); await runtime.executeMessage(sessionId, "hello"); const turnSpan = exporter.captured.find((s) => s.name === "session.turn"); // each captured span is a PleachSpanSnapshot: // { // name: string; // kind: "session.turn" | "llm.invocation" | "graph.stage" // | "tool.execution" | "host.custom"; // id: string; // parentId: string | undefined; // attributes: Record; // includes the pleach.* keys // status: "ok" | "error" | "unset"; // error: Error | undefined; // startTimeNs: bigint; // endTimeNs: bigint; // durationNs: bigint; // } ``` `CapturingOtelExporter` is the test/DevTools sink. Production hosts wire a real sink adapter instead — see the recipe below. ## Exporter substrate and a minimal OTLP recipe [#exporter-substrate-and-a-minimal-otlp-recipe] `@pleach/core` has zero `@opentelemetry/*` imports. It emits its four span types through one contract — `OtelExporter` — and nothing else. The runtime does **not** read the global tracer provider. If `config.otelExporter` is omitted, the runtime uses the default `NoopOtelExporter`, which drops every span. A NodeSDK started alongside the runtime captures no pleach spans on its own. So a real OTLP pipeline needs two pieces: the OTel SDK (configures the destination and registers a tracer provider) **and** a small bridge that implements `OtelExporter` against `@opentelemetry/api`, passed to the runtime as `config.otelExporter`. The bridge lives in your host code (or a `@pleach/observability` adapter) — it's the only part that imports the OTel SDK. ```typescript import { NodeSDK } from "@opentelemetry/sdk-node"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; import { trace, SpanStatusCode } from "@opentelemetry/api"; import { SessionRuntime } from "@pleach/core"; import type { OtelExporter, PleachSpanStart, PleachSpanHandle, } from "@pleach/core/otel"; // 1. Configure and start the OTel SDK. This registers a tracer // provider and points the OTLP exporter at your collector. const sdk = new NodeSDK({ traceExporter: new OTLPTraceExporter({ url: process.env.OTLP_ENDPOINT, // e.g. https://api.honeycomb.io/v1/traces headers: { "x-honeycomb-team": process.env.HONEYCOMB_API_KEY ?? "", }, }), }); sdk.start(); // 2. Bridge @pleach/core's OtelExporter contract to @opentelemetry/api. // This is the only file that imports the OTel SDK; core never does. const tracer = trace.getTracer("@pleach/core"); class OtelApiExporter implements OtelExporter { startSpan(start: PleachSpanStart): PleachSpanHandle { const span = tracer.startSpan(start.name, { attributes: { ...start.attributes }, // carries pleach.tenant_id, pleach.family, … }); return { id: crypto.randomUUID(), setAttribute: (k, v) => span.setAttribute(k, v as never), setAttributes: (attrs) => span.setAttributes(attrs as never), setStatus: (status, error) => span.setStatus({ code: status === "error" ? SpanStatusCode.ERROR : SpanStatusCode.OK, message: error?.message, }), recordError: (error) => { span.recordException(error); span.setStatus({ code: SpanStatusCode.ERROR }); }, end: () => span.end(), }; } async flush(): Promise { /* the SDK's batch processor drains on its own timer */ } async shutdown(): Promise { await sdk.shutdown(); } } // 3. Wire the bridge into the runtime. WITHOUT this, spans fall to the // default NoopOtelExporter and go nowhere. const runtime = new SessionRuntime({ storage: memoryAdapter, userId: "user-123", otelExporter: new OtelApiExporter(), }); ``` The bridge is destination-agnostic: the same `OtelApiExporter` lands in Honeycomb, Datadog's OTLP intake, Grafana Tempo, and any self-hosted OpenTelemetry Collector — only the OTLP `url` and auth headers in step 1 differ. For Datadog APM, point `OTLP_ENDPOINT` at the agent's OTLP intake (`http://localhost:4318/v1/traces` by default). For Tempo or a self-hosted Collector, point at the collector's OTLP HTTP receiver. ## `pleach.tenant_id` attribute [#pleachtenant_id-attribute] Every span carries `pleach.tenant_id` when `runtime.tenant` is configured, or when an outbound HTTP call rides through the `withTenantHeader` adapter. The attribute is set at span-start from the active tenant context, so it appears on all four span types without per-call wiring on your side. This is the load-bearing field for per-tenant trace queries — the Honeycomb / Tempo / Datadog query `WHERE pleach.tenant_id = $1` gives you a tenant's full trace history without joining to the audit ledger. See [Tenant facet](/docs/tenant-facet). ## Audit gate [#audit-gate] `audit:otel-noop-soak` runs in CI to catch span-emit drift. It asserts two things: 1. The substrate emits exactly one of each of the four span types when a turn runs against an in-process exporter. A refactor that silently stops emitting one (a missing `tracer.startSpan` after a control-flow change) fails the gate. 2. The noop default — no exporter wired, no SDK started — stays genuinely noop. No allocations from span construction, no buffered records hanging on a process-global. For consumers, the gate is informational: it tells you the substrate's emit contract is enforced upstream. You don't run it; the upstream CI does, and a release that ships with it red is the release that breaks your trace tree. ## What not to do [#what-not-to-do] * Don't add wall-clock timestamps as span attributes. Spans carry their own timing via `startTimeUnixNano` / `endTimeUnixNano`; a second clock disagrees with the first about subtle things (NTP skew, monotonic vs wall). * Don't emit your own per-call span at a custom seam. The `llm.invocation` span is the cardinality-correct one — one per audit row, one per actual provider call. A custom span at a provider-wrapper site double-counts; a span in a stream observer fires per chunk and floods the trace tree. * Don't share a single OTLP exporter across worker processes without configuring the SDK for it. The default batch processor isn't fork-safe; each worker needs its own `NodeSDK.start()` after the fork, or the parent's exporter ends up holding spans the workers emit. * Don't disable `pleach.tenant_id` to "reduce cardinality." It's the join key every tenant-scoped trace query reads. Cardinality control belongs at the exporter's sampler, not at the attribute source. ## Where to go next [#where-to-go-next] --- # Overview (/docs/overview) Pleach is a TypeScript agent runtime. Sessions hold state; turns drive change; every LLM call lands as one append-only audit row keyed by `turnId`. The runtime, the four-stage lattice, and the audit ledger form a triangle — the rest of the docs are detail. The name is horticultural. A `streamText` call is one branch upright in a pot. Pleach is the lattice — every addressable event the agent produces (each LLM call, tool dispatch, subagent spawn) lands as a branch, woven into one structure you can query, prune, and replay six months later. The four stages are the trellis; the audit row is the weave. This page gives you the shape in 90 seconds. Install steps live on [Getting started](/docs/getting-started); framework comparison lives on [Comparison](/docs/comparison). ## The mental model [#the-mental-model] One request in, one streamed answer out. The `SessionRuntime` owns the turn; the ledger hangs off it as an append-only sink so every addressable decision is queryable after the fact. ## Session vs turn [#session-vs-turn] A session is the unit of identity that outlives any one message. A turn is one message-in, one answer-out. They have different shapes: | Property | Session | Turn | | -------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------- | | Scope | Per-conversation; locks `(provider, model)` for its lifetime | Per-message; runs the four-stage lattice once | | Persistence | Durable row + checkpoint chain in storage | Derived from the event log; no separate row | | Abort behavior | Idempotent delete; checkpoints drop, audit rows stay | `AbortSignal` cancels in-flight provider calls; partial spend still lands a row | Session lifecycle (mint, resume, fork, delete) is documented at [Session lifecycle](/docs/session-lifecycle). The dynamic per-message arc lives at [Turn lifecycle](/docs/turn-lifecycle). ## A turn, in four stages [#a-turn-in-four-stages] The lattice is structural. Every node in the compiled graph belongs to exactly one stage; `audit:graph-stages` fails the build on an out-of-lattice edge. `post-turn` is terminal — no edge out. ### `anchor-plan` [#anchor-plan] Bootstraps the turn: intent classification, plan generation, and anchoring of references the tool loop will need. Fires `step.start` on the stream and writes one ledger row per call with `payload.kind: "planGeneration"`. ### `tool-loop` [#tool-loop] The iterative core. One `reasoning` call per iteration may emit tool calls; the runtime dispatches them and re-enters the loop. Fires `tool.started` / `tool.completed` per dispatch and writes `cacheBreakpoint`, `toolSelection`, and (on retry) `fallbackStep` rows. ### `synthesize` [#synthesize] Exactly one synthesize call per turn — capped by a per-runtime singleton seam plus an idempotent counter so the rendered string and the audited string are the same string. Fires `message.delta` and `message.complete`; writes one `synthesisQuality` row. ### `post-turn` [#post-turn] Terminal stage. Durable writes flush, enrichment plugins run, checkpoints land. No LLM calls fire here. Fires `checkpoint.created` and then `done`. For the full per-stage stream-event and ledger-payload tables, see [Turn lifecycle → the four-stage execution path](/docs/turn-lifecycle#the-four-stage-execution-path). ## What gets recorded [#what-gets-recorded] Every addressable decision — every seam-bound provider call, every in-family retry, every tool-cascade rung — lands one row. The ledger is append-only by contract: `ProviderDecisionLedger` exposes no `update` and no `delete`. A per-turn cost rollup is one `GROUP BY turn_id`; a per-stage budget is one `GROUP BY stage_id`. The four-field identity tuple is what makes joins cheap. `turnId` joins to your billing row; `stageId` slices the spend; `sessionId` joins to the session record; `seqWithinTurn` gives a stable order inside the turn. See [The AuditableCall row](/docs/auditable-call-row) for the full field shape and the typed-payload discriminants. Concretely, a per-turn cost rollup looks like: ```sql SELECT turn_id, SUM(input_tokens + output_tokens) AS total_tokens FROM harness_auditable_calls WHERE session_id = $1 GROUP BY turn_id; ``` That query works against a row shape that exists by construction — every row carries a non-null `turn_id` because a call that fires outside the lattice fails CI before it ever reaches a provider. The axis is yours to pick. `tenantId` carries `customer-acme` if you're invoicing end customers in a SaaS; it carries `cost-center-eng-platform` if you're attributing employee or team spend under one Anthropic Workspace or OpenAI Project on an Enterprise contract. The rollup is the same `GROUP BY` either way — no second vendor contract, no second admin key. See [Migrating from Anthropic Enterprise](/docs/migrating-from-anthropic-enterprise) and [Migrating from OpenAI Enterprise](/docs/migrating-from-openai-enterprise) for the composition walk-through. ## Three doors [#three-doors] Pick the closest. The rest of the index is reference. ## Where the rest of the docs live [#where-the-rest-of-the-docs-live] The sidebar is grouped by what you reach for, not by package boundary. **Core primitives** (SessionRuntime, Facets, Providers, Tools, Prompts) is the day-one surface. **Compose agents** (Agents, Subagents, Skills, Plans, Memory) is the multi-step shape. **Runtime lifecycle** (Channels, Event log, Projections, Stream events, Interrupts, Async tasks) is what the runtime is doing between your calls. **State & persistence** (Storage, Cache, Checkpointing, Sync) is how state survives a process restart. **Safety & determinism** (Safety policies, Scrubbers, Fabrication detection, Determinism, Fingerprint) is the property set replay and eval depend on. **Audit & observability** (Audit ledger, Hash chain, AuditableCall row, Lineage, OTEL spans) is the read side. **Frontend** (React, Server, API routes, Query, DevTools) and **Integration** (MCP, LangChain adapter, Host adapter, Plugin contract) wire the runtime into a real application. **Operations** (Deployment, Multi-tenant, Performance, Security, Compliance, Error codes) is what production needs. ## What this site is not [#what-this-site-is-not] Not where the package source lives. `@pleach/core` is published from the upstream repository at `github.com/pleachhq/core` and consumed here as a documentation target — the version of `@pleach/core` rendered against this site is whatever the package manifest pins. Not the canonical reference for any individual package — each package's README on npm is. If this site disagrees with a published README (a row in the [model resolution matrix](/docs/model-resolution-matrix), a field name in the [AuditableCall row](/docs/auditable-call-row), a verdict in the [stream-observer ladder](/docs/architecture#3-seams)), the README wins. ## Where to go next [#where-to-go-next] --- # Ownership boundaries (/docs/ownership) This page answers one question per row: *if I want to change behavior X, whose surface is X, and is it a config field, a plugin hook, or a fork?* It complements the other orientation docs without duplicating them — [Packages](/docs/packages) describes what each SKU does, [Which SKU do I need?](/docs/which-sku) maps tasks to install lists, and this page draws the boundary between the SKU's locked surface and your host code. Every row sorts into one of three columns: | Column | What it means | | -------------------- | ----------------------------------------------------------------------------------------------------------------------- | | **Owned by the SKU** | Fixed by the package. The audit gates refuse builds that try to alter it. Your only path here is upstream contribution. | | **You configure** | A typed config field, a plugin hook, or a registered registry. Supported, additive, no forking required. | | **You must supply** | Required at construction. The SKU throws or short-circuits without it. | Two structural pins hold across every SKU: * **The four-stage lattice** — `anchor-plan → tool-loop → synthesize → post-turn`. Every node in every SKU belongs to one of the four stages; see [Graph](/docs/graph) for the gate and [Node catalog](/docs/graph-node-catalog) + [Edge catalog](/docs/graph-edge-catalog) for the enumerated surface. * **The audit row** — one `AuditableCall` row per LLM call, keyed by `turnId`. Every SKU writes through the same row shape; see [`AuditableCall` row](/docs/auditable-call-row) for the schema. You can't disable, replace, or bypass either pin from any extension path. They're substrate, not policy. ## `@pleach/core` — the substrate [#pleachcore--the-substrate] The lattice and the audit ledger. Every other SKU plugs in through `HarnessPlugin`; `@pleach/core` is the only package whose absence breaks the build. | Owned by the SKU | You configure | You must supply | | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | | [Four-stage lattice](/docs/graph#the-lattice-gate) | `llmExecutor` — your LLM provider call | At least one LLM provider | | [Channel kinds and reducers](/docs/channels) | `toolExecutor` — tool dispatch | A storage adapter (or the in-memory default) | | [Singleton synthesize seam](/docs/graph#singleton-synthesize-seam) | `subagentExecutor` — subagent runtime construction | The `SessionRuntime` config object | | [`AuditableCall` row shape](/docs/auditable-call-row) | `plugins[]` — a `HarnessPlugin[]` for [extra graph nodes](/docs/graph-node-catalog#plugin-path--add-a-node-alongside-the-canonical-set), tool registration, stream observers | | | [Checkpoint format](/docs/checkpointing) | The 43-name [Node catalog](/docs/graph-node-catalog) — register a subset by omitting the matching executor | | | [`StreamEvent` shapes](/docs/stream-events) | `storage` — a `StorageAdapter` for session persistence | | | [Determinism contract](/docs/determinism) — five substrate rules | `metaToolNames` — names the substrate should treat as meta-tools | | | [Family lock](/docs/family-lock) | `setHarnessModuleLoader` — [host-adapter seam](/docs/host-adapter) for hosts mid-migration | | The canonical decision tree: [Runtime construction](/docs/runtime-construction). The full plugin extension surface: [Plugin contract](/docs/plugin-contract). ## `@pleach/compliance` — scrubbing, hash chain, GDPR [#pleachcompliance--scrubbing-hash-chain-gdpr] PII/PHI redaction, tamper-evident hash chain over the audit ledger, GDPR-style soft-delete (erasure tombstones). | Owned by the SKU | You configure | You must supply | | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------- | | [Hash-chain middleware](/docs/compliance/hashchain-middleware) — the chained-hash format | Per-turn scrubber set via `ComplianceRuntime` | A `ComplianceRuntime` instance | | [GDPR erasure](/docs/compliance/gdpr-erasure) — tombstone shape | Custom [Scrubbers](/docs/scrubbers) (PII, PHI, PCI patterns) | Storage that supports the tombstone write pattern | | Attestation envelope shape ([Attestation](/docs/attestation)) | Detector regexes per data class | | | The `@pleach/compliance-contract` typed surface | The [audit gates](/docs/audit-gates) the hash chain enforces | | `@pleach/compliance` writes through the same audit row `@pleach/core` defines — it doesn't introduce a second ledger. See [Compliance](/docs/compliance). ## `@pleach/gateway` — multi-tenant routing + cost attribution [#pleachgateway--multi-tenant-routing--cost-attribution] Per-tenant model routing, BYOK provider keys, rate limiting, cost event emission. | Owned by the SKU | You configure | You must supply | | ------------------------------------------------------------ | -------------------------------------------------------------------------- | ---------------------------------- | | [Cost event](/docs/gateway/cost-events) row shape | [BYOK provider keys](/docs/gateway/byok) per tenant | A `GatewayClient` instance | | Routing logic (cost-aware, family-locked) | [Rate limits](/docs/gateway/rate-limit) per tenant or per key | Tenant identifier on every request | | Per-tenant tenant facet — [Tenant facet](/docs/tenant-facet) | [OTel destination](/docs/gateway/otel) for cost rollup | | | | [Migration from `@pleach/core`](/docs/gateway/migration-from-core) routing | | Per-tenant cost attribution rolls up through the same `AuditableCall` row — `tenantId` is a column, not a side-table. See [Gateway](/docs/gateway) and [Multi-tenant](/docs/multi-tenant). ## `@pleach/observe` — hosted observability + Export Bridge [#pleachobserve--hosted-observability--export-bridge] OTel-instrumented spans across the lattice. Read [Observe](/docs/observe) for the SKU framing. | Owned by the SKU | You configure | You must supply | | ---------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------- | | Span structure across the four stages | Destinations — Postgres, Supabase, OTel collector, custom webhook | An OTel collector, Postgres, or destination of your choice | | Trace ID propagation through `AuditableCall` | Per-tenant routing of spans | | | Cost rollup integration with `@pleach/gateway` | Retention rules at the destination | | `@pleach/observe` is destination-flexible. The library writes audit rows through existing `@pleach/core` `ProviderDecisionLedger` adapters; the destination is yours. ## `@pleach/eval` — deterministic replay + diff [#pleacheval--deterministic-replay--diff] Regression testing against the audit ledger. Replays past turns through the same node graph and diffs the output. | Owned by the SKU | You configure | You must supply | | ---------------------------------------------------------- | ------------------------------------------- | ----------------------------------------------- | | Deterministic replay engine | [Eval parity](/docs/eval/parity) thresholds | A `ReplayClient` instance from `@pleach/replay` | | Diff format over `AuditableCall` rows | Replay-time seam binding | Source audit rows to replay against | | Lifecycle hook reads — `onToolCompleted`, `onMessageAdded` | Per-eval node-graph subset | | `@pleach/eval` consumes `@pleach/replay`'s `ReplayClient` via DI. The split lifecycle hooks let it read the same ledger every SKU writes through. See [`@pleach/eval`](/docs/eval). ## `@pleach/replay` — event-granular session forking [#pleachreplay--event-granular-session-forking] Forks a session from any checkpoint or any event. The substrate carrier for `@pleach/eval` but usable independently. | Owned by the SKU | You configure | You must supply | | -------------------------------------------------- | -------------------------------------------------------- | ----------------------------- | | Fork algorithm (event-granular, not turn-granular) | Checkpoint storage backend (via core's `StorageAdapter`) | A source session to fork from | | [Lineage](/docs/lineage) record format | Per-fork seam re-binding | | | Replay determinism contract — node-level | | | See [Replay](/docs/replay) and [Time travel](/docs/time-travel). ## `@pleach/mcp` — MCP server + client [#pleachmcp--mcp-server--client] Wraps Pleach sessions as Model Context Protocol servers, and consumes external MCP servers as Pleach tools. | Owned by the SKU | You configure | You must supply | | --------------------------------------------------------- | --------------------------------------------------- | ---------------------------------------- | | MCP transport (Streamable HTTP per protocol `2025-06-18`) | Tool exposure — which Pleach tools become MCP tools | The session config that backs the server | | Resource and prompt handlers | Resource list | | | Tools/list, tools/call, ping, notifications handlers | External MCP servers to consume as tools | | See [`@pleach/mcp`](/docs/mcp) and [MCP integration](/docs/mcp-integration). ## `@pleach/tools` + `@pleach/base-tools` — tool primitives [#pleachtools--pleachbase-tools--tool-primitives] Generic tool primitives (`@pleach/tools`) and a curated base set (`@pleach/base-tools`). | Owned by the SKU | You configure | You must supply | | ------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------- | | Tool interface — name, schema, executor | Tool registration through `SessionRuntimeConfig.toolRegistry` | The tool's executor function | | Structured-parse and filesystem primitives | Per-tool seam binding | Permissions and sandbox boundaries for filesystem/shell tools | | Tool result row shape — feeds the post-tool chain | Tool result post-processing through plugin nodes | | Pleach's tool contract is the same shape regardless of whether the tool comes from `@pleach/base-tools`, your own code, or an MCP server. See [Tools](/docs/tools). ## `@pleach/coding-agent` + `@pleach/sandbox` — sandboxed code execution [#pleachcoding-agent--pleachsandbox--sandboxed-code-execution] A coding-agent recipe (`@pleach/coding-agent`) plus the sandbox primitives it sits on (`@pleach/sandbox`). | Owned by the SKU | You configure | You must supply | | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------ | | Sandbox lifecycle — boot, exec, snapshot, teardown | [Sandbox provider](/docs/coding-agent/sandbox-providers) (Vercel Sandbox, Docker, custom) | Sandbox provider credentials | | [File tools](/docs/coding-agent/file-tools) — read, write, patch | [Multi-synthesize](/docs/coding-agent/multi-synthesize) parameters | The repo or workspace to mount | | [Long-session](/docs/coding-agent/long-session) checkpointing | Per-task tool subset | | | [SWE-bench recipe](/docs/coding-agent/swe-bench-recipe) — eval shape | | | See [`@pleach/coding-agent`](/docs/coding-agent). ## `@pleach/react` — React hooks + components [#pleachreact--react-hooks--components] Hook surface for client-side chat UI. Server-side runtime construction is in [`@pleach/core`](/docs/core); this package owns the React-specific shape. | Owned by the SKU | You configure | You must supply | | ------------------------------------------------------------- | ------------------------------ | --------------------------------------------------------------------------- | | `useChat`, `useSession`, `useStream` hook shapes | UI components, layout, styling | A backend route that proxies to the runtime (typically `createPleachRoute`) | | `HarnessProvider` context shape | Hook subscription behavior | | | [Building chat UI](/docs/building-chat-ui) component patterns | | | See [`@pleach/react`](/docs/react). ## `@pleach/langchain` — LangChain / LangGraph adapter [#pleachlangchain--langchain--langgraph-adapter] Adapter that lets a LangChain agent run as a Pleach node, and lets a Pleach session feed a LangGraph. | Owned by the SKU | You configure | You must supply | | ------------------------------------------------------------------ | ------------------------- | --------------------------------- | | Adapter shapes both directions | Per-call adapter wiring | A LangChain or LangGraph instance | | State-mapping between LangChain `BaseMessage` and Pleach `Message` | Tool-translation strategy | | See [`@pleach/langchain`](/docs/langchain) and [Migrating from LangChain](/docs/migrating-from-langchain). ## Transport packages — `@pleach/transport-bedrock`, `@pleach/transport-azure-openai`, `@pleach/transport-vertex` [#transport-packages--pleachtransport-bedrock-pleachtransport-azure-openai-pleachtransport-vertex] Provider-side transport adapters for AWS Bedrock, Azure OpenAI, and Google Vertex. | Owned by the SKU | You configure | You must supply | | -------------------------------------- | ------------------------ | --------------------- | | The transport's wire-shape translation | Per-region endpoints | Provider credentials | | Provider-specific retry and backoff | Model identifier mapping | Account or project ID | | Streaming envelope adaptation | | | See [`@pleach/transport-bedrock`](/docs/transport-bedrock), [`@pleach/transport-azure-openai`](/docs/transport-azure-openai), [`@pleach/transport-vertex`](/docs/transport-vertex). ## Cross-SKU concerns [#cross-sku-concerns] A few surfaces span multiple SKUs. The boundary on those is structural — the SKUs each own a slice but the contract is shared. ### The audit ledger row [#the-audit-ledger-row] Every SKU writes through one `AuditableCall` row shape, keyed by `turnId`. See [`AuditableCall` row](/docs/auditable-call-row). | SKU | Writes | Reads | | -------------------- | ------------------------------------------------------------- | -------------------------------- | | `@pleach/core` | The row itself; `nodeId`, `stageId`, `tokenUsage`, `toolName` | — | | `@pleach/compliance` | `hashChainPrev`, `hashChainSelf`, scrubber tombstones | Self-validates the chain at read | | `@pleach/gateway` | `tenantId`, `providerKey`, `costEvent` | Per-tenant rollup | | `@pleach/observe` | OTel span IDs threaded onto the row | Whole row for span construction | | `@pleach/eval` | — | Whole row for replay | | `@pleach/replay` | `lineageParent` for forked sessions | Source row for the fork | ### Cost rollup [#cost-rollup] Two SKUs participate. `@pleach/core` rolls token usage per turn; `@pleach/gateway` rolls cost per tenant by joining the `AuditableCall` row to its `costEvent` extension. See [Cost events](/docs/gateway/cost-events). ### Storage [#storage] `@pleach/core` defines the `StorageAdapter` interface; every SKU that persists data uses it. See [Storage](/docs/storage) for the adapter shape and the bundled implementations. ### Determinism [#determinism] The five substrate-level rules are in [Determinism](/docs/determinism). Every SKU that registers a node must satisfy them — same input state, same partial-state output. ## Quick lookup — "I want to change X" [#quick-lookup--i-want-to-change-x] | You want to | Surface | Path | | -------------------------------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | Add a node to the canonical graph | `@pleach/core` | Plugin — `HarnessPlugin.extraGraphNodes()`. See [Node catalog — plugin path](/docs/graph-node-catalog#plugin-path--add-a-node-alongside-the-canonical-set) | | Remove a node from the canonical graph | `@pleach/core` | Config — omit the gated executor. See [Node catalog — config omission](/docs/graph-node-catalog#config-omission--remove-a-gated-node) | | Wire a fully custom topology | `@pleach/core` | Custom builder — raw `StateGraph`. See [Node catalog — custom builder](/docs/graph-node-catalog#custom-builder--full-control) | | Add a new edge inside the lattice | `@pleach/core` | Plugin or custom builder; lattice gate still binds. See [Edge catalog](/docs/graph-edge-catalog#plugin-path--edges-around-a-plugin-registered-node) | | Change the four-stage lattice itself | `@pleach/core` | Upstream contribution only. See [Edge catalog — removing an allowed pattern](/docs/graph-edge-catalog#removing-an-allowed-pattern) | | Swap the LLM provider | `@pleach/core` | Config — `llmExecutor`. See [Providers](/docs/providers) | | Add a tool | `@pleach/core` + `@pleach/tools` | Config — `toolRegistry` or `contributeTools` plugin hook. See [Tools](/docs/tools) | | Scrub PII from prompts | `@pleach/compliance` | Configured `Scrubber` set. See [Scrubbers](/docs/scrubbers) | | Verify the audit chain hasn't been tampered with | `@pleach/compliance` | [Hash-chain middleware](/docs/compliance/hashchain-middleware) | | Route per-tenant on a different provider key | `@pleach/gateway` | [BYOK](/docs/gateway/byok) | | Send spans to Datadog / Honeycomb / your collector | `@pleach/observe` | Destination config — see [Observe](/docs/observe) | | Replay a past turn and diff | `@pleach/eval` + `@pleach/replay` | Lifecycle hooks. See [Eval and replay](/docs/eval-and-replay) | | Fork a session at an event | `@pleach/replay` | `ReplayClient.forkAt(eventId)`. See [Replay](/docs/replay) | | Expose Pleach as an MCP server | `@pleach/mcp` | MCP transport config. See [`@pleach/mcp`](/docs/mcp) | | Run a coding agent against a repo | `@pleach/coding-agent` + `@pleach/sandbox` | Sandbox provider config. See [`@pleach/coding-agent`](/docs/coding-agent) | | Build a chat UI in React | `@pleach/react` | Hook surface. See [Building chat UI](/docs/building-chat-ui) | | Stream from AWS Bedrock / Azure OpenAI / Vertex | matching transport SKU | Transport package config. See [Transport — Bedrock](/docs/transport-bedrock), [Azure](/docs/transport-azure-openai), [Vertex](/docs/transport-vertex) | | Migrate from LangChain | `@pleach/langchain` | Adapter. See [Migrating from LangChain](/docs/migrating-from-langchain) | | Change the `AuditableCall` row shape | `@pleach/core` | Upstream contribution only. The row shape is a cross-SKU contract | | Change the determinism rules | `@pleach/core` | Upstream contribution only. See [Determinism](/docs/determinism) | ## Where to go next [#where-to-go-next] --- # Packages (/docs/packages) Every sibling package plugs into `@pleach/core`'s `HarnessPlugin` contract. `@pleach/core` is the lattice; the siblings are branches grafted onto it through one shared contract — none of them rewrite the substrate, none of them slip past the audit gates. Each one adds a specific capability (compliance scrub, hash-chain verification, OTel destinations, recipe factories) without altering the four-stage shape or the row keyed by `turnId`. The first-wave publish ceremony cuts every shipping package at the same `0.1.0` floor under FSL-1.1-Apache-2.0 — uniform version, uniform license, one row per package. The reader signal that used to live in the version number now lives in the **Notes** column: which packages are substrate-complete, which expose a typed contract with one method still throwing a sentinel, and which aren't in the first wave yet — `@pleach/observe` (contract locked, scoped at `0.1.0-alpha.0`, not yet on npm) and `@pleach/trust-pack` (reserved placeholder, no contract). Pin `0.1.0` exactly. The `0.0.x → 0.1.0` jump and the `0.1.x → 1.0.0` jump are both non-caretable, and `^0.1.0` will not pick up later `0.x` releases either. Use `"@pleach/": "0.1.0"` in your `dependencies` — not `^0.1.0`. Each sibling fills a specific subset of the slots documented in [Plugin contract](/docs/plugin-contract): `@pleach/compliance` ships scrubbers and the `ComplianceRuntime` contract substrate today, with attestation + hash-chain verifier + GDPR erasure landing in later versions. `@pleach/eval` reads the `AuditableCall` ledger via the split lifecycle hooks (`onToolCompleted` / `onMessageAdded`) and consumes `@pleach/replay`'s `ReplayClient` via DI. `@pleach/gateway` wraps `@pleach/core`'s model-family substrate through a typed `GatewayClient`. None of them can add an out-of-lattice edge or bypass the singleton synthesize seam — those are CI gates on the core repo, not optional discipline. | Package | Role | Status / notes | | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | `@pleach/core` | Runtime substrate — sessions, graph, channels, audit ledger, checkpointing, sync, storage adapters. | `0.1.0` · [source](https://github.com/pleachhq/core) · substrate complete | | `@pleach/tools` | Substrate tool-calling primitives — `UnifiedToolDefinition` contract, queryable registry, Zod schema helpers, form-config + hints surfaces. Ships shapes only; bring your own concrete tool definitions. | `0.1.0` · [npm](https://www.npmjs.com/package/@pleach/tools) · substrate complete | | `@pleach/react` | React-binding primitives — runtime acquisition, message stream, event-log facade, interrupt UI, correction dedup, plus a Vercel AI SDK migration adapter. The lower-level surface that `@pleach/core/react` composes. | `0.1.0` · [npm](https://www.npmjs.com/package/@pleach/react) · substrate complete | | [`@pleach/base-tools`](/docs/base-tools) | Domain-agnostic tool primitives: math, datetime, scratchpad, unit\_convert, text\_search, opt-in url\_fetch, plus a markdown citation extractor. Now ships **inside `@pleach/core`** at the opt-in subpath `@pleach/core/base-tools`; the standalone SKU is a thin re-export shim. | `0.1.0` · [npm](https://www.npmjs.com/package/@pleach/base-tools) · ships in `@pleach/core` | | `@pleach/replay` | `ReplayClient` walks the canonical event log via `runtime.events.iterate`/`fold`. `createReplayRuntime` factory ships `replayTurn`, `fromSnapshot`, `fork`, and `aggregateMultiTenant` bodies (Pack 258 Slice 5 — zero throw sites remain). `PACK_148_FIRST_SLICE_NOT_IMPLEMENTED_MESSAGE` stays exported for back-compat detection only. | `0.1.0` · [npm](https://www.npmjs.com/package/@pleach/replay) | | `@pleach/sandbox` | Vendor-neutral `SandboxProvider` contract for `@pleach/core` runtimes. Contract + in-memory fixture today; canonical name `SandboxProvider` with the legacy `SandboxAdapter` retained as a `@deprecated` alias. Anchors third-party `@pleach/sandbox-` adapters. | `0.1.0` · [npm](https://www.npmjs.com/package/@pleach/sandbox) · contract | | [`@pleach/langchain`](/docs/langchain) | Bidirectional LangChain / LangGraph adapter — tools, agents, and message history bridged into the Pleach event log. Surface still moving — pin tight. | `0.1.0` · [npm](https://www.npmjs.com/package/@pleach/langchain) | | [`@pleach/compliance`](/docs/compliance) | PII / PHI scrubbers (`SsnUsScrubber`, `CreditCardScrubber` (Luhn-validated), `UsDriverLicenseScrubber`, `KeyedRegexScrubber`), event-chain attestation, and verification utilities for HIPAA / GDPR / PCI-DSS / SOC 2 deployments. Implements the `Scrubber` contract from `@pleach/core/scrubbers`. Re-exports the `ComplianceRuntime` + `ComplianceProfile` types from `@pleach/compliance-contract`. | `0.1.0` · [npm](https://www.npmjs.com/package/@pleach/compliance) | | `@pleach/compliance-contract` | Zero-dependency contract sub-SKU carving out the `ComplianceRuntime` interface + `ComplianceProfile` literal union + structural `Scrubber` mirror. Breaks the `@pleach/core` ↔ `@pleach/compliance` type-graph cycle: both packages depend one-way on this contract; neither needs the other at the type layer. No runtime code. | `0.1.0` · [npm](https://www.npmjs.com/package/@pleach/compliance-contract) · type-only contract | | `@pleach/eval` | `EvalSuite` + `EvalCase` discriminated union + `EvalReport`. Subpaths under `./scorers`, `./replay`, `./report` for built-ins, replay coupling, and report formatters. Deterministic fixture grading, custom scorers, LLM-as-judge ensembles, event-log replay. | `0.1.0` · [npm](https://www.npmjs.com/package/@pleach/eval) | | `@pleach/gateway` | `GatewayClient` — thin SKU wrapper over `@pleach/core`'s model-family substrate. Per-tenant BYOK key routing, family-strict cascade pivot, per-call cost emission (`domain.gateway.cost.recorded`), OTel `llm.invocation` span emission. Routing math, family matrix, and BYOK resolution stay in core. | `0.1.0` · [npm](https://www.npmjs.com/package/@pleach/gateway) | | `@pleach/mcp` | Concrete `MCPServer` class wrapping `SessionRuntime` — tools, resources (`pleach://artifact/*`, `pleach://sandbox/*`), and prompts surfaced over MCP. Pluggable `MCPTransport` with stdio, SSE, and WebSocket implementations. Multi-tenant routing via `registerSession({ tenantId? })` for composition with `@pleach/gateway`. | `0.1.0` · [npm](https://www.npmjs.com/package/@pleach/mcp) | | [`@pleach/coding-agent`](/docs/coding-agent#pleachcoding-agent-runtime-contract) | SWE-bench-shaped coding-agent runtime opt-in for `@pleach/core`. All three async methods have real bodies (Pack 270 D1 / D2 / D3): `start()` composes the underlying `SessionRuntime`, `stop()` drains it, `executeStep()` drives one turn through `SessionRuntime.executeMessage`. The body throws `PACK_270_D3_EXECUTE_STEP_NOT_STARTED_MESSAGE` only when called before `start()` — no auto-start, by design. LRU `CodingContextManager` for file-context budget management. | `0.1.0` · [npm](https://www.npmjs.com/package/@pleach/coding-agent) | | [`@pleach/observe`](/docs/observe) | Destination-flexible observability SDK — a thin facade (`init` + per-turn recorder + destination plug) that hooks into existing AI SDK / LangChain / custom loops and writes one `AuditableCall` v7 subset row per LLM call. Destinations: Postgres, Supabase, OTel, memory, and Pleach Hosted. | `0.1.0-alpha.0` · scoped, not yet on npm | | [`@pleach/recipes`](/docs/recipes-pleach-recipes) | Composable one-line factories over `@pleach/core` — `simpleChatbot`, `ragChatbot`, `observableChatbot`, `compliantChatbot`, `instrumentedCodingAgent`. Each subpath pulls only the `@pleach/*` peers its recipe needs; peer deps for `observe`, `compliance`, `coding-agent`, and `sandbox` are optional. | `0.1.0` · [npm](https://www.npmjs.com/package/@pleach/recipes) | | `@pleach/trust-pack` | Reserved npm name. No scoped contract, no shipping code, no upstream `packages/trust-pack/` directory. The placeholder stays at `0.0.1 · UNLICENSED` until a future cut. | `0.0.1` · placeholder · UNLICENSED | ## Subpath exports per package [#subpath-exports-per-package] Every `@pleach/*` package uses a dir-style entry convention: the root export is the public API, with named subpaths under it for narrower surfaces (`/react`, `/server`, `/testing`, and similar). Each `exports` map orders conditions types-first per Node's resolution spec, so TypeScript sees the `.d.ts` before the runtime entry. The per-subpath inventory lives in [Subpath exports](/docs/subpath-exports) — one table per package, listing every documented entry and what it's for. The full packaging conventions — `exports` ordering rules, `sideEffects` discipline, and `engines` floors — live in [Publishing contract](/docs/publishing-contract). ## Language-agnostic contract [#language-agnostic-contract] The runtime contract — sessions, checkpoints, storage, tools, sync, SSE wire — is language-agnostic by design. The TypeScript distribution is the reference implementation, in production today. A Go implementation has been built against the same contract and round-trips a shared corpus of recorded turns — that's the test that catches anything TypeScript-flavored leaking into the wire. The Go implementation isn't published as a SKU yet; an official `@pleach` Go runtime is the next planned published implementation. The five contract shapes a non-TS consumer implements (HTTP+SSE wire, `StreamEvent` union, `AuditableCall` row, `Checkpoint` envelope, version vector) are enumerated on the [Language-agnostic contract](/docs/language-agnostic-contract#whats-in-the-contract) page. If a claim in these docs only holds in JavaScript, that's a documentation bug. Issues welcome on the relevant package's repo. ## Licensing [#licensing] Every shipping `@pleach/*` package — substrate, adapter, and SDK alike — publishes under **FSL-1.1-Apache-2.0** (Functional Source License with Apache 2.0 as the future license). Source-available, usable in production, free of charge during the FSL window; the only restriction is on competing offerings. Each package auto- transitions to permissive Apache 2.0 two years after first stable publish. The single exception is `@pleach/trust-pack`: a reserved npm name with no shipping code, kept at `0.0.1 · UNLICENSED` until a future cut. See [Pricing](/pricing) for commercial-use guidance. ## Where to go next [#where-to-go-next] --- # Performance (/docs/performance) The runtime's overhead per call is small but not zero. This page documents what the substrate does for you, what it expects you to configure, and where to look when latency or throughput matter. ## Where the time goes [#where-the-time-goes] A single LLM call through the runtime walks five distinct phases. Knowing the rough budget for each helps when you're profiling. | Phase | Typical share | Optimized by | | ---------------------------------------------------------------------- | ------------------------- | --------------------------------------------------------- | | Prompt composition | 1–5 ms | Static-vs-runtime-aware split; pre-resolved core baseline | | [Fingerprint](/docs/fingerprint) compute + [cache](/docs/cache) lookup | `<1 ms` | `sha256` is fast; key fields are pre-canonicalized | | Provider invoke + stream | 200 ms – tens of seconds | Provider-side; runtime is along for the ride | | [Channel](/docs/channels) / reducer updates | `<1 ms` per chunk | Channel kinds picked for low-overhead writes | | [Audit ledger](/docs/audit-ledger) write | `<1 ms` (fire-and-forget) | Batched at the adapter; never blocks the turn | The provider call dominates. Everything else stays in the single-millisecond range. If your per-call overhead is multi-tens of ms, something custom is the cause — usually a slow plugin hook, a heavy runtime-aware prompt contribution, or a stream observer doing work that should be async. Concretely: a turn that takes 4.2s end-to-end against a model that reports 4.1s of provider latency on the ledger row leaves \~100ms of runtime overhead — well within the table's budget (1–5ms prompt + \<1ms fingerprint + \<1ms channels per chunk + \<1ms ledger), with the slack spent on the chunk-by-chunk channel writes during streaming. A turn that takes 4.5s against the same 4.1s provider latency leaves \~400ms unaccounted — that's the signal to profile plugin hooks, because the substrate's own phases don't add up to that number. ## Fingerprint-based dedup [#fingerprint-based-dedup] Every call computes a fingerprint. Two calls with the same fingerprint hash are interchangeable — the runtime can serve the second from the first's recorded result. The cache layer isn't built in; it's a contract that lets you plug one in. The simplest pattern: ```typescript import { computeFingerprint, canonicalize, sha256 } from "@pleach/core/fingerprint"; const fp = computeFingerprint(fingerprintInput); // The Fingerprint carries typed component fields (promptHash, toolsHash, …); // derive the canonical cache key with the same canonical-JSON + sha256 helpers // the fingerprint uses internally, so keys join cleanly across processes. const fpKey = sha256(canonicalize(fp)); const cached = await myCache.get(fpKey); if (cached) return cached; const result = await provider.execute(config); await myCache.set(fpKey, result, { ttl: 3600 }); return result; ``` Wrap this in a provider decorator and you get cache-hit reuse without changing application code. The fingerprint contract guarantees the cache is sound — same hash means same output for the same model. ### What survives a cache hit [#what-survives-a-cache-hit] A cache hit is byte-identical to the recorded result; the audit ledger still writes a row, but the row carries a `cacheHit: true` flag in the payload. Two implications: 1. Latency drops to a single network round trip (cache fetch + audit write). 2. The ledger remains complete — every call is still recorded, even cached ones. `GROUP BY model` rollups stay accurate. ### What invalidates the cache [#what-invalidates-the-cache] The fingerprint includes `pleachVersion`, so substrate upgrades invalidate automatically. Within a version, anything in the fingerprint's IN set (model, messages, tools, system prompt, temperature bucket, seed, family, call class, runtime mode, tenant, active safety policies) is the invalidation surface. A new prompt contribution invalidates the cache; toggling a safety policy invalidates the cache; changing model id invalidates the cache. That's the contract. ## Prompt caching (provider-side) [#prompt-caching-provider-side] Distinct from the fingerprint cache: provider-side prompt caching is what Anthropic and OpenAI offer to discount the input-token bill for repeat prefixes. Two integration paths: ### Native via `AnthropicSdkProvider` [#native-via-anthropicsdkprovider] The Anthropic provider passes through Anthropic's prompt-cache control headers. Mark cacheable sections with `cache_control` markers in the system prompt; the provider forwards them. ```typescript const runtime = new SessionRuntime({ provider: new AnthropicSdkProvider({ apiKey: process.env.ANTHROPIC_API_KEY!, model: "claude-sonnet-4-5", // Prompt cache is on by default for cacheable content blocks. }), // ... }); ``` ### Via the AI SDK [#via-the-ai-sdk] The AI SDK's `experimental_providerOptions` field on `streamText` threads through `AiSdkProvider.providerConfig`. Use it to pass prompt-cache flags per call. In both cases, prompt caching and fingerprint dedup compose: the fingerprint cache replays the whole response; prompt caching discounts the prefix tokens on a fresh provider call. Use both. ## Batching tool calls [#batching-tool-calls] `ToolBatchExecutor` groups concurrent tool calls per the batching strategy declared on each tool. The default inferred strategy is conservative (serial); override on tools that are safe to parallelize. ```typescript // lib/tools/fetchDocument.ts import { defineTool, Semaphore } from "@pleach/core"; export const fetchDocument = defineTool({ name: "fetch_document", description: "Fetch one document by id.", inputSchema: z.object({ id: z.string() }), async execute(input, ctx) { /* ... */ }, // @ts-expect-error — extended field batching: { strategy: "parallel", maxConcurrency: 5 }, }); ``` Choosing strategy: | Strategy | When | | ---------- | ------------------------------------------------------------------ | | `serial` | Tool mutates external state, or order matters | | `parallel` | Read-only tools that can fan out — searches, fetches | | `batched` | Tool's underlying API accepts arrays — issue one call for N inputs | Use `Semaphore` inside `execute` for hand-rolled concurrency limits when the tool itself fans out further. A search tool that fetches the top hits in parallel, capped at four in flight at once: ```typescript // lib/tools/searchCorpus.ts import { defineTool, Semaphore } from "@pleach/core"; const gate = new Semaphore(4); export const searchCorpus = defineTool({ name: "search_corpus", description: "Search the knowledge base and hydrate the top hits.", inputSchema: z.object({ query: z.string(), topK: z.number().default(8) }), async execute({ query, topK }, ctx) { const hits = await index.search(query, { limit: topK, signal: ctx.signal }); return Promise.all( hits.map(async (h) => { await gate.acquire(); try { return await fetchDocument(h.id, ctx.signal); } finally { gate.release(); } }), ); }, }); ``` The semaphore lives at module scope, so it caps concurrency across every concurrent invocation of `search_corpus` in the process — not just within one call. ### Fan-out, fan-in subagents [#fan-out-fan-in-subagents] When a turn needs three independent sub-analyses, spawn them in parallel and await the joined result. The runtime tracks each [subagent](/docs/subagents) under its own `turnId`; the audit ledger keeps the attribution clean: ```typescript async execute(input, ctx) { const [summary, facts, label] = await Promise.all([ ctx.spawnSubagent({ tool: "summarize", input: { docId: "doc-abc123" } }), ctx.spawnSubagent({ tool: "extract_facts", input: { docId: "doc-abc123" } }), ctx.spawnSubagent({ tool: "classify", input: { docId: "doc-abc123" } }), ]); return { summary, facts, label }; } ``` Three rows in `harness_auditable_calls`, three `subagent.completed` events, one parent turn — wall-clock latency is the slowest branch, not the sum. ## Lazy module loading [#lazy-module-loading] Hosts mid-migration use `setHarnessModuleLoader` with dynamic imports. The runtime caches resolved modules — each key resolves once per runtime lifetime, then stays in memory. For cold-start-sensitive deployments (Fluid Compute, Lambda, Cloudflare Workers), the first call after cold boot pays the dynamic-import cost for every key it hits. Two mitigations: 1. **Warm critical paths in your handler init.** Resolve the keys the first turn will need before the request lands. 2. **Use the typed config surface where possible.** `provider`, `tools`, `plugins`, `metaToolNames`, and the storage adapters never go through dynamic import — they're direct references. ## Channel write overhead [#channel-write-overhead] Channel writes are O(1) for `LastValue` / `EphemeralValue` / `NamedBarrier`, O(N) for `Topic` / `BinaryOperatorAggregate` (N = size of the existing accumulator), and O(log N) for `DataChannel` (LRU map operations). For high-throughput per-step writes, prefer `Topic` over `BinaryOperatorAggregate` — appending a single item is O(1) amortized, whereas a `BinaryOperatorAggregate` write applies the reducer over the existing value. A concrete case: a `search_corpus` result with 500 hits, fan-in through `BinaryOperatorAggregate + appendReducer`, applies the reducer once per write — the 500th write copies a 499-element array. The same result through a `Topic` lands each item in O(1). For a single 500-hit turn the difference is irrelevant; for a 5000-hit turn it's the difference between a turn that lands in 100ms and one that lands in 2s of pure channel-write overhead. When the reducer dominates (e.g. a per-message dedup pass over many thousand messages), bucket the writes — accumulate in an ephemeral channel within a step, flush once at end-of-step. ## Stream observer cost [#stream-observer-cost] Observers fire on every chunk. `onChunk` is sync-only by contract, so the cost is bounded; the runtime won't await an observer that returns a promise. For observers that need to do something expensive (network call, DB write), pattern: observe synchronously, emit a named-channel envelope, handle the expensive work in a post-turn node. ```typescript contributeStreamObservers: () => [{ name: "detect-citation", onChunk(chunk, ctx) { if (looksLikeCitation(chunk.text)) { ctx.emit("citations.detected", { fragment: chunk.text }); } return { verdict: "continue" }; }, }], ``` The post-turn node reads `citations.detected` and does the network resolution — async work happens off the hot path. ## Audit ledger batching [#audit-ledger-batching] The `ProviderDecisionLedger` contract permits batching: up to 50 ms or 32 records, flushed unconditionally at end-of-turn (writing one [auditable call row](/docs/auditable-call-row) per record). Reference Supabase adapter implementations batch by default. For very high-throughput deployments (multi-thousand-RPS), the adapter is the natural place to add a queue + worker pattern — the contract is fire-and-forget, so a queued write that lands later still satisfies the invariants. ## DevTools-driven profiling [#devtools-driven-profiling] When a turn is mysteriously slow in development: ```javascript __HARNESS_DEVTOOLS__.events({ types: ["step.start", "step.end"] }); // Walk pairs to see per-step latency. __HARNESS_DEVTOOLS__.ledger().map((r) => ({ call: r.callClass, model: r.modelId, latency: r.latencyMs, })); // Per-call latency breakdown. ``` The per-call latency on the audit row is provider-side latency. Subtract from observed end-to-end and you get a runtime overhead estimate. Typical overhead is single-millisecond; multi-tens of ms is the signal to look for a hot-path plugin. The DevTools ledger view also surfaces `cacheHit` — a turn with three rows all showing `cacheHit: true` should report effectively zero provider latency (the fingerprint replay path doesn't dial out), so a cache-hit row reporting 500ms of `latencyMs` is itself a bug signal (likely a stale recording that's slower than the live provider). ## When to bring in `@pleach/gateway` [#when-to-bring-in-pleachgateway] The gateway SKU is the right pick when: * Per-tenant routing rules need to be policy-driven, not hardcoded. * Cost attribution needs to roll up across providers (Anthropic rate-limited fallback to OpenAI, for example). * Observability needs to feed an external sink (Datadog, Honeycomb) at call granularity. It's a plugin against `@pleach/core` — no architectural change, just an extra contribution in your plugin set. ## Where to go next [#where-to-go-next] --- # Plans (/docs/plans) Plans are one half of the **cross-session state** [thematic island](/docs/concept-clusters#what-lives-outside-the-cluster-pattern) — paired with [memory](/docs/memory). Plans span turns within or across sessions; memory spans sessions. Both outlive a single session arc; neither fits the runtime-lifecycle cluster. A `Plan` is the variable surface for work that spans more than one turn: the steps the agent committed to, which session executed each one, what evidence each one produced, and the revision-by-revision history of how the plan changed. The substrate doesn't pick the steps — the application's planner does — but it owns the persistence shape, the dependency-aware step picker, and the revision audit trail. See [Lineage](/docs/lineage) for how plans join sessions, and [Subagents](/docs/subagents) for how a step's `suggestedAgent` routes to a spec. Ships from `@pleach/core/plans`. `PlanManager` is the orchestrator; `Plan`, `PlanStep`, and `PlanRevision` are the wire shapes. ```typescript import { PlanManager, type Plan, type PlanStep, type PlanRevision, } from "@pleach/core/plans"; ``` ## `Plan` shape [#plan-shape] | Field | Type | Notes | | ------------------ | ------------ | ----------------------------------------------- | | `planId` | string | 10-char id, assigned at create | | `title` | string | Human-readable | | `objective` | string | High-level goal | | `status` | enum | `active` / `completed` / `paused` / `abandoned` | | `steps` | `PlanStep[]` | Ordered | | `createdAt` | ISO-8601 | UTC | | `createdInSession` | string | Session that created the plan | | `createdInChat` | string | Chat that created the plan | | `updatedAt` | ISO-8601 | Bumped on every revision | | `revision` | number | Monotonic; bumped on every mutation | | `tags` | string\[] | Optional categorization | `status` flips to `completed` automatically when every step is `completed` or `skipped` — `PlanManager.updateStep` moves the record from the `active` namespace to the `completed` namespace at that boundary. ## `PlanStep` shape [#planstep-shape] | Field | Type | Notes | | -------------------- | ---------------------- | --------------------------------------------------------- | | `stepId` | string | Stable across revisions — `step_` + 8-char id | | `title` | string | Short label | | `description` | string | What this step should accomplish | | `status` | enum | `pending` / `active` / `completed` / `skipped` / `failed` | | `executedInSession` | string \| undefined | Session that ran this step | | `executedInChat` | string \| undefined | Chat that ran this step | | `suggestedAgent` | string \| undefined | Recommended spec name | | `suggestedTools` | string\[] \| undefined | Expected tool calls | | `dependsOn` | string\[] \| undefined | Other `stepId`s that must complete first | | `completionEvidence` | object \| undefined | `canvasCards`, `jobIds`, `summary` | | `updatedAt` | ISO-8601 | Bumped on every step mutation | `stepId` stability is the load-bearing invariant — `revisePlan` preserves `completionEvidence`, `executedInSession`, and `executedInChat` for steps whose `stepId` survives the revision, even if their position or surrounding steps change. Steps without a passed-in `stepId` get a new one and start clean. ## `PlanManager` methods [#planmanager-methods] | Method | Returns | Effect | | ----------------------------------------------- | ------------------ | -------------------------------------------------------------------------------------------- | | `createPlan(input)` | `Plan` | Assigns `planId`, stamps revision 1, writes the initial `PlanRevision` snapshot | | `updateStep(planId, stepId, update, sessionId)` | `Plan` | Mutates one step; auto-flips plan to `completed` when all steps are `completed` or `skipped` | | `revisePlan(planId, revision, sessionId)` | `Plan` | Replace `title`, `objective`, or the full step list; rejects revisions to `completed` plans | | `getActivePlans()` | `Plan[]` | Up to 50 | | `getCompletedPlans()` | `Plan[]` | Up to 50 | | `getPlan(planId)` | `Plan \| null` | Checks active first, then completed | | `getRevisions(planId)` | `PlanRevision[]` | Sorted ascending by revision | | `getNextStep(plan)` | `PlanStep \| null` | First `pending` step whose `dependsOn` are all `completed` or `skipped` | | `buildActivePlanSection(plans)` | string | Renders an active-plan summary block for system-prompt injection | `getNextStep` is pure; the rest read or write through the `BaseStore` the manager was constructed with. ## Revision history [#revision-history] Every mutation writes a `PlanRevision` to a per-plan namespace alongside the plan itself. | Field | Type | Notes | | ------------------- | -------- | ---------------------------------------------------------- | | `revision` | number | Matches `Plan.revision` at the time of the snapshot | | `snapshot` | `Plan` | `structuredClone` of the plan at this revision | | `changeDescription` | string | Free-text; `updateStep` writes `Step "" → <status>` | | `sessionId` | string | Which session made the change | | `timestamp` | ISO-8601 | UTC | Revisions are append-only. `getRevisions(planId)` returns them sorted ascending so a UI can render the plan's evolution as a timeline without re-sorting. ## Example [#example] ```typescript import { PlanManager } from "@pleach/core/plans"; import { MyStore } from "./my-store"; const plans = new PlanManager(new MyStore(), orgId, userId); const plan = await plans.createPlan({ title: "Q3 vendor evaluation", objective: "Pick a vendor for the queue rewrite.", steps: [ { title: "List candidates", description: "Pull from prior research." }, { title: "Run benchmarks", description: "p99 + cost.", dependsOn: [] /* filled in after createPlan assigns stepIds */ }, { title: "Write recommendation", description: "1-pager." }, ], sessionId: session.id, chatId: chat.id, }); // Pick what to do next — dependency-aware. const next = plans.getNextStep(plan); if (next) { await plans.updateStep( plan.planId, next.stepId, { status: "active", executedInSession: session.id, executedInChat: chat.id }, session.id, ); } ``` ## Revising mid-flight [#revising-mid-flight] The reader can see how `revisePlan` carries `stepId`s forward so completed work survives a re-plan. ```typescript const current = await plans.getPlan(plan.planId); if (!current) throw new Error("plan vanished"); const revised = await plans.revisePlan( current.planId, { steps: [ // Pass existing stepIds back in to preserve their evidence + executedIn* fields. ...current.steps.filter((s) => s.status === "completed"), { title: "Cross-check sources", description: "Verify two corroborating refs." }, { title: "Write recommendation", description: "1-pager." }, ], changeDescription: "Inserted cross-check after benchmark results came in.", }, session.id, ); ``` Steps without a `stepId` get a fresh one and start `pending`. Steps whose `stepId` survives the revision keep `completionEvidence`, `executedInSession`, and `executedInChat` even if their position shifts. ## Walking revision history [#walking-revision-history] The snippet renders a plan's evolution by reading revisions ascending. ```typescript const history = await plans.getRevisions(plan.planId); for (const rev of history) { console.log( `r${rev.revision}`, rev.changeDescription, `(${rev.snapshot.steps.length} steps)`, ); } ``` `changeDescription` is the free-text label `updateStep` and `revisePlan` write; the snapshot is a full `structuredClone` of the plan at that revision, so a UI can diff one snapshot against the next without re-querying. ## Where to go next [#where-to-go-next] <Cards> <Card title="Agents" href="/docs/agents" description="The persistent per-spec profile a step's `suggestedAgent` resolves against." /> <Card title="Subagents" href="/docs/subagents" description="Steps with a `suggestedAgent` route to subagent specs." /> <Card title="Lineage" href="/docs/lineage" description="Plans persist across chats — lineage joins the sessions that executed each step." /> <Card title="Query" href="/docs/query" description="Aggregate readers project plan + revision history into a review dashboard." /> <Card title="Subpath exports" href="/docs/subpath-exports" description="`@pleach/core/plans` ships PlanManager + the public types." /> </Cards> --- # Platform & operations recipes (/docs/platform-recipes) Four complete-enough-to-crib implementations covering the patterns a platform or ops team reaches for: long-running async jobs, multi-step approval [interrupts](/docs/interrupts), per-call cost reporting against the [audit ledger](/docs/audit-ledger), and OpenTelemetry wiring with in-process span access. Each links back to the reference page for the primitives it builds on. For consumer-facing wiring (streaming chat, custom tools, custom storage, BYOK, moderation, multi-tenant, compliance), see [Recipes](/docs/recipes). ## 1. Long-running async job tool [#1-long-running-async-job-tool] A tool that dispatches work to a queue, emits progress, and resolves when the job completes. ```typescript // lib/tools/runBenchmark.ts import { defineTool } from "@pleach/core"; export const runBenchmark = defineTool({ name: "run_benchmark", description: "Run a multi-hour benchmark; returns results when complete.", inputSchema: z.object({ suite: z.string(), config: z.record(z.unknown()), }), async execute(input, ctx) { const job = await queue.enqueue({ type: "benchmark", suite: input.suite, config: input.config, }); // Wait for terminal status. Honor abort. return new Promise((resolve, reject) => { const onComplete = (result) => resolve(result); const onFail = (err) => reject(new Error(err.message)); const onAbort = () => { queue.cancel(job.id); reject(new Error("Aborted")); }; ctx.signal?.addEventListener("abort", onAbort); queue.on(job.id, "complete", onComplete); queue.on(job.id, "fail", onFail); }); }, }); ``` Two patterns at play: 1. The tool returns a `Promise` that settles only on the queue's terminal `complete`/`fail` event — the runtime awaits the tool for the full job duration and surfaces the result when it lands. 2. The abort signal (`ctx.signal`) cancels both the runtime-side wait and the underlying queue job, so the user pressing stop reclaims the queue slot. See [Stream events](/docs/stream-events) for the `job.*` event shapes. ## 2. Multi-step interrupt flow (approve → edit → execute) [#2-multi-step-interrupt-flow-approve--edit--execute] A tool that requests human approval for arguments before executing, with the option to edit. ```typescript const runtime = new SessionRuntime({ interrupt: { enabled: true, perToolApproval: { requireApprovalFor: ["delete_resource", "send_email"], defaultDecision: "pending", }, }, // ... }); ``` Drive the turn and resolve each approval request as it arrives. The stream pauses on `interrupt.requested`; call `runtime.interrupts.resolve` with an `ApprovalDecision` to resume: ```tsx import type { ApprovalDecision } from "@pleach/core"; for await (const event of runtime.executeMessage(sessionId, prompt)) { if (event.type === "interrupt.requested") { const { toolCall } = event.interrupt; // Show your modal for `toolCall.name` / `toolCall.arguments`, // then file the user's decision: const decision: ApprovalDecision = await showApprovalUI(toolCall); // approve as-is: { approved: true } // approve with edited args: { approved: true, modifiedArguments: {...} } // reject (tool won't fire): { approved: false } runtime.interrupts.resolve(event.interrupt.id, decision); } } ``` `resolve` resumes the turn from where it paused. A tool approved as-is fires with its original args; `modifiedArguments` dispatch it with edited args; `{ approved: false }` skips it. For a React component surface that tracks the pending interrupts as state, use `useInterruptUI` from `@pleach/core/react` (see [Interrupts](/docs/interrupts)). See [Interrupts](/docs/interrupts). ## 3. Per-call cost reporter [#3-per-call-cost-reporter] A `ProviderDecisionLedger` decorator that emits a per-call cost event for a billing pipeline. ```typescript import type { ProviderDecisionLedger, AuditableCall } from "@pleach/core/audit"; import { lookupModelCost } from "@pleach/core/query"; class CostEmittingLedger implements ProviderDecisionLedger { constructor( private primary: ProviderDecisionLedger, private emit: (event: CostEvent) => void, ) {} async recordCall(call: AuditableCall): Promise<void> { const cost = lookupModelCost(call.call.model); if (call.tokenCost) { const usage = call.tokenCost; // `cost.input` / `cost.output` are USD per 1K tokens, so // cents = tokens * rate / 1000 * 100 = tokens * rate / 10. const cents = Math.ceil( (usage.inputTokens * cost.input + usage.outputTokens * cost.output) / 10, ); this.emit({ sessionId: call.sessionId, turnId: call.turnId, modelId: call.call.model, cents, at: call.createdAt, }); } return this.primary.recordCall(call); } } ``` Wire it in: ```typescript appRegistries.setProviderDecisionLedgerFactory( () => new CostEmittingLedger( new SupabaseProviderDecisionLedger(supabase), (event) => billing.recordCost(event), ), ); ``` Every LLM call produces a cost event before the row lands in the ledger. Idempotent on `(sessionId, turnId, stageId, seqWithinTurn)` — retries don't double-charge. See [Audit ledger](/docs/audit-ledger). ## 4. OpenTelemetry wiring + reading spans in-process [#4-opentelemetry-wiring--reading-spans-in-process] A production OTel setup that exports the four substrate-emitted spans — `session.turn`, `llm.invocation`, `graph.stage`, `tool.execution` — plus the in-process `runtime.spans` path so tests and DevTools can read the trace tree without an external collector. ### `lib/otel.ts` [#libotelts] ```typescript // lib/otel.ts import { NodeSDK } from "@opentelemetry/sdk-node"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; import { Resource } from "@opentelemetry/resources"; import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions"; export function startOtel() { const sdk = new NodeSDK({ resource: new Resource({ [SemanticResourceAttributes.SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? "agent-api", [SemanticResourceAttributes.SERVICE_VERSION]: process.env.GIT_SHA ?? "dev", }), traceExporter: new OTLPTraceExporter({ url: process.env.OTLP_ENDPOINT, headers: { "x-honeycomb-team": process.env.HONEYCOMB_API_KEY ?? "" }, }), }); sdk.start(); return sdk; } ``` Start the SDK before constructing any `SessionRuntime`. The `pleach.tenant_id` attribute is set on every span automatically when `runtime.tenant` is configured — no per-call wiring. ### `lib/runtime.ts` [#libruntimets] ```typescript // lib/runtime.ts import { SessionRuntime } from "@pleach/core"; export function buildRuntime(userId: string, tenantId: string) { return new SessionRuntime({ storage: pgAdapter, userId, tenantId, otelFlushIntervalTurns: 1, // flush at the end of every turn }); } ``` `otelFlushIntervalTurns: 1` drains the exporter buffer per turn — lowest tail latency for dev. Raise it (`5`, `10`) under high-throughput production where per-turn flush cost shows up. ### `app/api/dev/spans/route.ts` [#appapidevspansroutets] ```typescript // app/api/dev/spans/route.ts import { buildRuntime } from "@/lib/runtime"; export async function GET(req: Request) { const runtime = buildRuntime("dev-user", "dev-tenant"); const spans = runtime.spans.snapshot(); return Response.json({ spans }); } ``` `runtime.spans.snapshot()` returns the in-process buffer (last 1,000 spans by default) without round-tripping through an exporter. Useful for DevTools panels and integration tests that assert on the trace tree directly. See [OTel observability](/docs/otel-observability) and [Facets](/docs/facets). ## Where to go next [#where-to-go-next] <Cards> <Card title="Recipes" href="/docs/recipes" description="The consumer-facing recipes — streaming chat, custom tools, custom storage, BYOK, moderation, multi-tenant, compliance." /> <Card title="Observability" href="/docs/observability" description="The full OTel + Datadog + Honeycomb wiring around the ledger and span exports." /> <Card title="Audit ledger" href="/docs/audit-ledger" description="The ProviderDecisionLedger the cost reporter decorates." /> <Card title="Interrupts" href="/docs/interrupts" description="The interrupt contract the approval recipe builds on." /> <Card title="Deployment" href="/docs/deployment" description="Production deployment patterns — long-lived processes, serverless functions, edge runtime constraints." /> </Cards> --- # Playground & devtools (/docs/playground-and-devtools) This page is direct about what's in the box today and what isn't. Some peers (Mastra Studio, Inngest dev server, VoltOps Console, the OpenAI Tracing UI) ship a `localhost:NNNN` playground out of the box. Pleach doesn't — by design. The audit row, the OTel span, and the event log all land in **your** database / **your** OTel backend, and you pick the dashboard. That's a real trade. Here's what each side gets you, what ships today for the in-process / in-browser case, and where to reach when you want a richer dev surface. ## The trade [#the-trade] | | Hosted-dashboard peer | Pleach | | -------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------ | | Where data lands | Vendor service ↔ your code | Your Postgres + your OTel backend | | Vendor lock-in | Some (account, retention, billing per-trace) | None — schema is documented, rows are yours | | Dashboard out of the box | ✅ `localhost:NNNN` UI on `npm run dev` | ❌ — bring Grafana / Honeycomb / Tempo / Datadog / your own panel | | Compliance posture (HIPAA / GDPR / data residency) | "Trust the vendor's SOC 2" | Self-hosted by construction; no vendor boundary to add to your DPA | | Per-tenant cost rollup | Per-vendor pricing model | One `GROUP BY tenant_id` against your `auditable_call` table | | Replay-deterministic export | Vendor-shaped | Your DB → your CI → bit-exact `StreamEvent` corpus | The honest summary: **if you want a polished dashboard with no config, peers are better. If you want the data in your hands with no third party, pleach is better.** The two postures don't combine — you pick. ## What ships today [#what-ships-today] Three real surfaces, none of them a hosted dashboard. ### 1. `window.__HARNESS_DEVTOOLS__` — browser console [#1-window__harness_devtools__--browser-console] In development the runtime exposes a debugging interface on `window.__HARNESS_DEVTOOLS__`. Inspect the live session, walk back through checkpoints, force-sync the outbox without leaving the browser console. Gate behind `NODE_ENV !== "production"` so the surface doesn't ship in production bundles. Full surface on [DevTools](/docs/devtools). ### 2. `inspectRuntime()` — typed runtime introspection [#2-inspectruntime--typed-runtime-introspection] Programmatic, not visual. `inspectRuntime(runtime)` returns a typed snapshot of which `contribute*` hooks the plugins on a constructed `SessionRuntime` actually implement — of the \~45 known hooks, which a registered plugin covers, which are unwired, and which use deprecated forms slated for removal. Useful when a plugin "doesn't seem to do anything" and you're not sure if it actually wired. Full surface on [Runtime inspector](/docs/runtime-inspector). ### 3. The event log + OTel spans — your dashboard of choice [#3-the-event-log--otel-spans--your-dashboard-of-choice] Every turn writes to `harness_event_log` (or whatever you configured); every stage emits an OTel span with parent threading. Pipe to: * **Grafana + Tempo + Loki** — self-hosted; the panel template for the four-stage lattice lives at [observability](/docs/observability#grafana-panel-templates). * **Honeycomb / Datadog / Lightstep** — the OTel spans flow as standard `llm.invocation` / `tool.execution` / `session.turn` / `graph.stage`; no Pleach-specific exporter required. * **A Next.js page in your own app** — read from `harness_event_log` directly, render with whatever you like. The `auditable_call_row` schema is documented at [the AuditableCall row](/docs/auditable-call-row). * **A SQL notebook** — Hex, Mode, Hightouch. The grain is per-turn; you join to billing on `tenant_id` + `turn_id`. ## What ISN'T in the box [#what-isnt-in-the-box] Setting expectations explicitly so you don't go looking: * **No `npm run pleach:dev` that opens `localhost:NNNN`.** The closest analog is `npx pleach init`'s scaffolded app — it gives you a working chat at `/`, not a dashboard at a separate port. * **No hosted SaaS dashboard.** `@pleach/observe` ships destination plugs (Postgres, Supabase, OTel, memory) but no Pleach-Hosted destination today. If a Pleach-Hosted offering ships in the future, it will be opt-in and have its own separate SOC 2 boundary; the self-hosted path will remain first-class. * **No "click on a turn, see the stages light up" visualizer.** The OTel spans carry that data; pipe them to a tracing UI that can render the parent-child topology. * **No replay-in-browser UI.** `@pleach/replay`'s `replayTurn(turnId)` is a programmatic surface that returns a bit-exact `StreamEvent` stream. Render it in your own UI or diff it in CI; there's no shipped replay viewer. ## Common dev-loop recipes [#common-dev-loop-recipes] A few patterns people land on: ### See every event during one turn [#see-every-event-during-one-turn] Tail `harness_event_log` filtered by your dev `userId`: ```sql SELECT seq_within_turn, ts, event_type, payload FROM harness_event_log WHERE user_id = 'me@example.com' AND ts > now() - interval '5 minutes' ORDER BY seq_within_turn ASC; ``` ### Walk the checkpoints for a turn [#walk-the-checkpoints-for-a-turn] Programmatic via `runtime.checkpoints.list(sessionId, turnId)`; the [Time travel](/docs/time-travel) page has the full walk. ### See what a plugin actually contributes [#see-what-a-plugin-actually-contributes] `inspectRuntime(runtime).plugins[i].contributions` returns the list of populated `contribute*` hooks. Empty list = the plugin isn't doing anything. ### Confirm a turn is replay-deterministic [#confirm-a-turn-is-replay-deterministic] Run the same turn twice against `HARNESS_MOCK_MODE=true`. The two `StreamEvent` streams should be byte-identical (modulo timestamps). If they're not, you've introduced a non-determinism source — see [Determinism](/docs/determinism) for the audit. ### Test a plugin headlessly, continuously [#test-a-plugin-headlessly-continuously] `npx pleach dev --plugin ./my-plugin.mjs --scenarios ./b.json --watch` drives a battery through your plugin with no browser, folds each turn into a report (the six panels + signals), and re-runs + diffs on every edit. Add `--assert ./expect.json` to gate it in CI. See [`pleach dev` → plugin-test harness](/docs/cli#plugin-test-harness--headless-continuous). ## The `harness` chatinit flag [#the-harness-chatinit-flag] The CLI harness above is one consumer of a surface you can flip on in your **own** runtime. The runtime already exposes the four capture seams (`eventLogWriter`, `onGraphChannelSnapshot`, `auditRowSink`, `onRuntime` → `inspectRuntime`); the `harness` chatinit flag wires them for you and folds each turn into a `TurnReport` — the same six surfaces + derived signals (incl. `capabilities wired n/total`) — handed to `onTurnReport`. Use it for self-tests, CI gates, telemetry, or an in-app `run → observe → edit` agent loop. ```ts import { createPleachRoute } from "@pleach/core/quickstart"; import { evaluateAssertions } from "@pleach/core/harness"; export const POST = createPleachRoute({ // flip on behind your own env flag — off by default in prod harness: process.env.PLEACH_HARNESS === "1" ? { onTurnReport: (report) => { const { passed, failures } = evaluateAssertions( { plugins: report.signals.plugins, scenarios: [report] }, { all: { reachedTerminal: true, notStalled: true } }, ); if (!passed) console.warn("[harness]", failures); }, } : undefined, }); ``` The same flag exists on `new SessionRuntime({ harness: { onTurnReport } })` for the non-route path (event-log + channel-snapshot signals; the route flag additionally folds in the `inspectRuntime` capability signal, since it holds the per-request runtime). Audit rows are folded by the CLI harness, not the chatinit flag (the audit sink is process-global) — so the audit-ledger predicates (`minAuditRows`, `everyAuditRowClassified`, `everyAuditRowTenantScoped`, `exactlyOneTerminalSynthesis`, `singleFamily`) only apply on the CLI path; from the chatinit flag, assert the event-log + channel signals (`reachedTerminal`, `notStalled`, `eventTypes`, `snapshotChannels`, `minCapabilitiesWired`). `onTurnReport` is fail-soft — a throwing callback can never break the turn. `@pleach/core/harness` also exports the pure pieces so you can wire the loop over the existing seams however you like: | Export | Purpose | | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `foldTurnReport(capture)` | Fold one turn's captured signals into a `TurnReport`. | | `foldTurnReportFromEvents(events)` | Same, from a raw `StreamEvent` / NDJSON stream. | | `createHarnessCapture({ onTurnReport, inspect })` | Composable sinks (`eventLogWriter` / `onGraphChannelSnapshot` / `auditRowSink`) that fold + fire per turn. | | `projectInspector(report)` | The `inspectRuntime` → `{ plugins, wired, total, notContributed }` projection. | | `evaluateAssertions(report, spec)` | `{ passed, failures }` against an expectations spec. | | `diffReports(prev, next)` | Human-readable per-scenario change lines (the `--watch` signal). | | `buildReportMarkdown(report, { verbose })` | Render a battery report as Markdown. `{ verbose: true }` appends the audit-ledger table (`seq · stage · callClass · family · model · transport · status`), the runtime-log stream, and tool cards — all folded from generic `TurnReport` fields. Default output is byte-identical to the non-verbose form. | | `detectEdgeCases(report)` | The answer-quality battery — pure detectors over a `TurnReport` that flag a shipped-but-broken answer: XML/`<thinking>` leak, delegate-envelope leak, plan-narration-as-answer, thin/empty/duplicated/divergent answer, fabricated citation ref, no-runtime degradation, guard truncation. Returns `EdgeCaseFinding[]`. Three detectors accept hints (`toolInputKeyHints` / `dataSourceHints` / `additionalToolRanMarkers`) and domain checks register via a `supplementaryChecks` slot, so a consumer with its own tool vocabulary reuses the battery without forking it. | | `hasSubstantiveAnswerBody(text)` | The pure predicate the battery and the answer-sufficiency backstop both build on — does a draft carry a real answer body (table / ≥2 headers / citation / ≥2 analytical paragraphs after subtracting `PLAN_NARRATION_PATTERNS`) vs. pure "Let me search…" narration. Zero domain vocabulary. | | `getDiagnosticsBus()` / `DiagnosticsBus` | A `messageId`-keyed in-memory pub/sub bus (`publishCrumb` / `publishTurnReport` / `subscribe` / `endTurn`) that replays a turn's console breadcrumbs + folded `TurnReport` to an out-of-band consumer without touching the chat stream. Redaction is injectable (`redactPatterns`; core default = none). | ## Where to go next [#where-to-go-next] <Cards> <Card title="DevTools" href="/docs/devtools" description="The window.__HARNESS_DEVTOOLS__ browser-console surface in detail." /> <Card title="Runtime inspector" href="/docs/runtime-inspector" description="inspectRuntime() — typed introspection of which plugin hooks are wired." /> <Card title="Observability" href="/docs/observability" description="OTel spans, the event log, and the Grafana panel templates." /> <Card title="Time travel" href="/docs/time-travel" description="Walking checkpoints, rewinding to a prior stage, and replay." /> </Cards> --- # Plugin authoring standards (/docs/plugin-authoring-standards) These standards are for plugin **authors** — what to do when building a plugin against `@pleach/core`. They sit between the contract surface ([Plugin contract](/docs/plugin-contract)) and the multi-file authoring layer ([Plugin bundles](/docs/plugin-bundles)). Standards split into three classes by enforcement: | Class | Rules | Mechanism | | ---------------- | ---------- | ------------------------------------------------------------------------------- | | Lint-enforceable | S1, S3 | `@pleach/core/lint` — doc-only at 1.0.0; warn at 1.1.0; error on novel at 1.2.0 | | Wizard-enforced | S4, S6 | `npx pleach init plugin` scaffolds standards-compliant defaults | | Doc-recommended | S2, S5, S7 | Convention; reviewers + authors apply judgment | The retirement cadence (1.0.0 → 1.1.0 warn → 1.2.0 error) matches `audit:harness-plugin-deprecated-usage`'s existing pattern, so existing plugins aren't surprised by strict enforcement on a minor bump. ## S1 — Identity discipline [#s1--identity-discipline] **Rule**: `name` and `version` on every plugin construction call MUST be string literals at construction time. ```typescript // ✓ Acceptable const plugin = definePleachPlugin({ name: "corpus-safety", version: "1.0.0", capabilities: {/* ... */}, }); const composed = composePlugin( { name: "corpus-safety", version: "1.0.0" }, promptsFacet, safetyFacet, ); ``` ```typescript // ✗ Rejected — runtime-computed name import pkg from "../package.json"; const plugin = definePleachPlugin({ name: pkg.name, // not a string literal version: pkg.version, // not a string literal capabilities: {/* ... */}, }); // ✗ Rejected — env-derived name const NAME = process.env.PLUGIN_NAME ?? "corpus-safety"; const plugin = definePleachPlugin({ name: NAME, // not a string literal version: "1.0.0", capabilities: {/* ... */}, }); ``` **Why literals**: * **Replay determinism** — runtime-computed identity drifts across replay runs (different `process.env`, different `package.json` snapshot) and silently breaks `@pleach/replay`'s diff harness. * **Tree-shaking** — bundlers prove plugin identity from string literals; computed values defeat shaking. * **Tooling** — `audit:plugin-contract-completeness` and the lint rules below inspect literals at parse time; they have no view into runtime values. Lint rule: `@pleach/core/lint:plugin-identity-literal`. ## S2 — Semver discipline [#s2--semver-discipline] Plugins follow the same semver as packages but with plugin-specific edge cases. Where this table conflicts with [Versioning](/docs/versioning), this table wins for plugin authors. | Change | Bump | | -------------------------------------------------------------------------- | --------- | | Remove a hook contribution (consumers rely on it via runtime behavior) | **MAJOR** | | Rename plugin `name` field | **MAJOR** | | Bump required `@pleach/core` peer range floor | **MAJOR** | | Drop a facet (e.g., previously safety + tools, now safety-only) | **MAJOR** | | Add a hook contribution (additive at runtime, registration unaffected) | **MINOR** | | Add a new facet (e.g., previously prompts-only, now adds safety) | **MINOR** | | Add an optional field to a contribution shape | **MINOR** | | Bug fix within existing hook return values | **PATCH** | | Perf improvement inside existing contributions, no observable shape change | **PATCH** | | Documentation-only changes | **PATCH** | | First public release | **0.1.0** | | Stable, contract-bound | **1.0.0** | When in doubt, bump higher. A surprised consumer recovering from a too-high bump (lockfile update, version pin) is cheaper than a silent broken consumer from a too-low bump. The package-level table in [Versioning](/docs/versioning) treats "added function" as MINOR. That reduces to the same intuition for plugins: anything additive that consumers can opt into is MINOR; anything that breaks an existing consumer is MAJOR. ## S3 — Peer dependency on `@pleach/core` [#s3--peer-dependency-on-pleachcore] **Rule**: declare `@pleach/core` as `peerDependencies`, not `dependencies`. ```jsonc // ✓ Acceptable — package.json { "peerDependencies": { "@pleach/core": "^1.0.0" }, "devDependencies": { "@pleach/core": "^1.0.0", "typescript": "^5.0.0" } } ``` ```jsonc // ✗ Rejected — package.json { "dependencies": { "@pleach/core": "^1.0.0" // multi-version hazard } } ``` **Why peer**: two `@pleach/core` instances in the same Node module cache produce two `PluginManager` singletons. Registration silently routes to whichever instance the consumer's `SessionRuntime` imports — usually not the one your plugin imports. Debugging this is hours of "but I registered it." Peer dependency lets the consumer's package manager resolve to one shared instance. Range follows [Packages](/docs/packages): | `@pleach/core` cut | Plugin's peer range | | --------------------- | ------------------------------------- | | `0.1.0` (first-wave) | `"@pleach/core": "0.1.0"` (exact pin) | | Future `0.x` releases | `"@pleach/core": "0.x.y"` (exact pin) | | `1.x` stable | `"@pleach/core": "^1.0.0"` | Lint rule: `@pleach/core/lint:plugin-peer-dep`. ## S4 — File layout [#s4--file-layout] Choose by plugin size and growth trajectory. Both shapes register identically with `new SessionRuntime({ plugins: [...] })`. ### Single-facet plugin [#single-facet-plugin] ``` src/ └── pleach.plugin.ts # definePleachPlugin({capabilities, _raw}) ``` For plugins with 1–5 hooks across one semantic domain. The `definePleachPlugin` factory's flat capability menu is the right tool here. ### Multi-facet plugin [#multi-facet-plugin] ``` src/myPlugin/ ├── index.ts # composePlugin(base, ...facets) ├── facets/ │ ├── prompts.ts # definePromptsPlugin({...}) │ ├── safety.ts # defineSafetyPlugin({...}) │ ├── tools.ts # defineToolsPlugin({...}) │ └── ... # one file per facet you contribute to ├── strategies/ # pure functions implementing contributions │ ├── fabrication.ts │ ├── policies.ts │ └── observers.ts └── package.json # if publishable as a standalone npm package ``` For plugins with 10+ hooks across 3+ semantic domains. Each facet file's import line is the surface area in scope; a reviewer reading `safety.ts` doesn't need to context-switch. See [Plugin bundles](/docs/plugin-bundles) for the `composePlugin` mechanic and the facet sub-paths. ### Choosing between them [#choosing-between-them] The wizard `npx pleach init plugin` scaffolds the multi-facet shape inline in a single file by default — the bundle pattern without the multi-file cost. When the file grows past \~150 lines, move each `defineXxxPlugin` const into its own facet file under `facets/<name>.ts`. Mechanical refactor, zero shape change. ## S5 — Naming convention [#s5--naming-convention] | Field | Convention | | ------------------------ | ------------------------------------------------------------------------- | | Package name | `kebab-case` (npm convention) | | Scope | `@<org>/<plugin-name>` when published; bare when local | | Plugin `name` field | **MUST match the package name** when published; bare name when local-only | | Plugin `version` field | semver per S2 | | File name (single-facet) | `pleach.plugin.ts` at root or `src/pleach.plugin.ts` | | File name (multi-facet) | `src/<plugin-name>/index.ts` | **Reserved namespace**: `@pleach/*` is for first-party plugins shipped by the Pleach team (the SKUs documented in [Packages](/docs/packages)). Third-party plugins use their own scope — `@yourorg/yourplugin` — and there is no required prefix or suffix on the plugin name. When a plugin is published to npm, the plugin `name` field field MUST match the package name to avoid silent mismatch on registration (a `package.json` saying `@bar/baz` but a plugin literal saying `name: "foo"` is almost always a typo). A future `@pleach/core/lint:plugin-name-matches-package` rule may catch this; doc-only at 1.0.0. ## S6 — License default [#s6--license-default] The `npx pleach init plugin` wizard defaults to `license: "Apache-2.0"` for the plugin's own scaffolded `package.json`. This is the consumer-scaffold default for the plugin you author; the `@pleach/*` substrate itself ships under FSL-1.1-Apache-2.0 (which auto-transitions to Apache 2.0). The defaults are coherent because the FSL future-license target is Apache 2.0 — plugin authors landing on Apache 2.0 today produce a plugin ecosystem aligned with where `@pleach/*` converges. You can choose any license. The wizard prompts and accepts overrides. There is no enforcement here — this standard is purely a sensible default. If your plugin needs MIT or another SPDX identifier, set it explicitly at scaffold time: ```bash npx pleach init plugin --name corpus-safety --license MIT ``` ## S7 — Deprecation breadcrumb [#s7--deprecation-breadcrumb] **Rule**: when a plugin removes a hook contribution it added in a prior minor (or PATCH) release, emit a one-time breadcrumb at registration time for the duration of the major version cycle the removal is part of. ```typescript // lib/plugins/corpusSafety.ts // At plugin registration time, in the major cycle following removal: import { definePleachPlugin } from "@pleach/core"; export const corpusSafety = definePleachPlugin("corpus-safety", { _raw: { version: "2.0.0", // Emitted at registration; consumers can opt out. prePlanPrimer: () => { console.warn(JSON.stringify({ type: "Pleach:plugin-removed-hook", plugin: "corpus-safety", hook: "contributeRefClassValidators", removedIn: "2.0.0", lastSupportedIn: "1.9.7", })); return null; }, }, }); ``` The breadcrumb gives consumers a one-cycle window to discover the removal and update their integration. Mirrors the `audit:harness-plugin-deprecated-usage` cadence for first-party hooks. **Future shape (post 1.0)**: a typed `deprecatedHooks?: ReadonlyArray<DeprecatedHookSpec>` field on `HarnessPlugin` would let the runtime emit the breadcrumb automatically. Tracked for a future decision; not in scope at 1.0.0. ## Where standards meet the wizard [#where-standards-meet-the-wizard] `npx pleach init plugin` scaffolds standards-compliant defaults: * S1 — `name` and `version` literals (the wizard generates literal strings; no runtime computation). * S3 — `peerDependencies` declaration with the right `@pleach/core` range for the installed bucket. * S4 — multi-facet shape inline by default; `--facets-file split` for the multi-file layout. * S5 — kebab-case `name` matching the scaffolded `package.json`. * S6 — Apache-2.0 default for the plugin author's own `package.json`; `--license <SPDX>` override. (Coherent with the `@pleach/*` substrate's FSL-1.1-Apache-2.0 → Apache 2.0 conversion target.) See [CLI](/docs/cli#pleach-init-plugin) for the subcommand spec. ## Related [#related] * [Plugin contract](/docs/plugin-contract) — the underlying `HarnessPlugin` interface and the `definePleachPlugin` factory. * [Plugin bundles](/docs/plugin-bundles) — thematic facet sub-paths + `composePlugin()` for multi-file authoring. * [Versioning](/docs/versioning) — package-level semver policy. * [Packages](/docs/packages) — SKU matrix and pin guidance. * [CLI](/docs/cli) — `pleach init plugin` subcommand. --- # Plugin bundles (/docs/plugin-bundles) `@pleach/core` ships **10 thematic plugin facet sub-paths** plus a top-level `composePlugin()` helper. They are a structured surfacing layer above `HarnessPlugin`, peer-equivalent to [`definePleachPlugin`](/docs/plugin-contract#definepleachplugin--the-typed-factory). Both APIs produce a `HarnessPlugin`; both route through the same `PluginManager.registerPlugin` path. Choose by plugin size, not by contract surface. | You are building... | Reach for | | ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | A 1–5-hook plugin in one file (custom system prompt, a single safety policy, a single observer) | A raw `HarnessPlugin` literal | | A 5–10-hook plugin with autocomplete on the most-used slots | [`definePleachPlugin({ capabilities, _raw })`](/docs/plugin-contract#definepleachplugin--the-typed-factory) | | A 10+-hook plugin spanning 3+ semantic domains, authored across multiple files | Bundle facets + `composePlugin()` (this page) | <SourceMeta source="{ label: "src/plugins/", href: "https://github.com/pleachhq/core/tree/main/src/plugins" }" /> ## The 10 facet sub-paths [#the-10-facet-sub-paths] Each facet exports a `Pick<HarnessPlugin, ...>` type-narrowed view + a `defineXxxPlugin` identity helper + every type that facet's hooks reference. The boundaries are drawn from the runtime's collector layout — the same way the substrate already dispatches contributions internally. | Sub-path | Hooks covered | When you reach for it | | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | `@pleach/core/plugins/lifecycle` | `name`, `version`, `metadata`, `prePlanPrimer`, `postSynthesisGuard`, `onJobDispatch`, `onJobComplete`, `extraGraphNodes`, `eventResolver`, `contributeEventTypes` | Identity + plan/synthesis hooks + long-running job indexing + custom graph nodes | | `@pleach/core/plugins/prompts` | `contributePromptHints`, `contributePrompts`, `contributeRuntimeAwarePrompts`, `contributePromptContextBridge`, `contributeFallbackSystemPromptBuilder`, `contributePromptSections`, `contributePromptSectionGeneratorBundle`, `contributeMetaLearningContext`, `contributeSynthesisDirectiveBlocks` | Anything that touches the system-prompt seam — persona, runtime-aware sections, meta-learning context, synthesis directives | | `@pleach/core/plugins/tools` | `contributeTools`, `contributeBatchingHints`, `registerAsyncExecutors`, `contributeIntentToolMap`, `contributeToolCouplingHints`, `contributeToolFollowUpIntents`, `contributeEntityExtractors`, `contributeIntentClassifiers`, `contributeIntentMentionDetector` | Tool definitions + how the planner reaches for them + post-tool follow-up + entity extraction | | `@pleach/core/plugins/stream` | `contributeStreamObservers`, `contributeObserverConsumers`, `contributeStreamChunkHandlers`, `contributeStreamEventHandlersAdapters`, `contributeStreamEndDiagnosticsAnalyzer`, `contributeDetectionRules` | Per-chunk observation, mutation, provider-detection rules, stream-end diagnostics | | `@pleach/core/plugins/safety` | `contributeSafetyPolicies`, `contributeFabricationDetectors`, `contributeFabricationDetectorRules`, `contributeFabricationGuard`, `contributeRefClassValidators`, `contributeHallucinatedToolDetectors` | The complete safety pipeline — refusal patterns, fabrication detectors, ref-class validators | | `@pleach/core/plugins/synthesis` | `contributeFinalizationPasses`, `contributeCitationRuleSet`, `contributeCitationEntityExtractor`, `contributeCitationInjector` | Post-synthesis sanitizers + citation eligibility + citation injection | | `@pleach/core/plugins/retry` | `contributeRetryPolicy`, `contributeContinuationPolicy`, `contributeFamilyPivot`, `contributeFamilyExhaustedSurface` | Retry loops, continuation resolver, model-family cascade | | `@pleach/core/plugins/middleware` | `contributeMiddleware`, `contributeRuntimeAwareMiddleware` | AI-SDK middlewares + orchestrator-level interceptors | | `@pleach/core/plugins/sandbox` | `contributeSandboxBridge`, `contributeSandboxInitialization`, `contributeSandboxAvailabilityEvaluator` | Sandbox completion plumbing — only if your plugin runs sandboxed code | | `@pleach/core/plugins/ui` | `contributeApprovalFlow`, `contributeInterruptUIHandlers`, `contributeChatManifestProvider` | UI-coupled bits — interrupt handlers, approval policy, manifest provider | The taxonomy is finite by design. Adding an 11th facet requires a contract decision. Most plugins fill 1–3 facets. ### What's not in a facet [#whats-not-in-a-facet] Some `HarnessPlugin` hooks are operator-level knobs, not plugin- author surface: the host-private strategy hooks (`contributeEntityNameCounter`, `contributeStructurePrefetcher`, `contributeArtifactCacheReader`, `contributeGetDataHandlerFactory`, `contributeDataChannelRefetch`, `contributeContinuationShadowResolver`, `contributeGarbledOutputRecorder`, `contributePreserveDataRefFields`, `contributeGuestDeniedTools`), plus `contributeFabricationGuard` (which is the alternative-shape companion to `contributeFabricationDetectors` and stays umbrella-only on purpose). Reach for them through [`definePleachPlugin._raw`](/docs/plugin-contract#_raw--the-forward-compat-escape-hatch) or set them directly when constructing the runtime. ## The mechanic — three pieces per facet [#the-mechanic--three-pieces-per-facet] Each `@pleach/core/plugins/<facet>` sub-path exports exactly three things, illustrated for the `safety` facet: ```typescript // What you import: import { defineSafetyPlugin, type SafetyPluginFacet, type FabricationDetector, type SafetyContribution, type RefClassValidator, } from "@pleach/core/plugins/safety"; ``` 1. `SafetyPluginFacet` — a `Pick<HarnessPlugin, ...>` view. This is the safety-only mental model. Every hook your safety facet can contribute is here; nothing else compiles. 2. `defineSafetyPlugin(facet)` — an identity function that returns the facet. Its only job is the type inference: passing a literal to it makes TypeScript infer the tightest possible types for your contributions. 3. The types your contributions reference — `FabricationDetector`, `SafetyContribution`, `RefClassValidator`. You don't have to reach back to `@pleach/core` for them. ## `composePlugin()` — assembling a multi-facet plugin [#composeplugin--assembling-a-multi-facet-plugin] ```typescript // lib/plugins/myDomainPlugin.ts import { composePlugin } from "@pleach/core/plugins"; import { definePromptsPlugin } from "@pleach/core/plugins/prompts"; import { defineSafetyPlugin } from "@pleach/core/plugins/safety"; import { defineToolsPlugin } from "@pleach/core/plugins/tools"; import { defineStreamPlugin } from "@pleach/core/plugins/stream"; import { defineLifecyclePlugin } from "@pleach/core/plugins/lifecycle"; const promptFacet = definePromptsPlugin({ name: "my-domain", version: "1.0.0", contributeRuntimeAwarePrompts: (ctx, state) => [ /* per-turn prompt blocks */ ], contributeSynthesisDirectiveBlocks: (ctx) => [ /* synthesis-time directives */ ], }); const safetyFacet = defineSafetyPlugin({ name: "my-domain", version: "1.0.0", contributeSafetyPolicies: () => DOMAIN_SAFETY_POLICIES, contributeFabricationDetectors: () => DOMAIN_FABRICATION_DETECTORS, }); const toolsFacet = defineToolsPlugin({ name: "my-domain", version: "1.0.0", contributeTools: () => DOMAIN_TOOLS, contributeIntentToolMap: () => DOMAIN_INTENT_TOOL_MAP, }); export const myDomainPlugin = composePlugin( { name: "my-domain", version: "1.0.0" }, promptFacet, safetyFacet, toolsFacet, ); ``` `composePlugin(base, ...facets)` merges via `Object.assign({}, ...facets, base)` — base last so `name` and `version` cannot be overridden by a facet that drifted. Facets earlier in the argument list lose to facets later on per-hook collision; in practice you don't contribute the same hook from two facets in one plugin (the build catches it). The output is a plain `HarnessPlugin`. The substrate has no idea it was composed. ```typescript import { SessionRuntime } from "@pleach/core"; const runtime = new SessionRuntime({ storage: new SupabaseAdapter({ client: supabase }), checkpointer: new SupabaseSaver({ client: supabase }), plugins: [myDomainPlugin, compliancePlugin, gatewayPlugin], userId: "user_123", }); ``` ## Type inheritance — why `Pick<>` and not a new interface [#type-inheritance--why-pick-and-not-a-new-interface] Facets are type-narrowing views. If `contributeFabricationDetectors`'s signature widens at the canonical `HarnessPlugin` interface — say, the context argument gains a new field — every facet picks up the change automatically with zero edits. This is what keeps `audit:plugin-contract-completeness` running as your authoritative contract gate. Bundles are not a parallel type to maintain; they are a structured projection. ## Authoring across files [#authoring-across-files] The intended usage is one facet per file in a `facets/` directory: ``` src/myDomainPlugin/ ├── index.ts # composePlugin(base, ...facets) ├── facets/ │ ├── prompts.ts # definePromptsPlugin({ ... }) │ ├── safety.ts # defineSafetyPlugin({ ... }) │ ├── tools.ts # defineToolsPlugin({ ... }) │ ├── stream.ts # defineStreamPlugin({ ... }) │ └── lifecycle.ts # defineLifecyclePlugin({ ... }) └── strategies/ # implementation details — pure functions ├── fabrication.ts ├── policies.ts └── observers.ts ``` Each facet file imports its `define*` helper and the types it needs from one sub-path. A reviewer reading `safety.ts` doesn't need to context-switch to understand what's in scope — the import line shows the surface area, and TypeScript's autocomplete only suggests members of the facet view. For a worked example of this shape at scale (\~42 hooks across 5 facets, \~400 LoC of business logic distributed across the files), see the open-source plugin authoring layout linked under [Reference apps](/docs/reference-apps). ## Empty implementations are now explicit [#empty-implementations-are-now-explicit] The 10-facet split makes "this plugin chose not to contribute this" structurally legible. Compare: ```typescript // Without facets — the absence of `contributeChatManifestProvider` // is invisible unless you know to look for it: export const myPlugin: HarnessPlugin = { name: "my-plugin", contributePrompts: () => [...], contributeSafetyPolicies: () => [...], contributeTools: () => [...], // ... 30 other contribute* hooks }; // With facets — each file's surface area is exactly what's // in scope, and absent hooks are absent from the file entirely: export const safetyFacet = defineSafetyPlugin({ name: "my-plugin", version: "1.0.0", contributeSafetyPolicies: () => [...], contributeFabricationDetectors: () => [...], // contributeRefClassValidators not contributed — intentional }); ``` ## Diagnostic breadcrumbs [#diagnostic-breadcrumbs] Two runtime diagnostics help authors notice when the construction shape diverges from what the runtime expected. Both are non-blocking — they emit once per session at startup. ### `[Pleach:facet-not-contributed]` — missing domain plugin [#pleachfacet-not-contributed--missing-domain-plugin] When **no plugin in the runtime** contributes any hook for a high-leverage facet, the runtime emits one breadcrumb naming the facet. This is diagnostic of a misconfigured runtime — a domain plugin was probably forgotten at registration time — not a per-plugin design choice. Instrumented for 5 facets where the aggregate-empty state is suspicious: | Facet | Why aggregate-empty matters | | ----------- | ------------------------------------------------------------------------------------------ | | `safety` | No safety policies, fabrication detectors, or ref-class validators anywhere in the runtime | | `tools` | Zero tool definitions across all plugins — synthesize-only mode | | `prompts` | Naked baseline prompt — almost certainly missing a domain plugin | | `stream` | No observers + no detection rules — silent PII / metrics gap | | `lifecycle` | No `extraGraphNodes`, no plan primer, no job dispatch hooks — no domain integration | Not instrumented: `sandbox`, `middleware`, `ui`, `retry`, `synthesis` — aggregate-empty here is a legitimate operational choice (many runtimes don't sandbox, don't use React, inherit default retry behavior, and so on). A correctly-configured runtime with at least one domain plugin typically emits zero of these breadcrumbs. ### `[Pleach:single-facet-composePlugin]` — over-ceremony [#pleachsingle-facet-composeplugin--over-ceremony] When `composePlugin(base, oneFacet)` is called with exactly one facet in a non-production build, the runtime suggests the flat factory — `definePleachPlugin({capabilities})` — as a shorter path. Bundles pay off at 2+ facets across 2+ files; one facet is just ceremony. Suppressed in production. Suppress either breadcrumb with the standard [breadcrumb opt-out](/docs/runtime-inspector#breadcrumbs). ## Choosing the right construction path [#choosing-the-right-construction-path] You don't have to commit to one. All three forms are interoperable; they all produce a `HarnessPlugin` that registers the same way. ```typescript // Mix and match — a small plugin embedded inside a larger app: const runtime = new SessionRuntime({ /* ... */ plugins: [ myDomainPlugin, // composePlugin(...) — multi-facet rawPlugin satisfies HarnessPlugin, // raw literal — one observer definePleachPlugin({ // flat factory — 3 capabilities name: "telemetry", version: "0.1.0", capabilities: { streamObservers: [recordChunks], }, }), ], }); ``` A useful rule of thumb: switch from `definePleachPlugin` to bundle facets when your plugin's single-file shape stops fitting on one screen, or when you find yourself wanting to test one domain's contributions in isolation. Both are signals the multi-file shape is paying off. ## Related [#related] * [Plugin contract](/docs/plugin-contract) — the underlying `HarnessPlugin` interface and the `definePleachPlugin` factory. * [Concept clusters](/docs/concept-clusters) — why plugins are a thematic island, and where bundles sit relative to the six cluster triplets. * [Reference apps](/docs/reference-apps) — open-source plugin authoring at multi-facet scale. * [Fabrication detection](/docs/fabrication-detection) — the safety facet's largest hook (`contributeFabricationDetectors`). * [Prompts](/docs/prompts) — the prompt facet's composition order. --- # Plugin contract (/docs/plugin-contract) `HarnessPlugin` is the consumer extension contract for `@pleach/core`. Every sibling SKU — `@pleach/compliance`, `@pleach/eval`, `@pleach/gateway`, `@pleach/replay`, `@pleach/mcp`, `@pleach/coding-agent`, `@pleach/sandbox`, `@pleach/langchain`, `@pleach/base-tools`, `@pleach/observe`, `@pleach/recipes` — is a plugin that implements this contract. All ship at `0.1.0 · FSL-1.1-Apache-2.0` in the first-wave cut; `@pleach/trust-pack` alone remains a reserved npm name. See [Packages](/docs/packages) for the canonical per-SKU status table. Your own consumer code extends the runtime through the same surface; sibling SKUs slot in alongside without changing graph shape. The contract is small on purpose. Plugins fill named slots; they don't get a handle on the lattice, the synthesize seam, or the modelfamily matrix. That bounded surface is what makes substrate guarantees (one synthesize per turn, family-locked routing, replay determinism) hold no matter what plugins do. > **Plugins are a thematic island.** Not one of the [six cluster > triplets](/docs/concept-clusters#the-six-cluster-triplets) — > `HarnessPlugin` is a bounded contract (74 optional hooks — 67 > `contribute*` hooks plus 7 top-level lifecycle hooks — plus > four structural invariants), not a three-concept cluster. See > [What lives outside the cluster pattern](/docs/concept-clusters#what-lives-outside-the-cluster-pattern). <SourceMeta source="{ label: "src/plugins/", href: "https://github.com/pleachhq/core/tree/main/src/plugins" }" /> ## `definePleachPlugin()` — the typed factory [#definepleachplugin--the-typed-factory] `definePleachPlugin()` is the recommended construction surface. It takes the same fields as a raw `HarnessPlugin` literal plus a flat `capabilities` menu, and returns a `HarnessPlugin` with autocomplete that surfaces "what can my plugin contribute?" in one place instead of paging the 1100-line interface. The factory exports from the top-level barrel: ```typescript import { definePleachPlugin } from "@pleach/core"; import type { PluginCapabilities } from "@pleach/core"; ``` ```typescript export const myPlugin = definePleachPlugin("my-plugin", { prompts: [personaBlock], safetyPolicies: [refusalPolicy], fabricationDetectors: [phantomToolDetector], streamObservers: [redactPiiObserver], tools: [searchCorpus, fetchUrl], intentToolMap: [intentMapEntry], toolCouplingHints: [couplingHint], runtimeAwarePrompts: (ctx, state) => buildPerTurnPrompts(ctx, state), _raw: { version: "0.1.0", // hooks not yet covered by the structured menu — see below contributeFabricationGuard: () => myFabricationGuard, contributeSynthesisDirectiveBlocks: (ctx) => [domainDirective(ctx)], }, }); ``` Capability fields take **values directly**, not thunks — `prompts: [personaBlock]`, not `prompts: () => [personaBlock]`. The factory forwards each field into the corresponding `contribute*` method on `HarnessPlugin`. One field is the exception: `runtimeAwarePrompts` accepts either a `(ctx, state) => PromptContribution[]` function or a fixed array, mirroring the underlying hook's per-turn shape. ### `PluginCapabilities` — the initial 8 slots [#plugincapabilities--the-initial-8-slots] | Key | Type | Maps to | | ---------------------- | ---------------------------------------------------------------------------------- | -------------------------------- | | `prompts` | `readonly PromptContribution[]` | `contributePrompts` | | `runtimeAwarePrompts` | `((ctx, state) => readonly PromptContribution[]) \| readonly PromptContribution[]` | `contributeRuntimeAwarePrompts` | | `safetyPolicies` | `readonly SafetyContribution[]` | `contributeSafetyPolicies` | | `fabricationDetectors` | `readonly FabricationDetector[]` | `contributeFabricationDetectors` | | `tools` | `readonly ToolDefinitionLite[]` | `contributeTools` | | `streamObservers` | `readonly StreamObserverRegistration[]` | `contributeStreamObservers` | | `intentToolMap` | `readonly IntentToolMapEntry[]` | `contributeIntentToolMap` | | `toolCouplingHints` | `readonly CouplingHint[]` | `contributeToolCouplingHints` | The cluster covers the most-used contribution surface. Future hooks land additively — widening `PluginCapabilities` doesn't break existing call sites. ### `_raw` — the forward-compat escape hatch [#_raw--the-forward-compat-escape-hatch] `_raw` is typed as `Partial<HarnessPlugin>` and accepts any contribution hook not surfaced through the structured cluster above. Reach for it when contributing: * `contributeFabricationGuard` (the typed hook for the prior untyped-bag fabrication-guard implementation — see below) * `contributeSynthesisDirectiveBlocks` * `contributeFinalizationPasses` * `contributeDetectionRules` * `contributeIntentClassifiers` * `contributeSandboxBridge` * `contributeInterruptUIHandlers` * `contributeCitationRuleSet` * `contributeChatManifestProvider` * `contributeBatchingHints`, `contributeEventTypes` * `extraGraphNodes`, `prePlanPrimer`, `postSynthesisGuard` * The deprecated bare-property forms — flagged in [`inspectRuntime()`](/docs/runtime-inspector) as `deprecated-wired` Keys present in `_raw` win on collision with the structured cluster (last-write-wins) so authors migrating bespoke plugins keep full control. The raw object literal still works — `HarnessPlugin` is a public type and existing plugins keep loading. The factory is what new plugins should reach for; it picks up future capability additions without the plugin author having to chase type changes by hand. > **For larger plugins**, see [Plugin bundles](/docs/plugin-bundles) > — thematic facet sub-paths > (`@pleach/core/plugins/safety`, `@pleach/core/plugins/prompts`, …) > plus `composePlugin()` for assembling a plugin across multiple > files. Both APIs produce a `HarnessPlugin`; choose by plugin > size, not contract surface. The substrate's `audit:plugin-contract-completeness` audit asserts every `contributeX` hook on `HarnessPlugin` has both a paired `PluginManager.collectX` accessor and at least one substrate consumer site. **41 hooks** are catalogued today: 38 wired end-to-end, 3 baselined awaiting consumer (`contributeBatchingHints` is on the 1.2.0 retirement track per `audit:harness-plugin-deprecated-usage`; `contributeEventTypes` runs an intentional collector-bypass; `contributeMiddleware` ships the Stage 1 contract while Stage 2 consumer rewires land). The 5 hard-deprecated lifecycle hooks (`onSessionCreated`, `onToolCompleted`, `onMessageAdded`, `queryExtensions`, `sandboxAvailability`) have been retired; replace them with `contributeStreamObservers`, `contributeDetectionRules`, or the typed `runtime.events.iterate` projection surface. ## What a plugin can do [#what-a-plugin-can-do] | Capability | API surface | When you reach for it | | ---------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Register **extra graph nodes** in lattice enrichment slots | `extraGraphNodes` | Intent detection, plan generation, safety review, quality scoring | | Register **stream observers** on provider seams | `contributeStreamObservers` | Per-chunk inspection, content rewriting, halt on policy violation | | Register **prompt contributors** | `contributePrompts`, `contributeRuntimeAwarePrompts` | Static system blocks, runtime-aware sections, retrieval-driven additions | | Register **safety policies** | `contributeSafetyPolicies` | Refusal-pattern detectors, policy-bound rewriters | | **Subscribe** to lifecycle events | `runtime.events.iterate({chatId, fromSequenceNumber})` + `contributeStreamObservers` | Domain events, cross-cutting telemetry, third-party sinks. The legacy lifecycle callbacks (`onSessionCreated`, `onToolCompleted`, `onMessageAdded`) have been retired — read from the event log projection instead. | | **Emit** named-channel envelopes | seam observer `emit` verdict | Pipe structured chunks through to downstream consumers | The slot model means a plugin's surface area is auditable: what it contributes is visible at registration time, not threaded through opaque callbacks. A reviewer reading `plugins: [compliancePlugin, gatewayPlugin, myPlugin]` against a runtime construction sees the exact list of contributions each name brings by walking each plugin's `contributeStreamObservers`, `extraGraphNodes`, `contributePrompts`, and `contributeSafetyPolicies` exports — there is no hidden channel through which a plugin could mutate the graph, the seam set, or the modelfamily matrix. ## What a plugin cannot do [#what-a-plugin-cannot-do] These boundaries are enforced by lint, by type system, or by runtime invariant — not by convention. | Forbidden | Enforced by | | -------------------------------------------------------------- | ----------------------------------------------------------- | | Add an out-of-lattice edge to the graph | `audit:graph-stages` (CI gate) | | Bypass the singleton synthesize seam | `SynthesizeSeamHolder` + `TurnSynthesizeCounter` (runtime) | | Reach across the `seams/` boundary into the modelfamily matrix | `lint:harness-boundary` (CI gate) | | Register an async stream-observer dispatch path | Type signature is `onChunk(chunk, ctx): Verdict`, sync only | | Use a raw `callClass: "..."` literal outside a seam factory | `lint:callclass-literals` (CI gate) | The async-observer restriction is the one that surprises people. The reason it's sync is the replay determinism story: an observer that returns `Promise<Verdict>` introduces non-determinism into the stream, and replay determinism is the load-bearing property that the shipping `@pleach/eval@0.1.0` and `@pleach/replay@0.1.0` SKUs are built around — and that any host's own diff harness can lean on today. ## The lattice slots [#the-lattice-slots] Each of the four lattice stages exposes named enrichment slots that plugins fill. Slots are typed; a plugin contributing to a slot declares which stage it belongs to and what channels it reads and writes. | Stage | Enrichment slots | | ------------- | ------------------------------------------------------- | | `anchor-plan` | `intentDetection`, `planGeneration`, `anchorBuilding` | | `tool-loop` | `toolSelection`, `toolExecution`, `dataSilo`, `jobSilo` | | `synthesize` | `synthesizerPreamble`, `citationInjector` | | `post-turn` | `qualityScoring`, `consolidation`, `recoveryShaping` | A plugin can fill zero or more slots. Slots themselves don't multiply — two plugins contributing to `intentDetection` chain deterministically by registration order, not by reduction. `synthesize` is a true singleton stage — only the `synthesizer` node runs there. Recovery shaping (refusal hints, retry narration, garble recovery) is a `post-turn` concern: it moved off the lattice into the recovery stream filters, which fire at stage completion via the `StreamObserverRegistry`. See [Stream observers](/docs/plugins/stream-observers). ## Stream observer verdicts [#stream-observer-verdicts] Observers ride on top of every provider seam. For each inbound chunk the seam dispatches the observer ladder; each observer returns one of four verdicts: | Verdict | Effect | | ---------- | --------------------------------------------------------- | | `continue` | Pass through unchanged | | `amend` | Replace chunk content 1:1 — strict, no multiplex | | `emit` | Pass through and emit a named-channel envelope downstream | | `stop` | Stop the stream; downstream reads the stop sentinel | `amend` being 1:1 is deliberate: a one-chunk-in, many-chunks-out observer would break the byte-replay property. Plugins that need fan-out emit named envelopes on a channel, not extra stream chunks. Concretely: a redaction observer that replaces a span with `[REDACTED]` returns an `amend` verdict whose `chunk.text` is the cleaned string, and the chunk count stays the same. A metrics observer that wants to ship a structured side-effect returns an `emit` verdict with an envelope on a named channel (e.g. a `metrics` channel), and the main stream sees the original chunk pass through unchanged. ## `contributeFabricationDetectors` slot [#contributefabricationdetectors-slot] A plugin can register one or more fabrication detectors through this slot. Each detector receives a typed context — the completed tools for the turn, the assistant content, the user text, the set of known tool names, and the current call class — and returns a `FabricationFinding` or `null`. The graph's `FabricationNode` iterates the union of every plugin's detectors per turn. ```typescript contributeFabricationDetectors?(): readonly FabricationDetector[] ``` ```typescript // lib/plugins/phantomToolDetector.ts import type { HarnessPlugin } from "@pleach/core"; import type { FabricationDetector } from "@pleach/core/plugins"; const phantomToolDetector: FabricationDetector = { id: "phantom-tool", detect(ctx) { const match = ctx.assistantContent.match(/\b([a-z_]+)\(/); if (!match) return null; const name = match[1]; if (ctx.knownToolNames.has(name)) return null; return { detectorId: "phantom-tool", severity: "medium", reason: `Mentions ${name}() but no such tool is registered`, evidence: { name }, }; }, }; export const myPlugin: HarnessPlugin = { name: "my-plugin", contributeFabricationDetectors: () => [phantomToolDetector], }; ``` See [Fabrication detection](/docs/fabrication-detection) for the full detector contract, the context shape, and how findings flow through the post-graph pipeline. ## `contributeSynthesisDirectiveBlocks` [#contributesynthesisdirectiveblocks] A plugin can return synthesis-mode directive blocks that the runtime composes into the synthesis-time prompt seam. Each block declares an `id`, a `priority`, and the directive text the seam should fold in. ```typescript contributeSynthesisDirectiveBlocks?( ctx: SynthesisDirectiveContext, ): readonly SynthesisDirectiveBlock[] ``` The hook receives a runtime-context argument carrying the channel, the resolved model, and the tenant. Contributions can be context-conditional — return a block for `gateway` channels and skip it for others, or vary the text by tenant tier. The substrate ships its default synthesis-mode block at priority 10\. Higher-priority contributions stack over the default; same- priority contributions append in registration order. See [Prompts](/docs/prompts) for the composition order overview. ## `contributePrompts` / `contributeRuntimeAwarePrompts` [#contributeprompts--contributeruntimeawareprompts] Two prompt-contribution hooks split by when their inputs resolve. `contributePrompts(ctx)` returns prompts that are independent of runtime state — persona blocks, static system instructions, provider-version notes. `contributeRuntimeAwarePrompts(ctx)` returns prompts that depend on values resolved per-turn, such as the active tool set, the channel mode, or the tenant. ```typescript contributePrompts?(ctx: PromptContext): readonly PromptContribution[] contributeRuntimeAwarePrompts?( ctx: RuntimePromptContext, ): readonly PromptContribution[] ``` The runtime calls each hook once per turn and merges results into the composed prompt via the order documented in [Prompts](/docs/prompts). For the friendly API helpers (`appendPrompt`, `prependPersona`, `replaceCore`, `scopedPrompt`, `gatedPrompt`, `createPlugin`) that wrap these hooks, see [Prompt builder](/docs/prompt-builder). ## `contributeScrubbers` [#contributescrubbers] Host plugins register additional `Scrubber` instances by returning them from `contributeScrubbers()`. The runtime composes scrubbers across plugins; each scrubber declares the event-type allowlist it covers. ```typescript contributeScrubbers?(): readonly Scrubber[] ``` `audit:c8-event-type-allowlist-coverage` gates the build on every persisted event type having at least one scrubber registered — even a pass-through counts. See [Scrubbers](/docs/scrubbers) for the gate's rationale and [Compliance](/docs/compliance) for the four bundled scrubbers. ## `DomainContextStrategy` (`isTableValueWord` host-supply) [#domaincontextstrategy-istablevalueword-host-supply] `DomainContextStrategy` is the host-supply seam for domain-context heuristics the substrate previously hardcoded. Hosts pass a strategy object at runtime construction; the substrate calls into it from the word-classification and garble-recovery paths. The substrate's word-classification step now calls out to `DomainContextStrategy.isTableValueWord(word)` instead of consulting a built-in word list. Hosts override this to teach the substrate domain-specific vocabulary without forking the substrate — a search host registers query types, a medical host registers ICD codes, a finance host registers ticker shapes. Other strategy entries (body-garble dispatcher, short-content garble, early-coherence) are similarly host-overridable. See the source for the full strategy interface. <SourceMeta source="{ label: "src/strategies/", href: "https://github.com/pleachhq/core/tree/main/src/strategies" }" /> ## Minimal plugin shape [#minimal-plugin-shape] ```typescript // pleach.plugin.ts import type { HarnessPlugin } from "@pleach/core"; export const myPlugin: HarnessPlugin = { name: "my-plugin", contributeStreamObservers: () => [ { when: { callClass: "synthesize" }, factory: () => ({ observerId: "redact-pii", onChunk(chunk) { // Return { kind: "continue" } to pass the chunk through, or // { kind: "amend", chunk: rewritten } to rewrite it in-flight. return { kind: "continue" }; }, }), }, ], contributePrompts: () => [ /* PromptContribution entries */ ], }; ``` Lifecycle events (session created, tool completed, message added) are **not** plugin callbacks — the `onSessionCreated` / `onToolCompleted` / `onMessageAdded` hooks have been retired. Subscribe to the same signals through the typed event-log projection instead: ```typescript for await (const event of runtime.events.iterate({ chatId })) { /* react to tool.completed, message.added, … */ } ``` Register the plugin once at runtime construction: ```typescript const runtime = new SessionRuntime({ storage: new SupabaseAdapter({ client: supabase }), checkpointer: new SupabaseSaver({ client: supabase }), plugins: [myPlugin, compliancePlugin, gatewayPlugin], userId: "user_123", }); ``` Registration order is the dispatch order — the substrate doesn't re-order plugins. Two plugins that genuinely commute will produce the same observable output regardless of order; two that don't should be sequenced explicitly at the registration site. The canonical non-commuting pair is a PII-redaction observer and a metrics observer that records chunk lengths: register redaction first and the metrics observer sees the redacted text length; register metrics first and it sees the original length. Both orderings are legal; the substrate's job is to make the choice visible at the construction site, not to pick for you. ## `contributeFabricationGuard` [#contributefabricationguard] The fabrication guard hook returns one `FabricationGuardImpl` instance — a single object whose methods the substrate's fabrication pipeline calls during a synthesized turn. Until recently the implementation reached the runtime through the `orchestratorHotpath` untyped bag; the typed hook replaces it. ```typescript contributeFabricationGuard?(): FabricationGuardImpl | null | undefined ``` `FabricationGuardImpl` carries one method per detection signal: | Method | Purpose | | -------------------------------------------------------------- | ------------------------------------------------------------------------------------- | | `applyFabricationGuard(params)` | The top-level pipeline — wipes paragraphs, returns the `FabricationGuardResultShape`. | | `isGuardBlockedFailure(errorMessage)` | Classify a failed-tool error as guard-blocked vs real upstream failure. | | `detectBulkFailureFabrication(params)` | Fires when failure ratio is high and the response carries quantitative content. | | `detectUnqueriedCitations(params)` | Citations to sources the model never queried this turn. | | `detectSelfFabricationConfession(params)` | Confession phrase + an 8-hex prefix matching a prior tool-use's jobId. | | `detectMethodResultFabricationConfession(params)` | Phrase-only confessions retracting fabricated method-result tables. | | `contentReferencesMissingTools(content, unavailableToolNames)` | Bytewise scan over prose for any tool in an unavailable set. | | `detectIdentifierMismatch(params)` | Identifiers in prose absent from tool results. | The hook returns one impl per plugin. If multiple plugins contribute, registration order determines dispatch order. Returning `null` or `undefined` skips the contribution — the substrate falls through to its baseline behavior, and any signal whose slot is unfilled cleanly skips per [Fabrication detection](/docs/fabrication-detection). `contributeFabricationGuard` is not in the structured `PluginCapabilities` cluster (the initial 8 keys). Reach for it through `_raw`: ```typescript // lib/plugins/corpusGuard.ts import { definePleachPlugin } from "@pleach/core"; import type { FabricationGuardImpl } from "@pleach/core/plugins"; const corpusGuard: FabricationGuardImpl = { applyFabricationGuard(params) { /* ... */ }, isGuardBlockedFailure(err) { /* ... */ }, // ...remaining methods }; export const corpusGuardPlugin = definePleachPlugin("corpus-guard", { _raw: { version: "0.2.0", contributeFabricationGuard: () => corpusGuard, }, }); ``` ### `bag-entry-retirement-readiness` audit status [#bag-entry-retirement-readiness-audit-status] The audit tracks per-entry migration status from the `orchestratorHotpath` untyped bag to the typed hook surface. Current state on the `OrchestratorHotpathModules` shape: | Entry | Status | Detail | | ------------------ | ---------------- | --------------------------------------------------------------------------------------------------------------------- | | `streamHelpers` | **RETIRED** | Bag field deleted; the typed `SessionRuntimeConfig.metaToolNames` is canonical. Field count on the bag dropped 3 → 2. | | `toolCoupling` | **RETIRE-READY** | Typed replacement landed; no novel bag reads remain. | | `fabricationGuard` | **NEAR-READY** | Typed `contributeFabricationGuard` hook landed; residual bag reads remain on the deprecation path. | Hosts still reading from the bag should migrate via the bare → method codemod documented in [Host adapter](/docs/host-adapter#pleach-plugin-modernize-codemod). ## `[Pleach:capability-not-contributed]` breadcrumbs [#pleachcapability-not-contributed-breadcrumbs] Eleven `contribute*` collectors emit a one-shot `[Pleach:capability-not-contributed]` console line when they aggregate to an empty array. The breadcrumb is dedup-keyed on `${sessionId}:${capability}`, so it fires once per session per capability rather than per turn. The runtime keeps working — the collector returns its empty result and the substrate proceeds — but the line surfaces a missing plugin without spamming logs. The eleven gated capabilities: | Capability hook | | ------------------------------------ | | `contributeStreamObservers` | | `contributeStreamFilters` | | `contributeSafetyPolicies` | | `contributeFabricationDetectors` | | `contributePostToolTier` | | `contributeRefClassValidators` | | `contributeMiddleware` | | `contributeRuntimeAwareMiddleware` | | `contributeToolCouplingHints` | | `contributeIntentToolMap` | | `contributeIntentParameterResolvers` | Payload shape: ```js console.log("[Pleach:capability-not-contributed]", { capability: "contributeStreamObservers", sessionId: "sess_018f...", hint: "register a HarnessPlugin that implements contributeStreamObservers() to provide this capability", }); ``` To surface the same gaps at construction time — before any seam fires — call `inspectRuntime(runtime)` and walk `report.capabilities` for rows whose `wiredCount === 0`. See [Runtime inspector](/docs/runtime-inspector#capability-breadcrumbs-separate-from-the-inspector). ## Sibling SKUs as plugins [#sibling-skus-as-plugins] The `@pleach/*` siblings surface as plugins (or as DI-friendly clients that consume the plugin contract). The table below reflects where each SKU sits today — most ship a real Phase A surface; a few are contract-only or still landing slice-by-slice. Pin exact versions per the [Packages](/docs/packages) bucket guidance. | Package | What it contributes | Status | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | | `@pleach/compliance` `0.1.0` | Scrubbers (SSN-US, Luhn, US-DL, KeyedRegex) + `ComplianceRuntime` contract substrate; event-chain attestation; verification utilities for HIPAA / GDPR / PCI-DSS / SOC 2. | Shipping | | `@pleach/eval` `0.1.0` | `EvalSuite` + `EvalCase` discriminated union + scorers + report formatters. Reads the `AuditableCall` ledger via the event-log projection (`runtime.events.iterate`); consumes `@pleach/replay`'s `ReplayClient` via DI. | Shipping | | `@pleach/gateway` `0.1.0` | `GatewayClient` — thin wrapper over `@pleach/core`'s model-family substrate; per-tenant BYOK key routing; family-strict cascade pivot; per-call cost emission; OTel `llm.invocation` span emission. | Shipping | | `@pleach/replay` `0.1.0` | `ReplayClient` walks the canonical event log via `runtime.events.iterate` / `fold`. `createReplayRuntime` factory ships real bodies for `replayTurn`, `fromSnapshot`, `fork`, and `aggregateMultiTenant` — zero throw sites remain. | Shipping | | `@pleach/mcp` `0.1.0` | Concrete `MCPServer` class wrapping `SessionRuntime` over stdio. SSE + WebSocket transport arms ship in the union for shape-locking; `server.start({ transport: "sse" \| "websocket" })` throws `NotImplementedError("D-PA-181")` until the next slice. Multi-tenant `registerSession()` throws `NotImplementedError("D-PA-184")` until `@pleach/gateway` C3 lands. | Shipping (stdio end-to-end) | | `@pleach/coding-agent` `0.1.0` | Typed `CodingAgentRuntime` contract at `@pleach/coding-agent/runtime`. `start()`, `stop()`, and `executeStep()` bodies all land at the `0.1.0` cut. `executeStep()` throws `PACK_270_D3_EXECUTE_STEP_NOT_STARTED_MESSAGE` only when called before `start()`. | Shipping | | `@pleach/sandbox` `0.1.0` | Vendor-neutral `SandboxProvider` contract + in-memory fixture. Canonical name `SandboxProvider`; legacy `SandboxAdapter` retained as a `@deprecated` alias. | Shipping (contract) | | `@pleach/observe` `0.1.0` | Destination-flexible audit-row SDK. `init` + per-turn recorder + destination plug, five destinations (memory, postgres, supabase, otel, pleachHosted), PII redaction, sampling, fingerprint, subagent. | Shipping | | `@pleach/recipes` `0.1.0` | Composable one-line factories: `simpleChatbot`, `ragChatbot`, `observableChatbot`, `compliantChatbot`, `instrumentedCodingAgent`. Each subpath pulls only the `@pleach/*` peers its recipe needs. | Shipping | The plug-and-play guarantee already holds — none of the above can break the lattice, the singleton seam, or the family lock (those are CI gates on the core repo, not opt-in discipline). Each sibling's own README documents the slots it fills and the config it expects. ## Where to go next [#where-to-go-next] <Cards> <Card title="Contribution namespaces" href="/docs/plugins/namespaces" description="The nine namespaces every contribution hook resolves to — the canonical map of where a plugin plugs in." /> <Card title="Architecture" href="/docs/architecture" description="The six pieces of the substrate — stage lattice, call classes, seams, family-lock, audit ledger, event log." /> <Card title="Packages" href="/docs/packages" description="The @pleach/* matrix — which sibling does what." /> <Card title="Auditable call row" href="/docs/auditable-call-row" description="The audit row every plugin's contributions are reflected in." /> <Card title="Host adapter" href="/docs/host-adapter" description="setHarnessModuleLoader and the R-track that retires loader keys into plugin slots." /> </Cards> --- # Project layout (/docs/project-layout) Pleach is a library, not a framework. There's no `pleach.config.ts`, no `defineConfig`, no required directory. The runtime is a class you construct; adapters are constructor arguments; storage paths live in whichever adapter you wire. This page exists because that freedom is unhelpful on day one. So: what's actually load-bearing, what a layout looks like when you stop thinking about it, and what each [use case](#descend-into-a-use-case) adds on top. ## What the runtime actually requires [#what-the-runtime-actually-requires] Three things. Everything else is convention. | Requirement | Where it lives | Page | | --------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------- | | Exactly one `SessionRuntime` per host process | A module you write — typically exports the instance | [SessionRuntime](/docs/session-runtime) | | A `ProviderDecisionLedger` you've picked | Passed into the `SessionRuntime` constructor | [Audit ledger](/docs/audit-ledger) | | A host adapter, if you serve HTTP | A route handler in your web framework | [Host adapter](/docs/host-adapter) · [API routes](/docs/api-routes) | The ledger choice is the one that catches people. The default is `MemoryProviderDecisionLedger` — fine for tests, lost on restart. `NoopProviderDecisionLedger` writes nowhere; pick it deliberately, not by accident. Anything durable is an adapter you wire. ## A layout that works [#a-layout-that-works] A Next.js App Router app with one agent, one tool, and an HTTP route. Move any of these — the runtime resolves whatever your imports resolve. ``` my-app/ src/ pleach/ runtime.ts # constructs and exports `runtime` tools/ search.ts # → /docs/tools prompts/ triage.ts # → /docs/prompt-builder app/ api/ agents/[id]/route.ts # → /docs/api-routes page.tsx # → /docs/react package.json ``` The split is convention, not contract: * **`src/pleach/runtime.ts`** — the single place `new SessionRuntime(...)` is called. Exports the instance so the host route and any background worker import the same one. * **`src/pleach/tools/`** — each file exports a `defineTool(...)` call. The runtime doesn't scan this directory; you import the tools where you register them. * **`src/pleach/prompts/`** — plain modules that build prompt objects. The runtime doesn't scan this directory either. * **`src/app/api/agents/[id]/route.ts`** — exports `createPleachRoute()` from `@pleach/core/quickstart`. This is the shipped host adapter the runtime expects when it's behind HTTP. Read the inverse to internalize the model: nothing the runtime does depends on directory names. Rename `pleach/` to `agents/`, flatten everything into `src/`, split per-feature — the runtime boots the same. The only thing it sees is the `SessionRuntime` you handed it. A common second instinct: "where do checkpoints and the audit database live on disk?" Wherever the adapter you wired writes them. `MemoryProviderDecisionLedger` writes nowhere; the [SQLite recipe](/docs/recipes#3-custom-storage-adapter-sqlite) writes to a path you pass in; a managed-DB adapter writes to a connection string. Pleach doesn't pick the path because Pleach doesn't know whether you're on one machine, a Lambda, or a multi-region cluster. ## Descend into a use case [#descend-into-a-use-case] Each shape adds a small, predictable delta to the baseline above. Open the page for the shape closest to yours; the layout grows in one of a few directions, not many. <Cards> <Card title="Customer support" href="/docs/customer-support-agent" description="Baseline layout fits. Add a tool module per integration; the ledger row is what your QA team reads." /> <Card title="Research" href="/docs/research-agent" description="Adds a subagent registry and a deterministic replay path so 'why did it cite that?' is one command, not a log dive." /> <Card title="Coding agent" href="/docs/coding-agent" description="Adds a sandboxed tool surface and a checkpoint-per-edit pattern. Survives restarts means a durable checkpoint adapter, not the in-memory default." /> <Card title="Internal knowledge" href="/docs/internal-knowledge-agent" description="Adds an index/embeddings module the tools call into, plus scrubbers on the audit-ledger plug-points." /> <Card title="Multi-tenant SaaS" href="/docs/multi-tenant-saas-agent" description="Build the runtime per request from the tenant resolver. The tenant facet keys the cache, ledger, and OTel attributes." /> <Card title="Regulated domain" href="/docs/regulated-domain-agent" description="Adds @pleach/compliance — tamper-evident hash chain on the ledger, PII scrubbers on the row, GDPR soft-delete on the request." /> </Cards> ## Filesystem-touching recipes [#filesystem-touching-recipes] Recipes are the small, copy-pasteable pieces. Three touch the project's on-disk shape directly: | Recipe | What it adds | | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | | [Next.js App Router chat with streaming](/docs/recipes#1-nextjs-app-router-chat-with-streaming) | The `app/api/agents/[id]/route.ts` + client component pair the baseline assumes. | | [Custom storage adapter: SQLite](/docs/recipes#3-custom-storage-adapter-sqlite) | A durable adapter that picks a real on-disk path for the audit ledger. The first thing past `MemoryProviderDecisionLedger`. | | [Building a custom event-log projection](/docs/recipes#8-building-a-custom-event-log-projection) | Where projection state lives — typically alongside the ledger, in whatever store the ledger writes to. | ## What Pleach won't tell you to do [#what-pleach-wont-tell-you-to-do] A short list because each of these is a deliberate non-choice. * **No config file.** The runtime is wired in code so the TypeScript compiler sees every adapter you pass and your IDE jumps to the definition. A `pleach.config.ts` would buy convention at the cost of static analysis. * **No reserved directory.** Nothing called `.pleach/` is created by the runtime. Storage paths live in whichever adapter you wire — the deployment shape (one machine vs. Lambda vs. managed DB) decides the path, not the library. * **No `agents/` folder convention.** The `agents/<specName>/` prefix you'll see in [Agents](/docs/agents) is an internal *channel-path* prefix the runtime uses to namespace agent state. It's a string in the runtime's address space, not a directory on your disk. * **No required test layout.** [Eval and replay](/docs/eval-and-replay) reads recorded sessions, not test files. Put tests wherever your test runner finds them. If a piece of structure isn't on this page, the runtime doesn't have an opinion on it — and that's the contract. --- # Prompt builder (/docs/prompt-builder) The prompt builder is the second half of the **prompts** [thematic island](/docs/concept-clusters#what-lives-outside-the-cluster-pattern) — paired with the [contribution registry](/docs/prompts). The registry collects typed contributions; the builder composes them. `composeBudgetedPrompt` is the last step of prompt construction. It takes an ordered candidate list, applies a tier gate against the resolved budget, and joins the included sections with `\n\n`. Two calls with the same candidates and the same budget produce byte-identical output — that's what makes [fingerprint](/docs/fingerprint)-based caching and `@pleach/eval@0.1.0` work. The active-tier ceiling uses `Math.floor` to avoid floating-point drift between platforms. The composer never lets you see fractional budgets; every section's allocation is an integer. See [Prompts](/docs/prompts) for the contribution contract feeding the composer. ```typescript import { composeBudgetedPrompt, applyBudgetGate, getEffectiveBudget, shouldCondense, skipInformational, wouldFit, buildPromptBuiltEvent, DEFAULT_ACTIVE_BUDGET_FRACTION, DEFAULT_CONDENSE_DEPTH, DEFAULT_SKIP_INFORMATIONAL_DEPTH, } from "@pleach/core/prompt-builder"; import type { BudgetConfig, BudgetedSection, BudgetResolver, ComposedPrompt, PromptBuiltEvent, PromptTier, } from "@pleach/core/prompt-builder"; ``` <SourceMeta subpath="@pleach/core/prompt-builder" source="{ label: "src/prompt-builder/", href: "https://github.com/pleachhq/core/tree/main/src/prompt-builder" }" /> ## The composer signature [#the-composer-signature] ```typescript function composeBudgetedPrompt( candidates: readonly BudgetedSection[], config: BudgetConfig, probes?: { onBuilt?: (event: PromptBuiltEvent) => void }, ): ComposedPrompt; interface BudgetedSection { readonly id: string; readonly content: string; readonly tier: "core" | "active" | "informational"; readonly chars: number; // canonically content.length } interface BudgetConfig { readonly depth: number; readonly runtimeRole?: keyof RuntimeRoles; readonly resolveBudget?: BudgetResolver; readonly totalBudget?: number; readonly activeBudgetFraction?: number; // default 0.95 } interface ComposedPrompt { readonly composed: string; // included sections joined by "\n\n" readonly included: readonly BudgetedSection[]; readonly dropped: readonly string[]; // ids of dropped candidates readonly totalChars: number; readonly totalTokensEst: number; // Math.ceil(totalChars / 4) readonly effectiveBudget: number; readonly budgetUsedPct: number; readonly sectionChars: Readonly<Record<string, number>>; } ``` The caller picks section ordering; the composer never reorders. `composed` is `included.map(s => s.content).join("\n\n")` — byte-identical inputs produce byte-identical output. ## When you call it directly [#when-you-call-it-directly] Most agent code doesn't. The runtime composes the system prompt internally at each LLM call — contributions registered through [plugins](/docs/plugin-contract) ride through the hooks and the composer assembles them. Call `composeBudgetedPrompt` directly when you're building a custom provider and need the composed prompt outside the standard seam, writing prompt-introspection tooling (DevTools panels, prompt diffing), or testing prompt assembly in isolation. ## Composition order and the tier gate [#composition-order-and-the-tier-gate] The composer makes one pass over `candidates` and decides inclusion per section by `tier`: 1. Sum `coreChars` across all `core`-tier candidates. 2. `activeCeiling = coreChars + Math.floor((budget - coreChars) * activeBudgetFraction)`. 3. For each candidate in order: * `core` → always include. * `active` → include if `totalChars + chars <= activeCeiling`. * `informational` → include if `totalChars + chars <= budget`. | Tier | Drop behavior | | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `core` | Never dropped. If a `core` section can't fit, the composition still includes it and the budget overflows; the caller decides whether to surface as error. | | `active` | Preserved while within the active-tier ceiling. | | `informational` | Preserved while strictly within the remaining budget. First to drop under pressure. | The active-ceiling-via-remaining-after-core computation matters: a large core base (\~60K chars in production hosts) consuming a fraction of the raw budget would otherwise starve the active tier even when active sections total only \~2K chars. ## Budget resolution [#budget-resolution] `getEffectiveBudget(config)` is the function the composer consults. Precedence: 1. `config.totalBudget` — direct override; useful for tests. 2. `config.resolveBudget({ depth, runtimeRole })` — closure mapping config to budget. 3. `Number.POSITIVE_INFINITY` — pass-through; every candidate is included. ```typescript const config: BudgetConfig = { depth: 1, resolveBudget: ({ depth }) => depth === 0 ? 60_000 : depth === 1 ? 40_000 : 20_000, }; ``` ## Budget quantization [#budget-quantization] The active-ceiling computation uses `Math.floor` to avoid floating-point drift between platforms. Two callers with the same candidates and the same budget produce byte-identical output even when float arithmetic would otherwise diverge. ### Why the floor matters in practice [#why-the-floor-matters-in-practice] Replay-deterministic caching depends on byte equality. If session A composes a prompt at `0.7833333` tokens-per-section and session B at `0.7833334` (rounding difference from float arithmetic), the resulting prompts differ by one token boundary, the fingerprints diverge, and the cache misses. The composer never lets you see fractional budgets — every section's allocation is an integer. The user-facing budget contract is "I gave you `maxTokens`; you give me deterministic bytes." ## Dropped sections [#dropped-sections] When candidates exceed the budget, the gate drops sections from the lowest-tier end. `result.dropped` lists the ids that didn't make it in; `result.sectionChars` records the char count for each included section by id. ```typescript const result = composeBudgetedPrompt(candidates, { depth: 0, totalBudget: 2_000, // tight }); if (result.dropped.length > 0) { console.warn(`Dropped ${result.dropped.length} sections:`, result.dropped); } ``` `core`-tier sections are never dropped — if they overflow the budget, the composition includes them anyway and the caller decides what to do with the overflow. ## Depth-based predicates [#depth-based-predicates] Two helpers expose the same depth threshold the host's default composition path uses. The composer itself doesn't consult them — they're for callers building the candidate list. | Helper | Default threshold | | --------------------------------- | ----------------------------------------------- | | `shouldCondense(depth)` | `depth >= DEFAULT_CONDENSE_DEPTH` (2) | | `skipInformational(depth)` | `depth >= DEFAULT_SKIP_INFORMATIONAL_DEPTH` (3) | | `wouldFit(section, used, budget)` | `used + section.chars <= budget` | ## `buildPromptBuiltEvent` and the `probes` hook [#buildpromptbuiltevent-and-the-probes-hook] Pass `probes.onBuilt` to fire a structured event once per composition. The composer hands you a `PromptBuiltEvent` you can forward to your logger without re-deriving section sizes. ```typescript const result = composeBudgetedPrompt(candidates, config, { onBuilt: (event) => eventLog.write({ type: "prompt.built", ...event }), }); interface PromptBuiltEvent { sections: number; totalChars: number; totalTokensEst: number; budgetUsedPct: number; effectiveBudget: number; sectionChars: Record<string, number>; dropped?: readonly string[]; droppedChars?: Record<string, number>; } ``` `buildPromptBuiltEvent` is also exported directly for callers that want to construct the event shape outside the composer. ## Reference section builders [#reference-section-builders] `@pleach/core/prompt-builder/sections` ships generic builders for common runtime-state-driven sections. Each returns a string body the caller wraps as a `BudgetedSection`. | Builder | Produces | | ------------------------------------- | --------------------------------------------------- | | `buildExhaustedToolsSection` | "Tools you've already tried this turn" recap | | `buildToolGuidanceSection` | Inline guidance for tool-error recovery | | `buildRecentToolErrorsSection` | Bounded list of recent tool errors | | `buildPriorTruncationRecoverySection` | Recovery prompt when the prior turn was truncated | | `buildProviderErrorRecoverySection` | Recovery prompt when the provider errored last turn | | `buildUserRequestedToolsSection` | "User asked for these tools" recap | The guidance subsystem is registry-driven: ```typescript import { createGuidanceRegistry, resolveGuidance, estimateGuidanceChars, DEFAULT_RECENT_TOOL_ERRORS_LIMIT, } from "@pleach/core/prompt-builder"; const registry = createGuidanceRegistry([ { match: { toolName: "search_*" }, mode: "prefix", text: "Always cite sources." }, ]); const resolved = resolveGuidance({ registry, toolName: "search_corpus" }); ``` `GuidanceEntry`, `GuidanceMatchMode`, `GuidanceRegistry`, `RecentToolErrorEntry`, `ResolveGuidanceParams`, and `ResolvedGuidance` are the typed surfaces. ## Reference assemblies [#reference-assemblies] `@pleach/core/prompt-builder/examples` ships reference assemblies that exercise every branch of the composer. Crib from these when building your own; they're the substrate's own tests, so they round-trip through replay. ## Friendly API: appendPrompt / prependPersona / replaceCore / scopedPrompt / gatedPrompt / createPlugin [#friendly-api-appendprompt--prependpersona--replacecore--scopedprompt--gatedprompt--createplugin] These helpers wrap the underlying `contributePrompts` and `contributeRuntimeAwarePrompts` hooks for the common composition shapes. Import them from `@pleach/core/prompts`. They return contribution objects you hand to a `HarnessPlugin`; the contract itself lives at [plugin contract](/docs/plugin-contract). ### `appendPrompt(prompt)` [#appendpromptprompt] Appends content after the existing composed prompt at its slot. Use when your plugin adds context the runtime should see last, without disturbing prior contributions. ### `prependPersona(persona)` [#prependpersonapersona] Places persona content at the top of the persona slot, ahead of other persona contributions. Use when your plugin needs to set the voice before any downstream persona contribution layers on. ### `replaceCore(replacement)` [#replacecorereplacement] Replaces the bundled core fragment for a given fragment id. Use sparingly — the bundled core is what gives the runtime its baseline behavior, and replacing a fragment opts you out of any future improvement to that fragment. ### `scopedPrompt({ scope, content })` [#scopedprompt-scope-content-] Contributes a prompt conditionally — only when the runtime context matches the scope. Scopes typically resolve against a channel, a tenant, or a runtime role; the composer skips the contribution entirely when the scope doesn't match. ### `gatedPrompt({ when, content })` [#gatedprompt-when-content-] Conditional contribution gated by a predicate evaluated per turn. Use when inclusion depends on per-turn state — recent tool errors, prior truncation, an in-flight async job — rather than on static scope. ### `createPlugin({ ... })` [#createplugin--] Sugar for assembling a `HarnessPlugin` from a flat object of contributions. Useful when the plugin doesn't need lifecycle hooks and you're only wiring up prompt contributions. ## `core.*-template` fragments [#core-template-fragments] The bundled core ships around 30 additive `core.*-template` fragments. Identifiers include `core.response-stylesheet-defaults`, `core.role-tool-allowlist-template`, `core.async-job-lifecycle-template`, `core.job-history-template`, `core.rules-condensed-template`, `core.role-condensed-template`, and `core.export-rules-template`. These fragments exist on `ALL_CORE_FRAGMENT_TEMPLATE_IDS` but the runtime does not auto-compose them into the base prompt. Hosts opt in by contributing a replacement for the matching id via `replaceCore`, or through `contributePrompts` with `mode: "replace"`. The shape is an additive-scaffold protocol. A fragment id can land in the core surface without affecting any host that hasn't opted in. New ids are pure additions — no consumer breaks when the bundled core grows a new template slot. For the full identifier list, read `ALL_CORE_FRAGMENT_TEMPLATE_IDS` from the bundled core source at [github.com/pleachhq/core](https://github.com/pleachhq/core). ## Where to go next [#where-to-go-next] <Cards> <Card title="Prompts" href="/docs/prompts" description="The contribution contract — static vs runtime-aware, namespacing, scope." /> <Card title="Fingerprint" href="/docs/fingerprint" description="Why the composer's determinism armor matters." /> <Card title="Safety policies" href="/docs/safety" description="The contributions that compose last with reserved budget." /> <Card title="Determinism" href="/docs/determinism" description="The replay-determinism story end-to-end." /> </Cards> --- # Prompt caching (/docs/prompt-caching) A cache hit is determined entirely by the fingerprint. The variable surface the backend stores against it is the recorded response — `content`, `toolCalls`, `finishReason`, token `usage`, `modelId`, and (for streaming calls) the chunk sequence — plus `recordedAt`/`recordedBy` provenance and a `sizeBytes` accounting field the backend maintains on `set`. The seam derives the fingerprint key from `@pleach/core/fingerprint`; this module is only about storage and retrieval semantics. <SourceMeta subpath="@pleach/core/cache" source="{ label: "src/cache/", href: "https://github.com/pleachhq/core/tree/main/src/cache" }" /> ## Public exports [#public-exports] | Export | Kind | Purpose | | --------------------------- | --------- | ----------------------------------------------------- | | `CacheBackend` | interface | Storage contract every backend implements. | | `CacheEntry` | type | Recorded response payload + provenance. | | `CacheGetMode` | union | Per-call failure-mode policy passed to `get()`. | | `CacheGetOptions` | type | `{ mode: CacheGetMode }` — `get()`'s second argument. | | `CacheStats` | type | O(1) counters returned by `stats()`. | | `MemoryCacheBackendOptions` | type | Memory backend constructor options. | | `createMemoryCacheBackend` | factory | Returns a `CacheBackend` backed by an in-memory LRU. | ## `CacheBackend` [#cachebackend] Memory and Supabase backends both implement this surface. The seam holds only this contract. ```typescript interface CacheBackend { readonly id: string; get(key: Fingerprint, options: CacheGetOptions): Promise<CacheEntry | null>; set(key: Fingerprint, entry: CacheEntry): Promise<void>; delete(key: Fingerprint): Promise<void>; list(prefix: Partial<Fingerprint>): AsyncIterable<Fingerprint>; stats(): Promise<CacheStats>; } ``` Four invariants every implementation maintains: 1. `get()` returns a deep-frozen entry — callers must not mutate. 2. `set()` is idempotent on fingerprint (last-write-wins). 3. `stats()` is O(1) — counters update on every mutation. 4. `list()` is async-iterable even for memory implementations so callers handle pagination from day one. ## `CacheGetMode` [#cachegetmode] The per-call failure-mode policy. Distinct from `CacheReadPolicy` in `@pleach/core/fingerprint`, which governs cross-`RuntimeMode` boundary reads. | Mode | Behavior on backend error | When to use | | ------------- | ------------------------------- | --------------------------------------------------------------------------------- | | `strict-fail` | Error propagates to the caller. | `headless-replay` — a missed hit breaks determinism. | | `best-effort` | Error returns `null` and logs. | Interactive production traffic — a cache outage must not break user-facing calls. | Same backend instance serves both — the mode is a per-call decision, not a backend configuration. ## `CacheEntry` [#cacheentry] The recorded payload. Opaque to the backend; the seam owns shape validation. | Field | Type | Notes | | ----------------------- | ------------------------------------ | --------------------------------------------------------------------------- | | `fingerprint` | `Fingerprint` | Cache key, replicated onto the entry. | | `metadata` | `FingerprintMetadata` | Key/metadata split sibling — diagnostic only. | | `response.content` | string | Final aggregated response text. | | `response.toolCalls` | `ReadonlyArray<unknown>` | Provider-shaped tool calls. | | `response.finishReason` | string | Unmodified provider string. | | `response.usage` | `{ inputTokens; outputTokens }` | Token accounting. | | `response.modelId` | string | The model that produced the entry. | | `streamChunks` | `ReadonlyArray<unknown>` (optional) | Set on streaming invocations so replay can re-emit deltas in arrival order. | | `recordedAt` | ISO-8601 | When the entry was written. | | `recordedBy` | `{ pleachVersion; nodeId; stageId }` | Provenance for cross-version replays. | | `sizeBytes` | number | Maintained by the backend on `set`; drives LRU eviction. | ## `CacheStats` [#cachestats] ```typescript interface CacheStats { readonly id: string; readonly entryCount: number; readonly sizeBytes: number; readonly hits: number; readonly misses: number; readonly hitRatio: number; readonly evictions: number; } ``` `hitRatio` is `hits / (hits + misses)`, or `0` when both are zero. Counters update on every mutation, so `stats()` is O(1). ## Memory backend [#memory-backend] ```typescript import { createMemoryCacheBackend } from "@pleach/core/cache"; const cache = createMemoryCacheBackend({ maxEntries: 1000, // default 1000 maxBytes: 64 * 1024 * 1024, // default 64 MB id: "memory", // default "memory" }); const entry = await cache.get(fingerprint, { mode: "best-effort" }); if (!entry) { const response = await seam.invoke(...); await cache.set(fingerprint, buildEntry(response)); } ``` The implementation rides on JavaScript's insertion-ordered `Map`: `get()` re-inserts on hit so the iterator's first entry is the LRU candidate. Eviction runs after every `set()` until both `maxEntries` and `maxBytes` caps are satisfied. ## Wiring into a runtime [#wiring-into-a-runtime] `SessionRuntimeConfig.cacheBackend` is the field every seam reads through. Pass the backend at construction and every provider call shares the same cache instance. ```typescript import { SessionRuntime } from "@pleach/core"; import { createMemoryCacheBackend } from "@pleach/core/cache"; const cacheBackend = createMemoryCacheBackend({ maxEntries: 5_000 }); const runtime = new SessionRuntime({ storage, userId: "user_123", cacheBackend, }); // Later, inspect counters: const stats = await cacheBackend.stats(); console.log(stats.hits, stats.misses, stats.hitRatio); ``` ## Picking a mode per call [#picking-a-mode-per-call] Same backend instance; different `mode` per `get()`. Production traffic reads `best-effort` so a backend outage degrades to a miss; replay reads `strict-fail` so a missed hit fails fast instead of re-invoking the provider. ```typescript // Interactive request — a cache outage must not break the user-facing call. const cached = await cacheBackend.get(fingerprint, { mode: "best-effort" }); if (cached) return cached; return await seam.invoke(...); // Replay run — a missed hit means the cache is wrong, not the model. const replayed = await cacheBackend.get(fingerprint, { mode: "strict-fail" }); if (!replayed) throw new Error(`replay miss: ${fingerprint.promptHash}`); return replayed; ``` ## Where to go next [#where-to-go-next] <Cards> <Card title="Fingerprint" href="/docs/fingerprint" description="How the cache key is derived — what's in, what's deliberately excluded, and why." /> <Card title="Determinism" href="/docs/determinism" description="`strict-fail` mode is what keeps `headless-replay` deterministic." /> <Card title="Eval and replay" href="/docs/eval-and-replay" description="Replay reads cache entries by fingerprint and re-emits stream chunks in arrival order." /> <Card title="Subpath exports" href="/docs/subpath-exports" description="The `./cache` subpath in the package exports map." /> </Cards> --- # Prompts (/docs/prompts) > **Prompts are a thematic island.** Not one of the [six cluster > triplets](/docs/concept-clusters#the-six-cluster-triplets) — the > prompts surface is a pair (the contribution registry on this > page + the [budgeted composer](/docs/prompt-builder)), not a > three-concept cluster. See [What lives outside the cluster > pattern](/docs/concept-clusters#what-lives-outside-the-cluster-pattern). There is no `systemPrompt` string. System prompts are *composed* from a registry of typed contributions: each one carries a namespaced id, a composition mode (`append` / `prepend` / `replace`), an optional scope filter (call class, family, runtime role), and content (a string or a context-aware function). The split between *static* and *runtime-aware* contributions is load-bearing for replay determinism: static contributions participate in the fingerprint cache key, runtime-aware ones are fingerprint-excluded. See [Fingerprint](/docs/fingerprint) for the cache-key contract and [Prompt builder](/docs/prompt-builder) for the composer. ```typescript import { PromptContributionRegistry, resolvePromptContributions, registerGlobal, promptContributionId, appendPrompt, prependPersona, replaceCore, scopedPrompt, gatedPrompt, createPlugin, ReservedNamespaceError, UnnamespacedIdError, DuplicateContributionIdError, } from "@pleach/core/prompts"; import { composeBudgetedPrompt } from "@pleach/core/prompt-builder"; import type { CallClass, ProviderFamily, PromptContribution, PromptContributionId, PromptContributionMode, PromptContributionScope, PromptContext, ResolvedPromptBundle, RuntimeRoles, RuntimeStateSnapshot, } from "@pleach/core/prompts"; ``` <SourceMeta subpath="@pleach/core/prompts" source="{ label: "src/prompts/", href: "https://github.com/pleachhq/core/tree/main/src/prompts" }" /> ## The contribution shape [#the-contribution-shape] ```typescript interface PromptContribution { readonly id: PromptContributionId; // "<plugin>.<name>" readonly mode: "append" | "prepend" | "replace"; readonly scope?: PromptContributionScope; // filter by callClass / family / runtimeRole readonly content: | string | ((ctx: PromptContext) => string) | ((ctx: PromptContext, state: RuntimeStateSnapshot) => string); } ``` | Field | Purpose | | --------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `id` | Namespaced identifier. `core.*` reserved; plugins MUST use `<plugin-name>.*` | | `mode` | How this contribution combines — append/prepend stacks; `replace` overrides a same-id contribution from another origin | | `scope` | Optional filter. Contribution only fires when the call's `callClass` / `family` / `runtimeRole` matches | | `content` | Verbatim string, a static `(ctx) => string`, or a runtime-aware `(ctx, state) => string`. The resolver dispatches by function arity | Composition order is registration order, preserved across buckets. There is no `priority` field — order is the source of truth; if you need finer control, register earlier or use `mode: "replace"` against a known id. ## Namespace rules [#namespace-rules] The registry enforces these at registration time — your plugin's own test suite fails fast, never in production. | Rule | Error | | ------------------------------------ | ------------------------------ | | `core.*` is reserved | `ReservedNamespaceError` | | Unnamespaced ids are forbidden | `UnnamespacedIdError` | | Duplicate ids within the same origin | `DuplicateContributionIdError` | To override a `core.*` or another plugin's contribution, register your own with `mode: "replace"` and the same id — the registry treats `replace` across origins as an explicit override. ## Static vs runtime-aware [#static-vs-runtime-aware] Two hooks, two buckets, one invariant. | Bucket | Fired from | Fingerprint? | When to use | | ----------------- | ----------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------- | | **Static** | `HarnessPlugin.contributePrompts()` | Eligible — participates in cache key | Contributions whose content depends only on `PromptContext` (callClass, family, tenantId, runtimeRole) | | **Runtime-aware** | `HarnessPlugin.contributeRuntimeAwarePrompts()` | Excluded — re-collected per turn | Contributions whose content depends on session state (recent tool results, in-flight jobs, current artifacts) | The structural split is what keeps replay deterministic. Static contributions can be safely included in the fingerprint; if they referenced runtime state, two runs of the "same" turn would hash differently. The contract says: if you read session state, declare yourself runtime-aware. ```typescript const myPlugin: HarnessPlugin = { name: "my-plugin", contributePrompts: () => [ { id: "my-plugin.domain-instructions", mode: "append", scope: { callClass: "synthesize" }, content: "Respond in the domain-specific output format.", }, ], contributeRuntimeAwarePrompts: (ctx, snapshot) => { const loaded = snapshot.loadedTools ?? []; if (loaded.length === 0) return []; return [{ id: promptContributionId("my-plugin.loaded-tools"), mode: "append", content: `Available this turn: ${loaded.join(", ")}.`, }]; }, }; ``` ## `PromptContext` (static) [#promptcontext-static] What the static hook receives. Fingerprint-eligible — every field is part of the cache key. | Field | Type | | ------------- | -------------------------------------------------------- | | `callClass` | `"utility" \| "reasoning" \| "converse" \| "synthesize"` | | `family` | `ProviderFamily` (`"anthropic" \| "openai" \| ...`) | | `tenantId` | `string?` | | `runtimeRole` | A consumer-augmentable role enum | ### Augmenting runtime roles [#augmenting-runtime-roles] The role enum is empty by default; consumers extend it via TS module augmentation: ```typescript declare module "@pleach/core/prompts" { interface RuntimeRoles { admin: true; contributor: true; reviewer: true; } } ``` Then scope contributions to your role: ```typescript { id: "my-plugin.admin-only", mode: "append", scope: { runtimeRole: "admin" }, content: "You may use destructive tools.", } ``` ## `RuntimeStateSnapshot` (runtime-aware) [#runtimestatesnapshot-runtime-aware] What the runtime-aware hook receives in addition to `PromptContext`. Carries per-turn state; surfaced in `FingerprintMetadata` but never participates in the lookup key. | Field | Type | | ----------------------- | ------------------------------------------------------------------------------------- | | `loadedTools` | `readonly string[]?` — tool names registered for this seam | | `detectedIntents` | `readonly string[]?` — intent classifier output | | `turnDepth` | `number` — 0 for the root turn, >0 inside subagents | | `priorTurnHadToolCalls` | `boolean?` — set on retries / recovery paths | | `pluginMetadata` | `Record<string, unknown>?` — opaque per-plugin metadata; plugin authors own the shape | Read what you need, but be deliberate — anything you reference here means the contribution can't be replayed without that state being present. ## Composition via `composeBudgetedPrompt` [#composition-via-composebudgetedprompt] The composer is character-budget-aware. Resolve contributions through the registry, wrap each as a `BudgetedSection` with a `tier`, and hand the candidate list to the composer. See [Prompt builder](/docs/prompt-builder) for the full surface; the short form: ```typescript import { composeBudgetedPrompt } from "@pleach/core/prompt-builder"; const result = composeBudgetedPrompt( candidates, // BudgetedSection[] { depth: 0, totalBudget: 40_000 }, // BudgetConfig { onBuilt: (event) => log(event) }, ); // result.composed — joined section text // result.included — sections that made the budget // result.dropped — ids of dropped candidates // result.sectionChars — per-section char counts // result.budgetUsedPct — used / budget * 100 ``` ## Friendly-API helpers [#friendly-api-helpers] Six helpers cover the common contribution shapes so plugin code doesn't repeat the boilerplate. Each expands to a raw `PromptContribution` — drop down to the raw shape any time you need more control. | Helper | Builds | | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | `appendPrompt(id, content)` | An append-mode contribution. Default for the 90% case | | `prependPersona(id, content)` | A prepend-mode persona block — sits at the top of the composed prompt | | `replaceCore(coreSlotId, content)` | A `mode: "replace"` against a `core.*` slot. `coreSlotId` is typed `` `core.${string}` `` — non-`core.*` ids are a compile-time error | | `scopedPrompt({ id, callClass?, family?, runtimeRole?, content, mode? })` | Flattened scope shape — pass scope fields directly without nesting. `mode` defaults to `"append"` | | `gatedPrompt({ id, when, content, mode? })` | A runtime-aware contribution. When `when(ctx, state)` returns false the helper emits `""` — fires the `onEmptyContent` probe for audit trails | | `createPlugin({ name, version, prompts?, runtimePrompts?, safetyPolicies?, streamObservers? })` | Packages a `HarnessPlugin` from a flat config — hides the two-hook split | ```typescript // lib/plugins/myPlugin.ts import { appendPrompt, prependPersona, replaceCore, scopedPrompt, gatedPrompt, createPlugin, } from "@pleach/core/prompts"; // append-mode (the 90% case) appendPrompt("my-plugin.outro", "Cite sources for every claim."); // persona at the top of the prompt prependPersona("my-plugin.persona", "You are a careful research assistant."); // substitute a core.* slot replaceCore("core.response-stylesheet-defaults", "Use code blocks. Be concise."); // scope to a specific family without nesting scopedPrompt({ id: "my-plugin.gemini-tweak", family: "google", content: "Tool call budget: 3-4 per response batch.", }); // runtime-gated — emits "" when the predicate is false gatedPrompt({ id: "my-plugin.modal-guidance", when: (_, state) => state.loadedTools?.includes("modal") ?? false, content: "Modal compute is available this turn.", }); // hide the two-hook split entirely export const myPlugin = createPlugin({ name: "my-plugin", version: "1.0.0", prompts: [ appendPrompt("my-plugin.intro", "I cite sources for every claim."), ], runtimePrompts: [ gatedPrompt({ id: "my-plugin.modal", when: (_, s) => s.loadedTools?.includes("modal") ?? false, content: "Modal compute is available this turn.", }), ], }); ``` ## The `onEmptyContent` resolver probe [#the-onemptycontent-resolver-probe] The resolver fires `onEmptyContent(id, origin)` whenever a registered contribution resolves to an empty string — either a literal `""` or a content function that returns `""`. Useful for catching dead contributions in production: a contribution that always resolves to empty is doing no work and probably wants retiring. Wire it through `ResolverProbeSink`: ```typescript import type { ResolverProbeSink } from "@pleach/core/prompts"; const probes: ResolverProbeSink = { onEmptyContent: (id, origin) => { metrics.increment("prompt.empty", { id, origin }); }, onReplaceTargetMissing: (id) => log.warn(`replace target missing: ${id}`), onDuplicateReplace: (ids) => log.warn(`duplicate replace: ${ids.join(", ")}`), }; ``` Probes are observability-only — the resolver's behavior does not depend on the return value. They MUST NOT throw; an uncaught throw aborts resolution. ## Multi-slot replace composition [#multi-slot-replace-composition] The resolver composes multiple `replace`-mode contributions targeting the same id correctly: each replace's body lands in registration order, joined with a paragraph break. Earlier behavior was last-wins with the prior body silently discarded and a `[PluginContract:duplicate-prompt-replace]` diagnostic emitted — that is no longer the case. Two host plugins independently replacing `core.response-stylesheet-defaults` both contribute; the order is registration order; the host that wants total control still uses `replace` against a uniquely-named id rather than fighting another plugin for the same slot. The regression-lock test `__tests__/core/prompts/multiSlotReplaceComposition.test.ts` pins this contract — it flipped from RED tripwire to GREEN at the resolver extension landing. ## The `core.*` baseline [#the-core-baseline] `seedCoreDefaults: true` (the default) auto-seeds the core baseline. Three exported arrays drive what gets seeded: | Array | Origin tag | Count | | --------------------------------------- | --------------- | --------------------------------------- | | `allCoreDefaultContributions` | `core-default` | 10 base ids — see table below | | `allCoreFragmentExtensionContributions` | `core-default` | 4 extension ids — see table below | | `allCoreFragmentTemplateContributions` | `core-template` | 41 template scaffolds — see table below | `CORE_DEFAULT_SYSTEM_PROMPT_ID` is the id used by `defaultSystemPromptContribution`; replace that single id to fully substitute the baseline. Opt out wholesale with `seedCoreDefaults: false`; opt into template seeding with `seedCoreTemplates: true`. ### Base contributions (10) [#base-contributions-10] `CORE_DIRECTIVES_ID`, `FORMATTING_BASELINE_ID`, `PRE_CALL_CHECKLIST_ID`, `ACTION_BEFORE_NARRATION_ID`, `ANTI_PATTERNS_CORE_ID`, `BANNED_YIELD_PHRASES_ID`, `DATA_INTEGRITY_ID`, `NO_DATA_PROTOCOL_ID`, `NO_AGGREGATE_FAILURE_ID`, `SUBAGENT_DELEGATION_PRIMITIVE_ID`, `TOOL_REFERENCE_INTEGRITY_ID`. ### Extension contributions (4) [#extension-contributions-4] `ASYNC_JOB_OUTPUT_DEPENDENCY_ID`, `ASYNC_ORCHESTRATION_RULES_ID`, `INDEPENDENT_SUBTASK_RULE_ID`, `TOOL_AVAILABILITY_VERIFICATION_ID`. ### Template scaffolds (41) [#template-scaffolds-41] The `allCoreFragmentTemplateContributions` array — `ALL_CORE_FRAGMENT_TEMPLATE_IDS` is the enumeration tests + tooling key on. Slot-naming suffix `-defaults` means the agnostic baseline is shippable as-is; `-template` means it is a skeleton the host plugin is expected to flesh out. | Group | Ids | | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Defaults (shippable) | `RESPONSE_STYLESHEET_DEFAULTS_ID`, `TOOL_ERROR_RECOVERY_DEFAULTS_ID` | | Identity & rules | `ROLE_TEMPLATE_ID`, `RULES_AND_PROTOCOL_TEMPLATE_ID`, `EXPORT_RULES_TEMPLATE_ID` | | Workflow & planning | `WORKFLOW_PROGRESS_TEMPLATE_ID`, `MULTI_STEP_WORKFLOW_TEMPLATE_ID`, `SOFT_PLAN_PROGRESS_TEMPLATE_ID` | | Tool surfaces | `DYNAMIC_TOOL_AVAILABILITY_TEMPLATE_ID`, `LOADED_TOOLS_CONDENSED_TEMPLATE_ID`, `TOOL_BUDGET_CONSTRAINT_TEMPLATE_ID`, `TOOL_FAILURE_RECOVERY_TEMPLATE_ID` | | Code execution | `CODE_EXECUTION_TEMPLATE_ID`, `CODE_EXECUTION_GUEST_TEMPLATE_ID` | | Search & data | `SEARCH_STRATEGY_TEMPLATE_ID`, `DATA_REF_ATTRIBUTION_TEMPLATE_ID`, `OFFLOADED_RESULT_EXPLORATION_TEMPLATE_ID` | | Async jobs | `ASYNC_JOB_LIFECYCLE_TEMPLATE_ID`, `JOB_HISTORY_TEMPLATE_ID` | | Delegation | `SUBAGENT_DELEGATION_TEMPLATE_ID`, `SUBAGENT_DELEGATION_GUEST_TEMPLATE_ID` | | Context surfaces | `INTENT_CONTEXT_TEMPLATE_ID`, `DOMAIN_CONTEXT_TEMPLATE_ID`, `APP_STATE_MANIFEST_TEMPLATE_ID`, `ARTIFACT_CONTEXT_TEMPLATE_ID`, `META_LEARNING_CONTEXT_TEMPLATE_ID`, `EXTRACTED_FACTS_TEMPLATE_ID` | | Guidance registry | `GUIDANCE_REGISTRY_TEMPLATE_ID`, `PROACTIVE_EXAMPLES_TEMPLATE_ID`, `ANTI_PATTERNS_EXTENDED_TEMPLATE_ID` | | Condensed-tier siblings | `RULES_CONDENSED_TEMPLATE_ID`, `ROLE_CONDENSED_TEMPLATE_ID`, `STYLESHEET_CONDENSED_TEMPLATE_ID` | | Guest-tier siblings | `ROLE_TOOL_ALLOWLIST_TEMPLATE_ID`, `RULES_AND_PROTOCOL_GUEST_TEMPLATE_ID` | | Output discipline | `UNCERTAINTY_ACKNOWLEDGMENT_TEMPLATE_ID`, `CITATION_DISCIPLINE_TEMPLATE_ID`, `RESPONSE_LENGTH_CALIBRATION_TEMPLATE_ID`, `STRUCTURED_OUTPUT_DISCIPLINE_TEMPLATE_ID`, `EVIDENCE_GROUNDING_TEMPLATE_ID` | ### Phase E.0 additive-scaffold protocol [#phase-e0-additive-scaffold-protocol] Templates ship via `allCoreFragmentTemplateContributions()` — a **separate** array from `allCoreFragmentContributions()` and `allCoreFragmentExtensionContributions()`. The template array is NOT wired into `composeDefaultSystemPrompt` by default. That separation is load-bearing. Landing a new template carries no `BYTE_EQUALITY_FIXTURE_BUMP`: the composed-default fingerprint stays byte-identical pre- and post-landing, so caches don't cliff and the existing byte-equality CI gate stays green. A host opts a template into composition explicitly (registry registration with `seedCoreTemplates: true`, or a host-side composer that pulls from the template array); only then does the fingerprint cliff fire — once, predictably, on the host's schedule rather than on every library expansion. The protocol exists so the template library can grow continuously (per-template micro-sessions, one slot at a time) without breaking downstream consumers. Templates are skeletons waiting for a host to opt in, not retroactive changes to anyone's baseline. ## Listing the registry [#listing-the-registry] ```typescript const list = registry.list(); // → RegisteredContribution[] interface RegisteredContribution { readonly contribution: PromptContribution; readonly origin: ContributionOrigin; // "static" | "runtime-aware" | "core-default" | "core-template" readonly plugin: string | null; // null for core defaults + globals readonly registrationOrder: number; } ``` `PromptContributionRegistry` also exposes `get(id)` and `listByOrigin(origin)` for narrower introspection. Records are stable across reads — the resolver doesn't mutate them. ## Cross-runtime contributions [#cross-runtime-contributions] `registerGlobal(contribution, { targetRuntimes, origin? })` is the escape hatch for cross-cutting telemetry / compliance plugins that need to observe every runtime. The `*` literal in `targetRuntimes` matches all runtimes. Treat this as the rare path — it re-introduces shared mutable state and complicates replay determinism. ## Examples [#examples] The package ships reference contributions under `@pleach/core/prompts/examples`. They exercise every branch of the contribution contract and round-trip through the resolver without surprises. ## Where prompt content comes from [#where-prompt-content-comes-from] A composed system prompt is the layered output of four contribution sources, applied in order: 1. **Core fragments** — the bundled core's baseline prompt fragments, identified by `core.<name>` ids. 2. **Host `contributePrompts(ctx)`** — static contributions registered via `HarnessPlugin`. 3. **Host `contributeRuntimeAwarePrompts(ctx)`** — per-turn dynamic contributions. 4. **Safety** — composed LAST via the `SafetyPolicyRegistry` (see next section). The composer holds to a byte-equality contract: a refactor of the layering can't silently change the composed byte output for a given input. A fixture-hash test in the reference implementation hashes the composed prompt across a fixture matrix and fails on drift, so consumer plugins don't break on substrate upgrades. ## SafetyPolicyRegistry [#safetypolicyregistry] A per-runtime registry constructed at `SessionRuntime` construction time. It holds the safety policies whose text gets layered onto every composed prompt. Configure it via the `enabledSafetyPolicies?` field on `SessionRuntimeConfig` — a list of policy ids to enable. A missing field means defaults apply. Three accessors on the runtime expose the registry state: | Accessor | Returns | | --------------------------------------- | -------------------------------------------------- | | `runtime.listAvailableSafetyPolicies()` | Every registered policy, enabled or not. | | `runtime.getActiveSafetyPolicies()` | Only the currently-enabled subset. | | `runtime.getSafetyRegistry()` | The live registry object for direct interrogation. | The registry composes LAST via `composeSafetyContent` inside `resolvePromptForSeam`. Safety policy text always rides over host prompt contributions, never under them — a host plugin can't suppress a safety directive by appending later. See [Safety](/docs/safety) for the individual policies and [Facets](/docs/facets) for the accessor pattern these three methods follow. ## Where to go next [#where-to-go-next] <Cards> <Card title="Plugin contract" href="/docs/plugin-contract" description="The two hooks (`contributePrompts` / `contributeRuntimeAwarePrompts`) on `HarnessPlugin`." /> <Card title="Providers" href="/docs/providers" description="Where the composed prompt lands — `AgentExecutionConfig.systemPrompt`." /> <Card title="AuditableCall row" href="/docs/auditable-call-row" description="The audit row records the fingerprint that includes static contributions." /> </Cards> --- # Providers (/docs/providers) A provider is what the runtime invokes to talk to a model. `@pleach/core` ships two — `AiSdkProvider` (Vercel AI SDK) and `AnthropicSdkProvider` (Anthropic's official SDK) — plus one interface (`AgentProvider`) you implement to wrap any other SDK. The underlying SDK packages are optional peer deps. The runtime imports them dynamically at provider construction, so you only install the ones you use. See [Model resolution matrix](/docs/model-resolution-matrix) for how `(family × callClass)` resolves to a concrete model id, and [Tools](/docs/tools) for how `ProviderToolDef` is built. This page is the reference for the routing cluster's transport layer. The three concepts that decide *what* fires through which provider — `CallClass`, `Seam`, `family-lock` — are framed in [Family-lock → the routing cluster](/docs/family-lock#the-routing-cluster). `ProviderFamily` is a closed union of seven: `anthropic | openai | google | deepseek | moonshot | mistral | xai`. Each family locks the tokenizer, prompt-cache key, tool-call dialect, and refusal pattern — see [Family-lock](/docs/family-lock). The transport is one of four: `native | openrouter | byok-native | byok-openrouter`, locked at session start and never silently mutated. ## Same shape, different provider [#same-shape-different-provider] The fastest swap path is `AiSdkProvider` + OpenRouter. The model identifier is a `<family>/<model>` string — change it, change providers. Everything else on the page stays identical. ```typescript // Anthropic import { createOpenRouter } from "@openrouter/ai-sdk-provider"; import { AiSdkProvider, SessionRuntime } from "@pleach/core"; const openrouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY! }); const runtime = new SessionRuntime({ provider: new AiSdkProvider({ model: openrouter("anthropic/claude-sonnet-4-5"), }), storage: supabaseAdapter, userId: "user_123", }); ``` ```typescript // OpenAI — same file, change one string const runtime = new SessionRuntime({ provider: new AiSdkProvider({ model: openrouter("openai/gpt-4o"), }), storage: supabaseAdapter, userId: "user_123", }); ``` ```typescript // Google Gemini — same file, change one string const runtime = new SessionRuntime({ provider: new AiSdkProvider({ model: openrouter("google/gemini-2.5-flash"), }), storage: supabaseAdapter, userId: "user_123", }); ``` One `OPENROUTER_API_KEY`, seven families (`anthropic | openai | google | deepseek | moonshot | mistral | xai`), identical request shape. The runtime's `family-lock` reads the `<family>/` prefix and locks the tokenizer, prompt-cache key, and tool-call dialect for the session — no silent cross-family fallback. Want direct vendor SDKs instead? Drop the OpenRouter wrapper for `@ai-sdk/anthropic`, `@ai-sdk/openai`, `@ai-sdk/google` etc. — covered below. ```typescript import { AiSdkProvider, AnthropicSdkProvider } from "@pleach/core"; import type { AgentProvider, AgentExecutionConfig, ProviderMessage, ProviderToolDef, ProviderCapabilities, ProviderStreamEvent, TokenUsage, } from "@pleach/core/providers"; ``` <SourceMeta source="{ label: "src/providers/", href: "https://github.com/pleachhq/core/tree/main/src/providers" }" /> ## `AiSdkProvider` (Vercel AI SDK) [#aisdkprovider-vercel-ai-sdk] Wraps `streamText` from `ai@6.x`. Right pick when you want unified provider switching, the AI SDK's tool dialect, and the ecosystem of community providers. The recommended default — pair with [OpenRouter](https://openrouter.ai) to reach any family from one key. ```typescript import { createOpenRouter } from "@openrouter/ai-sdk-provider"; import { AiSdkProvider, SessionRuntime } from "@pleach/core"; const openrouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY! }); const runtime = new SessionRuntime({ provider: new AiSdkProvider({ model: openrouter("anthropic/claude-sonnet-4-5"), maxSteps: 5, }), storage: supabaseAdapter, userId: "user_123", }); ``` Drop the OpenRouter wrapper for a direct provider package any time — `@ai-sdk/anthropic`, `@ai-sdk/openai`, `@ai-sdk/google`, etc.: ```typescript import { anthropic } from "@ai-sdk/anthropic"; const runtime = new SessionRuntime({ provider: new AiSdkProvider({ model: anthropic("claude-sonnet-4-5"), maxSteps: 5, }), storage: supabaseAdapter, userId: "user_123", }); ``` ### Config [#config] | Field | Type | Default | Purpose | | ---------- | ---------------------- | -------- | ---------------------------------------------- | | `model` | AI SDK `LanguageModel` | required | Any AI SDK model factory output | | `maxSteps` | `number` | `1` | Maps to AI SDK v6's `stopWhen: stepCountIs(N)` | Install the `ai` package plus a provider package — OpenRouter is the default; switch to `@ai-sdk/anthropic` / `@ai-sdk/openai` / `@ai-sdk/google` for direct vendor access: ```bash npm install ai @openrouter/ai-sdk-provider # or @ai-sdk/anthropic, @ai-sdk/openai, @ai-sdk/google, etc. ``` ## `AnthropicSdkProvider` [#anthropicsdkprovider] Wraps `@anthropic-ai/sdk` directly. Right pick when you want native Anthropic features (prompt caching, extended thinking, tool-use beta flags) without going through a unified wrapper. ```typescript import { AnthropicSdkProvider, SessionRuntime } from "@pleach/core"; const runtime = new SessionRuntime({ provider: new AnthropicSdkProvider({ apiKey: process.env.ANTHROPIC_API_KEY!, model: "claude-sonnet-4-5", maxTokens: 4096, }), storage: supabaseAdapter, userId: "user_123", }); ``` ### Config [#config-1] | Field | Type | Default | Purpose | | ----------- | -------- | ------------------------------ | ----------------------------------------- | | `apiKey` | `string` | required | Anthropic API key | | `model` | `string` | `"claude-sonnet-4-5-20250514"` | Model id | | `maxTokens` | `number` | `4096` | Output token cap per call | | `baseURL` | `string` | – | Override for proxies / regional endpoints | ```bash npm install @anthropic-ai/sdk ``` ## The `AgentProvider` interface [#the-agentprovider-interface] Implement this to wrap any other SDK — OpenAI directly, a custom gateway, a local Ollama instance, anything that streams. ```typescript interface AgentProvider { execute(config: AgentExecutionConfig): AsyncIterable<ProviderStreamEvent>; abort(): void; readonly capabilities: ProviderCapabilities; } ``` ### `AgentExecutionConfig` [#agentexecutionconfig] What the runtime hands you per call. | Field | Type | Purpose | | ---------------- | -------------------------- | ---------------------------------------------------------- | | `messages` | `ProviderMessage[]` | Normalized conversation history | | `tools` | `ProviderToolDef[]?` | Tools available this call | | `systemPrompt` | `string?` | Composed system prompt (post-`composeBudgetedPrompt`) | | `model` | `string?` | Specific model id when the matrix resolved one | | `temperature` | `number?` | Sampling temperature | | `maxTokens` | `number?` | Output token cap | | `abortSignal` | `AbortSignal?` | User pressed stop / turn aborted | | `providerConfig` | `Record<string, unknown>?` | Provider-specific pass-through (cache headers, beta flags) | ### `ProviderMessage` [#providermessage] The normalized message shape your provider converts to its native format. ```typescript interface ProviderMessage { id?: string; role: "user" | "assistant" | "system" | "tool"; content: unknown; tool_calls?: Array<{ id: string; name: string; arguments: unknown }>; tool_call_id?: string; name?: string; } ``` ### `ProviderStreamEvent` [#providerstreamevent] A tagged union. The runtime adapts these into the public `StreamEvent` consumers see. ```typescript type ProviderStreamEvent = | { type: "message.start"; messageId: string; role: string } | { type: "message.delta"; delta: string } | { type: "message.complete"; messageId: string; usage?: TokenUsage } | { type: "thinking.delta"; delta: string } | { type: "thinking.complete" } | { type: "tool.started"; toolCallId: string; toolName: string; arguments?: unknown } | { type: "tool.completed"; toolCallId: string; toolName: string; result?: unknown } | { type: "tool.failed"; toolCallId: string; toolName: string; error: string } | { type: "error"; error: string; code?: string } | { type: "done" }; ``` `TokenUsage` carries `inputTokens`, `outputTokens`, `totalTokens`, `cacheReadTokens`, `cacheWriteTokens` — all optional, all surfaced on `message.complete`. ### `ProviderCapabilities` [#providercapabilities] Feature detection so the runtime knows what to ask for. ```typescript interface ProviderCapabilities { streaming: boolean; toolCalling: boolean; thinking: boolean; structuredOutput: boolean; maxContextTokens: number; parallelToolCalls: boolean; } ``` The runtime reads `capabilities` to decide what to ask for. If `toolCalling` is `false`, tool-using sessions won't dispatch tools through this provider; if `thinking` is `false`, thinking-delta events are dropped. ### Minimal custom provider sketch [#minimal-custom-provider-sketch] ```typescript // lib/providers/myProvider.ts import type { AgentProvider, AgentExecutionConfig, ProviderStreamEvent } from "@pleach/core"; export class MyProvider implements AgentProvider { readonly capabilities = { streaming: true, toolCalling: true, thinking: false, structuredOutput: false, maxContextTokens: 128_000, parallelToolCalls: false, }; private controller: AbortController | null = null; async *execute(config: AgentExecutionConfig): AsyncIterable<ProviderStreamEvent> { this.controller = new AbortController(); const signal = this.controller.signal; config.abortSignal?.addEventListener("abort", () => this.controller?.abort()); const stream = await callMyApi(config, { signal }); for await (const chunk of stream) { yield translateChunk(chunk); } } abort() { this.controller?.abort(); } } ``` Drop it onto the runtime: ```typescript const runtime = new SessionRuntime({ provider: new MyProvider(), storage: supabaseAdapter, userId: "user_123", }); ``` ## Family-strict cascade with `pickNextInFamily` [#family-strict-cascade-with-picknextinfamily] Provider failure inside the graph cascade walks the locked family ladder rather than silently widening cross-family. The primitive is `pickNextInFamily(family, currentModel, triedModels)` — it returns the next rung in the same family, or `null` when every rung is exhausted. ```typescript // `pickNextInFamily` is host-supplied — the harness ships no model // registry, so you author the in-family ladder walk and register it via // the `contributeFamilyPivot` plugin hook (below). Its signature: declare function pickNextInFamily( family: string, currentModel: string, triedModels: Set<string>, ): { modelId: string; callClass: string } | null; const next = pickNextInFamily( "anthropic", "claude-opus-4-7", new Set(["claude-opus-4-7"]), ); // → { modelId: "claude-sonnet-4-7", callClass: "synthesize", ... } | null ``` The cascade pivot in `defaultAgentGraph.ts` (its `providerErrorCascadeAttempted` and `synthesisRecoveryAttempted` blocks) walks `pickNextInFamily` in-family. When every rung exhausts, it emits `[UXParity:family-exhausted]`, calls `setFamilyExhaustedState({...})`, and returns `shouldContinue: false`. Hosts surface a `FamilyExhaustedToast` (or wire `contributeFamilyExhaustedSurface` — see below) and the user explicitly picks another family. **Do not silently widen cross-family.** That was the prod regression shape from `canvas2-prompt7-audit-2026-05-18`: a cross- family fallback inside the cascade doubled cost on synthesize failures and broke the family-lock invariants downstream tools depend on. `null` from `pickNextInFamily` is the explicit termination signal — surface it, don't paper over it. Non-matrix-resolvable models (BYOK / `modal-llm` / unrecognized slugs) preserve legacy cross-family fallback inside the `family === null` branch. ## Reasoning-only completion recovery [#reasoning-only-completion-recovery] A reasoning / chain-of-thought model (deepseek-v4, o1-class, gemini-thinking) can finish a stream **cleanly** having emitted reasoning but **zero user-facing text and zero tool calls** — or hang until the seam watchdog force-unwinds it. Treated naively both look like an outage: the turn cascades to another provider, doubling cost, and can ship an empty answer. The correct behavior is one same-model **content-elicitation retry** ("write your final answer directly") before declaring the model unavailable. The seam layer owns this recovery so it fires on the graph path, gated by an opt-in DI on `SessionRuntimeConfig`. Unset, the seam is byte-identical to today — no recovery arm: ```typescript import type { ReasoningRecoveryStrategy } from "@pleach/core/types/strategies"; const reasoningRecovery: ReasoningRecoveryStrategy = { // Authoritative: is this a reasoning model eligible for recovery? classifier: (modelId) => modelId.startsWith("deepseek-v4"), // The re-ask. A STATIC string, or a FUNCTION of the empty turn's // own (bounded) reasoning for a CORRECTIVE directive — higher // recovery reliability than a cold generic re-ask. elicitationPrompt: (ctx) => ctx.reasoningText ? `You reasoned:\n${ctx.reasoningText}\n\nNow write the final answer for the user directly.` : "Now write your final answer for the user directly. Do not include your reasoning.", }; const runtime = new SessionRuntime({ /* … */, reasoningRecovery }); ``` The strategy shape mirrors `ProviderDegradationStatsResolver` — the host owns every domain value (the model set, the elicitation string) so no reasoning-model list lives in core. The recovery fires at most once per invoke, on **both** the clean-empty return path and the watchdog-unwind throw path, and only cascades if the retry is also content-free. It emits `[UXParity:reasoning-only-recovery:*]`. `@pleach/core/providers` also publishes the shared failure taxonomy — `FailureCategory` (`AUTH` / `BILLING` / `RATE_LIMIT` / `MODEL_UNAVAILABLE` / `TOOL_HALLUCINATION` / …), `FailureClassification`, `BillingDisposition`, `ModelUnavailableReason` — plus `classifyEmptyStream` (the precondition classifier the seam recovery keys on). Cross-SKU consumers (gateway, replay, observe) share the vocabulary; the provider-specific string→category matchers stay host-injected. ## Plugin contribution hooks for retry, continuation, and family pivot [#plugin-contribution-hooks-for-retry-continuation-and-family-pivot] Four optional `HarnessPlugin` contribution hooks lift the retry loop's domain knowledge out of the host. Each is opt-in; an unset hook returns `null` and the runtime falls back to its default behavior. | Hook | Returns | Collector | What it owns | | ---------------------------------- | ------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `contributeRetryPolicy` | `RetryPolicyContribution` | `runtime.plugins.collectRetryPolicy()` | Retry behavior on transient failures — discovery-only tool set, workflow-narration nudge patterns, nudge template | | `contributeContinuationPolicy` | `ContinuationPolicy` | `runtime.plugins.collectContinuationPolicy()` | When a tool result needs continuation vs termination — fetch-tool name set, stuck-discovery namespace prefixes, sandbox-clause regex | | `contributeFamilyPivot` | `FamilyPivotContribution` | `runtime.plugins.collectFamilyPivot()` | Classify a model into a family, then pick the next in-family rung — `deriveFamilyFromModelId` + `pickNextInFamily` paired | | `contributeFamilyExhaustedSurface` | `FamilyExhaustedSurfaceContribution` | `runtime.plugins.collectFamilyExhaustedSurface()` | UI surface (toast, banner, modal) when the family ladder exhausts — receives `family`, `triedModels`, `chatId`, optional `messageId` | The four hooks split the responsibility cleanly: `contributeRetryPolicy` * `contributeContinuationPolicy` carry DATA (tool name sets, regex patterns, templates); `contributeFamilyPivot` carries OPERATIONS (the harness has no model registry — both classification and in-family pick are host-supplied); `contributeFamilyExhaustedSurface` carries UI strategy (the harness has no view layer). Sketch: ```typescript import type { HarnessPlugin } from "@pleach/core"; import type { ProviderFamily } from "@pleach/core/modelfamily"; // Host-supplied — your own model-registry helpers. The harness has no // model registry, so you author these and hand them to the runtime // through `contributeFamilyPivot`. declare function deriveFamilyFromModelId(modelId: string): ProviderFamily | null; declare function pickNextInFamily( family: ProviderFamily, current: string, tried: Set<string>, ): { modelId: string } | null; const myPlugin: HarnessPlugin = { name: "my-host", contributeFamilyPivot() { return { deriveFamilyFromModelId, pickNextInFamily: (family, current, tried) => pickNextInFamily(family, current, new Set(tried))?.modelId ?? null, }; }, contributeFamilyExhaustedSurface() { return { surfaceFamilyExhausted: ({ family, triedModels, chatId, messageId }) => { myToastBus.emit({ kind: "family-exhausted", family, attempted: Array.from(triedModels), chatId, messageId, }); }, }; }, }; ``` BYOK credential resolution is a sibling path: the `createPleachRuntime` factory accepts a `byokResolver` callback (per-session credential lookup) that the `TurnOrchestrator` reads via `SessionRuntime.getByokResolver()`. Any host with its own secrets store implements the same shape. ## Provider cascade with audit attribution [#provider-cascade-with-audit-attribution] Compose a primary plus fallback by wrapping two providers in a third. The cascade records why it fell through so the audit row joins back to a cause, not just a model id. ```typescript class CascadeProvider implements AgentProvider { constructor(private primary: AgentProvider, private fallback: AgentProvider) {} readonly capabilities = this.primary.capabilities; async *execute(config: AgentExecutionConfig): AsyncIterable<ProviderStreamEvent> { try { yield* this.primary.execute(config); } catch (err) { yield { type: "error", error: String(err), code: "5001" }; yield { type: "message.delta", delta: "[fallback engaged]" }; yield* this.fallback.execute({ ...config, providerConfig: { ...config.providerConfig, cascadeReason: "primary_5001" }, }); } } abort() { this.primary.abort(); this.fallback.abort(); } } ``` ## BYOK provider from session-scoped credentials [#byok-provider-from-session-scoped-credentials] A multi-tenant deployment carries the customer key on the session, not the process env. Resolve it at construction time from the same `userId` / `organizationId` you pass to the runtime. ```typescript async function providerForOrg(organizationId: string): Promise<AgentProvider> { const apiKey = await loadOrgKey(organizationId); // your secrets store return new AnthropicSdkProvider({ apiKey, model: "claude-sonnet-4-5", maxTokens: 4096, }); } const runtime = new SessionRuntime({ provider: await providerForOrg("org-acme"), storage: supabaseAdapter, userId: "user-7", }); ``` ## Provider vs `orchestratorConfig` [#provider-vs-orchestratorconfig] `SessionRuntimeConfig` accepts both `provider` (the `AgentProvider`-shaped surface above) and `orchestratorConfig` (a richer config object used by the legacy orchestrator path). Use `provider` for new code — it's the public substrate API. `orchestratorConfig` exists so hosts mid-migration don't break; new consumers should ignore it. The class behind `orchestratorConfig` was renamed: `OrchestratorClient` → [`TurnOrchestrator`](/docs/orchestrator-client). `OrchestratorClient` remains exported from `@pleach/core` as a deprecated alias for the `@pleach/core@1.x` migration window and is removed at `2.0.0`. Existing imports keep working; new code should reach for `TurnOrchestrator` (or, better, the `provider` surface above). ## Where to go next [#where-to-go-next] <Cards> <Card title="SessionRuntime" href="/docs/session-runtime" description="The `provider` config field." /> <Card title="Tools" href="/docs/tools" description="How `ProviderToolDef` is built from `defineTool` calls." /> <Card title="Model resolution matrix" href="/docs/model-resolution-matrix" description="How `(family × callClass)` resolves to a `modelId` the provider receives." /> <Card title="Prompts" href="/docs/prompts" description="Where `systemPrompt` comes from before it lands on the provider." /> </Cards> --- # First-publish packaging contract (/docs/publishing-contract) Every `@pleach/*` SKU follows the same packaging conventions at its first 1.x publish. For day-to-day consumers, this page is informational — knowing the contract makes debugging install or import issues faster. For forkers and vendored installs, it's load-bearing: diverging breaks the audit gates that enforce the contract upstream. <SourceMeta source="{ label: "github.com/pleachhq/core/package.json", href: "https://github.com/pleachhq/core/blob/main/package.json" }" /> ## Dual ESM + CJS emit [#dual-esm--cjs-emit] Every `@pleach/*` package ships both module systems from a single TypeScript source tree. ESM (`.mjs`) is the primary distribution; CJS (`.js`) is a compatibility layer for Node consumers on older toolchains that can't resolve native ESM cleanly. The `package.json` `exports` block routes the right format per resolver. ESM consumers resolve `.mjs`; CJS consumers resolve `.js`; TypeScript reads `.d.ts`. The dual emit is non-negotiable — dropping CJS would break a substantial fraction of consumers on Node 18 and below. | Format | Extension | Resolver | | ------ | --------- | ------------------- | | ESM | `.mjs` | `import` condition | | CJS | `.js` | `require` condition | | Types | `.d.ts` | `types` condition | ## Types-first `exports` ordering [#types-first-exports-ordering] The Node spec walks `exports` conditions top-to-bottom and picks the first match. The `types` condition must come first in each export entry. Wrong order → TypeScript falls through to `import` or `default`, reads the JavaScript file as if it were types, and the consumer sees "no type declarations" errors. ```jsonc { "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" } } } ``` This is a Node spec rule, not a Pleach convention — but the upstream `audit:package-export-validation` gate asserts the ordering anyway, since the failure mode is silent and consumer-side. ## Dir-style tsup entries [#dir-style-tsup-entries] Build entries are directories (`src/audit/`, `src/cache/`, `src/sessions/`), not single files. tsup walks each entry directory's `index.ts` and emits a matching `dist/<dir>/index.{mjs,js,d.ts}` triple. ```json { "tsup": { "entry": ["src/audit", "src/cache", "src/sessions"], "format": ["esm", "cjs"] } } ``` The directory convention keeps the entry list compact as the package grows. Adding a new subpath is one entry, not three artifact paths. ## DTS via `tsc --build`, not tsup's `dts` block [#dts-via-tsc---build-not-tsups-dts-block] tsup's bundled-DTS mode tends to break with complex type re-exports — it loses some declarations and mangles others when a module re-exports a generic across barrels. The convention is to disable tsup's DTS output entirely and run `tsc --build` for declarations. ```json { "scripts": { "build": "tsup && tsc --build tsconfig.build.json" } } ``` tsup emits JS only; the separate `tsc` pass emits `.d.ts`. The build script runs both in order. The `tsconfig.build.json` typically sets `emitDeclarationOnly: true` and `declarationMap: false`. ## Sourcemap exclusion from the published tarball [#sourcemap-exclusion-from-the-published-tarball] Sourcemaps live on disk in dev (`dist/*.map`) but the `files` field in `package.json` excludes them from `npm pack`. ```json { "files": [ "dist/**/*.mjs", "dist/**/*.js", "dist/**/*.d.ts", "LICENSE", "NOTICE", "README.md" ] } ``` The empirical reason: including sourcemaps pushed the published tarball from \~25 MB to >100 MB, which slowed CI installs across every consumer. Sourcemaps remain available in the git repo for debugging — clone the source tag and rebuild locally if you need them. ## PeerDep tightening [#peerdep-tightening] At 1.x, peer-dep ranges tighten. The typical pattern is a caret on a single major version, sometimes narrower for packages with known breakage windows. | Range style | When | | -------------- | ----------------------------------------------- | | `^18.0.0` | Stable peer with predictable semver | | `>=18.0.0 <20` | Peer with known breakage in 20.x | | `^18.2.0` | Pinning to a specific minor for a bug-fix floor | The discipline avoids the "two copies of React" duplicate-install class of bug, where the consumer pulls one major and a transitive dep pulls another. Consumer responsibility: install peers explicitly rather than relying on transitive resolution. ## NOTICE, CHANGELOG, provenance, sideEffects [#notice-changelog-provenance-sideeffects] Four baselines every 1.x publish carries. ### `NOTICE` [#notice] License-attribution file referenced from `package.json` via the `files` array. Required at first 1.x publish. Carries the package's own license notice plus third-party attributions for any bundled or derived code. ### `CHANGELOG.md` [#changelogmd] [Keep a Changelog](https://keepachangelog.com/) format. Every publish writes an entry; no "Unreleased" tail at publish time. The audit gate `audit:package-version-vs-registry` (next section) fails the publish if the changelog top entry doesn't match the `package.json#version`. ### npm provenance [#npm-provenance] Publishes use `npm publish --provenance`, which ties the tarball to the GitHub Actions workflow that built it via a signed SLSA attestation. Consumers verify with: ```bash npm audit signatures ``` The attestation links the tarball hash to the commit SHA, the workflow file, and the runner. Useful for supply-chain auditing and for confirming a published artifact matches the source tag. ### `sideEffects: false` [#sideeffects-false] Set in `package.json` where accurate — for `@pleach/*`, every published package qualifies. The flag lets bundlers tree-shake unused exports out of consumer bundles. ```json { "sideEffects": false } ``` The setting is honored by Webpack, Rollup, esbuild, and Vite. Roughly a 30% bundle reduction for apps that only import a handful of named subpaths from `@pleach/core`. ## Audit gates enforcing the contract [#audit-gates-enforcing-the-contract] Two upstream audits gate every publish. ### `audit:package-export-validation` [#auditpackage-export-validation] Walks every entry in `package.json#exports` and asserts: 1. The file referenced exists on disk after build. 2. The file re-exports the declared symbols (parsed from the matching `.d.ts`). 3. The `types` condition comes first in each entry. 4. ESM and CJS entries resolve to files with the correct extensions. The gate runs in CI on every PR and as a pre-publish hook locally. Diverging — adding an export without a corresponding file, or reordering conditions — fails the publish. ### `audit:package-version-vs-registry` [#auditpackage-version-vs-registry] Compares the in-tree `package.json#version` to the latest tag on npmjs.org. Catches "silently past 1.0.0" drift between source and registry — the failure mode where a local bump happens but the publish never lands, then a subsequent change publishes with the wrong version. The gate also asserts the `CHANGELOG.md` top entry matches the in-tree version, so the changelog can't drift either. ## Consumer-rehearsal audit [#consumer-rehearsal-audit] Before any first-publish OR any 1.x release tag, run the consumer-rehearsal audit — a six-phase end-to-end simulation of what a real npm consumer experiences. The audit runs the package through `npm pack` + install into a scratch directory outside the source tree, then exercises the published surface the way a consumer would. ```bash npm run audit:consumer-rehearsal:core ``` The six phases: | Phase | What it does | What it catches | | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | 0 | `npm run packages:build` | Build failures, missing artifacts | | 1 | `npm pack` the target + peer packages; install all tarballs into a scratch dir | Tarball-shape regressions, missing `files` entries, peer-dep resolution failures | | 2 | Auto-discovered facet smoke imports from `packages/<pkg>/src/facets/*.ts` (one accessor probe per facet) | Facet barrel breakage, missing re-exports, accessor-shape drift | | 3 | Auto-discovered subpath imports from `package.json:exports` (skipping `.`, `./package.json`, wildcards) | Subpath resolution failures, missing `dist/` files behind declared exports | | 4 | TypeScript consumer surface — `tsc --noEmit -p tsconfig.json` under strict `NodeNext` against a consumer file importing the published types + values | `.d.ts` regressions invisible to source-tree `tsc` (consumer resolution differs from in-monorepo resolution) | | 5 | Auto-discovered examples runs — every `examples/<name>/index.mjs` with a 30s timeout | Example-script breakage; functional smoke on the public surface | Exit codes: `0` = all phases pass; `1` = any phase failed. The `--json` mode emits structured per-phase records for CI consumption. The audit writes only to the scratch directory outside the repo, so it's idempotent across runs. When to run it: * Before any first-publish of a new `@pleach/*` SKU * Before any 1.x release tag * After touching `packages/<pkg>/package.json:exports` * After relocating or renaming any `src/facets/*.ts` accessor * After adding new files to the `files` array The audit caught the standalone-install blocker on `@pleach/core` that motivated the no-eager-react-import fix — a failure mode that was invisible to every in-monorepo gate because workspace path aliases silently resolved cross-tree. ## For consumers — debugging install issues [#for-consumers--debugging-install-issues] Three common symptoms and what they usually mean. | Symptom | Likely cause | | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `Cannot find module "@pleach/core/<subpath>"` | The subpath isn't in the exports map — check [Subpath exports](/docs/subpath-exports) | | `Module has no exported member` on a type-only import | `types` condition out of order in the consumer's bundler resolver — pin to a recent bundler version | | Sourcemap warnings in dev tools | Benign — the tarball deliberately excludes sourcemaps | The first one is the most common: a deep import that worked against `@pleach/core/*` (wildcard) before a named barrel landed, then stopped resolving the way the consumer expected. The fix is to import from the barrel, not the deep path. The second usually means an old bundler or `moduleResolution: "node"` instead of `"bundler"` / `"node16"` in the consumer's `tsconfig.json`. The types-first ordering is correct on the package side; the resolver needs to honor it. ## For forkers and vendored installs [#for-forkers-and-vendored-installs] If you vendor `@pleach/*` into a private registry or fork the source, mirror the conventions above. The upstream audit gates will fail on a fork that diverges — the gates live in the package source, not in the publish pipeline, so they run in any fork's CI too. The canonical enforcer scripts live in the source tree: | Script | Asserts | | ----------------------------------- | ------------------------------------------------ | | `scripts/audit-package-exports.mjs` | Exports map validity, types-first ordering | | `scripts/audit-package-version.mjs` | In-tree version vs registry, changelog top entry | | `scripts/audit-build-artifacts.mjs` | Dual-format emit, DTS presence, tarball contents | A fork that wants to publish under a different scope should keep the gates intact and update the registry URL the version audit checks against. Don't disable the gates — the failure modes they catch are silent on the consumer side, and disabling them shifts debugging cost onto every downstream user. ## Where to go next [#where-to-go-next] <Cards> <Card title="Subpath exports" href="/docs/subpath-exports" description="The named import paths under @pleach/core and what each ships." /> <Card title="Versioning policy" href="/docs/versioning" description="Semver, deprecation, pinning rules, and the FSL-1.1-Apache-2.0 license posture." /> <Card title="Packages" href="/docs/packages" description="The @pleach/* package matrix — what each SKU does." /> <Card title="Contributing" href="/docs/contributing" description="How to file a docs bug or propose a contract change." /> </Cards> --- # Query (/docs/query) Query is one surface in the **frontend integration** [thematic island](/docs/concept-clusters#what-lives-outside-the-cluster-pattern) — siblings of [react](/docs/react), [server](/docs/server), [api-routes](/docs/api-routes), and [devtools](/docs/devtools). `@pleach/core/query` is the server-side read API over persisted harness data. Every function takes a Supabase service-role `QueryClient` as its first argument and returns plain data — no side effects, no writes. **Server only.** This subpath assumes a service-role Supabase client and bypasses RLS. Never import it into browser bundles. The hooks in `@pleach/core/react` are the browser-safe equivalent backed by RLS-bound clients. ```typescript import { createClient } from "@supabase/supabase-js"; import { getChatUsage, getSessionReview, queryHarnessEvents, // ... see catalog below } from "@pleach/core/query"; const client = createClient( process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, ); const usage = await getChatUsage(client, "sess_abc"); ``` Every function returns plain data. Errors surface as `QueryError`, which carries one of three `code` values: `INVALID_INPUT` (bad input shape, missing required field, or wrong client type), `DB_ERROR` (the underlying Supabase call failed), and `NOT_FOUND` (a required parent row is missing). Most readers return an empty array or `null` for missing rows rather than throwing — the exception is functions that must resolve a parent chat first, such as `getSessionReview`, which throws `NOT_FOUND` when the chat doesn't exist. <SourceMeta subpath="@pleach/core/query" source="{ label: "src/query/", href: "https://github.com/pleachhq/core/tree/main/src/query" }" /> ## Usage and costs [#usage-and-costs] | Function | Returns | Use | | ---------------------------------------- | ----------------------------------- | ------------------------------ | | `getChatUsage(client, chatId, options?)` | `UsageResult` (token counts + cost) | Per-chat cost rollups | | `lookupModelCost(modelId)` | `ModelCostEntry` | Per-call cost calculation | | `MODEL_COSTS` | `Record<string, ModelCostEntry>` | Static map of supported prices | ```typescript const usage = await getChatUsage(client, chatId); // → { chat_id, model, provider, // tokens: { input, output, total, context_window, utilization_pct }, // cost_estimate_usd, message_count, tool_call_count, // total_duration_ms, models_used } const cost = lookupModelCost("claude-sonnet-4-5"); // → { input: 0.003, output: 0.015, contextWindow: 200000 } // input/output are USD per 1K tokens ``` `MODEL_COSTS` ships with the package and gets updated as providers publish new prices. Treat it as a snapshot — for production billing, override with your own pricing table that matches your contracted rates. ### Per-model cost rollup [#per-model-cost-rollup] `getChatUsage` returns the per-chat cost estimate plus a `models_used` breakdown — token share per model across the conversation, including provider fallback and mid-stream retries: ```typescript const usage = await getChatUsage(client, chatId); console.log(usage.cost_estimate_usd); // total USD for the chat for (const m of usage.models_used ?? []) { console.log(m.model, m.tokens, `${m.pct}%`); } // → claude-sonnet-4-5 18432 72% // gpt-4o-mini 7104 28% ``` Per-turn granularity isn't shipped as a convenience reader. The `harness_auditable_calls` table carries `turn_id` (stable across retries within a turn) and per-call token usage — query it directly through the service-role client when you need a cost report keyed to user-visible turns. ## Sessions, transcripts, and review [#sessions-transcripts-and-review] | Function | Returns | Use | | ---------------------------------------------------- | -------------------------------------- | ----------------------------------------------------------------------- | | `getSessionReview(client, chatId, options?)` | Tool calls + completions for a session | History UIs | | `getEnrichedSessionReview(client, chatId, options?)` | Same plus citations/usage/entities | When you opt in via `includeCitations`/`includeUsage`/`includeEntities` | | `configureQueryEnrichers({ citations, entities })` | – | Register domain extractors | Two extractors that previously lived in this package — chat citations and chat entities — were relocated to the host application layer because they pull domain-coupled rules. Wire them back in via `configureQueryEnrichers`: ```typescript import { configureQueryEnrichers, getEnrichedSessionReview } from "@pleach/core/query"; configureQueryEnrichers({ citations: myCitationExtractor, entities: myEntityExtractor, }); const review = await getEnrichedSessionReview(client, chatId, { includeCitations: true, includeUsage: true, includeEntities: true, }); ``` Enrichment is driven by the `include*` option flags, not by which enrichers are registered. Each flag you omit leaves that section off the result entirely — so with no options `getEnrichedSessionReview` returns exactly the `getSessionReview` shape. `includeUsage` runs `getChatUsage` directly and needs no enricher; `includeCitations` and `includeEntities` invoke the registered extractors, or resolve to an empty result when no extractor has been configured. ## Events [#events] | Function | Returns | Use | | ------------------------------------------------------ | ---------------------------------------------------- | ------------------------------------------------- | | `queryHarnessEvents(client, filter)` | `EventQueryResult` (`{ events, nextCursor, count }`) | Filter event log by type/session, cursor-paginate | | `getAllChatEvents(client, chatId)` | `HarnessEvent[]` | All events for one chat (auto-paginated) | | `countEventsByType(client, chatId)` | `Record<string, number>` | Aggregate counts | | `getInterruptChain(client, chatId, toolCallId, opts?)` | Interrupt → resolution walk | Audit a HITL decision | ```typescript const errors = await queryHarnessEvents(client, { chat_id: chatId, event_types: ["error", "tool.failed"], severity: "error", }); // → { events: [...], nextCursor: "…" | null, count: number } const counts = await countEventsByType(client, chatId); // → { "message.added": 12, "tool.completed": 8, "checkpoint.created": 4, ... } ``` `queryHarnessEvents` returns `{ events, nextCursor, count }`, with `events` ordered by `created_at` ascending. When more rows remain, `nextCursor` is a base64-encoded `created_at|id` compound key (the compound form avoids skipping events that share a timestamp); pass it back as `cursor` to fetch the next page. A `null` `nextCursor` means there are no more rows. Page size is capped at 200. Walk a long-running chat page by page: ```typescript let cursor: string | undefined; while (true) { const page = await queryHarnessEvents(client, { chat_id: "chat-018f-abc", cursor, limit: 200, }); for (const ev of page.events) handle(ev); if (!page.nextCursor) break; cursor = page.nextCursor; } ``` ## Manifests, jobs, assets [#manifests-jobs-assets] | Function | Returns | Use | | ------------------------------------------------- | ------------------------- | -------------------------------- | | `getChatManifest(client, chatId)` | Full job manifest | Long-running async work status | | `getManifestJobsByStatus(client, chatId, status)` | Filtered manifest entries | "Show me pending jobs" | | `getConversationState(client, chatId)` | High-level snapshot | Sidebar previews | | `refreshManifestFromEvents(client, chatId)` | – | Rebuild manifest from event log | | `getChatAssets(client, chatId)` | Asset list | Materialized artifacts | | `getChatAsset(client, { chatId, assetId })` | One asset | Detail view | | `diffAssets(a, b)` | Asset diff | Compare two asset sets | | `getBatchJobStatus(client, jobIds)` | Status map | Batch poll | | `getChatJobLinks(client, chatId)` | Job links | Job provenance | | `recordJobLink(client, link)` | – | Write helper retained for parity | ## Tool inspection [#tool-inspection] The registry inspectors (`listTools` / `getToolDetails` / `getToolSchemaQuery` / `getToolSummary`) read the in-process tool registry — they are **session-agnostic** and take no client. Only `getToolResults` is DB-backed (chat-keyed). | Function | Returns | Use | | ---------------------------------------------------------------------- | ----------------------------------------------------------- | ------------------- | | `listTools(options?)` | `ToolListResult` — every tool in the registry | Settings panel | | `getToolDetails(name)` | Tool descriptor + `name` (or `null`) | Detail view | | `getToolSchemaQuery(name)` | JSON schema (or `null`) — aliased `getToolSchema` | Schema-driven forms | | `getToolResults(client, chatId, { toolName?, statusFilter?, limit? })` | Recent results for the chat | History panel | | `getToolSummary()` | `Record<string, string[]>` — tool names grouped by category | Dashboard | ## Cross-session analytics [#cross-session-analytics] Four of these also take a `BaseStore` as the middle argument (they read meta-learning / aggregate state from the store, not just the DB): `getAggregateUsage`, `getToolEffectiveness`, `getIntentDistribution`, and `detectAnomalies`. The other four are `(client, filter)`. | Function | Returns | Use | | ---------------------------------------------- | ---------------------------------- | --------------------- | | `getAggregateUsage(client, store, filter)` | Token / cost rollups | Org dashboards | | `compareAgents(client, { agentIds })` | Side-by-side metrics | A/B comparison | | `getToolEffectiveness(client, store, filter)` | Success rate + latency per tool | Tool-quality reviews | | `getPlanDashboard(client, filter)` | Plan completion stats | Planner observability | | `getSessionTimeline(client, filter)` | Session activity timeline | Per-user views | | `getLearningHealth(client, filter)` | Memory / learning health | Eval pipelines | | `getIntentDistribution(client, store, filter)` | Intent classification distribution | Routing analysis | | `detectAnomalies(client, store, filter)` | Outliers | Drift detection | These accept a filter object with `{ from, to, userId?, organizationId?, modelId?, ... }`. The full filter shape is in the TypeScript types — your IDE autocomplete is the canonical reference. ## Required tables [#required-tables] The query layer reads from the same tables the runtime writes to: | Table | Read by | | -------------------- | ----------------------------------- | | `harness_sessions` | Sessions, conversation state | | `harness_event_log` | Events, manifests, interrupt chains | | `ai_chat_messages` | Usage, costs, model rollups | | `harness_outbox` | Job links | | `chat_session_links` | Provenance to upstream chat | All reads use service-role credentials and bypass RLS. The query functions construct typed SQL against these tables — they don't re-read the schema bundle at runtime, so additive schema migrations are forward-compatible. ## `QueryError` [#queryerror] The only error type the query layer throws. Its `code` field is one of `INVALID_INPUT` (bad input shape, missing required field, or wrong client type), `DB_ERROR` (the underlying Supabase call failed), or `NOT_FOUND` (a required parent row is missing). Most missing rows return empty arrays or `null` rather than throwing; the exception is readers that must resolve a parent chat first — `getSessionReview` throws `NOT_FOUND` when the chat doesn't exist. ```typescript import { QueryError } from "@pleach/core/query"; try { const review = await getSessionReview(client, ""); } catch (err) { if (err instanceof QueryError) { // Programmer error — log and fix the caller. } throw err; } ``` ## Where to go next [#where-to-go-next] <Cards> <Card title="React" href="/docs/react" description="Browser-side hooks for the same data, RLS-bound." /> <Card title="HarnessServer" href="/docs/server" description="Framework-agnostic write handlers — the runtime-side counterpart to these read functions." /> <Card title="API routes" href="/docs/api-routes" description="The HTTP + SSE wire contract these queries observe the results of." /> <Card title="DevTools" href="/docs/devtools" description="Browser-console surface for inspecting in-memory state before it reaches these queries." /> </Cards> --- # Quickstart (/docs/quickstart) If you're adding a streaming chat surface to an existing app — Next.js, Astro, SvelteKit, Remix, Vite, Workers, Hono, plain Node — this is the shortest path. It's the same wire-level surface [Getting started](/docs/getting-started) covers; this page goes deeper on the env-var detection, the error contract, and what `useChat` actually gives you. ## One install, one env var, three files [#one-install-one-env-var-three-files] ```bash npm install @pleach/core ``` Set one provider env var: ```bash # .env (or your platform's env config) ANTHROPIC_API_KEY=sk-ant-... ``` ```typescript // app/api/chat/route.ts import { createPleachRoute } from "@pleach/core/quickstart"; export const POST = createPleachRoute(); ``` ```tsx // app/page.tsx "use client"; import { ChatBox } from "@pleach/core/quickstart"; export default function Home() { return <ChatBox apiUrl="/api/chat" />; } ``` Run `npm run dev`. You have a streaming chat against Anthropic. ## .env setup [#env-setup] The runtime checks env vars in priority order. The first one with a non-empty value wins. **Default order is alphabetical** — not preference-biased — so `ANTHROPIC_API_KEY` wins over `OPENROUTER_API_KEY` when both are set. This behavior is regression-tested at `packages/core/test/quickstart/providerDetection.test.mjs`. | Provider | Env var | | ---------- | ----------------------------------------------------------- | | Anthropic | `ANTHROPIC_API_KEY` | | DeepSeek | `DEEPSEEK_API_KEY` | | Google | `GOOGLE_GENERATIVE_AI_API_KEY` (or legacy `GOOGLE_API_KEY`) | | Mistral | `MISTRAL_API_KEY` | | Moonshot | `MOONSHOT_API_KEY` | | OpenAI | `OPENAI_API_KEY` | | OpenRouter | `OPENROUTER_API_KEY` | The seven-provider surface mirrors the `ProviderFamily` set exported from `@pleach/core/modelfamily` plus OpenRouter as a gateway transport. The `(family × callClass)` resolution matrix itself is host-supplied — reached through the `AgentAdapter.resolveModel<C>()` seam, not shipped in the package. See [Provider detection](/docs/quickstart/provider-detection) for the full resolution matrix and how to override priority. ## What you get [#what-you-get] * **Streaming response** — `<ChatBox />` renders tokens as they arrive via the `useChat` hook, which speaks Pleach's `StreamEvent` discriminated union directly. * **Multi-turn memory** — `useChat` owns a `sessionId` and reuses it across calls. Each user message appends to the same session history. * **`bot.reset()`** equivalent — in the React surface, this is `clearMessages()` on the `useChat` return; on the recipes path (below), it's `bot.reset()` or `bot.newSession()` on the `Chatbot`. * **`ChatStreamError` rejection** — when the underlying stream emits `{ type: "error" }` or the generator throws, the failure surfaces to the caller. The original cause is preserved on `error.cause`; any stream-emitted `code` is preserved on `error.code`. The factory contract is at `packages/recipes/src/chatbot.ts`. ## Troubleshooting [#troubleshooting] ### `"No provider API key found in environment."` (HTTP 503) [#no-provider-api-key-found-in-environment-http-503] The route handler fails loudly when no provider env var is set rather than silently falling back to a stub. Surface the 503 in your client; it's the signal that `ANTHROPIC_API_KEY` (or your equivalent) didn't reach the runtime. The error payload is: ```json { "error": "no_provider_detected", "detail": "No provider API key found in environment. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, or pass `provider` to createPleachRoute()." } ``` Fix: set one of the env vars from the table above, or pass `provider: "anthropic"` explicitly to `createPleachRoute({ provider })`. For keyless local dev — no env var, no 503 — pass `createPleachRoute({ demo: process.env.NODE_ENV !== "production" })`. With no key it drives a real graph turn over canned model text and writes real audit rows (response header `x-pleach-mode: demo`); a resolved key always wins, and it stays off in a production build. It's the mode `npx pleach init` scaffolds, behind an `/audit` view. ### `localStorage is not defined` [#localstorage-is-not-defined] If you see this from the runtime in an SSR / Node context (server route, edge function, test harness), it was resolved 2026-06-15 in `fix(core): generateClientId guards window.localStorage consistently (jsdom + SSR safe)`. The fix lives at `packages/core/src/utils/uuid.ts` — `generateClientId()` now checks `typeof window === "undefined" || !window.localStorage` before attempting any read. Upgrade `@pleach/core` to a version published after 2026-06-15. ### `ChatStreamError` rejection [#chatstreamerror-rejection] `Chatbot.ask()` (on the recipes path) rejects with `ChatStreamError` when the underlying stream fails. The original failure is preserved: ```typescript try { await bot.ask("hello"); } catch (err) { if (err instanceof ChatStreamError) { console.error("stream failed", err.message); console.error("root cause:", err.cause); console.error("stream code:", err.code); } } ``` Common root causes: provider API key invalid (check `.env`), provider rate limit hit, request aborted upstream. Inspect `err.cause` for the provider-level error. ## Alternative: `@pleach/recipes/chatbot` (no route handler) [#alternative-pleachrecipeschatbot-no-route-handler] If you don't need an HTTP boundary — for example you're building a CLI, a Node script, or a server-internal helper — reach for the recipes path: ```bash npm install @pleach/recipes @pleach/core ``` ```typescript import { simpleChatbot } from "@pleach/recipes/chatbot"; import { setOrchestratorAdapterCtor } from "@pleach/core/runtime"; // At app boot: register your provider adapter ctor once. setOrchestratorAdapterCtor(MyOrchestratorAdapter); const bot = simpleChatbot({ systemPrompt: "You are a helpful assistant.", orchestratorConfig: { provider: "anthropic", model: "claude-sonnet-4", apiKey: process.env.ANTHROPIC_API_KEY!, }, }); console.log(await bot.ask("hello")); console.log(await bot.ask("what did I just say?")); // same session await bot.reset(); console.log(await bot.ask("fresh conversation")); // new session ``` **Honest scope-limit:** unlike the route handler, `simpleChatbot` does NOT auto-detect provider env vars. You supply the provider explicitly via `orchestratorConfig` and register the adapter ctor at app boot. When `orchestratorConfig` is omitted, `ask()` resolves to the substrate placeholder string (`"This is a placeholder response from the harness runtime."`) — by design, so consumers without a wired provider see something deterministic rather than a crash. The `bot.runtime` field is the underlying `SessionRuntime`. Use it directly when you need plugins, checkpoint inspection, custom observers, or anything below the `ask()` surface. See [Upgrading to @pleach/core](/docs/quickstart/upgrading-to-core). ## Where to go next [#where-to-go-next] <Cards> <Card title="Provider detection" href="/docs/quickstart/provider-detection" description="Env-var resolution matrix, priority override, custom env-var aliases." /> <Card title="Upgrading to @pleach/core" href="/docs/quickstart/upgrading-to-core" description="When `simpleChatbot` isn't enough — plugins, custom tools, custom orchestratorConfig, direct SessionRuntime use." /> <Card title="Which SKU do I need?" href="/docs/which-sku" description="14 published packages, but most projects need 3 or 4." /> <Card title="Recipes" href="/docs/recipes" description="End-to-end runnable patterns — Next.js chat, OTel, multi-tenant, compliance, RAG, subagent swarms." /> </Cards> --- # React (/docs/react) The canopy — what your visitors see and touch. The React surface ships as two layered packages. > **Frontend integration is a thematic island.** Five wiring > surfaces (`react`, `server`, `api-routes`, `query`, `devtools`) > — not a three-concept cluster. See [What lives outside the > cluster pattern](/docs/concept-clusters#what-lives-outside-the-cluster-pattern). | Package | Role | Status | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `@pleach/react` | Lower-level primitive hooks — runtime acquisition, message stream, event log, interrupt UI. The building blocks the higher-level facades compose internally. | 0.1.0 · FSL-1.1-Apache-2.0 ([npm](https://www.npmjs.com/package/@pleach/react)) | | `@pleach/core/react` | Higher-level facade — `HarnessProvider`, `useHarness`, sessions, tools, sync, team, compliance, devtools. Composes the primitives into one-call-fits-most surfaces. | Back-compat shim; each `@pleach/core/react/*` module re-exports from `@pleach/react/legacy`. Shims retire at a future major. | Use the facade when you're building a typical chat UI and want batteries included. Drop down to the primitives when you need to own the runtime lifecycle, the stream state machine, or the interrupt-UI routing yourself — for example, when embedding inside an existing app framework that already owns React tree state. `@pleach/react` is a real implementation, published to npm at `0.1.0` under FSL-1.1-Apache-2.0 with a stable public surface. Pin it exactly — `^0.1.0` will **not** pick up the non-caretable `0.1.x → 1.0.0` jump (nor the `0.0.x → 0.1.0` one), matching the pin-exact policy on the [Packages](/docs/packages) status table. ## 30-second start [#30-second-start] Install the package alongside its peers: ```bash npm install @pleach/react @pleach/core react react-dom ``` Acquire a runtime, then drive a turn: ```tsx import { useState } from "react"; import { useSessionRuntime, useSessionMessageStream } from "@pleach/react"; import { createPleachRuntime } from "@pleach/core/runtime"; function Chat() { const { runtime, sessionId } = useSessionRuntime({ buildRuntime: () => createPleachRuntime({ /* config */ }), initSession: async (rt) => rt.sessions.create(), }); const { streamMessage, isStreaming } = useSessionMessageStream({ runtime, sessionId, onEvent: (event, ctx) => { // optional intercept — return { handled: true } to short-circuit. // `ctx.accumulator` carries the post-append streaming buffer. }, }); const [input, setInput] = useState(""); return ( <form onSubmit={(e) => { e.preventDefault(); streamMessage({ content: input }); }}> <input value={input} onChange={(e) => setInput((e.target as unknown as { value: string }).value)} disabled={isStreaming} /> {/* render the accumulator via your onEvent reducer; gate UI on isStreaming */} </form> ); } ``` `@pleach/react` does NOT ship a provider — `useSessionRuntime` owns the construct-once / cleanup-on-unmount lifecycle behind a ref, and `useSessionMessageStream` takes a single config object whose `runtime` + `sessionId` are nullable until `useSessionRuntime` resolves. Drop down to a context-based provider only when you need the legacy `@pleach/react/legacy` `HarnessProvider` facade (re-export shim for `@pleach/core/react/*`). See the [Primitives](#primitives-pleachreact) and [Facade](#facade-pleachcorereact) sections below; the [`@pleach/react` README](https://github.com/pleachhq/react) is the canonical surface reference. ## Peer dependency contract [#peer-dependency-contract] `@pleach/react` declares `react` and `react-dom` as **peer** dependencies, not dependencies. Install them explicitly in the consumer app. Two copies of React in one bundle break hook calls at runtime — the peer-dep boundary is what keeps that from happening silently. `@pleach/core` is also a peer. The hooks call into the core runtime directly, so the consumer app owns the core install and pins it to a version range the package supports. Mismatched cores produce type-level drift today and runtime drift once the wire shape evolves. Peer-dep ranges tighten at minor bumps. Pin via `^` on the major version in consumer apps, then re-verify the declared peer ranges at each minor bump of `@pleach/react` — the [Packages](/docs/packages) status table tracks current ranges per release. ### Standalone `@pleach/core` install is safe [#standalone-pleachcore-install-is-safe] The peer-dep relationship is one-directional. `@pleach/react` declares `@pleach/core` as a peer — the hooks call into the core runtime, so the consumer pins core. The reverse isn't true: `@pleach/core` can be installed without `@pleach/react`. Server-only consumers (background workers, evaluation harnesses, CLI tools) routinely install just `@pleach/core` and never touch the React surface. `import "@pleach/core"` loads cleanly with no React peer present. The React hooks remain available at the `@pleach/core/react` subpath. Consumers who want hooks import from that subpath and install the `@pleach/react` peer explicitly: ```bash # Server-only — no React peer needed npm install @pleach/core # UI consumer — install the peer npm install @pleach/core @pleach/react react react-dom ``` ## Surface at a glance [#surface-at-a-glance] ```typescript // Primitives — @pleach/react (1.0) import { useSessionRuntime } from "@pleach/react/hooks/useSessionRuntime"; import { useSessionMessageStream } from "@pleach/react/hooks/useSessionMessageStream"; import { useEventLog } from "@pleach/react/hooks/useEventLog"; import { useInterruptUI } from "@pleach/react/hooks/useInterruptUI"; import { createCorrectionDedup } from "@pleach/react/utils/correctionDedup"; import { createStreamLock } from "@pleach/react/utils/streamLock"; // Facade — @pleach/core/react (1.x with back-compat shim) import { HarnessProvider, useHarness, useHarnessContext, useRuntime, useIsEnterpriseEnabled, useSessionList, useSessionCheckpoints, useTools, useToolsByCategory, useTool, useToolValidation, useSyncStatus, useIsSynced, usePendingChanges, useTeam, useCompliance, useHarnessDevTools, updateDevToolsSession, createCorrectionDedup, createDefaultCorrectionDedup, } from "@pleach/core/react"; ``` <SourceMeta subpath="@pleach/react" source="{ label: "src/", href: "https://github.com/pleachhq/react/tree/main/src" }" /> ## Primitives: `@pleach/react` [#primitives-pleachreact] Four hooks plus two utils. Each one owns one substrate concern. ### `useSessionRuntime` [#usesessionruntime] Constructs the runtime exactly once on mount, optionally binds a session, and exposes a `ready` promise. The factory is **not** re-invoked across renders — swap the runtime by unmounting and remounting the owner. ```tsx const { runtime, sessionId, ready } = useSessionRuntime({ buildRuntime: () => new SessionRuntime({ storage: new IndexedDBAdapter({ databaseName: "pleach" }), tenantId, }), initSession: async (rt) => (await rt.createSession()).id, onError: (err) => reportToSentry(err), }); // runtime + sessionId are null until the mount effect commits. // `ready` resolves when both are live. ``` | Field | Type | Notes | | ----------- | ------------------------ | ----------------------------------------------------------- | | `runtime` | `SessionRuntime \| null` | `null` until first commit; stable identity after | | `sessionId` | `string \| null` | Result of `initSession`; `null` permanently when omitted | | `extras` | `TExtras \| null` | Side artifacts when `buildRuntime` returns a wrapped result | | `ready` | `Promise<void>` | Resolves after construct + bind; rejects on either failure | ### `useSessionMessageStream` [#usesessionmessagestream] Fires one turn at a time against the active runtime/session, serializing concurrent calls through a per-component stream lock. The hook drives the message-state machine; the consumer owns the UI reduction via `onEvent`. ```tsx const { streamMessage, abort, isStreaming, isReasoning, reasoningText, } = useSessionMessageStream({ runtime, sessionId, onEvent: (event, ctx) => dispatch({ type: "STREAM_EVENT", event }), onTurnComplete: (ctx) => track("turn.complete", { events: ctx.events.length }), onError: (err) => dispatch({ type: "STREAM_ERROR", err }), }); await streamMessage({ content: "Hello" }); ``` `streamMessage` resolves cleanly whether the turn returned or aborted; errors route through `onError`, so a straight-line `await` works without per-call `try/catch`. `abort()` cancels the in-flight stream without firing `onError` — aborts are the explicit cancellation path. **Reasoning traces.** `isReasoning` is `true` while a reasoning model streams its chain-of-thought (the `reasoning.delta` [events](/docs/stream-events#reasoning-traces-reasoningdelta)) before the answer begins; it flips back to `false` on the first answer delta. `reasoningText` accumulates that chain-of-thought for the current turn — never answer content — and is retained after the answer starts so you can render a collapsible "thinking" disclosure. Both reset at the next turn: ```tsx {isReasoning && <Spinner label="Thinking…" />} {reasoningText && ( <details> <summary>Thought process</summary> <pre>{reasoningText}</pre> </details> )} ``` `content.reset` events are deduped inside a 200ms window by default; tune via `dedupeWindowMs`. The stream-lock drain timeout defaults to 120s; tune via `streamLockTimeoutMs`. ### `useEventLog` [#useeventlog] A facade over a caller-owned `EventLogClient`. Returns a stable `log(event)` callback you can pass through memo deps without invalidating downstream callbacks every render. ```tsx const { log, flush } = useEventLog({ client: eventLogClient, // or `clientRef: { current: ... }` for // refs constructed inside a `useEffect` onEvent: (e) => devtools.push(e), }); log({ type: "ui.bubble.copied", messageId }); await flush(); // best-effort drain before unmount ``` Pass `client: null` while the client is being constructed — `log` / `flush` no-op gracefully. The `clientRef` overload is the path when construction happens inside `useEffect` and the component doesn't re-render on the `null → populated` transition. ### `useInterruptUI` [#useinterruptui] Subscribes to the runtime's interrupt stream and routes each `PendingInterrupt` to the matching plugin-contributed handler. Plugins contribute handlers via `pluginManager.collectInterruptUIHandlers()`; the first handler whose `interruptType` matches wins. ```tsx const { activeInterrupts, renderActive, resolveInterrupt, cancelInterrupt } = useInterruptUI({ runtime, handlers: pluginManager.collectInterruptUIHandlers(), onInterrupt: (i) => track("interrupt.shown", { type: i.toolCall?.name }), onResolve: (id, decision) => eventLog.log({ type: "interrupt.resolved", id, decision }), onCancel: (id) => eventLog.log({ type: "interrupt.cancelled", id }), onTimeout: (id) => eventLog.log({ type: "interrupt.timeout", id }), }); return <>{renderActive()}</>; ``` The hook accepts `runtime: null` and no-ops until a runtime arrives — pair it with `useSessionRuntime` without ordering gymnastics. ### Internal-but-exposed helpers [#internal-but-exposed-helpers] `applyRoutingScaffoldCase` and `isWrappedBuildResult` are exported from the top-level `@pleach/react` barrel and from `@pleach/react/hooks` (7 exports there total) because the H-7 ladder still has callers in existing host code that depend on the helpers during the multi-session cutover. They are NOT part of the supported consumer API: shape may change in any 1.x minor release, and the pre-merge `audit:streamSingleTurn-consumer-surface` keeps that invariant honest. Consumers building against the public surface should reach for `useSessionMessageStream` (which wraps both) rather than the raw helpers. ### Utils [#utils] | Util | Signature | Use case | | ----------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `createCorrectionDedup` | `(config) => (ts, lastTs, lastReason, newReason) => boolean` | Factory returning a predicate over **correction reasons + timestamps** (NOT content payloads). Allows when (a) outside the `windowMs` window, OR (b) the new reason has strictly higher priority than the previous reason per `priorityMap`. `createDefaultCorrectionDedup(windowMs?)` is a convenience baseline that dedupes only same-reason emits inside the window. | | `createStreamLock` | `() => { acquire, waitForDrain, isHeld }` | Factory returning a single-slot async mutex. `acquire()` returns a `{ release }` handle; `waitForDrain(timeoutMs?)` returns `{ timedOut }`; `isHeld()` is a synchronous diagnostic. Use when composing a bespoke streaming surface that wants the same drain semantics `useSessionMessageStream` uses internally. | ### Composing the primitives [#composing-the-primitives] The three primary hooks work together — `useSessionRuntime` owns the lifecycle, `useSessionMessageStream` owns the turn, `useInterruptUI` owns the human-in-the-loop: ```tsx function Chat({ tenantId }: { tenantId: string }) { const { runtime, sessionId } = useSessionRuntime({ buildRuntime: () => new SessionRuntime({ storage: new IndexedDBAdapter({ databaseName: "pleach" }), tenantId, }), initSession: async (rt) => (await rt.createSession()).id, }); const [messages, setMessages] = useState<Message[]>([]); const { streamMessage, isStreaming } = useSessionMessageStream({ runtime, sessionId, onEvent: (e) => setMessages((prev) => reduceMessage(prev, e)), }); const { renderActive } = useInterruptUI({ runtime, handlers: interruptHandlers, }); return ( <> <Transcript messages={messages} /> {renderActive()} <Composer disabled={isStreaming} onSend={(text) => streamMessage({ content: text })} /> </> ); } ``` No provider, no global state — each component owns its own runtime. Pair with a parent component that holds the runtime in context when multiple children need to share it. ## Facade: `@pleach/core/react` [#facade-pleachcorereact] One provider mounted near the root, plus a set of focused hooks that compose the primitives above into the typical chat-UI surface. Hooks return stable references — safe to destructure at the top of a component. <SourceMeta subpath="@pleach/core/react" source="{ label: "src/react/", href: "https://github.com/pleachhq/core/tree/main/src/react" }" /> ### `HarnessProvider` [#harnessprovider] Mounts the runtime into the React tree. Every hook below requires it as an ancestor. ```tsx // app/page.tsx 'use client'; import { SessionRuntime, AiSdkProvider } from "@pleach/core"; import { IndexedDBAdapter } from "@pleach/core/sessions"; import { HarnessProvider } from "@pleach/core/react"; import { createOpenRouter } from "@openrouter/ai-sdk-provider"; const openrouter = createOpenRouter({ apiKey: process.env.NEXT_PUBLIC_OPENROUTER_API_KEY! }); const runtime = new SessionRuntime({ storage: new IndexedDBAdapter({ databaseName: "pleach" }), provider: new AiSdkProvider({ model: openrouter("anthropic/claude-sonnet-4-5"), maxSteps: 5, }), userId: "user_123", }); export function App() { return ( <HarnessProvider runtime={runtime}> <Chat /> </HarnessProvider> ); } ``` Construct the runtime once outside the React render tree (or inside a `useMemo` with stable deps) so it isn't re-created per render. ### Low-level escape hatches [#low-level-escape-hatches] | Hook | Returns | Use when | | -------------------------- | ------------------------- | --------------------------------------- | | `useHarnessContext()` | Raw context value | Building custom hooks | | `useRuntime()` | The bare `SessionRuntime` | Imperative calls that don't have a hook | | `useIsEnterpriseEnabled()` | `boolean` | Gating extension-only UI | Prefer the higher-level hooks below for typical UI work. ### `useHarness` [#useharness] The primary chat hook. Returns messages, send / create / abort helpers, and a `SyncStatus`. ```tsx function Chat() { const { messages, sendMessage, createSession, abort, isStreaming, syncStatus, session, } = useHarness(); return ( <div> {messages.map((m) => ( <Bubble key={m.id} message={m} /> ))} <Composer onSend={(text) => sendMessage(text)} disabled={isStreaming} /> {isStreaming && <button onClick={abort}>Stop</button>} <SyncPill status={syncStatus} /> </div> ); } ``` | Field | Type | | --------------- | ------------------------------------------- | | `messages` | `Message[]` — live transcript | | `session` | `SessionState \| null` | | `sendMessage` | `(content: string, opts?) => Promise<void>` | | `createSession` | `(config?) => Promise<SessionState>` | | `abort` | `() => void` | | `isStreaming` | `boolean` | | `syncStatus` | `SyncStatus` — coarse status pill | `sendMessage` triggers `executeMessage` under the hood; the hook reduces stream events into `messages` for you. ### Session-list hooks [#session-list-hooks] For sidebars, inboxes, multi-session UIs. Read-only and live — subscribe to storage updates so you don't have to refetch. ```tsx function Sidebar() { const { sessions, isLoading } = useSessionList({ filter: { userId: currentUser.id }, limit: 50, }); if (isLoading) return <Skeleton />; return ( <ul> {sessions.map((s) => ( <li key={s.id}> <a href={`/c/${s.id}`}>{s.title ?? "Untitled"}</a> </li> ))} </ul> ); } ``` ```tsx function CheckpointPicker({ sessionId }: { sessionId: string }) { const checkpoints = useSessionCheckpoints(sessionId); return ( <select> {checkpoints.map((cp) => ( <option key={cp.id} value={cp.id}> {cp.stageId} — {new Date(cp.createdAt).toLocaleTimeString()} </option> ))} </select> ); } ``` ### Tool hooks [#tool-hooks] Read-only views over the registered tool registry. Do not invoke tools — they're for command palettes, schema-driven forms, and per-tool settings. | Hook | Returns | Use case | | ------------------------- | ----------------------------------- | ----------------------------------------------------------------- | | `useTools(opts?)` | Full tool list with optional filter | Command palette | | `useToolsByCategory()` | Tools grouped by category | Settings panel sections | | `useTool(name)` | Single tool definition | Per-tool detail view | | `useToolValidation(name)` | `(args) => ValidationResult` | Validate form args against the tool's JSON schema before dispatch | ```tsx function ToolPalette() { const { tools } = useTools({ enabledOnly: true }); return tools.map((t) => <ToolCard key={t.name} tool={t} />); } function ToolArgsForm({ toolName }: { toolName: string }) { const validate = useToolValidation(toolName); const [args, setArgs] = useState({}); const result = validate(args); return ( <form> <SchemaForm value={args} onChange={setArgs} /> {!result.ok && <ValidationErrors errors={result.errors} />} </form> ); } ``` ### Sync hooks [#sync-hooks] Coarse and fine-grained views over the sync coordinator. | Hook | Returns | Use case | | ---------------------- | ------------------------------------------- | ------------------------ | | `useSyncStatus(opts?)` | `{ stats: SyncStats; errors: SyncError[] }` | Diagnostics panel | | `useIsSynced()` | `boolean` | "All changes saved" pill | | `usePendingChanges()` | `number` | Unsaved-changes guard | ```tsx function SavedPill() { const synced = useIsSynced(); return <span>{synced ? "Saved" : "Saving…"}</span>; } function NavGuard() { const pending = usePendingChanges(); useBeforeUnload(pending > 0, "You have unsaved changes."); return null; } ``` ### `useTeam` [#useteam] Multi-user presence + cursor info for collaborative session views. ```tsx function PresenceBar({ sessionId }: { sessionId: string }) { const { members, cursors } = useTeam({ sessionId }); return ( <div> {members.map((m) => ( <Avatar key={m.userId} user={m} active={m.lastSeenMs < 5000} /> ))} </div> ); } ``` Pair with `useSyncStatus` for the full real-time picture. ### `useCompliance` [#usecompliance] Read-only view over the runtime's compliance surface — current PII redaction policy, GDPR soft-delete status, the tamper-evident hash chain head. Use it to surface compliance state in admin panels and audit views; pair with `@pleach/compliance` when the sibling SKU lands for richer policy control. ```tsx import { useCompliance } from "@pleach/core/react"; function ComplianceBadge() { const compliance = useCompliance({ sessionId, compliance: complianceExtension }); return <span>audit records: {compliance.auditRecordCount}</span>; } ``` `UseComplianceReturn` is the typed shape; treat the return as opaque until the policy surface stabilizes for 1.0. ### Correction dedup [#correction-dedup] `createCorrectionDedup({ priorityMap, defaultPriority, windowMs })` returns a closure that decides whether an incoming `content.correction` should be allowed past the dedup window. Lift this into any consumer that fans correction events into a single rendering surface — it's the same dedup the substrate uses internally. ```typescript import { createCorrectionDedup, createDefaultCorrectionDedup } from "@pleach/core/react"; const shouldAllow = createCorrectionDedup({ priorityMap: { "tool.input": 10, "tool.output": 5 }, defaultPriority: 1, windowMs: 500, }); // or the defaults: const shouldAllowDefault = createDefaultCorrectionDedup(); ``` `CorrectionPriorityMap`, `CorrectionDedupConfig`, and `ShouldAllowContentCorrectionFn` are the typed surfaces. ### `useHarnessDevTools` [#useharnessdevtools] Wires `window.__HARNESS_DEVTOOLS__` to the active runtime so you can drive checkpoints, sync, and tool inspection from the browser console. ```tsx import { useHarnessDevTools } from "@pleach/core/react"; function App() { if (process.env.NODE_ENV !== "production") { // eslint-disable-next-line react-hooks/rules-of-hooks useHarnessDevTools(); } return <HarnessProvider runtime={runtime}>...</HarnessProvider>; } ``` The exposed surface: ```javascript __HARNESS_DEVTOOLS__.session; // SessionState __HARNESS_DEVTOOLS__.checkpoints(); // Checkpoint[] __HARNESS_DEVTOOLS__.rollback("cp_..."); // restore to checkpoint __HARNESS_DEVTOOLS__.tools(); // ToolDefinition[] __HARNESS_DEVTOOLS__.syncStatus(); // sync coordinator status __HARNESS_DEVTOOLS__.forceSync(); // push a sync now ``` `HarnessDevToolsAPI` is exported for typing `window.__HARNESS_DEVTOOLS__` in a `.d.ts` shim. `updateDevToolsSession(state)` is the manual-push API for forcing a session-state refresh into the devtools surface from outside the React render cycle. ## Minimal end-to-end example [#minimal-end-to-end-example] ```tsx 'use client'; import { useMemo } from "react"; import { SessionRuntime } from "@pleach/core"; import { IndexedDBAdapter } from "@pleach/core/sessions"; import { IndexedDBSaver } from "@pleach/core/checkpointing"; import { HarnessProvider, useHarness, } from "@pleach/core/react"; function App({ userId }: { userId: string }) { const runtime = useMemo( () => new SessionRuntime({ storage: new IndexedDBAdapter({ databaseName: "pleach" }), checkpointer: new IndexedDBSaver({ databaseName: "pleach" }), userId, }), [userId], ); return ( <HarnessProvider runtime={runtime}> <Chat /> </HarnessProvider> ); } function Chat() { const { messages, sendMessage, isLoading } = useHarness(); return ( <> {messages.map((m) => ( <p key={m.id}>{m.content}</p> ))} <button disabled={isLoading} onClick={() => sendMessage("Hello")} > Send </button> </> ); } ``` ## Local playground — `<PleachLab>` [#local-playground--pleachlab] The `@pleach/react/lab` subpath ships a zero-config split-pane local playground: a render-isolated chat on the left, a live execution visualizer on the right. Drop it on a page, hand it a runtime factory, and watch the graph run. ```tsx import { PleachLab } from "@pleach/react/lab"; <PleachLab buildRuntime={() => new SessionRuntime(/* … */)} /> ``` The right pane visualizes execution at three depths, each folded purely from the agnostic `StreamEvent` stream via `useLatticeState` — no privileged hooks. * **Stages** — the 4-stage lattice (`anchor · plan → tool loop → synthesize → post-turn`). The dominant stage lights; minority stages glow dimmer; self-loops show a `↻`. * **Nodes** — each graph-node chip glows on `node.fired` and fades over the next few firings, so you see *which* node ran, not just which stage is active. * **Channels** — a writer/clearer lane tickers each `channel.write` (version bump) and `channel.cleared` (ephemeral step-boundary reset), with a per-turn "hot channels" leaderboard. That's the subscription substrate that drives the next superstep. Because the visualizer is `StreamEvent`-driven, the same `node.fired` / `channel.write` / `channel.cleared` events are subscribable from any `useChat({ onEvent })` consumer — or asserted in a headless test (see [Testing](/docs/testing#headless-turn-driver-pleachcoretesting)). Mount `LatticePanel` / `ChannelActivityStrip` standalone next to your own chat UI when you don't want the full shell. ## Where to go next [#where-to-go-next] <Cards> <Card title="HarnessServer" href="/docs/server" description="Framework-agnostic HTTP handlers — what the hooks call into when the runtime lives on a server." /> <Card title="API routes" href="/docs/api-routes" description="The HTTP + SSE wire contract these hooks consume." /> <Card title="Query" href="/docs/query" description="Server-side read API over persisted harness data — the RLS-bypassing counterpart to these RLS-bound hooks." /> <Card title="DevTools" href="/docs/devtools" description="window.__HARNESS_DEVTOOLS__ — the browser-console surface useHarnessDevTools wires." /> </Cards> --- # @pleach/recipes (/docs/recipes-pleach-recipes) `@pleach/recipes` is the use-case-targeted composition layer over `@pleach/core`. Each subpath export is a one-line factory that returns a wrapper around a real [`SessionRuntime`](/docs/session-runtime). Reach into `bot.runtime` when you need the full primitive surface; reach for the recipe when the boilerplate is what's in the way. Each recipe lives at its own subpath so consumers only pay the import cost for what they use. ```bash npm install @pleach/recipes @pleach/core ``` Optional peers — install only the ones a recipe needs: ```bash npm install @pleach/observe # for observability + coding-agent recipes npm install @pleach/compliance # for compliance recipe (v0.2) npm install @pleach/coding-agent # for instrumentedCodingAgent npm install @pleach/sandbox # for instrumentedCodingAgent ``` ## simpleChatbot [#simplechatbot] A minimal conversational agent. Composes only `@pleach/core`; no sibling peers required. The factory lazily creates a session on first `ask()` and reuses it on subsequent calls so a single `simpleChatbot` instance holds conversational state. ```ts import { simpleChatbot } from "@pleach/recipes/chatbot"; import { setOrchestratorAdapterCtor } from "@pleach/core/runtime"; import { MyOrchestratorAdapter } from "./my-provider"; // your adapter // Register the OrchestratorAdapter ctor once at app boot. The recipes // package does NOT bundle a provider — supply your own (OpenAI, // Anthropic, OpenRouter, BYOK, etc.). setOrchestratorAdapterCtor(MyOrchestratorAdapter); const bot = simpleChatbot({ systemPrompt: "You are a helpful assistant.", orchestratorConfig: { provider: "openai", model: "gpt-4o-mini", apiKey: process.env.OPENAI_API_KEY!, }, }); console.log(await bot.ask("hello")); console.log(await bot.ask("what did I just say?")); ``` When `orchestratorConfig` is omitted, `ask()` resolves to the substrate placeholder (`"This is a placeholder response from the harness runtime."`). Supplying `orchestratorConfig` routes the call through your registered `OrchestratorAdapter`. `ask()` returns the final assistant text. It collects the `message.delta` and `message.complete` events from `runtime.executeMessage()` and concatenates the deltas; streaming consumers should reach into `bot.runtime` directly. A single `bot` instance holds the session across `ask()` calls. Call `await bot.reset()` (or its alias `await bot.newSession()`) to discard the memoized session id; the next `ask()` lazily creates a fresh session via `runtime.createSession()`. If the underlying stream emits an `{ type: "error" }` event or throws, `ask()` rejects with a `ChatStreamError` and preserves the original failure on `error.cause` — branch on `cause` to distinguish provider errors from abort signals from network timeouts. The same session API and error type are inherited by `ragChatbot`, `observableChatbot`, and `compliantChatbot`. Reach for this when you want a working chatbot in under ten lines and don't yet need retrieval, observability, or compliance. ## ragChatbot [#ragchatbot] A chatbot plus retrieval stage. The consumer supplies the `Retriever` — the recipe is storage-agnostic on purpose; it does not bind a vector store. ```ts import { ragChatbot } from "@pleach/recipes/rag"; const bot = ragChatbot({ retriever: async (query, opts) => myVectorDb.search(query, { topK: opts?.topK ?? 4 }), }); console.log(await bot.ask("what's our refund policy?")); ``` Per turn, `ask()` calls the retriever, prepends the top-K chunks to the user message as context with stable `[N]` indices for citation, and delegates to the underlying `simpleChatbot`. When the retriever returns no chunks or throws, the message passes through unmodified — the bot stays useful if retrieval is down. Reach for this when answers must be grounded in your documents and you already have a vector store or full-text index. Bring your own retrieval; the recipe stays out of that decision. ## observableChatbot [#observablechatbot] A chatbot wrapped with `@pleach/observe` sub-agent attribution. Every `recordCall(...)` issued during `ask()` inherits the outer `subagent` path — no recorder argument threaded through call sites. ```ts import { init } from "@pleach/observe"; import { memory } from "@pleach/observe/destinations"; import { observableChatbot } from "@pleach/recipes/observability"; const dest = memory(); init({ destination: dest }); const bot = observableChatbot({ serviceName: "my-app-chatbot", }); await bot.ask("hello"); console.log(dest.rows); // rows tagged subagent: "my-app-chatbot" ``` `@pleach/observe` is an optional peer. If it's not installed, `ask()` still works as a plain chatbot — instrumentation just doesn't fire. Set `serviceName: false` to disable the ALS scope when composing inside a buyer's outer scope. Reach for this when traces, prompt/completion logs, and cost attribution are required on every turn and the boilerplate of threading the recorder through call sites isn't earning its keep. ## compliantChatbot [#compliantchatbot] A chatbot wrapped with PII / PHI scrubbing at the message-content boundary. As of v0.2 the `profile` field routes to a curated bundle from `@pleach/compliance/scrubbers` and applies it (plus any `extraScrubbers`) to the user message string before `ask()` forwards it to the runtime. ```ts import { compliantChatbot } from "@pleach/recipes/compliance"; const bot = compliantChatbot({ profile: "hipaa" }); await bot.ask("patient called about prescription refill"); ``` Profile → scrubber bundles: * `hipaa` → `SsnUsScrubber` + `UsDriverLicenseScrubber` + `CreditCardScrubber` * `gdpr` → `SsnUsScrubber` + `CreditCardScrubber` * `pci-dss` → `CreditCardScrubber` * `soc2` → `SsnUsScrubber` + `CreditCardScrubber` **Scope-limit (v0.2).** Scrubbing is applied at the message-content boundary, NOT at the durable `EventLogWriter` boundary (next iteration). Assistant responses are not currently scrubbed. EventLogWriter-boundary scrubbing is substrate-ready — `@pleach/core`'s `EventLogWriter` already accepts a `scrubbers: readonly Scrubber[]` constructor option with a `DefaultScrubberChain` enforced at every `write()`, and the `host.modules.eventLogWriter` parameter on `createPleachRuntime` plumbs a custom writer through. Consumers needing full hash-chain attestation + `attestRun` today should construct `ComplianceRuntime` directly from `@pleach/compliance`. Reach for this when HIPAA, GDPR, PCI-DSS, or SOC 2 requires the event log persisted or exported to be free of unredacted PII. ## instrumentedCodingAgent [#instrumentedcodingagent] A coding agent whose `executeStep(...)` calls automatically inherit a `@pleach/observe` sub-agent attribution path. Composes `@pleach/coding-agent`, `@pleach/sandbox`, and `@pleach/observe`. ```ts import { init } from "@pleach/observe"; import { memory } from "@pleach/observe/destinations"; import { createInMemorySandboxProvider } from "@pleach/sandbox/testing"; import { instrumentedCodingAgent } from "@pleach/recipes/coding-agent"; const dest = memory(); init({ destination: dest }); const agent = instrumentedCodingAgent({ sandboxProvider: createInMemorySandboxProvider({ execHandlers: new Map() }), subagent: "coding-agent", }); await agent.start(); try { const result = await agent.executeStep({ userMessage: "fix the failing test in lib/parser.ts", stepId: "step-1", }); console.log(result.ok, dest.rows); // rows tagged subagent: "coding-agent" } finally { await agent.stop(); } ``` Compose with outer scopes for nested attribution: ```ts import { subagent } from "@pleach/observe"; await subagent("tenant-abc").run(async () => { await agent.executeStep({...}); // path: ["tenant-abc", "coding-agent"] }); ``` `init({ destination })` from `@pleach/observe` must be called once at process start before any `executeStep`; the SDK is singleton-per-process. Reach for this when each turn decomposes into `planner → executor → critic` sub-roles and every LLM call needs to surface on the dashboard tagged by the sub-role that issued it. ## Composing recipes by hand [#composing-recipes-by-hand] Every recipe returns a `Chatbot` with a `runtime` escape hatch: ```ts interface Chatbot { readonly runtime: SessionRuntime; ask(message: string): Promise<string>; newSession(): Promise<void>; reset(): Promise<void>; } ``` When you need a shape the recipe doesn't cover — multi-session state, streaming to a UI, custom interrupt handling — drop down to `bot.runtime` and use [`executeMessage`](/docs/session-runtime) directly. Recipes are convenience over `@pleach/core`; nothing they wrap is hidden. --- # Recipes (/docs/recipes) Patterns the gardener follows. Ten complete-enough-to-crib implementations covering the consumer-facing patterns most products land on: Next.js streaming chat, custom [tools](/docs/tools), custom [storage](/docs/storage), BYOK provider routing, moderation, multi-tenant, compliance, custom projections, hash-chain verification, and a regulated-host end-to-end. Each recipe links back to the reference page for the primitives it builds on. For platform-team patterns (long-running jobs, multi-step [interrupts](/docs/interrupts), per-call cost reporting, OTel), see [Platform & operations recipes](/docs/platform-recipes). | Recipe | Centers on | | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | | [1. Next.js App Router chat with streaming](#1-nextjs-app-router-chat-with-streaming) | `@pleach/core/react` + `HarnessProvider` | | [2. Custom tool: weather lookup with caching](#2-custom-tool-weather-lookup-with-caching) | `defineTool` + `ctx.signal` | | [3. Custom storage adapter: SQLite](#3-custom-storage-adapter-sqlite) | `StorageAdapter` + optimistic concurrency | | [4. BYOK: per-tenant provider credentials](#4-byok-per-tenant-provider-credentials) | Per-request `AnthropicSdkProvider` | | [5. Moderation pipeline with safety policies](#5-moderation-pipeline-with-safety-policies) | `contributeSafetyPolicies` + stream observers | | [6. Multi-tenant runtime with the runtime.tenant facet](#6-multi-tenant-runtime-with-the-runtimetenant-facet) | `tenantId` facet + RLS | | [7. Adopting `@pleach/compliance` — scrubbers + audit gates](#7-adopting-pleachcompliance--scrubbers--audit-gates) | `complianceProfilePlugin` + CI gates | | [8. Building a custom event-log projection](#8-building-a-custom-event-log-projection) | `GraphProjection<T>` fold | | [9. Verifying the hash chain (manual SQL pass)](#9-verifying-the-hash-chain-manual-sql-pass) | `harness_event_log` recursive CTE | | [10. Regulated host: attestation + hash chain + scrubbers + audit ledger end-to-end](#10-regulated-host-attestation--hash-chain--scrubbers--audit-ledger-end-to-end) | `withAttestation` + `c9PhaseBEnabled` | ## 1. Next.js App Router chat with streaming [#1-nextjs-app-router-chat-with-streaming] The canonical pattern. SSE-streamed chat with persisted sessions, running on Fluid Compute. ### `lib/runtime.ts` [#libruntimets] ```typescript // lib/runtime.ts import { SessionRuntime, AiSdkProvider } from "@pleach/core"; import { SupabaseAdapter } from "@pleach/core/sessions"; import { SupabaseSaver } from "@pleach/core/checkpointing"; import { createOpenRouter } from "@openrouter/ai-sdk-provider"; const openrouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY! }); import { createClient } from "@supabase/supabase-js"; export function buildRuntime(userId: string, orgId?: string) { const supabase = createClient( process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, ); return new SessionRuntime({ storage: new SupabaseAdapter({ client: supabase }), checkpointer: new SupabaseSaver({ client: supabase }), provider: new AiSdkProvider({ model: openrouter("anthropic/claude-sonnet-4-5"), maxSteps: 5, }), userId, organizationId: orgId, metaToolNames: new Set(["set_step_complete", "wait_for_jobs"]), }); } ``` ### `app/api/chat/route.ts` [#appapichatroutets] ```typescript // app/api/chat/route.ts import { auth } from "@/lib/auth"; import { buildRuntime } from "@/lib/runtime"; export async function POST(req: Request) { const session = await auth.verifyRequest(req); if (!session) return new Response("Unauthorized", { status: 401 }); const { sessionId, content } = await req.json(); const runtime = buildRuntime(session.userId, session.orgId); const stream = new ReadableStream({ async start(controller) { try { for await (const event of runtime.executeMessage(sessionId, content)) { controller.enqueue(`data: ${JSON.stringify(event)}\n\n`); } } finally { controller.close(); } }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", "Connection": "keep-alive", "X-Accel-Buffering": "no", }, }); } ``` ### `app/(chat)/page.tsx` [#appchatpagetsx] ```tsx // app/(chat)/page.tsx "use client"; import { useMemo } from "react"; import { SessionRuntime } from "@pleach/core"; import { HarnessProvider, useHarness } from "@pleach/core/react"; export default function ChatPage({ userId }: { userId: string }) { const runtime = useMemo( () => new SessionRuntime({ // Client-side runtime is for streaming consumption; server // owns the actual storage. This client doesn't need a real // adapter — it reads via the API route. userId, }), [userId], ); return ( <HarnessProvider runtime={runtime} apiBase="/api/chat"> <Chat /> </HarnessProvider> ); } function Chat() { const { messages, sendMessage, isLoading } = useHarness(); return ( <div> {messages.map((m) => <Bubble key={m.id} message={m} />)} <Composer onSend={sendMessage} disabled={isLoading} /> </div> ); } ``` See [API routes](/docs/api-routes), [React](/docs/react), and [Deployment](/docs/deployment) for the underlying contracts. ## 2. Custom tool: weather lookup with caching [#2-custom-tool-weather-lookup-with-caching] A tool that hits an external API, honors the abort signal, and returns a typed result. ```typescript // lib/tools/lookupWeather.ts import { defineTool } from "@pleach/core"; import { z } from "zod"; const cache = new Map<string, { ts: number; value: WeatherResult }>(); const TTL_MS = 5 * 60_000; export const lookupWeather = defineTool({ name: "lookup_weather", description: "Current weather conditions for a city.", inputSchema: z.object({ city: z.string().min(1), country: z.string().length(2).optional(), }), outputSchema: z.object({ tempC: z.number(), conditions: z.string(), humidity: z.number(), }), async execute(input, ctx) { const key = `${input.city}|${input.country ?? ""}`.toLowerCase(); const cached = cache.get(key); if (cached && Date.now() - cached.ts < TTL_MS) { return cached.value; } const url = new URL("https://api.example.com/weather"); url.searchParams.set("q", input.city); if (input.country) url.searchParams.set("country", input.country); const res = await fetch(url, { headers: { "X-API-Key": process.env.WEATHER_API_KEY! }, signal: ctx.signal, }); if (!res.ok) { throw new Error(`Weather API ${res.status}: ${res.statusText}`); } const data = await res.json(); const value = { tempC: data.temperature, conditions: data.description, humidity: data.humidity, }; cache.set(key, { ts: Date.now(), value }); return value; }, }); ``` Three things worth noting: 1. The cache is a process-local Map — fine for a single-instance deployment; for horizontal scale, swap in Redis. 2. `ctx.signal` threads into the `fetch` call. The user pressing stop aborts the HTTP request. 3. The output schema validates the return shape. A misbehaving upstream that returns malformed data trips this validator, surfacing as `tool.failed` instead of poisoning the conversation. See [Tools](/docs/tools) for the full `defineTool` contract. ## 3. Custom storage adapter: SQLite [#3-custom-storage-adapter-sqlite] A storage adapter that persists to SQLite via `better-sqlite3`. Useful for single-instance deployments and desktop apps (Electron, Tauri). ```typescript // lib/storage/sqliteAdapter.ts import Database from "better-sqlite3"; import type { StorageAdapter, SessionState } from "@pleach/core"; export class SqliteAdapter implements StorageAdapter { private db: Database.Database; constructor(opts: { path: string }) { this.db = new Database(opts.path); this.db.exec(` CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, state TEXT NOT NULL, version INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id, updated_at DESC); `); } async createSession(state: SessionState): Promise<void> { this.db.prepare(` INSERT INTO sessions (id, user_id, organization_id, state, version, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) `).run( state.id, state.userId, state.organizationId ?? null, JSON.stringify(state), state.version, state.createdAt.getTime(), state.updatedAt.getTime(), ); } async getSession(sessionId: string): Promise<SessionState | null> { const row = this.db .prepare("SELECT state FROM sessions WHERE id = ?") .get(sessionId) as { state: string } | undefined; if (!row) return null; return reviveDates(JSON.parse(row.state)); } async updateSession(sessionId: string, state: SessionState): Promise<void> { const result = this.db.prepare(` UPDATE sessions SET state = ?, version = ?, updated_at = ? WHERE id = ? AND version = ? `).run( JSON.stringify(state), state.version, state.updatedAt.getTime(), sessionId, state.version - 1, // optimistic-concurrency check ); if (result.changes === 0) { const err = new Error("Version conflict") as Error & { code: string }; err.code = "3001"; throw err; } } async deleteSession(sessionId: string): Promise<void> { this.db.prepare("DELETE FROM sessions WHERE id = ?").run(sessionId); } async listSessions(filter: { userId: string; limit?: number }): Promise<SessionState[]> { const rows = this.db.prepare(` SELECT state FROM sessions WHERE user_id = ? ORDER BY updated_at DESC LIMIT ? `).all(filter.userId, filter.limit ?? 50) as Array<{ state: string }>; return rows.map((r) => reviveDates(JSON.parse(r.state))); } // ... event log + checkpoint methods follow the same pattern } function reviveDates(obj: any): SessionState { obj.createdAt = new Date(obj.createdAt); obj.updatedAt = new Date(obj.updatedAt); obj.lastActiveAt = new Date(obj.lastActiveAt); return obj; } ``` Three load-bearing details: 1. The `version` check on `updateSession` enforces optimistic concurrency — the same protocol the Supabase adapter uses. 2. Version conflicts throw with `code: "3001"` so the runtime's [sync](/docs/sync) layer surfaces them through the standard error path. 3. `reviveDates` is necessary because JSON doesn't preserve Date objects. Every storage adapter has this concern. See [Storage](/docs/storage) for the full interface. ## 4. BYOK: per-tenant provider credentials [#4-byok-per-tenant-provider-credentials] A runtime build function that resolves the provider per-request from the tenant's stored credentials. ```typescript // lib/runtime.ts import { SessionRuntime, AnthropicSdkProvider, AiSdkProvider } from "@pleach/core"; interface TenantConfig { providerType: "anthropic" | "openai" | "default"; apiKey?: string; // tenant-supplied (BYOK) model?: string; } export async function buildRuntimeForTenant(req: AuthedRequest) { const tenant = await loadTenant(req.user.orgId); const provider = pickProvider(tenant.config); return new SessionRuntime({ storage: sharedStorage, checkpointer: sharedCheckpointer, provider, userId: req.user.id, organizationId: req.user.orgId, }); } function pickProvider(config: TenantConfig) { if (config.providerType === "anthropic" && config.apiKey) { return new AnthropicSdkProvider({ apiKey: config.apiKey, // tenant's key model: config.model ?? "claude-sonnet-4-5", }); } if (config.providerType === "openai" && config.apiKey) { return new AiSdkProvider({ model: openai("gpt-4o", { apiKey: config.apiKey }), }); } // Fallback to the platform's shared credentials. return new AiSdkProvider({ model: openrouter("anthropic/claude-sonnet-4-5"), maxSteps: 5, }); } ``` The tenant's API key never appears in the [audit ledger](/docs/audit-ledger) (which records `family` / `modelId` / `transport`, not the credential) and never reaches the browser bundle (runtime construction is server-side). For more sophisticated routing — per-call key selection, rate limit management — `@pleach/gateway@0.1.0` ships the Phase A surface (`GatewayClient.route`, BYOK fingerprint, family-locked failover); other transports are deferred to Phase B per [Gateway](/docs/gateway). DIY routing or a third-party gateway remain viable for unsupported families. See [Multi-tenant](/docs/multi-tenant) for the full pattern. ## 5. Moderation pipeline with safety policies [#5-moderation-pipeline-with-safety-policies] A plugin that ships two safety policies (input scan and output scan) plus a stream observer that halts on policy violation. ```typescript // lib/plugins/moderation.ts import type { HarnessPlugin } from "@pleach/core"; const FORBIDDEN_PATTERNS = [ /\bcredit\s*card\s*number\b/i, /\bssn\b/i, // ... your patterns ]; export const moderationPlugin: HarnessPlugin = { name: "moderation", contributeSafetyPolicies: () => [ { id: "moderation.input-scan", version: "1.0.0", framework: "internal", enforcementLevel: "hard-gate", content: "Refuse requests asking to expose payment or identifier data.", preDispatchCheck: (msg) => { const matched = FORBIDDEN_PATTERNS.find((p) => p.test(msg.content)); return matched ? { verdict: "refuse", reason: `Pattern ${matched} detected in input` } : { verdict: "ok" }; }, }, { id: "moderation.output-scan", version: "1.0.0", framework: "internal", enforcementLevel: "hard-gate", content: "Never include credit card numbers or SSNs in responses.", postCompletionCheck: (msg) => { const matched = FORBIDDEN_PATTERNS.find((p) => p.test(msg.content)); return matched ? { verdict: "refuse", reason: `Pattern ${matched} detected in output` } : { verdict: "ok" }; }, }, ], contributeStreamObservers: () => [{ when: { callClass: "*" }, factory: () => ({ observerId: "redact-streaming", onChunk(chunk) { // SeamStreamEvent is { kind, payload } — the text rides in payload. if (chunk.kind !== "content_delta") return { kind: "continue" }; const { text } = chunk.payload as { text: string }; const redacted = FORBIDDEN_PATTERNS.reduce( (t, pattern) => t.replace(pattern, "[REDACTED]"), text, ); return redacted === text ? { kind: "continue" } : { kind: "amend", chunk: { kind: "content_delta", payload: { text: redacted } } }; }, }), }], }; ``` Register and enable: ```typescript const runtime = new SessionRuntime({ plugins: [moderationPlugin], enabledSafetyPolicies: [ "moderation.input-scan", "moderation.output-scan", ], // ... }); ``` The two policies fire at different lifecycle points — `preDispatchCheck` before the LLM sees the input, `postCompletionCheck` after the LLM produces output. The stream observer redacts in-flight chunks so the user never sees the offending text even before the post-completion gate fires. See [Safety policies](/docs/safety) and [Plugin contract](/docs/plugin-contract). ## 6. Multi-tenant runtime with the runtime.tenant facet [#6-multi-tenant-runtime-with-the-runtimetenant-facet] A Next.js API route that constructs a `SessionRuntime` per-tenant. `tenantId` flows to the [event log](/docs/event-log), audit ledger, OTel spans, and outbound HTTP without re-threading. RLS at the database layer is the enforcement; the facet provides the value. ### `lib/runtime.ts` [#libruntimets-1] ```typescript // lib/runtime.ts import { SessionRuntime } from "@pleach/core"; export function buildRuntime( userId: string, tenantId: string, subTenantId?: string, ) { return new SessionRuntime({ storage: pgAdapter, userId, tenantId, subTenantId, }); } ``` `tenantId` rejects the empty string at construction with `TenantIdEmptyError` — an unset env var interpolated as `""` throws at init rather than silently leaking writes across tenants months later. ### `app/api/chat/route.ts` [#appapichatroutets-1] ```typescript // app/api/chat/route.ts import { auth } from "@/lib/auth"; import { buildRuntime } from "@/lib/runtime"; export async function POST(req: Request) { const session = await auth.verifyRequest(req); if (!session) return new Response("Unauthorized", { status: 401 }); const tenantId = session.tenantId; const subTenantId = session.teamId; const runtime = buildRuntime(session.userId, tenantId, subTenantId); const { sessionId, content } = await req.json(); const stream = new ReadableStream({ async start(controller) { try { for await (const event of runtime.executeMessage(sessionId, content)) { controller.enqueue(`data: ${JSON.stringify(event)}\n\n`); } } finally { controller.close(); } }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream" }, }); } ``` ### `db/policies.sql` [#dbpoliciessql] ```sql ALTER TABLE harness_event_log ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON harness_event_log USING (tenant_id = current_setting('app.tenant_id', true)::text); ``` The `true` flag on `current_setting` returns `NULL` instead of erroring when the setting is unset — the policy then refuses every row, which is the correct failure mode for an unauthenticated connection. Set `app.tenant_id` on session start from the JWT claim. ### `lib/outbound.ts` [#liboutboundts] ```typescript // lib/outbound.ts import { withTenantHeader } from "@pleach/core/tenant"; export function tenantFetch(tenantId: string) { return withTenantHeader(fetch, { header: "x-tenant-id", tenantId, }); } // Upstream gateway calls inherit the supplied tenant. await tenantFetch(req.tenantId)("https://gateway.internal/v1/chat", { method: "POST", body: JSON.stringify(payload), }); ``` The wrapper writes the configured header on every request the returned `fetch` makes; per-call overrides win when the caller sets the same header explicitly. The host passes `req.tenantId` once at construction — typically inside a tool handler that already has the request-scoped runtime context in scope, not at wrap time — a runtime whose tenant is set late still produces a correctly-stamped header. See [Tenant facet](/docs/tenant-facet), [Multi-tenant](/docs/multi-tenant), [Facets](/docs/facets), and [OTel observability](/docs/otel-observability). ## 7. Adopting `@pleach/compliance` — scrubbers + audit gates [#7-adopting-pleachcompliance--scrubbers--audit-gates] A regulated-domain agent that adopts `@pleach/compliance`, wires the four bundled scrubbers, registers a custom `KeyedRegex` for an MRN pattern, and inherits the two CI gates that fail the build when tenant scoping drifts. ### `package.json` [#packagejson] ```json { "dependencies": { "@pleach/core": "^1.1.0", "@pleach/compliance": "^1.0.0" } } ``` ### `lib/compliance.ts` [#libcompliancets] ```typescript // lib/compliance.ts import { KeyedRegexScrubber, complianceProfilePlugin, } from "@pleach/compliance"; import type { HarnessPlugin } from "@pleach/core"; // The HIPAA profile bundle (SSN_US + credit card + US_DL) plus an // MRN scrubber layered on top — composition is additive at construction. export const hipaaPlugin = complianceProfilePlugin("hipaa", { extraScrubbers: [ new KeyedRegexScrubber({ id: "mrn", entries: [ { name: "MRN", pattern: /\b\d{7,10}\b/g, replacement: "[MRN-REDACTED]" }, ], }), ], }); // A second freestanding plugin for site-specific patterns. The runtime // stacks both at registration time; per-plugin facets compose without // either side knowing about the other. const customMrn = new KeyedRegexScrubber({ id: "site-mrn", entries: [ { name: "site-MRN", pattern: /\bMRN-[A-Z]{2}\d{6}\b/g, replacement: "[MRN-REDACTED]" }, ], }); export const customScrubberPlugin: HarnessPlugin = { name: "site-mrn", contributeScrubbers: () => [customMrn], }; ``` `complianceProfilePlugin("hipaa")` returns a single plugin that contributes the profile's standard scrubber bundle (`SSN_US`, credit-card, `US_DL`) against the standard event-type allowlist. `extraScrubbers` layers additional `KeyedRegexScrubber` instances on top in the same plugin; a separately-registered second plugin demonstrates the stack-multiple-plugins pattern. ### `lib/runtime.ts` [#libruntimets-2] ```typescript // lib/runtime.ts import { SessionRuntime } from "@pleach/core"; import { hipaaPlugin, customScrubberPlugin } from "./compliance"; export function buildRuntime(userId: string, tenantId: string) { return new SessionRuntime({ storage, checkpointer, userId, tenantId, plugins: [hipaaPlugin, customScrubberPlugin], }); } ``` ### `.github/workflows/audit.yml` [#githubworkflowsaudityml] ```yaml name: audit on: [pull_request] jobs: audit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 22 } - run: npm ci - run: npm run audit:tenant-scoping - run: npm run audit:harness-event-log-tenant-id-required ``` The first gate scans source for write sites that bypass the facet; the second checks the applied schema for the `NOT NULL` constraint on `harness_event_log.tenant_id`. Together they close the loop: source-level catches code that would write `NULL`, schema-level catches schemas that would accept it. See [Compliance](/docs/compliance), [Scrubbers](/docs/scrubbers), [Plugin contract](/docs/plugin-contract), and [Tenant facet](/docs/tenant-facet). ## 8. Building a custom event-log projection [#8-building-a-custom-event-log-projection] A custom `GraphProjection<T>` that counts tool failures grouped by `toolName` across a session. Useful for dashboards that highlight tools degrading mid-conversation. ### `lib/projections/toolFailureCounts.ts` [#libprojectionstoolfailurecountsts] ```typescript // lib/projections/toolFailureCounts.ts import type { GraphProjection, EventLogRow } from "@pleach/core/eventLog"; export interface ToolFailureCounts { readonly byTool: Readonly<Record<string, number>>; } export const toolFailureCountsProjection: GraphProjection<ToolFailureCounts> = { name: "tool-failure-counts", initial: () => ({ byTool: {} }), reduce(state, row) { if (row.event_type !== "tool.failed") return state; const toolName = (row.payload as { toolName?: string }).toolName ?? "unknown"; return { byTool: { ...state.byTool, [toolName]: (state.byTool[toolName] ?? 0) + 1, }, }; }, }; ``` The early `event_type` guard keeps the fold tight — only `tool.failed` rows mutate state (`reduce` returns the same `state` reference otherwise) — so the projection cost scales with failures, not with total event volume. ### `app/api/sessions/[id]/health/route.ts` [#appapisessionsidhealthroutets] ```typescript // app/api/sessions/[id]/health/route.ts import { buildRuntime } from "@/lib/runtime"; import { toolFailureCountsProjection } from "@/lib/projections/toolFailureCounts"; export async function GET(req: Request, { params }: { params: { id: string } }) { const runtime = buildRuntime(/* … */); const counts = await runtime.events.fold(toolFailureCountsProjection, { sessionId: params.id, }); return Response.json({ byTool: counts.byTool }); } ``` Projections compute on demand from the event log. The substrate already persists every event — a materialized projection table would duplicate that storage and drift the moment a backfill or correction landed only in one place. Re-deriving from the row stream keeps the answer consistent with what actually happened. See [Event-log projections](/docs/event-log-projections), [Event log](/docs/event-log), and [Stream events](/docs/stream-events). ## 9. Verifying the hash chain (manual SQL pass) [#9-verifying-the-hash-chain-manual-sql-pass] A manual verification of the `harness_event_log` hash chain for a single tenant. Writer-side stamping is in soak behind the `c9PhaseBEnabled` flag. Programmatic verification ships in `@pleach/replay@0.1.0` as `verifyChainForChat(chatId)` and `generateProof(chatId, range)` — see [Hash chain](/docs/hash-chain). The SQL pattern below is the manual fallback for ad-hoc audits or hosts that haven't adopted the SKU. ### `db/verify-chain.sql` [#dbverify-chainsql] ```sql -- Walk a tenant's stamped slice in (tenant_id, created_at) order and -- report the first index where prev_hash diverges from the previous -- row's row_hash. compute_canonical_hash is a placeholder for the -- writer's canonical encoding helper — the canonical encoding is -- defined in @pleach/core's src/event-log/ and is the authoritative -- source. This SQL is illustrative, not a drop-in. WITH RECURSIVE chain AS ( SELECT record_id, tenant_id, created_at, prev_hash, row_hash, compute_canonical_hash(harness_event_log.*) AS recomputed_hash, LAG(row_hash) OVER ( PARTITION BY tenant_id ORDER BY created_at, record_id ) AS expected_prev_hash, ROW_NUMBER() OVER ( PARTITION BY tenant_id ORDER BY created_at, record_id ) AS idx FROM harness_event_log WHERE tenant_id = $1 AND row_hash IS NOT NULL ) SELECT idx, record_id, created_at, prev_hash, expected_prev_hash, row_hash, recomputed_hash FROM chain WHERE prev_hash IS DISTINCT FROM expected_prev_hash OR row_hash IS DISTINCT FROM recomputed_hash ORDER BY idx LIMIT 1; ``` The query returns zero rows when the chain verifies clean. When a row comes back, `idx` is the first break — either `prev_hash` doesn't match the previous row's `row_hash` (a reorder, a backfill, or a deletion upstream) or `row_hash` doesn't match the recomputed canonical hash (the row's persisted bytes were mutated after write). A verification break flags the tenant for manual review against backups. Don't auto-rewrite history — the chain's job is to make tampering visible, and a self-healing writer that quietly restamps every break defeats the proof. Open a ticket, pull the last clean snapshot, and reconcile the divergence by hand. Pre-hash rows — those written before the writer-side stamping rolled out for the tenant — carry `NULL` in both columns. The `WHERE row_hash IS NOT NULL` filter skips them; chain verification resumes at the first stamped row. See [Hash chain](/docs/hash-chain), [Audit ledger](/docs/audit-ledger), and [Eval and replay](/docs/eval-and-replay). ## 10. Regulated host: attestation + hash chain + scrubbers + audit ledger end-to-end [#10-regulated-host-attestation--hash-chain--scrubbers--audit-ledger-end-to-end] Regulated domains — healthcare, finance, government — need four properties at once: row-level signing so any post-hoc mutation is detectable, tamper-evident chaining so deletion or reordering is detectable, PII redaction so the event log can be stored without violating data-handling policies, and a portable proof an auditor can verify offline. Each has a dedicated subsystem in `@pleach/core` plus sibling SKUs; this recipe wires all four together. ### `lib/attestation-setup.ts` [#libattestation-setupts] ```typescript // lib/attestation-setup.ts import { AwsKmsAttestationKeyStore, type AttestationKeyStore, } from "@pleach/core/attestation"; import { FileBackedAttestationKeyStore } from "@pleach/core/attestation/testing"; export function buildKeyStore(): AttestationKeyStore { if (process.env.NODE_ENV === "production") { return new AwsKmsAttestationKeyStore({ region: process.env.AWS_REGION!, keyArn: process.env.ATTESTATION_KEY_ARN!, }); } // Dev / test — 32-byte ed25519 seed on disk. return new FileBackedAttestationKeyStore({ privateKeyPath: process.env.ATTESTATION_PRIVATE_KEY_PATH!, keyId: process.env.ATTESTATION_KEY_ID ?? "dev-key-1", }); } ``` The production constructor is a stub at v0.9 (interface conformance only; `sign()` throws `NotImplementedError`); the `FileBackedAttestationKeyStore` from the `/testing` subpath is the real implementation that wraps `@noble/curves/ed25519`. ### `lib/compliance-plugin.ts` [#libcompliance-plugints] ```typescript // lib/compliance-plugin.ts import type { HarnessPlugin } from "@pleach/core"; import { SsnUsScrubber, CreditCardScrubber, UsDriverLicenseScrubber, KeyedRegexScrubber, } from "@pleach/compliance"; export const compliancePlugin: HarnessPlugin = { name: "compliance", contributeScrubbers: () => [ new SsnUsScrubber(), new CreditCardScrubber(), new UsDriverLicenseScrubber(), new KeyedRegexScrubber({ entries: [ // Custom MRN pattern for the healthcare slice. { key: "MRN", pattern: /\b\d{7,10}\b/g }, ], }), ], }; ``` `@pleach/compliance` does not ship a pre-bundled `compliancePlugin` export at v0.9 — the host assembles the plugin from the four concrete scrubber classes. Composition is additive: stack additional `KeyedRegexScrubber` instances for site-specific patterns, or layer a second plugin alongside this one. ### `lib/attesting-ledger.ts` [#libattesting-ledgerts] ```typescript // lib/attesting-ledger.ts import { MemoryProviderDecisionLedger, type AuditableCall, type ProviderDecisionLedger, } from "@pleach/core/audit"; import { signAttestation, computePayloadHash } from "@pleach/core/attestation"; import { canonicalizeAttestationPayload } from "@pleach/core/attestation"; import type { AttestationKeyStore } from "@pleach/core/attestation"; export function withAttestation( inner: ProviderDecisionLedger, keyStore: AttestationKeyStore, keyId: string, ): ProviderDecisionLedger { return { async recordCall(call: AuditableCall) { // Build the unsigned payload from the call's canonical fields, // sign it, and attach the signature to the call before // forwarding to the underlying ledger. Payload shape and field // list are defined by AttestationPayloadV1 in @pleach/core. const payload = buildUnsignedPayload(call, keyId); const signed = await signAttestation(payload, keyStore); await inner.recordCall({ ...call, attestation: signed }); }, }; } ``` The wrapper composes around any `ProviderDecisionLedger` — the illustrative `MemoryProviderDecisionLedger` here, a Supabase-backed adapter in production, or an S3-backed sink. The signature lands in the persisted row alongside the canonical payload; verification re-canonicalizes the same fields and checks the signature against the public key resolved from `signaturePublicKeyId`. ### `app/api/harness/route.ts` [#appapiharnessroutets] ```typescript // app/api/harness/route.ts import { SessionRuntime, EventLogWriter } from "@pleach/core"; import { MemoryProviderDecisionLedger } from "@pleach/core/audit"; import { buildKeyStore } from "@/lib/attestation-setup"; import { compliancePlugin } from "@/lib/compliance-plugin"; import { withAttestation } from "@/lib/attesting-ledger"; export async function POST(req: Request) { const { userId, tenantId, chatId, content } = (await req.json()) as { userId: string; tenantId: string; chatId: string; content: string; }; const keyStore = buildKeyStore(); const ledger = withAttestation( new MemoryProviderDecisionLedger(), keyStore, process.env.ATTESTATION_KEY_ID ?? "dev-key-1", ); const eventLog = new EventLogWriter(supabase, { c9PhaseBEnabled: true, // hash-chain stamping on }); const runtime = new SessionRuntime({ storage, eventLogWriter: eventLog, // hash-chain-stamping writer userId, tenantId, // threads to chain + ledger plugins: [compliancePlugin], }); // The attesting `ledger` composed above is wired at the audit adapter / // route layer — audit capture is route-scoped, not a SessionRuntime config // field. // …executeMessage(chatId, content)… } ``` `c9PhaseBEnabled` must be on at write time, or the chain is sparse — pre-existing rows carry `NULL` in chain columns and verification will skip them. `tenantId` flows from the auth layer through to the event log row, the chain partition, the attestation payload's identity fields, and the audit ledger row. ### Verifier flow [#verifier-flow] The auditor's offline check fans out across the three layers: ```typescript import { gatherChainEntries, generateProof } from "@pleach/core/eventLog"; import { verifyAttestation } from "@pleach/core/attestation"; // 1. Walk the chat's chain once — gatherChainEntries returns BOTH the // (sequenceNumber, rowHash) entries a proof needs AND the verify verdict. // A clean verdict proves no rows were reordered, deleted, or mutated. const { entries, verifyResult } = await gatherChainEntries({ reader, tenantId, chatId, }); if (verifyResult.ok === false) { // 2. Chain broke. `failedIndex` is on the failure branch — pull the affected // window and re-verify each row's attestation in turn to localize. for await (const row of reader.iterateChatEvents({ tenantId, chatId, fromSequenceNumber: verifyResult.failedIndex, })) { const ok = await verifyAttestation(row.attestation, publicKey); if (!ok) console.error("attestation break at seq", row.sequenceNumber); } } // 3. For a clean range, generate a portable proof an external auditor can // verify without DB access. The head is the last gathered entry's sequence. const head = entries[entries.length - 1].sequenceNumber; const proof = generateProof({ entries, tenantId, chatId, headSequenceNumber: head, leafSequenceNumber: head, }); ``` `gatherChainEntries` walks the chain once — `verifyResult` is the broad-stroke "did anything change?" verdict and `entries` feed the proof; per-row `verifyAttestation` localizes which specific row(s) diverged; `generateProof` produces a `ChainProofV1` the auditor verifies against the published public key + the canonical encoding, no database needed. ### Caveats [#caveats] * `c9PhaseBEnabled` must be on at write time. Rows written with the flag off carry `NULL` in `prev_hash` and `row_hash`; the verifier skips them as the legacy prefix, and the chain only begins at the first stamped row. * Pre-existing rows carry `NULL` in the chain columns and read as the legacy prefix — `verifyChainForChat` accepts this and starts checking at `firstChainedSequenceNumber`. * Attestation signs the canonical payload hash. Rotating the keystore invalidates old signatures unless the rotation policy preserves the previous key for verification — keep the `getPublicKey(keyId)` resolver covering every keyId that's ever signed a row in the retention window. * Scrubbing happens **before** the event-log row is stamped — the chain hashes the redacted view. This is intentional: verifying the chain doesn't need access to the original PII, and storing the unredacted bytes anywhere would defeat the scrubber. * The `MemoryProviderDecisionLedger` here is illustrative. In production wrap a persistent ledger (Supabase, S3-backed); the `withAttestation` wrapper composes around either. * Tombstoning via the `GDPRSoftDelete` plug-point doesn't break the chain — `recordId` stays in place; identifying fields clear under the subject key. See [The AuditableCall row](/docs/auditable-call-row) for the soft-delete shape. See [Attestation](/docs/attestation), [Hash chain](/docs/hash-chain), [Scrubbers](/docs/scrubbers), [Audit ledger](/docs/audit-ledger), and [Compliance](/docs/compliance). ## Where to go next [#where-to-go-next] <Cards> <Card title="SessionRuntime" href="/docs/session-runtime" description="The constructor and config used across every recipe." /> <Card title="Tools" href="/docs/tools" description="`defineTool` — the contract recipes 2 and 6 build on." /> <Card title="Plugin contract" href="/docs/plugin-contract" description="The contract recipe 5's moderation plugin implements." /> <Card title="Deployment" href="/docs/deployment" description="Production setup for recipe 1." /> <Card title="OTel observability" href="/docs/otel-observability" description="The four span types and exporter wiring recipe 9 builds on." /> <Card title="Tenant facet" href="/docs/tenant-facet" description="`tenantId` plus `subTenantId` — the substrate read site recipe 10 threads through." /> <Card title="Compliance" href="/docs/compliance" description="`@pleach/compliance` — scrubbers and inherited CI gates recipe 11 adopts." /> </Cards> --- # Reference apps (/docs/reference-apps) `@pleach/core` ships two runnable examples under `examples/` in the package itself. Both run; both are commented; both demonstrate a distinct integration depth. For agent shapes by problem (customer support, research, coding, multi-tenant SaaS, regulated domain), see [Agent shapes](/docs/agent-shapes). For end-to-end wiring patterns (streaming chat, custom tools, custom storage, BYOK, moderation, jobs, interrupts, cost reporting, OTel, multi-tenant, compliance, projections, hash-chain), see [Recipes](/docs/recipes). After `npm install @pleach/core`, the examples live at: ``` node_modules/@pleach/core/examples/ minimal-agent/ host-adapter/ ``` For a working copy, clone the upstream repository: ```bash git clone https://github.com/pleachhq/core cd core/examples/minimal-agent ``` ## `examples/minimal-agent` [#examplesminimal-agent] The "does it construct?" baseline. Proves `new SessionRuntime()` (the [session runtime](/docs/session-runtime)) works with zero config, mock-mode storage, and a synthesized provider. No external services, no API keys, no migrations. **What it shows** * Default-config runtime construction. * A single [tool](/docs/tools) defined via `defineTool` with a Zod schema. * A one-turn `executeMessage` call that streams events to the console. **Running it** ```bash cd examples/minimal-agent npm install HARNESS_MOCK_MODE=true node index.mjs ``` Output is the event stream printed line-by-line — `session.created`, `step.start`, `message.delta` chunks, `tool.started`, `tool.completed`, `message.complete`, `step.end`. Inspect each shape against [Stream events](/docs/stream-events). **Structure** ``` minimal-agent/ index.mjs — entry point; constructs runtime + runs one turn tools.mjs — one defineTool example README.md — usage instructions package.json — minimal deps ``` **What to crib.** The `index.mjs` is the shortest possible end-to-end agent loop. Use it as the starting skeleton for a throwaway script that wants `@pleach/core` as a CLI, a unit test fixture that exercises the runtime against mock storage, or a first-cut prototype before you wire real storage and providers. **What it doesn't show.** Persistent [storage](/docs/storage) — mock mode wires `MemoryAdapter` and state evaporates on exit. A real provider — the synthesized one returns plausible mock text. React, API routes, [sync](/docs/sync), plugins, safety policies, or [audit ledger](/docs/audit-ledger) reads. The example is deliberately minimal. ## `examples/host-adapter` [#exampleshost-adapter] The "real integration" example. Documents the contract a host application must satisfy to call `runtime.executeMessage(...)` when the host has a legacy orchestrator integration. **What it shows** * `setHarnessModuleLoader` registration. * The per-key inventory of capabilities a mature host wires. * The R-1 retirement progression (which keys have been absorbed into the substrate as typed config, with `RETIRED` markers). * The `metaToolNames` typed-config migration as the canonical example of a key moving from loader to runtime config. **Two files in the directory** | File | Purpose | | ------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `index.mjs` | Minimal no-op adapter that throws a documentation-pointer error for every key. Starting point for a brand-new host. | | `production-reference.ts` | Full production-shaped adapter with paths neutralized to `@your-app/*`. Reference lifted from a real production host. | **Running the minimal version** ```bash cd examples/host-adapter npm install HARNESS_MOCK_MODE=true node index.mjs ``` The minimal adapter implements just enough keys for first-turn execution to land. Every other key throws a structured documentation-pointer error pointing at the relevant doc section. **Cribbing from `production-reference.ts`.** The reference adapter is a fully-wired host integration. Use it as a template, then: remove every arm marked `RETIRED YYYY-MM-DD` — those capabilities have moved into the substrate as typed config, and re-wiring them is a regression. Replace `@your-app/*` import paths with your own host's module structure. Trim arms for capabilities you don't use; the full set is illustrative, and minimum viable is much smaller. **R-1 retirement markers.** The reference file marks each retired arm with the substrate commit that absorbed the capability: ```typescript // RETIRED 2026-06-02 (R-1.A, commit 8e52b01f2) // case "orchestrator.streamHelpers": // return import("@your-app/streamHelpers"); ``` The comment block is left in place so future hosts cribbing from this file see the migration trail without re-reading the upstream commit log. ## Which to start from [#which-to-start-from] | Situation | Start from | | ------------------------------------ | -------------------------------------------------------- | | Brand-new project on `@pleach/core` | `minimal-agent` | | Tutorial or proof of concept | `minimal-agent` | | Existing app, no legacy orchestrator | `minimal-agent`, then add typed config + plugins | | Host with a legacy orchestrator | `host-adapter/index.mjs`, implement keys as you hit them | | Mature production integration | `host-adapter/production-reference.ts` | ## Where to go next [#where-to-go-next] <Cards> <Card title="Getting started" href="/docs/getting-started" description="Install plus first-turn quickstart, distilled from `minimal-agent`." /> <Card title="Agent shapes" href="/docs/agent-shapes" description="Six recurring production patterns and the primitive that handles each." /> <Card title="Recipes" href="/docs/recipes" description="End-to-end Next.js, Node, and SQL wiring patterns — chat, tools, storage, BYOK, moderation, jobs, cost, OTel, multi-tenant." /> <Card title="Packages" href="/docs/packages" description="The `@pleach/*` scope on npm — what ships today, what's reserved, and where each piece fits." /> </Cards> --- # Region-pinned agent (/docs/region-pinned-agent) A region-pinned agent is the shape an enterprise reaches for when the AI program is already in production and the question stops being "does it work" and starts being "is the inference auditable under the contract." Region of execution is fixed per call class. The model family is fixed per call class. Failover stays inside the vetted list. A parity-validation suite proves the substitute behaves the same as the primary. This page walks the four pieces a procurement reviewer asks about: region and family pinning at session start, parity validation across the failover list, attestation of which model served which call, and the audit row a regulator reads. **Related shapes.** [Regulated-domain agent](/docs/regulated-domain-agent) if the domain itself (HIPAA, FedRAMP, PCI, 21 CFR Part 11) drives the constraint. [Multi-tenant SaaS agent](/docs/multi-tenant-saas-agent) if one runtime serves many region-pinned customers. [Cloud-routed agent](/docs/cloud-routed-agent) if the inference runs through AWS Bedrock, Azure OpenAI Service, or GCP Vertex AI rather than the lab's direct API. ## What you're building [#what-youre-building] A turn loop whose routing decisions are pre-declared, not discovered at runtime. The shape is the same regardless of which lab is the primary: * A session locks one family per call class — utility, reasoning, converse, synthesize each pin to a vetted family at construction. * `permittedFamilies` constrains the cascade to the families the contract clears. Out-of-list providers can't be reached even as a last-resort fallback. * A parity-validation suite runs the substitute family against the primary on a held-out fixture set. The diff is what proves the failover is safe to take. * Every call writes one ledger row carrying the family, the model, the call class, and the region. ## Lock the permitted family set at session start [#lock-the-permitted-family-set-at-session-start] `permittedFamilies` locks the set of provider families a session can ever reach. The cascade walks rungs inside that set only; which family serves each call class is decided by the model-family matrix (family × callClass), never widened past the locked set. ```typescript // lib/runtime.ts import { SessionRuntime, definePleachPlugin } from "@pleach/core"; export function buildPinnedRuntime(req: AuthedRequest) { return new SessionRuntime({ provider: anthropicProvider, storage: new SupabaseAdapter({ client: supabase }), checkpointer: new SupabaseSaver({ client: supabase }), plugins: [definePleachPlugin("cleared-tools", { tools: clearedTools })], // The reachable family set is locked at session start. Which family // serves each call class (synthesize / reasoning / converse / utility) // is governed by the model-family matrix (family × callClass), not a // per-session field — the cascade only ever walks families in this set. permittedFamilies: new Set(["anthropic", "openai"]), // Residency is a hard constraint: an out-of-region family can't be reached. permittedRegions: new Set([req.region]), tenantId: req.tenantId, }); } ``` The lock is structural, not advisory. An out-of-list family can't be reached under any cascade condition. The permitted set is recorded in the session row — an auditor reading the contract clause and the session row sees the same family list. See [Family lock](/docs/family-lock) for the cascade rules and [Call classes](/docs/call-classes) for what each class covers. ## Parity validation across the failover list [#parity-validation-across-the-failover-list] A failover claim is unverified until the substitute behaves the same as the primary on a held-out fixture set. `@pleach/eval` records a fixture run against the primary, then replays it against each family in the permitted list. ```typescript import { createReplayRuntime } from "@pleach/replay"; for (const family of ["anthropic", "openai"]) { const challenger = buildPinnedRuntime({ ...req, overrideCallClassFamilies: { synthesize: family }, }); const replayRuntime = createReplayRuntime({ sessionRuntime: challenger, tenantId: req.tenantId, }); const replay = await replayRuntime.replayTurn({ chatId: sessionId, tenantId: req.tenantId, messageId: goldenMessageId, }); // `replay.state` is the `HydratedHarnessState` projection the turn // closed on; `replay.sequenceNumberRange` bounds the event window // walked. There is no built-in `.diff` field — compare the tool-call // set / refusal pattern / citation count across families from the // returned state yourself. console.log(family, replay.state, replay.sequenceNumberRange); } ``` The replayed state is what procurement reviews. A failover that changes the refusal pattern or the tool-call set isn't safe to take silently. (For tamper-evidence rather than parity, `@pleach/replay` also ships a hash-chain `verifyIntegrity` verdict — `{ valid, brokenAt? }` — that proves an event log wasn't altered between record and replay.) ## What today ships [#what-today-ships] Today, `@pleach/core` ships the family-strict cascade, `permittedFamilies`, deterministic replay, and the audit row that records which family served each call. `@pleach/eval` ships the recording + replay engine that drives the parity suite. ## Roadmap [#roadmap] Roadmap pieces, in expected order: * **Per-call-class family pinning.** Today `permittedFamilies` locks the reachable family *set*; the underlying matrix already keys on `(family, callClass)`, but a per-session surface that pins a specific family to each call class is still being widened. * **Cascade-time region enforcement.** `permittedRegions` is a real session field today, enforced at the lock-time guard (`isRegionPermitted`). Cascade-time enforcement — failing closed on an out-of-region resolution mid-cascade — lands when the matrix grows a region-keyed dimension. Until then region is a lock-time constraint, not an in-cascade one. * **Article 12 attestation pack.** A signed manifest of which family + model + region served each turn, in a shape an EU AI Act reviewer can verify offline. The audit row carries the underlying data today; the signed-attestation surface ships later. * **Parity-suite scaffold.** The replay engine handles the per-turn diff today; a multi-fixture parity suite that aggregates pass/fail across a corpus and reports per-family divergence is a planned `@pleach/eval` addition. No dates. Track the upstream package READMEs and CHANGELOG for landing notices. ## What the audit row carries [#what-the-audit-row-carries] Every row in `harness_auditable_calls` carries the fields a procurement reviewer reads first: | Column | Carries | Reviewer question it answers | | ------------ | -------------------------- | ----------------------------------------- | | `record_id` | ULID, monotonic | "is the chain intact?" | | `tenant_id` | runtime context | "which business unit?" | | `turn_id` | one per user message | "what was the unit of work?" | | `call_class` | utility / reasoning / etc. | "which class served this?" | | `family` | resolved family | "did routing stay in the contract list?" | | `model_id` | resolved at call time | "which model produced this?" | | `region` | runtime context | "did the call run in the cleared region?" | | `created_at` | server clock | "when, to the millisecond?" | See [Auditable call row](/docs/auditable-call-row) for the full column list. ## Where to go next [#where-to-go-next] <Cards> <Card title="Family lock" href="/docs/family-lock" description="The permittedFamilies constraint and the cascade rules it constrains." /> <Card title="Call classes" href="/docs/call-classes" description="utility, reasoning, converse, synthesize — the four classes a per-class pin keys on." /> <Card title="Model resolution matrix" href="/docs/model-resolution-matrix" description="The (family, callClass) lookup the runtime uses to resolve a model at call time." /> <Card title="Eval and replay" href="/docs/eval-and-replay" description="Recording, replay, and the diff engine the parity suite runs on top of." /> <Card title="Cloud-routed agent" href="/docs/cloud-routed-agent" description="The sibling shape for buyers reaching the same models through Bedrock, Azure OpenAI, or Vertex." /> <Card title="Regulated-domain agent" href="/docs/regulated-domain-agent" description="The HIPAA / FedRAMP / 21 CFR Part 11 shape, when the regulator drives the constraint instead of procurement." /> </Cards> --- # Regulated-domain agent (/docs/regulated-domain-agent) A regulated-domain agent is the use case where "we have audit logs" stops being the answer and "show me the chain of custody for turn T" becomes the question. Pleach answers it by construction: provider routing is constrained at session start, a PII gate runs before the prompt leaves the process, and every call writes one append-only row keyed by `(turnId, toolName, subagentDepth)`. This page walks the four pieces auditors ask about: provider routing constraints, the PII gate, the audit row, and the replay path that lets an engineer defend the answer six months later. **Related shapes.** [Multi-tenant SaaS agent](/docs/multi-tenant-saas-agent) if one runtime serves many regulated customers. [Internal knowledge agent](/docs/internal-knowledge-agent) if grounding comes from a private corpus you must cite from. [Customer support agent](/docs/customer-support-agent) if the regulated turn is part of a longer-lived support session. ## What you're building [#what-youre-building] A turn loop for a regulated domain — a clinical decision-support helper, a claims-review assistant, a know-your-customer reviewer, an FERPA-bound tutor. The shape is the same regardless of framework: * Provider routing is locked to a vetted family list at session start. * A PII redaction policy gates the call before it dispatches to the provider. * The ledger row carries enough identity to answer "who, what, when" without joining four tables. * The turn is replayable for as long as the storage adapter keeps the inputs. What `@pleach/core` ships today gets you the constrained routing, the PII gate contract, the auditable row, and the replay. `@pleach/compliance` layers on the bundled scrubbers, the tamper-evident hash chain, the typed audit records a regulator reads first, and the CI gates that keep tenant scoping honest. ## Lock provider routing at session start [#lock-provider-routing-at-session-start] `permittedFamilies` narrows the family-strict cascade to a vetted set. A model family that isn't on the list can't be picked, even as a fallback. ```typescript // lib/runtime.ts import { SessionRuntime, definePleachPlugin } from "@pleach/core"; export function buildClinicalRuntime(req: AuthedRequest) { return new SessionRuntime({ provider: anthropicProvider, storage: new SupabaseAdapter({ client: supabase }), checkpointer: new SupabaseSaver({ client: supabase }), plugins: [definePleachPlugin("clinical-tools", { tools: clinicalTools, safetyPolicies: [phiRedaction, refuseOnUncertainty], })], permittedFamilies: new Set(["anthropic-bedrock", "azure-openai"]), tenantId: req.tenantId, // e.g. hospital_id organizationId: req.organizationId, userId: req.userId, }); } ``` Why this matters under a BAA or FedRAMP boundary: the family-lock isn't a runtime hint, it's structural. The cascade walks in- family rungs only. If `openai-direct` isn't on the list, the cascade can't fall through to it under any condition. The constraint is auditable: the family list at session start is recorded in the session row. See [Providers](/docs/providers) for the cascade rules and [Multi-tenant](/docs/multi-tenant) for the three-place isolation pattern. ## Gate PII before the prompt leaves the process [#gate-pii-before-the-prompt-leaves-the-process] A pre-dispatch safety policy runs before the provider call. If it rewrites the message, the provider sees the rewrite. If it refuses, the call doesn't happen at all. ```typescript // lib/safety/phiRedaction.ts import { defineSafetyPolicy, safetyPolicyId } from "@pleach/core/safety"; export const phiRedaction = defineSafetyPolicy({ id: safetyPolicyId("regulated-domain.phi-redaction"), version: "1.0.0", framework: "HIPAA", enforcement: "guardrail", content: ({ runtimeRole }) => ` [PHI policy ${runtimeRole ?? "default"}] Before responding, scrub identifiers that match common PHI patterns (MRN, SSN, DOB, account numbers, full names paired with conditions). Replace each occurrence with the literal token "[REDACTED]". Do not paraphrase around the redaction — the original token never leaves. `.trim(), }); ``` Three load-bearing details: 1. `defineSafetyPolicy` is identity-at-runtime — the `SafetyContribution` shape it returns composes into the system prompt LAST, after every other plugin contribution. PHI guidance reaches the model before any tool call fires. 2. `enforcement: "guardrail"` records the operator's stated INTENT on every ledger row that consults this policy — a later audit can ask "which turns ran under the PHI guardrail" without re-running the policy. See [Audit ledger](/docs/audit-ledger). 3. The contract is **capability-subtracting**: a safety policy can refuse or constrain, but never generates new content. Per-pattern scrubbing for the redaction itself lives in `@pleach/compliance` (see [Scrubbers](/docs/scrubbers)); `defineSafetyPolicy` carries the operator-stated guidance alongside it. See [Safety](/docs/safety). ## What the audit row carries [#what-the-audit-row-carries] Every call writes one row to `harness_auditable_calls`. The identity columns are what an auditor reads first. | Column | Carries | Audit question it answers | | ---------------- | --------------------- | ------------------------------------------- | | `record_id` | ULID, monotonic | "is this row out of order or duplicated?" | | `tenant_id` | runtime context | "which covered entity is this for?" | | `turn_id` | one per user message | "what was the unit of work?" | | `tool_name` | per tool call | "what did the agent invoke?" | | `subagent_depth` | per spawn | "did a child agent do this?" | | `model_id` | resolved at call time | "which model produced this?" | | `family` | resolved family | "did routing stay inside the BAA list?" | | `created_at` | server clock | "when did this happen, to the millisecond?" | See [Auditable call row](/docs/auditable-call-row) for the full column list and [Audit ledger](/docs/audit-ledger) for the schema. ## The "chain of custody for turn T" query [#the-chain-of-custody-for-turn-t-query] A regulator hands the engineer a `turn_id` and a question. One query, ordered by `record_id`, returns the chain. ```sql select record_id, call_kind, tool_name, subagent_depth, model_id, family, payload -> 'identity' as identity, payload -> 'output' ->> 'auditNote' as audit_notes, created_at from harness_auditable_calls where turn_id = $1 order by record_id; ``` What this reads out: every model call, every tool call, every spawned subagent, every PII redaction notation, every routing decision — in the order they happened. No joins, no log correlation, no grep. For a regulator asking about a whole session instead of one turn, swap the `where` clause for `session_id = $1`. ## Replay for audit defense [#replay-for-audit-defense] A turn that's been recorded can be replayed against the same inputs months later. The replay walks deterministically: same provider seed, same tool returns, same final text. ```typescript import { createReplayRuntime } from "@pleach/replay"; const replayRuntime = createReplayRuntime({ sessionRuntime: runtime, tenantId, }); const replay = await replayRuntime.replayTurn({ chatId: sessionId, tenantId, messageId: turnId, }); // `replay.state` is the reconstructed HydratedHarnessState (typed `unknown`): // the tools that fired, the model's answer, the redaction notes, and the // family choice all hang off the rehydrated turn state. console.log(replay.state); ``` Why this matters in a regulated context: an auditor asking "how did the system reach this conclusion?" gets a byte-identical walkthrough of the original decision. The engineer doesn't reconstruct from logs — they replay the turn. See [Eval and replay](/docs/eval-and-replay) for the recording modes and [Determinism](/docs/determinism) for the fingerprint contract. ## Bundled scrubbers from `@pleach/compliance` [#bundled-scrubbers-from-pleachcompliance] `@pleach/compliance` ships four scrubbers implementing the `Scrubber` contract from `@pleach/core`. They cover the identifiers a US-regulated host hits first: | Scrubber | Matches | Notes | | ------------ | --------------------------- | ------------------------------------------- | | `SSN-US` | US Social Security Numbers | `NNN-NN-NNNN` and run-on `NNNNNNNNN` | | `Luhn` | Credit card numbers | Luhn-validated, not pattern-only | | `US-DL` | US driver's license numbers | per-state format set | | `KeyedRegex` | host-supplied identifiers | parameterized; one registration per pattern | Registration runs at runtime construction through the plugin's `contributeScrubbers` hook — the runtime composes the host's set into the same pre-dispatch gate the `phiRedaction` policy above plugs into. The fastest wiring is a profile-keyed convenience factory that returns a `HarnessPlugin` wired with the canonical bundle for the regulatory regime: ```typescript // lib/runtime.ts import { SessionRuntime } from "@pleach/core"; import { complianceProfilePlugin, KeyedRegexScrubber, } from "@pleach/compliance"; const compliance = complianceProfilePlugin("hipaa", { // Host-specific identifiers layer on top of the profile bundle. // KeyedRegexScrubber takes an `entries` array; each entry carries // the pattern + replacement + a stable name for audit-trail attribution. extraScrubbers: [ new KeyedRegexScrubber({ id: "mrn", entries: [ { name: "MRN", pattern: /\bMRN-\d{7}\b/g, replacement: "[MRN-REDACTED]" }, ], }), new KeyedRegexScrubber({ id: "ein", entries: [ { name: "EIN", pattern: /\b\d{2}-\d{7}\b/g, replacement: "[EIN-REDACTED]" }, ], }), ], }); export function buildRegulatedRuntime(req: AuthedRequest) { return new SessionRuntime({ plugins: [compliance], // ...routing, storage, tools, tenant as above }); } ``` The supported profile names are `"hipaa" | "gdpr" | "pci-dss" | "soc2"`. Each maps to a curated subset of the four ship-set scrubbers: * `hipaa` — `SsnUsScrubber` + `UsDriverLicenseScrubber` + `CreditCardScrubber` * `gdpr` — `SsnUsScrubber` + `CreditCardScrubber` * `pci-dss` — `CreditCardScrubber` (Luhn + IIN range) * `soc2` — `SsnUsScrubber` + `CreditCardScrubber` If you'd rather wire scrubbers by hand — for instance, to compose them with other plugin contributions (safety policies, prompts, tools) in a single plugin literal — `definePleachPlugin` from `@pleach/core` is the canonical surface: ```typescript // lib/plugins/myCompliancePlugin.ts import { definePleachPlugin } from "@pleach/core"; import { SsnUsScrubber, CreditCardScrubber, UsDriverLicenseScrubber, } from "@pleach/compliance/scrubbers"; export const myCompliancePlugin = definePleachPlugin("my-compliance", { scrubbers: [ new SsnUsScrubber(), new CreditCardScrubber(), new UsDriverLicenseScrubber(), ], // ...other contributions (safety policies, prompts, tools, etc.) }); ``` The `KeyedRegexScrubber` is the host's extension point. A regulated SaaS adds its domain identifiers without forking the core scrubber set. See [Scrubbers](/docs/scrubbers) for the contract and the bundled-set guarantees. ## Tamper-evident hash chain on the event log [#tamper-evident-hash-chain-on-the-event-log] `harness_event_log` carries two columns the chain depends on: `prev_hash` (the previous row's hash, by `record_id` order) and `row_hash` (this row's content hash, including `prev_hash`). A malicious DB write to any row breaks the chain at that row and every row after it; a verifier walks the table and surfaces the break. Writer-side stamping ships in `@pleach/core/eventLog` (`chainStep`, `computeRowHash`, `computeGenesisSeed`) behind the `c9PhaseBEnabled` flag. End-to-end verification ships in `@pleach/replay@0.1.0` as `verifyChainForChat` and `generateProof`. The combination is in soak — operators running the chain in production should pin to the `@pleach/core` + `@pleach/replay` versions that match. See [Hash chain](/docs/hash-chain) for the verification walk and the current soak status. ## Typed audit records [#typed-audit-records] Two record shapes earn their keep in regulated domains: * **`SynthesisQualityRecord`** — written on every `synthesize` row. Carries the synthesis-quality probes as typed boolean fields: `imperativeFabricationDetected` (the fabrication-detector arm fired) and `degenerationFired` (an observer halt fired mid-stream), alongside `finalContentLength`, `synthesisRetries`, `forceSynthesis`, `latencyMs`, and the `terminalSynthesis` marker. A regulator asking "did this answer trip a fabrication or degeneration guard" reads one row. * **`InterruptDecisionRecord`** — written when a human-in-the- loop interrupt resolves. Carries the interrupt reason, the resolver's identity, the chosen branch, and the timestamp. The chain of custody for a clinician override is one row. Both records share the audit row's identity columns (`tenant_id`, `turn_id`, `subagent_depth`) and chain into the hash-linked event log. See [Typed records](/docs/typed-records) for the full set and the schemas. ## CI gates from `@pleach/compliance` [#ci-gates-from-pleachcompliance] Two CI gates ship with the package; both fail the build on violation: * `audit:tenant-scoping` — every storage read and write carries a tenant predicate. A query that omits one stops the merge. * `audit:harness-event-log-tenant-id-required` — every emit to `harness_event_log` stamps `tenant_id`. An untagged write stops the merge. Combined with the [tenant facet](/docs/tenant-facet), the gates turn "we stamp tenant\_id everywhere" from a code-review promise into a CI invariant. See [Recipes](/docs/recipes) (recipes 11 and 13) for the end-to-end PHI-gate and hash-chain verification walks, and [Packages](/docs/packages) for the SKU status table. ## Project layout [#project-layout] The heaviest delta of the six shapes. On top of the [baseline](/docs/project-layout#a-layout-that-works), regulated deployments add a `compliance/` module wiring `@pleach/compliance` into all three audit-ledger plug-points, lock provider selection at the runtime construction site, and check in the SQL the auditor will read. ``` my-app/ src/ pleach/ runtime.ts # SessionRuntime with permittedFamilies + @pleach/compliance plug-ins compliance/ scrubbers.ts # the bundled scrubber set from @pleach/compliance hash-chain.ts # tamper-evident chain wiring on the event log typed-records.ts # SynthesisQualityRecord, InterruptDecisionRecord, etc. safety/ family-lock.ts # → /docs/family-lock — provider routing pinned at session start ledger/ gdpr-soft-delete.ts # the GDPR entry point (callable, audited) app/ api/agents/[id]/route.ts audit/ chain-of-custody.sql # the "turn T" query auditors run hash-chain-verify.sql # the manual prev_hash walk .github/ workflows/ compliance.yml # runs audit:tenant-scoping + audit:harness-event-log-tenant-id-required ``` What changes from the baseline: * **`compliance/` wires the three plug-points.** The audit ledger exposes `TamperEvidence`, `PiiRedactor`, and a typed-record emitter; this directory is where each one gets its real implementation from [`@pleach/compliance`](/docs/compliance). Keeping them adjacent makes the "audit-grade" claim a single-directory read, not a treasure hunt. * **`safety/family-lock.ts` enforces provider routing at session start.** `permittedFamilies` pins which model families a session can ever reach — the [family lock](/docs/family-lock) fails closed if the wrong family is requested mid-turn. This is the file an auditor asks to see first. * **`ledger/gdpr-soft-delete.ts` is callable code, not a SQL migration.** Soft-delete runs through the ledger's typed entry point so the deletion *itself* is an audited event. A direct `UPDATE` would erase the trail GDPR requires. * **`audit/*.sql` lives in the repo.** The [chain-of-custody query](#the-chain-of-custody-for-turn-t-query) and the [hash-chain verification walk](#tamper-evident-hash-chain-on-the-event-log) are what an external auditor runs. Checking them in keeps the query shape locked to the column shape — and gives the auditor one file path to ask for instead of a screenshot. * **`.github/workflows/compliance.yml` runs the CI gates.** The two gates listed above ([`audit:tenant-scoping`](#ci-gates-from-pleachcompliance) and `audit:harness-event-log-tenant-id-required`) only enforce the invariant if CI runs them. The workflow file is what makes "stamped on every write" a structural promise instead of an aspirational one. ## Where to go next [#where-to-go-next] <Cards> <Card title="Compliance" href="/docs/compliance" description="The @pleach/compliance plugin — scrubbers, hash chain, typed records, and the CI gates that come with it." /> <Card title="Scrubbers" href="/docs/scrubbers" description="The Scrubber contract, the four bundled scrubbers, and the KeyedRegex extension shape." /> <Card title="Hash chain" href="/docs/hash-chain" description="The prev_hash + row_hash columns on harness_event_log and the verification walk." /> <Card title="Typed records" href="/docs/typed-records" description="SynthesisQualityRecord, InterruptDecisionRecord, and the rest of the typed audit set." /> <Card title="Tenant facet" href="/docs/tenant-facet" description="The runtime.tenant config the CI gates and the chain both lean on." /> <Card title="Safety" href="/docs/safety" description="The capability-subtracting policy contract — refuse, rewrite, or annotate, but never generate." /> <Card title="Providers" href="/docs/providers" description="The family-strict cascade and the permittedFamilies constraint that locks it." /> <Card title="Audit ledger" href="/docs/audit-ledger" description="The harness_auditable_calls schema, indexes, and retention." /> <Card title="Eval and replay" href="/docs/eval-and-replay" description="runtimeMode, recording, and the diff engine that powers replay defense." /> </Cards> --- # @pleach/replay (/docs/replay) `@pleach/replay` is the event-granular replay client for the `@pleach/core` agent runtime. It opens a `ReplayHandle` over a chat's event log, walks rows in `sequence_number` order via the canonical `runtime.events.iterate` + `runtime.events.fold` surface, and reconstructs a `HydratedHarnessState` deterministically. Walking the rows in order is walking the lattice — every branch the agent grew, re-folded through the same reducer for byte-identical reconstructed state. (That byte-identity is the deterministic *fold* of a recorded log; reproducing the original *execution* is version-pinned, not bit-guaranteed — see [Re-execution reproducibility](#re-execution-reproducibility).) This page is the SKU reference. For the DIY workflow using `@pleach/core` primitives directly today (without this SKU), see [Eval and replay](/docs/eval-and-replay). <SourceMeta pkg="{ name: "@pleach/replay", href: "https://www.npmjs.com/package/@pleach/replay" }" source="{ label: "github.com/pleachhq/replay", href: "https://github.com/pleachhq/replay" }" /> ## Status [#status] <StatusBadge status="in-flight"> Phase A + Phase B </StatusBadge> The `ReplayClient` / `ReplayHandle` contract is stable and wired: `ReplayClient`, `ReplayHandle`, the `ReplayStepResult` / `ReplayDoneResult` return shapes, the error hierarchy, and the observation surface (`currentState()`, `currentSequenceNumber()`, `close()`) all ship in their final form. `ReplayHandle.step()`, `seek()`, and `replayTurn()` are live — they walk the canonical event log via `runtime.events.iterate` and fold each row through the same `hydrateFromEvents` reducer `@pleach/core` uses, so the incremental step state is byte-identical to a full fold. The Phase B `ReplayRuntime` factory (`createReplayRuntime`) ships alongside with real bodies for all four entry-point surfaces. `replayTurn(input)` walks the canonical event log via `runtime.events.iterate`. `fromSnapshot(input)` deserializes a real `HydratedHarnessState` via the same iterator * `hydrateFromEvents` from `@pleach/core/eventLog` when `config.sessionRuntime` is provided; without `sessionRuntime`, it falls back to the slice-4 minimal deterministic projection. `fork(opts)` walks the original event stream up to `forkAtSequenceNumber` and splices in-memory synthetic events to materialize a spliced-branch state (IMMUTABLE — the original log is read-only). `aggregateMultiTenant(opts)` fans out per `opts.tenantIds`, walks each tenant's events via the additive `tenantId?` iterate API, and folds the per-tenant `HydratedHarnessState` projections — with `aggregation: "merged"` the body folds tenant outcomes into one merged state slot. `verifyChainIntegrity(input, { reader })` delegates to `verifyChainForChat` from `@pleach/core/eventLog`. Zero throw sites remain on `createReplayRuntime`'s four entry points. `PACK_148_FIRST_SLICE_NOT_IMPLEMENTED_MESSAGE` stays exported for back-compat detection only. If you need a working replay loop right now, the workflow documented in [Eval and replay](/docs/eval-and-replay) covers it with the substrate primitives alone — and the `createReplayRuntime` factory above is the typed wrapper that lands on top of that workflow. ## Install [#install] <Tabs items="['npm', 'pnpm', 'yarn', 'bun']" groupId="pkg"> <Tab value="npm"> ```bash npm install @pleach/replay ``` </Tab> <Tab value="pnpm"> ```bash pnpm add @pleach/replay ``` </Tab> <Tab value="yarn"> ```bash yarn add @pleach/replay ``` </Tab> <Tab value="bun"> ```bash bun add @pleach/replay ``` </Tab> </Tabs> `@pleach/core` is a peer dependency. ```typescript import { ReplayClient, createDefaultReplayClient, ReplayError, ReplayDivergenceError, ReplayCacheMissError, ReplayUnknownEventError, NotImplementedError, } from "@pleach/replay"; import type { ReplayHandle, ReplayStepResult, ReplayDoneResult, FromEventLogOptions, CacheReadMode, } from "@pleach/replay"; ``` ## `ReplayClient` [#replayclient] `ReplayClient` is constructed against a `SessionRuntime` and an optional `cacheBackend`. The runtime is the only required dependency — the cache backend is consulted by `step()` for rows carrying a `payload.cacheKey` (see [Cache miss policy](#cache-miss-policy)); omit it for a pure event-log replay. ```typescript import { SessionRuntime } from "@pleach/core"; import { ReplayClient, type ReplayRuntimeFacet } from "@pleach/replay"; const runtime = new SessionRuntime({ /* ... */ }); // ReplayClient reads only the runtime's `events` facet (iterate + fold). const replay = new ReplayClient({ runtime: runtime as unknown as ReplayRuntimeFacet, }); ``` The constructor validates that `runtime` is present and throws `TypeError` otherwise. The error message is precise at the boundary — the client is meaningless without a runtime to fold against. ### Open a handle — `fromEventLog(chatId, opts)` [#open-a-handle--fromeventlogchatid-opts] ```typescript const handle = await replay.fromEventLog("chat_abc", { tenantId: "tenant_xyz", // REQUIRED — no default fromSequenceNumber: 0, // exclusive lower bound upToSequenceNumber: 42, // inclusive upper bound cacheReadMode: "cross-mode-readable", }); ``` `fromEventLog` validates its inputs eagerly: `chatId` must be a non-empty string, `tenantId` must be a non-empty string, the options object must be present. Each failure throws `TypeError` with a message naming the offending field, so the bad call site is the failure site. `cacheReadMode` defaults to `cross-mode-readable` — the production-fidelity mode where cache misses throw. See [Cache miss policy](#cache-miss-policy) below. The handle is single-use. Don't `fromEventLog` the same `(chatId, tenantId)` twice and expect the second call to resume the first — each call returns a fresh handle. To re-position inside an existing replay, use `seek()`. ## `tenantId` is required, with no default [#tenantid-is-required-with-no-default] `tenantId` is mandatory on `FromEventLogOptions` and there is no inference from `chatId`. The contract is enforced at the type level (`readonly tenantId: string`) and at runtime (an empty string, `null`, or `undefined` throws `TypeError`). ```typescript // ❌ Throws TypeError — tenantId is REQUIRED. await replay.fromEventLog("chat_abc", {} as any); // ❌ Throws TypeError — empty string is not a valid tenant. await replay.fromEventLog("chat_abc", { tenantId: "" }); // ✅ Explicit tenant from the host's auth context. await replay.fromEventLog("chat_abc", { tenantId: req.user.orgId }); ``` This is deliberate. Replay inherits the substrate's RLS posture: a cross-tenant replay is a cross-tenant read, and silently defaulting `tenantId` to anything (the chat's recorded tenant, the current request, `"default"`) would turn a forensic tool into a tenant-isolation vulnerability. The host's auth context is the only place the answer can come from, so the package makes you pass it. If you want to replay another tenant's chat as an admin, that's a per-host policy decision — fetch the tenant from your admin context and pass it explicitly. The package doesn't ship an "escape hatch" that would let an unscoped replay leak past a distracted code reviewer. ## `ReplayHandle` [#replayhandle] The handle is the per-replay context. Six members, all wired: | Member | Status | | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | `step(): Promise<ReplayStepResult \| ReplayDoneResult>` | Live — advances one event, folds, returns the step result (or `{ done: true }` past the window) | | `seek(sequenceNumber: number): Promise<void>` | Live — deterministic re-fold from `fromSequenceNumber` to the target (idempotent) | | `replayTurn(messageId?): Promise<ReplayTurnResult>` | Live — loops `step()` to the next `turn.completed` boundary | | `currentState(): HydratedHarnessState` | Live — zero-state before the first step, folded state thereafter | | `currentSequenceNumber(): number` | Live — the most-recently-processed `sequence_number` (or `fromSequenceNumber` pre-step) | | `close(): Promise<void>` | Live — releases the iterator, idempotent | The Phase B `ReplayRuntime` factory (`createReplayRuntime`) exposes a parallel surface for snapshot-rooted replay, fork-from-prefix, and hash-chain verification. Its state at the current cut: | `ReplayRuntime` method | Status | | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `replayTurn(input)` | Live — real event-walk via `runtime.events.iterate` | | `fromSnapshot(input)` | Live — real `HydratedHarnessState` deserialization via `runtime.events.iterate` + `hydrateFromEvents` when `config.sessionRuntime` is provided; deterministic-projection fallback otherwise | | `verifyChainIntegrity(input, { reader })` | Live — delegates to `verifyChainForChat` from `@pleach/core/eventLog` | | `fork(opts)` | Live — walks the original event stream to `forkAtSequenceNumber`, splices synthetic events in-memory, applies `hydrateFromEvents` for the spliced-branch state when `config.sessionRuntime` is provided; slice-4 deterministic-projection fallback otherwise | | `aggregateMultiTenant(opts)` | Live — fans out per `opts.tenantIds`, walks each tenant's events via the additive `tenantId?` iterate API, folds the per-tenant projections; `aggregation: "merged"` folds tenant outcomes into one merged state slot | `verifyChainIntegrity` pairs with the `verifyChainForChat` and `generateProof` exports from `@pleach/core/eventLog` — see [Tamper-evident hash chain](/docs/hash-chain) for the chain verification surface and proof-generation contract. ### `currentState()` [#currentstate] Returns the immutable folded state at the most recent step. Before any `step()` call, this is the canonical zero-state — the same shape `hydrateFromEvents` produces for a fresh session: ```typescript { interrupts: [], subagents: [], exports: [], userCards: [], eventCount: 0, } ``` The fresh-handle zero-state is frozen; you can read it across replay steps without defensive copies. Each `step()` returns a new `nextState` snapshot and `currentState()` reflects the latest folded state. ### `currentSequenceNumber()` [#currentsequencenumber] Returns the `sequence_number` of the most recently processed event, or `fromSequenceNumber` for a fresh handle. Useful for progress reporting and for choosing a `seek()` target. ### `close()` [#close] Releases the underlying iterator. Idempotent — calling `close()` twice never throws. Call it from a `finally` block when wrapping a replay in `try`/`catch`, the same way you'd close any async resource. ### `step()`, `seek()`, and `replayTurn()` [#step-seek-and-replayturn] ```typescript // Walk the log event by event: for (let r = await handle.step(); !("done" in r); r = await handle.step()) { console.log(r.eventType, r.sequenceNumber); console.log(handle.currentState()); // folded state as-of this event } // Jump to an absolute sequence (idempotent re-fold from the window start): await handle.seek(10); // Advance a whole assistant turn at once: const turn = await handle.replayTurn(); // next turn.completed console.log(turn.messageId, turn.stepResults.length); ``` `step()` advances one row, folds it through `hydrateFromEvents`, and returns a `ReplayStepResult` — or `{ done: true }` once the iterator (or the `upToSequenceNumber` window) is exhausted. It is the only forward-mutating path. `seek(n)` does **not** replay forward from the current cursor — it re-folds deterministically from `fromSequenceNumber` to `n`. That keeps `seek(n)` idempotent and independent of the order in which a caller `step()`s and `seek()`s; a subsequent `step()` continues from `n + 1`. Because incremental `step()` and full `seek()` re-fold share **one** reducer, N steps produce a state byte-identical to `seek(N)` — the determinism invariant below. `replayTurn(messageId?)` is a turn-granular convenience: it loops `step()` until the next `turn.completed` row (matching `payload.message_id` when supplied, or the next boundary overall when omitted), returning every intermediate `ReplayStepResult` plus the post-turn state. (`turn.completed` is the event-log row that records an assistant-turn boundary; `message.added` is a live-stream event, not a persisted log row.) ## Cache miss policy [#cache-miss-policy] The `cacheReadMode` option locks the handle's behavior when a provider-cache lookup misses. Three modes: | Mode | Cache state | Miss behavior | Use case | | ------------------------------- | ---------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------- | | `cross-mode-readable` (default) | Reads prod-mode cache from a test-mode runtime | Throws `ReplayCacheMissError` | Eval, forensics — production-fidelity replay | | `strict-mode` | Throws on ANY read (hit or miss) | Throws `ReplayCacheMissError` | Forensics — replay must derive from event log alone | | `best-effort` | Returns `null` on miss | Continues with `cacheLookup.via = "live"` | Education, partial replay, semester-over-semester analyses | The default throws because the typical replay caller — an eval or a forensic walk — wants to know when the recorded world diverges from what the cache can satisfy today. A silent fall-through to a live call masks the divergence. `best-effort` is the opt-in for callers who explicitly want partial replay to continue past a miss. The handle still records the lookup result on each `ReplayStepResult` so the caller can count `via: "live"` events after the fact. ```typescript // Forensic posture — fail-loud on any cache state. await replay.fromEventLog("chat_abc", { tenantId: req.user.orgId, cacheReadMode: "strict-mode", }); // Education posture — keep going past misses. await replay.fromEventLog("chat_abc", { tenantId: req.user.orgId, cacheReadMode: "best-effort", }); ``` Mode is locked at handle creation. To switch modes for the same chat, open a fresh handle — there's no `setCacheReadMode()` mutator because mid-replay mode flips would make the divergence signal incoherent. ## Canonical surface — `runtime.events.iterate` / `fold` [#canonical-surface--runtimeeventsiterate--fold] `ReplayClient` consumes only the public `SessionRuntime` surface. The handle reads through `runtime.events.iterate({ chatId, fromSequenceNumber, tenantId })` and folds each row through `hydrateFromEvents` from `@pleach/core/eventLog` — the same reducer `runtime.events.fold` uses — so no raw DB access, no `harness_event_log` schema coupling, no private accessors. This isolates replay from the substrate's storage layer. The dual-write → dual-read → snapshot-retire migration ladder documented in [Event-log projections](/docs/event-log-projections) happens behind the canonical surface; replay inherits each step transparently as the substrate flips. A consumer who depends on `runtime.events.fold` today picks up the eventual snapshot-table retirement without a code change. The structural contract `ReplayClient` requires is small enough to mock in tests: ```typescript import type { ReplayRuntimeFacet } from "@pleach/replay"; const mockRuntime: ReplayRuntimeFacet = { events: { iterate: ({ chatId, fromSequenceNumber }) => /* ... */, fold: async (projection) => /* ... */, }, }; const client = new ReplayClient({ runtime: mockRuntime }); ``` A real `SessionRuntime` from `@pleach/core` satisfies this structurally — no extra adapter is needed. ## Error hierarchy [#error-hierarchy] <Mermaid chart="classDiagram class ReplayError { +chatId +tenantId } class ReplayDivergenceError { live world diverged } class ReplayCacheMissError { +key +mode +sequenceNumber } class ReplayUnknownEventError { unknown event type } class NotImplementedError { implementation gap } ReplayError <|-- ReplayDivergenceError ReplayDivergenceError <|-- ReplayCacheMissError ReplayError <|-- ReplayUnknownEventError" /> `NotImplementedError` is a *separate* base — an implementation gap, not a divergence. It remains exported for any surface still landing, but the core `step()` / `seek()` / `replayTurn()` stepper no longer throws it; those methods are wired. Callers that catch `ReplayError` will not catch `NotImplementedError`, and that's deliberate: a missing handler is a bug to fix, not a replay outcome to attribute. Every replay error subclass carries `chatId` + `tenantId` so a caller catching at the boundary can attribute the failure back to the originating replay without re-deriving the scope. `ReplayCacheMissError` adds `key`, `mode`, and `sequenceNumber` so a caller can log which provider-cache key was missed at which point in the walk: ```typescript try { await handle.step(); } catch (err) { if (err instanceof ReplayCacheMissError) { log.warn({ chatId: err.chatId, tenantId: err.tenantId, key: err.key, mode: err.mode, sequenceNumber: err.sequenceNumber, }, "replay cache miss"); } throw err; } ``` `ReplayUnknownEventError` carries `eventType` + `sequenceNumber`. It signals a row the stepper cannot progress past — one with no usable `event_type` discriminator. Rows whose `event_type` the `hydrateFromEvents` projections don't model are not errors: they fold as no-ops (the reducer is intentionally lossy for event types it doesn't track), so the walk continues. To surface a custom domain event in the folded state, register a projection (see [Event-log projections](/docs/event-log-projections#custom-projections)). ## Eval coupling — DI via constructor [#eval-coupling--di-via-constructor] `@pleach/replay` does **not** import `@pleach/eval`. The dependency is the other direction: `@pleach/eval` accepts a `ReplayClient` instance through `EvalSuite`'s `setReplayClient()` method and delegates replay to it. (`EvalSuiteOptions` requires `suiteId`; it has no `replay` field.) ```typescript import { SessionRuntime } from "@pleach/core"; import { createReplayRuntime } from "@pleach/replay"; import { EvalSuite, type EvalReplayClient } from "@pleach/eval"; const runtime = new SessionRuntime({ /* ... */ }); const tenantId = "tenant_xyz"; const replayRuntime = createReplayRuntime({ tenantId, sessionRuntime: runtime }); // The eval side consumes replay through the structural `EvalReplayClient` // contract — `replay(chatId) => { output }`. Adapt the turn-granular // `ReplayRuntime` to that shape. const replay: EvalReplayClient = { async replay(chatId) { const { state } = await replayRuntime.replayTurn({ chatId, tenantId }); return { output: String(state ?? "") }; }, }; const suite = new EvalSuite({ suiteId: "regression", runtime }); suite.setReplayClient(replay); ``` `createReplayRuntime({ tenantId, sessionRuntime })` builds the turn-granular `ReplayRuntime` from a `SessionRuntime`; the small adapter above is what satisfies `@pleach/eval`'s `EvalReplayClient` DI slot. The two SKUs stay decoupled — `@pleach/replay` never imports `@pleach/eval`, and the adapter lives in host code. Two consequences of the DI direction: * **`@pleach/replay` is installable without `@pleach/eval`.** A host that only needs replay (forensics, audit walks, fork-from-checkpoint) takes the smaller surface. * **Replay's tests do not depend on eval.** The contract gates shipped with `@pleach/replay` are self-contained — see the `replayClient.test.ts` suite in the package repo. ## Determinism contract [#determinism-contract] Two `fromEventLog()` calls with identical arguments produce handles that walk byte-identically: stepping each to `done` yields the same `HydratedHarnessState` at every step, and that state equals a single `hydrateFromEvents` over the same rows. Incremental `step()` and full `seek()` re-fold share one reducer, so there is no second code path to drift — `seek(N)` equals N steps equals a full fold, byte for byte. This is the load-bearing guarantee replay rests on. If two replays of the same recorded slice diverge, the divergence is the signal — something non-deterministic slipped into the chain. See [Determinism](/docs/determinism) for the four ways the chain can break. `createStrictHandleReplay` (from `@pleach/replay/strict`) is the callable gate for exactly this: it opens N independent handles over the same `(chatId, tenantId, window)`, walks each to `done`, and reports `{ deterministic, steps, firstDivergenceAt? }` — the step index where the per-step state first diverges. ```typescript import { createStrictHandleReplay } from "@pleach/replay/strict"; const strict = createStrictHandleReplay({ client: replayClient }); const verdict = await strict.replay({ chatId, tenantId }); if (!verdict.deterministic) { console.error("non-determinism at step", verdict.firstDivergenceAt); } ``` (`createStrictReplay` is the sibling gate over the `ReplayRuntime.replayTurn` surface; `createStrictHandleReplay` gates the event-granular `ReplayHandle` stepper.) ## Re-execution reproducibility [#re-execution-reproducibility] The determinism contract above is about replaying the **recorded log** — re-folding immutable rows is byte-identical because there is one reducer and no second code path. Reproducing the original **execution** — re-running the agent and getting the same model bytes back — is a different, weaker guarantee, and it is important to be precise about it. True bit-for-bit re-execution is **not generally achievable against a hosted model provider**. The provider can change a model, a tokenizer, a sampler, or its internal routing without surfacing a version you can pin to (OpenRouter silently swapping an upstream is the canonical example). What replay *can* do is make every **controllable** variable a stamped version, so a divergence becomes *attributable* rather than mysterious. Record, as versions on the run, every end-to-end variable property: * **model** id and **provider** (and transport: native / openrouter / byok) * **`@pleach/core` + SKU package versions** and the upstream **provider SDK version** * **client / host package version** making the call * **config version** — the sampling params (`temperature`, `topP`, `maxTokens`, `stopSequences`, `seed`) plus system-prompt and safety-policy identity (these are exactly what the seam cache fingerprint keys on) * **plugin versions** — each registered `HarnessPlugin` With all of these pinned, replay reconstructs the same *decision context*; what it cannot pin is provider-internal change that ships without a version. **The closer you control the stack, the closer you get to true byte-identity** — a **locally hosted model** (fixed weights, fixed inference engine, pinned sampler + seed) can approach bit-for-bit re-execution, because there is no unversioned third party left in the loop. ## What's NOT in this package today [#whats-not-in-this-package-today] Honest about the current scope: * **No `StrictReplay` execution diff.** The handle determinism gate (`createStrictHandleReplay`) walks N independent handles over the same log and asserts byte-identical per-step state — it catches non-determinism in the replay itself. Comparing a replay against a *fresh execution* and reporting the first divergence (the live-vs- recorded diff) is still a follow-up slice. * **No automatic cache-key population (yet).** The `ReplayHandle` cache posture consults a `cacheBackend` for rows carrying an explicit `payload.cacheKey`, and `tool.completed` / `turn.completed` expose an optional typed `cacheKey` slot for it. The content-hash deriver (`deriveContentHashKey`) now lives in `@pleach/core/cache` (relocated there so core can use it without the forbidden `core → replay` import; `@pleach/replay/cache` re-exports it). The remaining step is wiring the runtime to derive + stamp the key at turn-emit time — left as an opt-in follow-up because hashing the full message set every turn is a real cost for a replay-only benefit. Until then the key is host- / `createCacheMiddleware`-populated. * **No verification CLI.** The tamper-evidence walk over the event log's hash chain (see [Tamper-evident hash chain](/docs/hash-chain)) ships as live runtime methods today (`verifyChainIntegrity` + the underlying `verifyChainForChat` / `generateProof` exports from `@pleach/core/eventLog`); a `pleach-replay verify-chain` CLI wrapper lands alongside the typed fork API. The practical surface today: construct a `ReplayClient`, open a handle for a `(chatId, tenantId)` pair, and `step()` / `seek()` / `replayTurn()` through the event log — reading `currentState()` for the folded `HydratedHarnessState` at each point. The substrate-only workflow at [Eval and replay](/docs/eval-and-replay) covers the DIY path against `@pleach/core` primitives. ## Related SKUs [#related-skus] * [`@pleach/core`](/docs/core) — the substrate replay reads through. `runtime.events.iterate`, `runtime.events.fold`, and `hydrateFromEvents` are the canonical surfaces replay consumes; no raw `harness_event_log` access. * [`@pleach/eval`](/docs/eval) — the canonical DI consumer. `EvalSuite` takes a `ReplayClient` through constructor; replay does not import eval. The dependency direction is one-way. * [`@pleach/compliance`](/docs/compliance) — writes the scrubbed, tenant-scoped rows replay walks. `verifyChainIntegrity` reads the hash chain `@pleach/core/eventLog` stamps under `c9PhaseBEnabled`. For the full SKU map see [Which SKU do I need?](/docs/which-sku). ## Where to go next [#where-to-go-next] <Cards> <Card title="Eval and replay" href="/docs/eval-and-replay" description="The DIY workflow against `@pleach/core` primitives — fingerprint, audit ledger, checkpoints, runtimeMode. The path you can adopt today without this SKU." /> <Card title="@pleach/eval" href="/docs/eval" description="The DI consumer SKU — EvalSuite wraps a ReplayClient for fixture-based regression eval." /> <Card title="Event-log projections" href="/docs/event-log-projections" description="GraphProjection<T>, runtime.events.iterate, and runtime.events.fold — the canonical surface this client consumes." /> <Card title="Tamper-evident hash chain" href="/docs/hash-chain" description="prev_hash / row_hash columns — the integrity guarantee that lets replay results stand as evidence." /> </Cards> --- # Deep research agent (/docs/research-agent) A research agent is the canonical multi-subagent workload: one anchor turn fans out into N parallel investigations, each of which may itself recurse. Pleach tracks the tree by construction — every subagent carries its parent's `turnId` and its own `subagentDepth`, so the whole investigation is one query against the ledger. This page walks the anchor → subagent shape, the fanout limits that protect the budget, and the rollup query that turns the tree into a UI. **Related shapes.** [Coding agent](/docs/coding-agent) if subagents run code in a sandbox. [Customer support agent](/docs/customer-support-agent) if the anchor turn is part of a longer-lived support session. [Multi-tenant SaaS agent](/docs/multi-tenant-saas-agent) if one runtime serves many research customers. ## What you're building [#what-youre-building] An agent that takes a research question and returns a synthesized answer, citing the sources it consulted. Under the hood: * The anchor agent decomposes the question into sub-questions. * Each sub-question dispatches a `web_search` subagent. * Each subagent has its own bounded tool surface and its own context window. * The anchor synthesizes the subagent returns into one answer. The anchor and the subagents all write to the same audit ledger. The tree structure is encoded in `parent_turn_id` and `subagent_depth`. ## Subagent spec [#subagent-spec] A subagent spec is authored as a **skill** — a markdown file with YAML frontmatter that the `SkillLoader` discovers at session start. The skill's `execution` block (`maxSteps`, `timeout`) marks it subagent-capable; its `intents` field is what the orchestrator's intent classifier routes against. `skills/builtin/web-search/SKILL.md`: ```markdown --- name: web-search description: Investigate one sub-question. Return up to 5 cited findings. allowed-tools: - search_web - fetch_page intents: - research - literature_review activation: intent-matched execution: maxSteps: 6 timeout: 60000 --- You investigate one focused question. Cite every claim with a URL. Return at most 5 findings. Do not synthesize across findings — that's the anchor's job. ``` The subagent's tool surface is bounded by `allowed-tools` — it can't escalate, it can't call the anchor's tools, and it can't dispatch past `SUBAGENT_LIMITS.maxDepth`. The boundary is structural, not prompted. See [Skills](/docs/skills) for the full `Skill` shape, the three-source merge order (builtin / org / user), and the `getByIntent` / `getByName` loader API. ## Dispatch from the anchor [#dispatch-from-the-anchor] The anchor agent runs on `SessionRuntime` with subagent concurrency enabled. The runtime picks a registered skill spec when the planner emits a subagent task or the intent classifier routes a sub-question to a subagent-capable skill. The cap is enforced at the runtime level. ```typescript import { SessionRuntime, AiSdkProvider, SUBAGENT_LIMITS, } from "@pleach/core"; import { SupabaseAdapter, createSupabaseAdapter } from "@pleach/core"; import { createOpenRouter } from "@openrouter/ai-sdk-provider"; const openrouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY!, }); const runtime = new SessionRuntime({ storage: createSupabaseAdapter({ client: supabase }), userId: "user_123", tenantId: "tenant_abc", // Subagent concurrency — off by default; cap is per-session. // The substrate enforces SUBAGENT_LIMITS.maxDepth (3) and // SUBAGENT_LIMITS.maxPerTurn (5) independently. enableSubagentConcurrency: true, maxConcurrentSubagents: Math.min(5, SUBAGENT_LIMITS.maxConcurrent), // Anchor-side orchestrator wiring (provider, intent detector, // prompt contributions) goes through orchestratorConfig; the // anchor's tool surface is registered with the orchestrator, // not on this config object directly. orchestratorConfig: { provider: new AiSdkProvider({ model: openrouter("anthropic/claude-sonnet-4-5"), }), }, }); ``` `maxConcurrentSubagents` caps how many subagents run in parallel within one session — additional spawns queue until a slot frees. `SUBAGENT_LIMITS` are the substrate-enforced hard ceilings (read-only constants): | Limit | Value | What it caps | | --------------- | --------- | ------------------------------------ | | `maxDepth` | `3` | Parent → child → grandchild nesting | | `maxConcurrent` | `3` | Concurrent subagents per session | | `maxPerTurn` | `5` | Subagents spawned in one parent turn | | `timeoutMs` | `120_000` | Default per-subagent timeout | A consumer-supplied `maxConcurrentSubagents` higher than `SUBAGENT_LIMITS.maxConcurrent` is clamped to the substrate ceiling. See [Subagents](/docs/subagents) for the full spawn / lifecycle surface. ## What the event stream looks like [#what-the-event-stream-looks-like] Subagent events are namespaced. The frontend can render a tree without rebuilding the structure from string parsing. ```text turn-start (anchor) tool-call synthesize_findings.preflight subagent-spawn web_search#1 question="...", depth=1 subagent-spawn web_search#2 question="...", depth=1 subagent-spawn web_search#3 question="...", depth=1 text-delta [web_search#1] "checking..." tool-call [web_search#1] search_web tool-result [web_search#1] search_web ok subagent-end web_search#1 ok, 4 findings ... tool-call synthesize_findings text-delta "Based on the investigation..." turn-complete ``` The `[name#id]` prefix is added by the runtime; you don't have to maintain it. See [Stream events](/docs/stream-events) for the event taxonomy. ## The tree query [#the-tree-query] Every subagent's audit rows carry `parent_turn_id` and `subagent_depth`. The investigation tree is a recursive CTE. ```sql with recursive tree as ( select turn_id, parent_turn_id, subagent_depth, tool_name, payload from harness_auditable_calls where turn_id = $1 union all select c.turn_id, c.parent_turn_id, c.subagent_depth, c.tool_name, c.payload from harness_auditable_calls c join tree t on c.parent_turn_id = t.turn_id ) select * from tree order by subagent_depth, created_at; ``` This is enough to render the full investigation as a tree, with each node's tool calls, model outputs, and timing. ## Budget guard [#budget-guard] Subagent fanout is where budgets evaporate. Two patterns to put in production from day one: 1. **Per-turn token budget.** Set `maxTokensPerTurn` on the runtime. The runtime aborts the turn when the cumulative token count across the anchor and all subagents crosses the line. The audit ledger records the cutoff. 2. **Per-tenant rate limit.** Outside the runtime, gate `runtime.runTurn` calls per tenant. The ledger's `tenant_id` makes after-the-fact attribution one query, but it doesn't stop a runaway request before it costs you. ## Eval: lock the tree, vary the model [#eval-lock-the-tree-vary-the-model] The investigation tree is recordable. Record one golden run, then replay it against a fresh runtime built around a different provider. The diff tells you whether the new model investigates differently or just synthesizes differently. The recording lives on the `harness_event_log` ledger as a sequence of `subagent.spawned` / `subagent.completed` rows keyed by `chatId` + `turnId`. Replay is built on top via `@pleach/replay`'s `ReplayClient` (which consumes the public `runtime.events.iterate` surface — no raw DB access). ```typescript import { SessionRuntime, AiSdkProvider } from "@pleach/core"; import { createReplayRuntime } from "@pleach/replay"; const challenger = new SessionRuntime({ storage: createSupabaseAdapter({ client: supabase }), userId: "user_123", tenantId: "tenant_abc", enableSubagentConcurrency: true, orchestratorConfig: { provider: new AiSdkProvider({ model: openrouter("anthropic/claude-opus-4-7"), // the candidate }), }, }); const replayRuntime = createReplayRuntime({ sessionRuntime: challenger, tenantId: "tenant_abc", }); // Replays the new provider through the same anchor → subagent dispatch tree // captured in the ledger; diverging spawn shapes surface in the rebuilt state. const result = await replayRuntime.replayTurn({ chatId: goldenChatId, tenantId: "tenant_abc", messageId: goldenTurnId, }); // `result.state` is the reconstructed HydratedHarnessState (typed `unknown`) — // inspect it to compare the subagent spawn tree and the synthesis against the // recorded turn. console.log(result.state); ``` See [Eval and replay](/docs/eval-and-replay) for the recording workflow and the full `ReplayClient` surface. ## Project layout [#project-layout] Two adds on top of the [baseline](/docs/project-layout#a-layout-that-works): a `subagents/` module — separate from `tools/` because subagents have their own contract — and an `eval/` entry point so the "lock the tree, vary the model" walk is one command, not a hand-assembled script. ``` my-app/ src/ pleach/ runtime.ts # constructs SessionRuntime + SkillLoader tools/ synthesize-findings.ts # the anchor's tools eval/ replay-fixed-tree.ts # createReplayRuntime + replayTurn app/ api/agents/[id]/route.ts skills/ builtin/ web-search/SKILL.md # subagent spec — own tools + execution block deep-read/SKILL.md # subagent spec evals/ fixtures/ # recorded sessions, gitignored or LFS ``` What changes from the baseline: * **`skills/` is separate from `src/pleach/tools/`.** A subagent isn't a tool — it has its own tool list (declared in the skill's `allowed-tools` frontmatter), its own [budget guard](#budget-guard), and its own channel for stream isolation. Skill files live at the repo root under `skills/builtin/<name>/SKILL.md` because that's where the `SkillLoader` scans by default. Keeping subagent specs out of `src/pleach/tools/` stops the two from drifting into each other and matches the [Subagents](/docs/subagents) contract. * **`eval/` is in `src/`, not `tests/`.** The replay path is product code — `replayTurn` runs against the same `SessionRuntime` your host route uses, just with `mode: "replay"`. Putting it under `src/pleach/eval/` means it imports the live runtime, not a test double. * **`evals/fixtures/` holds recorded sessions.** The fixtures are what the [eval section](#eval-lock-the-tree-vary-the-model) diffs against. Treat them like checked-in test data: small fixtures in git, large ones in LFS or object storage. ## Where to go next [#where-to-go-next] <Cards> <Card title="Subagents" href="/docs/subagents" description="The spawn surface, the workspace context, and SUBAGENT_LIMITS." /> <Card title="Channels" href="/docs/channels" description="Per-subagent stream isolation, so the UI can render the tree without rebuilding it." /> <Card title="Audit ledger" href="/docs/audit-ledger" description="The harness_auditable_calls schema the recursive tree query reads from." /> <Card title="Multi-tenant SaaS agent" href="/docs/multi-tenant-saas-agent" description="parent_turn_id rollup so a five-level fan-out still attributes to one user turn." /> </Cards> --- # Runtime construction (/docs/runtime-construction) [Getting started](/docs/getting-started) shows the route-handler path — `createPleachRoute()` wraps everything below into one fetch handler. This page is for the cases where you need direct access to the runtime: * A headless script, eval harness, or test fixture (no HTTP boundary). * A multi-tenant SaaS with per-tenant storage / policies / plugins. * A custom transport that doesn't fit the `Request → Response` shape. * An existing React app that wants the hook surface without the route handler. Three entry points, ordered by depth. ## `createPleachRuntime` — the factory [#createpleachruntime--the-factory] One line, sensible defaults baked in: in-memory storage, the default `memoryCacheBackend`, no plugins, no safety policies. Use it in tutorials, eval harnesses, and headless scripts. ```typescript import { createPleachRuntime } from "@pleach/core"; const runtime = createPleachRuntime(); const { id: sessionId } = await runtime.sessions.create(); for await (const event of runtime.executeMessage(sessionId, "Hello")) { console.log(event.type, event); } ``` Every field on `CreatePleachRuntimeConfig` is optional. The typical form layers on a persistent storage adapter, an explicit `tenantId`, and a plugin set: ```typescript import { createPleachRuntime } from "@pleach/core"; import { SupabaseAdapter } from "@pleach/core/sessions"; const runtime = createPleachRuntime({ userId: "alice@example.com", tenantId: "acme-corp", storage: new SupabaseAdapter({ client: supabase }), plugins: [myAppPlugin], }); ``` Model and provider are not locked at factory creation — per-session locking is the canonical pattern. Pass `provider` and `model` when you mint the session: ```typescript const session = await runtime.sessions.create({ provider: { type: "anthropic" }, model: { id: "claude-sonnet-4-5" }, }); ``` The factory is a thin layer over the `SessionRuntime` constructor; the `advanced?: Partial<SessionRuntimeConfig>` pass-through reaches the full config surface. ## `SessionRuntime` — the constructor [#sessionruntime--the-constructor] When you need persistent storage, checkpointing, or per-request user identity, drop to the constructor directly: ```typescript import { SessionRuntime } from "@pleach/core"; import { SupabaseAdapter } from "@pleach/core/sessions"; import { SupabaseSaver } from "@pleach/core/checkpointing"; const runtime = new SessionRuntime({ storage: new SupabaseAdapter({ client: supabase }), checkpointer: new SupabaseSaver({ client: supabase }), userId: "user_123", }); const { id: sessionId } = await runtime.sessions.create(); for await (const event of runtime.executeMessage(sessionId, "Hello")) { console.log(event.type, event); } ``` The full constructor surface — including plugin sets, the provider, the channel registry, the interrupt manager, and the extension loader — lives at [SessionRuntime](/docs/session-runtime). ## `HarnessProvider` — the React surface [#harnessprovider--the-react-surface] If you want the hook surface without the route handler — for example, embedding the runtime in an Electron app or a React Native shell — use `@pleach/core/react` directly: ```tsx // app/layout.tsx import { HarnessProvider, useHarness } from "@pleach/core/react"; function App() { return ( <HarnessProvider userId="user_123"> <ChatComponent /> </HarnessProvider> ); } function ChatComponent() { const { createSession, sendMessage, messages } = useHarness(); return ( <div> <button onClick={() => createSession()}>New Session</button> {messages.map((m) => ( <div key={m.id}>{m.content}</div> ))} </div> ); } ``` The provider owns one `SessionRuntime` instance per mount and hands it down through context. See the [React](/docs/react) page for the full hook + facade surface. ## Mock mode for tests and CI [#mock-mode-for-tests-and-ci] For local development without a database or provider account, `HARNESS_MOCK_MODE=true` gates the mock **tool executor** — a host-side utility (`createMockToolExecutor` / `isMockModeEnabled`) that synthesizes tool results. It is the only thing that reads the env var; the runtime constructors (`new SessionRuntime`, `createPleachRuntime`) do not, so wiring mock mode into a runtime is a manual step — construct the mock executor and pass it in, and pair it with the in-memory `MemoryAdapter` + `MemorySaver` yourself. The mock executor does not classify seams or write ledger rows; it only produces synthesized tool outputs. (The default provider-decision ledger is `NoopProviderDecisionLedger`, which drops all writes unless a host supplies a real ledger.) A regression test (planned: `@pleach/eval`; DIY today) composes these pieces itself. ## Choosing between the three [#choosing-between-the-three] | If you're building... | Reach for | | ----------------------------------------------------------- | -------------------------------------------------- | | A tutorial, eval harness, headless smoke script | `createPleachRuntime` | | An HTTP chat handler + UI in five minutes | [`@pleach/core/quickstart`](/docs/getting-started) | | A multi-tenant SaaS, regulated deployment, custom transport | `new SessionRuntime(...)` directly | | An existing React app composing the runtime | `HarnessProvider` + `useHarness` | The four are the same substrate at different abstraction layers — a `createPleachRoute` handler internally constructs a `SessionRuntime` per request; `createPleachRuntime` is the substrate factory the handler delegates to; `HarnessProvider` wraps the same constructor for React. ## Already on Anthropic Enterprise or OpenAI Enterprise? [#already-on-anthropic-enterprise-or-openai-enterprise] Pleach runs underneath. `AnthropicSdkProvider` and the AI SDK's OpenAI provider both accept your existing workspace or project admin key; the runtime stamps `tenantId` on every audit row so per-axis rollup runs inside the Workspace or Project you already pay for. See [Migrating from Anthropic Enterprise](/docs/migrating-from-anthropic-enterprise) and [Migrating from OpenAI Enterprise](/docs/migrating-from-openai-enterprise) for the contract-composition walk-through. ## Where to go next [#where-to-go-next] <Cards> <Card title="SessionRuntime" href="/docs/session-runtime" description="Full constructor surface — storage, checkpointer, plugins, channels, extensions." /> <Card title="Providers" href="/docs/providers" description="Wire a provider — AI SDK, Anthropic SDK, or your own AgentProvider implementation." /> <Card title="Turn lifecycle" href="/docs/turn-lifecycle" description="What executeMessage does — the event stream, the four seams, the audit row." /> <Card title="Subpath exports" href="/docs/subpath-exports" description="The full @pleach/* subpath surface, per package." /> </Cards> --- # Runtime inspector (/docs/runtime-inspector) Runtime inspector is one surface in the **observability** [thematic island](/docs/concept-clusters#what-lives-outside-the-cluster-pattern) — siblings of [observability](/docs/observability) (the orientation page), [OTel spans](/docs/otel-observability), and [lineage](/docs/lineage). `inspectRuntime(runtime)` returns a typed snapshot of which `contribute*` hooks the plugins on a constructed `SessionRuntime` actually implement. Use it to answer one question: of the 38 known hooks, which ones a registered plugin covers, which ones are unwired, and which ones still use the deprecated bare-property form slated for removal. The hook count is **\~45**: 41 modern `contribute*` hooks (per the `audit:plugin-contract-completeness` baseline — 38 wired end-to-end and 3 baselined awaiting consumer) plus the soft-deprecated bare-property forms (`tools`, `events`, `customEventTypes`, `batchingHints`) the inspector still inventories so it can flag plugins that haven't migrated. The 5 hard-deprecated lifecycle hooks (`onSessionCreated`, `onToolCompleted`, `onMessageAdded`, `queryExtensions`, `sandboxAvailability`) have been retired. The list is a static snapshot in the inspector source; new hooks append to it when they land in `HarnessPlugin`. The inspector is read-only. It doesn't mutate the runtime, doesn't register plugins, and doesn't trigger any `contribute*` hook — it walks the registered plugin list and checks which properties are defined as functions. Safe to call from a startup probe. ```typescript import { inspectRuntime } from "@pleach/core/inspector"; import type { InspectionReport, CapabilityReport, PluginReport, CapabilityDescriptor, } from "@pleach/core/inspector"; ``` <SourceMeta subpath="@pleach/core/inspector" source="{ label: "src/inspector/", href: "https://github.com/pleachhq/core/tree/main/src/inspector" }" /> ## What it returns [#what-it-returns] `InspectionReport` carries the runtime identity, a per-plugin contributions map, and one row per known hook. | Field | Type | Purpose | | --------------------- | ----------------------------------------------- | ----------------------------------------------- | | `runtime.tenantId` | `string` | The tenant the runtime is bound to | | `runtime.userId` | `string \| undefined` | The user, when one is bound | | `runtime.runtimeMode` | `"interactive" \| "deterministic" \| undefined` | The runtime's resolved mode | | `plugins` | `readonly PluginReport[]` | Per-plugin contributions, in registration order | | `capabilities` | `readonly CapabilityReport[]` | One row per known hook | ## `CapabilityReport` [#capabilityreport] Each capability row reports the status of one `contribute*` hook across all registered plugins. | Field | Type | Meaning | | -------------- | -------------------------------------------- | --------------------------------------------------------------- | | `name` | `string` | The hook name (matches the `HarnessPlugin` method) | | `deprecated` | `boolean` | `true` for hooks slated for removal per the retirement table | | `status` | `"wired" \| "deprecated-wired" \| "unwired"` | The discriminator (see below) | | `wiredCount` | `number` | Number of plugins implementing the hook | | `implementors` | `readonly string[]` | Plugin names that implement it, in registration order | | `description` | `string` | One-line description suitable for `runtime.help`-style surfaces | The `status` discriminator: * `"wired"` — at least one registered plugin implements the modern hook. * `"deprecated-wired"` — at least one plugin still uses the deprecated form. Migration to the modern hook is recommended; the codemod at `scripts/codemods/pleach-plugin-modernize.mjs` rewrites the four bare-property forms (see [Host adapter](/docs/host-adapter#pleach-plugin-modernize-codemod)). * `"unwired"` — no plugin implements the hook; the capability is unavailable on this runtime. `"deprecated-wired"` is the new fact. It's the row the inspector flags so a host that constructed a runtime out of older plugins can spot the migration debt without reading source. ## `PluginReport` [#pluginreport] | Field | Type | Meaning | | ------------------------- | ------------------- | ------------------------------------------------------ | | `name` | `string` | Plugin name as registered | | `contributions` | `readonly string[]` | `contribute*` hook names this plugin implements | | `deprecatedContributions` | `readonly string[]` | Subset of `contributions` that map to deprecated hooks | ## Calling it [#calling-it] ```typescript import { SessionRuntime } from "@pleach/core"; import { inspectRuntime } from "@pleach/core/inspector"; const runtime = new SessionRuntime({ plugins: [compliancePlugin, gatewayPlugin, myPlugin], userId: "user_123", }); const report = inspectRuntime(runtime); for (const row of report.capabilities) { if (row.status === "deprecated-wired") { console.warn( `[migrate] ${row.name} — implementors: ${row.implementors.join(", ")}`, ); } } for (const plugin of report.plugins) { if (plugin.deprecatedContributions.length > 0) { console.warn( `[plugin:${plugin.name}] still on deprecated: ${plugin.deprecatedContributions.join(", ")}`, ); } } ``` The call is synchronous. It reads the runtime's plugin registry without firing any seam. ## What the inventory covers [#what-the-inventory-covers] The current inventory breaks down as: * **41 modern `contribute*` hooks** — the active surface tracked by `audit:plugin-contract-completeness`. Examples: `contributePrompts`, `contributeRuntimeAwarePrompts`, `contributeSafetyPolicies`, `contributeFabricationDetectors`, `contributeFabricationDetectorRules`, `contributeFinalizationPasses`, `contributeSynthesisDirectiveBlocks`, `contributeIntentClassifiers`, `contributeIntentToolMap`, `contributeTools`, `contributeToolCouplingHints`, `contributeStreamObservers`, `contributeFabricationGuard`, `contributeCitationRuleSet`, `contributeChatManifestProvider`, `contributeRetryPolicy`, `contributeContinuationPolicy`, `contributeFamilyPivot`, `contributeFamilyExhaustedSurface`, `contributeMiddleware`, `contributeRuntimeAwareMiddleware`, `contributeSandboxBridge`, `contributeStreamEventHandlersAdapters`, `contributeGuestDeniedTools`, `contributePreserveDataRefFields`, `contributeHallucinatedToolDetectors`, `contributeStreamChunkHandlers`, `contributeDataChannelRefetch`, `contributeArtifactCacheReader`, `contributeGetDataHandlerFactory`, `contributeStructurePrefetcher`, `contributeEntityNameCounter`, `contributeGarbledOutputRecorder`, `contributeContinuationShadowResolver`, `contributeInterruptUIHandlers`, `contributeCitationEntityExtractor`, `contributeDetectionRules`, `contributePromptHints`, `contributeObserverConsumers`. 38 are wired end-to-end; 3 are baselined awaiting their consumer per the audit baseline. * **4 soft-deprecated bare-property forms** — `tools`, `events`, `customEventTypes`, `batchingHints`. Retired in 1.2.0; the inspector flags them so plugins migrate. Run `audit:harness-plugin-deprecated-usage:strict` to surface novel uses. The 5 hard-deprecated lifecycle hooks (`onSessionCreated`, `onToolCompleted`, `onMessageAdded`, `queryExtensions`, `sandboxAvailability`) have been retired and no longer appear on the `HarnessPlugin` type. The source of truth for the list is the `KNOWN_CAPABILITIES` constant in `src/inspector/index.ts`. The drift policy is documented inline: when a new hook lands in `HarnessPlugin`, the maintainer appends a `CapabilityDescriptor` so the inspector picks it up. ## Capability breadcrumbs (separate from the inspector) [#capability-breadcrumbs-separate-from-the-inspector] A separate runtime surface — `PluginManager` itself — emits a `[Pleach:capability-not-contributed]` console line on first call when a collector for one of **5 named capabilities** aggregates empty. The breadcrumb is dedup-keyed on `${sessionId}:${capability}` so it fires once per session per capability, not per turn. The 5 capabilities that emit the breadcrumb: | Capability | Hook | | --------------------- | -------------------------------- | | Stream observers | `contributeStreamObservers` | | Safety policies | `contributeSafetyPolicies` | | Fabrication detectors | `contributeFabricationDetectors` | | Tool-coupling hints | `contributeToolCouplingHints` | | Intent-tool map | `contributeIntentToolMap` | Payload shape: ```js console.log("[Pleach:capability-not-contributed]", { capability: "contributeStreamObservers", sessionId: "sess_018f...", hint: "register a HarnessPlugin that implements contributeStreamObservers() to provide this capability", }); ``` The inspector and the breadcrumb cover the same ground from different angles. The inspector is a synchronous pull — `inspectRuntime(runtime)` tells you the state right now. The breadcrumb is an asynchronous push — it surfaces at first-call time, dedup'd per session. Use the inspector at startup; rely on the breadcrumb in dev to catch a plugin you thought you'd registered but didn't. ## Pairing with the coverage audit [#pairing-with-the-coverage-audit] `audit:plugin-contract-completeness` is the upstream CI gate that asserts every `contribute*` hook on `HarnessPlugin` has a paired `PluginManager.collectX` accessor AND at least one substrate consumer site. Today's baseline reports **41 hooks** — 38 wired end-to-end, 3 baselined awaiting consumer (`contributeBatchingHints` on the 1.2.0 retirement track, `contributeEventTypes` collector-bypass per doc 50 §14.4 G5, and `contributeMiddleware` Stage 2 consumer rewires in flight). 0 novel dead hooks. The audit's hook count and the inspector's `KNOWN_CAPABILITIES` count don't collide — the audit covers *what plugins can contribute and the substrate will read*; the inspector covers *what a specific runtime construction actually has*. The audit answers a substrate question; the inspector answers a host question. ## Where to go next [#where-to-go-next] <Cards> <Card title="Plugin contract" href="/docs/plugin-contract" description="`definePleachPlugin` and the capability map the inspector inventories." /> <Card title="Host adapter" href="/docs/host-adapter" description="`host.{strategies, modules, raw}` — the typed host carveout the inspector sits alongside." /> <Card title="DevTools" href="/docs/devtools" description="`window.__HARNESS_DEVTOOLS__` — the browser-side counterpart for in-flight session state." /> <Card title="Harness loop" href="/docs/playground-and-devtools#the-harness-chatinit-flag" description="The `harness` chatinit flag folds this report (capabilities wired n/total) into a per-turn TurnReport for self-tests + CI." /> <Card title="Subpath exports" href="/docs/subpath-exports" description="`@pleach/core/inspector` — where the inspector ships from." /> </Cards> --- # Runtime strategies (/docs/runtime-strategies) `SessionRuntimeConfig` carries a second set of fields the substrate calls **strategies** — typed slots the host fills with domain code. A strategy is not a plugin. Plugins contribute through named slots on the `HarnessPlugin` contract; strategies are individual typed fields on the runtime config, each with one well-defined shape and one consumer inside the runtime or graph. When a strategy is unset, the consumer gracefully degrades — the runtime stays functional, the strategy-backed behavior goes inert. Plugins and strategies meet at the same constructor. The split is about shape, not lifecycle. A strategy is one typed field with one caller — easy to type, easy to swap in a test. A plugin is an extensible contributor that fills many slots on the `HarnessPlugin` contract (tier nodes, stream observers, prompts, fabrication detectors). Reach for a strategy when the runtime needs one answer from a host-specific function; reach for a plugin when a package wants to ship a bundle of contributions. For the plugin counterpart see [Plugin contract](/docs/plugin-contract); for how a host wires both at once see [Host adapter](/docs/host-adapter). <SourceMeta source="[ { label: "src/SessionRuntime.ts", href: "https://github.com/pleachhq/core/blob/main/src/SessionRuntime.ts" }, { label: "src/types/strategies.ts", href: "https://github.com/pleachhq/core/blob/main/src/types/strategies.ts" }, ]" /> ## Representative strategy slots [#representative-strategy-slots] | Field | Type | Purpose | | --------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `summaryExtractor` | `SummaryExtractor` | Domain-aware tool-result summarizer wired through the data-silo and recall-guard nodes. Default: empty-string no-op. | | `manifest` | `ChatManifestStrategy` | Side-effect surface for chat-manifest persistence (system notices, provider switches). Default: every method no-op. | | `toolCatalog` | `ToolCatalogProvider` | Tool descriptors, couplings, and `get_data` resolution. The graph asks here instead of importing a catalog directly. Default: empty. | | `toolExpansionTracker` | `ToolExpansionTracker` | Tracks tool-schema expansion requests across a session. Default: no-op tracker (zero pending always). | | `responseSanitizer` | `ResponseSanitizer` | Identity-by-default pass over the final synthesis text. | | `fabricationGuardStrategy` | `FabricationGuardStrategyBundle` | Typed bundle the post-graph fabrication node consumes. `quantitativePatterns` is required; every other slot is optional and degrades cleanly when omitted. See [Fabrication detection](/docs/fabrication-detection). | | `fabricationGuard` *(legacy)* | `FabricationGuard` | Older single-call shape. Retained for hosts that haven't migrated; the typed `fabricationGuardStrategy` bundle is the recommended path. | | `intentMentionDetector` | `IntentMentionDetector` | Extracts intent mentions used by the post-graph synthesis pipeline. | | `toolQualityEvaluator` | `ToolQualityEvaluatorStrategy` | Scores completed tool calls; the post-tool quality node reads the scored results. Default: empty array (branch skipped). | | `subagentExecutor` | `SubagentExecutorFn` | Spawns a child agent and returns its terminal result. Default: a no-op executor that emits a "not configured" failure so guard paths still emit `subagent.failed` without throwing. | | `capabilityRegistry` | `CapabilityRegistry` | Slim mirror of an orchestrator-side capability index. Surfaces tool clusters for diagnostics and fallback ordering. Default: empty stub. | | `planGuardProvider` | `PlanGuardProvider` | Plan-stage guard forwarded to `DefaultAgentGraphConfig` at graph-build time. Default: no-op. | | `repetitionGuard` | `RepetitionGuardSingleton` | Per-session repetition tracker forwarded to the graph. Default: no-op. | | `domainContextStrategy` | `DomainContextStrategy` | Per-runtime singleton consumed by the streamSingleTurn body to gate domain-aware detectors (token sets, regex predicates). Hosts wiring a domain (drug discovery, antibody engineering, etc.) supply a strategy whose predicate bodies enumerate that domain. Default: `createDefaultDomainContextStrategy()` — predicates return `false`, detectors fire on linguistic signal alone. | | `byokResolver` | `(provider: ProviderFamily) => string \| undefined` | Bring-your-own-key resolver. The runtime calls it once per provider when assembling auth headers; returning `undefined` falls back to the substrate's default key resolution. | | `sandboxToolNamePrefix` | `string` | Namespace prefix for the host's sandbox tools (default `"sandbox_"`). Override when your sandbox surface uses a different convention. | | `delegationToolPrefix` | `string` | Namespace prefix for subagent-dispatch tools (default `"delegate_to_"`). | | `asyncPlannerLlmCaller` | `AsyncPlannerLlmCaller` | Host-supplied LLM caller for the async planner. The runtime stays provider-agnostic; the caller routes through your model-family selection. | | `asyncPlannerStepParser` | `AsyncPlannerStepParser` | Companion step parser. When both planner strategies are set, the constructor installs an `AsyncPlannerHandler` so the cold-start gate can wait for an LLM plan. | The full set lives in `SessionRuntimeConfig` in [`src/SessionRuntime.ts`](https://github.com/pleachhq/core/blob/main/src/SessionRuntime.ts) and the shapes in [`src/types/strategies.ts`](https://github.com/pleachhq/core/blob/main/src/types/strategies.ts). ## Wiring strategies at construction [#wiring-strategies-at-construction] ```typescript import { SessionRuntime } from "@pleach/core"; import type { ToolCatalogProvider, SummaryExtractor, SubagentExecutorFn, } from "@pleach/core/types/strategies"; const toolCatalog: ToolCatalogProvider = { /* host implementation */ }; const summaryExtractor: SummaryExtractor = { extractSummary: (_toolName, result) => ({ description: typeof result === "string" ? result.slice(0, 280) : "", }), extractChainingFields: () => undefined, }; const subagentExecutor: SubagentExecutorFn = async (_config, _parent, options) => { /* host implementation */ return { id: options.id ?? crypto.randomUUID(), status: "completed", content: "", toolCalls: [], metrics: { totalSteps: 0, durationMs: 0 }, }; }; import { SupabaseAdapter } from "@pleach/core/sessions"; import { createClient } from "@supabase/supabase-js"; const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!); const runtime = new SessionRuntime({ storage: new SupabaseAdapter({ client: supabase }), userId: "user_123", toolCatalog, summaryExtractor, subagentExecutor, }); ``` ## `fabricationGuardStrategy` (typed bundle) [#fabricationguardstrategy-typed-bundle] The `fabricationGuardStrategy` slot takes the typed bundle. The empty default is the floor — `quantitativePatterns` is required, every other slot is optional and degrades cleanly when omitted. ```typescript import { SessionRuntime } from "@pleach/core"; import { DEFAULT_FABRICATION_GUARD_STRATEGY_BUNDLE, type FabricationGuardStrategyBundle, } from "@pleach/core/types/strategies"; const fabricationGuardStrategy: FabricationGuardStrategyBundle = { ...DEFAULT_FABRICATION_GUARD_STRATEGY_BUNDLE, quantitativePatterns: [/\b\d+(?:\.\d+)?%/g, /\$\d[\d,]*/g], }; const runtime = new SessionRuntime({ storage, userId: "user_123", fabricationGuardStrategy, }); ``` ## Strategy dep-injection via `harnessRuntime` accessors [#strategy-dep-injection-via-harnessruntime-accessors] A second category of strategy plumbing rides on `OrchestratorConfig.harnessRuntime` — a structural shape the runtime threads into the orchestrator config so the streamSingleTurn body and graph nodes can read per-runtime strategies without importing `SessionRuntime` directly. Each accessor is optional; CLI and raw orchestrator entry points leave the shape minimal (often only `getIntentPriorityTools`) and the consumer falls back to a default. The body lift (PO-A1) cohort threads four strategy slots this way: | Accessor | Returns | Read site | | ------------------------------------ | --------------------------------------- | ----------------------------------------------------------------- | | `getDomainContextStrategy?()` | `DomainContextStrategy` | streamSingleTurn body — domain predicate gating (C1). | | `getHallucinationDetectorFactory?()` | `HallucinationDetectorFactory` | Per-turn detector factory the body calls once at turn entry (C2). | | `getRepetitionGuard?()` | `RepetitionGuardSingleton \| undefined` | Per-session repetition tracker (C3). | | `getContinuationMetaToolNames?()` | `ReadonlySet<string>` | Depth-zero continuation gate exemption set (C4). | Sibling accessors carry plugin-contributed strategies and registries that share the same dep-inversion shape: | Accessor | Returns | Purpose | | ------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `collectFabricationGuard?()` | `FabricationGuardImpl \| null` | First-non-null fabrication-guard contribution from plugins. Threaded from `runtime.plugins.collectFabricationGuard()`. | | `collectGuestDeniedTools?()` | `GuestDeniedToolsThunk \| null` | Guest-mode tool denylist thunk. Invoked lazily so mid-session guest-mode flips reflect the contributing plugin's current state. | | `collectContinuationPolicy?()` | `ContinuationPolicy \| null` | Continuation-policy contribution consumed by the continuation resolver. | | `collectRuntimeAwareMiddleware?(ctx)` | `readonly MiddlewareContribution[]` | Per-turn middleware contributions resolved against backend, chat, and budget context. | | `collectRetryPolicy?()` | `RetryPolicyContribution \| null` | Last-wins retry-policy contribution consumed by the guarded stream runner. | | `collectCitationEntityExtractor?()` | `CitationEntityExtractor \| null` | First-non-null citation-entity extractor for the post-response citation injection step. | | `getSafetyRegistry?()` | `{ collectActiveContributions(): readonly SafetyContribution[] }` | Per-runtime `SafetyPolicyRegistry` accessor consumed by the resolver and BYOK / modal-llm fallback path. | The pattern is consistent: hosts wire strategies through plugin contributions (see [Plugin contract](/docs/plugin-contract)), the `SessionRuntime` exposes them through facet accessors (see [Facets](/docs/facets)), and the body lift reads them through `harnessRuntime?.getX?.()` or `harnessRuntime?.collectX?.()` optional chains. CLI and raw orchestrator paths leave the accessors undefined and the consumer falls back to an inline default — typically a no-op or a direct module import retained for back-compat. ## Newer injection hooks [#newer-injection-hooks] The substrate keeps growing strategy slots as more body-level behaviors get lifted into the typed contract. The hooks below ship today and follow the same shape rules as the slots above — typed field on `SessionRuntimeConfig`, one consumer, graceful no-op when omitted. | Hook | Shape | Purpose | | ----------------------------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `hallucinationDetectorFactory` | `(input: { availableToolNames, intent }) => HallucinationDetectorStrategy` | Per-turn factory the stream body calls once with the per-turn input bundle. Returns a detector that consumes streamed content via `feed` / `onToolUse` / `onMetaToolUse` and surfaces `hallucinationDetected` / `narratedWithoutExecution` getters. Default: `createNoOpHallucinationDetector` (every getter reports `false`, every mutator is inert) — the substrate's hallucination arm stays inert until a host wires a real detector. | | `continuationMetaToolNames` | `ReadonlySet<string>` | Names of "continuation meta-tools" the depth-zero continuation gate exempts from tool-call counting. Distinct from `metaToolNames` so a host can keep two exemption sets — the recovery-primer exemption set (`metaToolNames`) and the continuation exemption set — without conflating one surface. Default: undefined; consumer falls back to an empty set and counts every tool as continuation-relevant. | | `repetitionGuard` (extended) | `RepetitionGuardSingleton` with new optional methods | The existing `repetitionGuard` slot grew three optional methods consumed by the lifted body: `setPlanActive(active)` (tightens budget behavior while a deterministic plan is active), `getPriorTurnRunawayDirective()` (reads the runaway directive carried forward from the previous turn boundary, or `null`), and `clearPriorTurnRunawayDirective()` (paired consumer-side clear after injection). All three are optional — hosts that don't surface runaway directives leave them undefined and the body silently no-ops. | | `orchestrator.providers.providerFallback` | Registry-seam key | Module-loader registry-seam (not a config field) consumed by the body's provider-fallback path. Host wires a real implementation through the orchestrator's dynamic-import registry. This key has no substrate default: absent a registration, `dynamicImportApp` rejects, the body's catch attempts a legacy relative import that no longer resolves post-lift, and the call throws `MODULE_NOT_FOUND`. The consumer must wire the loader for this path to succeed. Lives next to the existing `orchestrator.providers.{circuitBreaker, fallbackExecutor, modelAvailabilityChecker}` cluster. | | `EMPTY_SINGLE_TURN_FLAGS` | Typed `Readonly<SingleTurnFlags>` export | Canonical per-turn reset baseline — a frozen object with the four boolean defaults (`truncated`, `modelFallback`, `contextOverflow`, `hallucinationDetected`). Not a config slot; a typed export hosts spread (`{ ...EMPTY_SINGLE_TURN_FLAGS }`) at turn boundaries instead of re-deriving the defaults inline. Reachable from `@pleach/core/types/singleTurn`. | The factory-shaped hook (`hallucinationDetectorFactory`) is the shape to reach for when a strategy needs **per-turn allocation** — the consumer calls the factory once at turn entry with a per-turn input bundle, and the returned detector instance is discarded at turn end. The other strategy slots above are **per-runtime singletons** the consumer calls many times across many turns. ```typescript import { SessionRuntime } from "@pleach/core"; import type { HallucinationDetectorFactory } from "@pleach/core/strategies/hallucinationDetector"; const hallucinationDetectorFactory: HallucinationDetectorFactory = (input) => { // Host builds a detector parameterized by this turn's // `input.availableToolNames` + `input.intent`. The body calls // `feed(chunk)` on every content delta and reads the two getters // after the turn settles. return /* host implementation */ {} as ReturnType<HallucinationDetectorFactory>; }; const runtime = new SessionRuntime({ storage, userId: "user_123", hallucinationDetectorFactory, continuationMetaToolNames: new Set(["set_step_complete", "wait_for_jobs"]), }); ``` See [Providers](/docs/providers) for the provider-fallback registry shape, [Fabrication detection](/docs/fabrication-detection) for the broader fabrication-guard surface (separate contract from hallucination detection — fabrication is per-call quantitative pattern matching; hallucination is per-turn narration vs. execution analysis), and the [Plugin contract](/docs/plugin-contract) for the plugin counterpart to the strategy injection pattern. ## Where to go next [#where-to-go-next] <Cards> <Card title="SessionRuntime" href="/docs/session-runtime" description="The class strategies attach to — construction, sessions, executeMessage, abort." /> <Card title="Plugin contract" href="/docs/plugin-contract" description="The companion extension surface — many slots per contributor." /> <Card title="Host adapter" href="/docs/host-adapter" description="The host integration that wires strategies and plugins side-by-side." /> <Card title="Fabrication detection" href="/docs/fabrication-detection" description="The fabrication-guard strategy bundle and its detector surface." /> </Cards> --- # Safety policies (/docs/safety) A safety policy is **advisory by default**: it annotates a call — contributing refusal / guardrail prose into the system prompt and recording the operator's stated intent on the audit row — never generates new content. The hard, seam-level effects the contract *describes* (refusing or rewriting a call before it fires) are a planned, forthcoming capability; v1.1 routes every enforcement level through `log_only`, so the policy prose surfaces in the prompt but the seam does not yet block or rewrite the call. The contract is capability-subtracting: `defineSafetyPolicy({ name, appliesTo: "pre-dispatch" | "post-tool", async check(state, ctx) → { allow, reason, forceText } })`, or with `rewrite(prompt) → { allow, rewrittenPrompt, auditNote }`. Both forms are async; both land their result on the audit row. A `SafetyContribution` is the registration shape behind those policies. It looks similar to a `PromptContribution` on the surface but the structural model is different in five places, which is why `@pleach/core` ships them as separate contracts. Plugin authors **register** policies (declare what they ship). Operators **enable** policies at runtime construction (declare what's active). Different defaults, different composition, different audit surface. ```typescript import { SafetyPolicyRegistry, composeSafetyContent, SAFETY_SECTION_HEADER, SAFETY_ENFORCEMENT_EFFECTS, KNOWN_REGULATORY_FRAMEWORKS, safetyPolicyId, } from "@pleach/core/safety"; import type { SafetyContribution, SafetyPolicyId, SafetyPolicySummary, SafetyEnforcement, SafetyPolicyScope, SafetyGuard, RegulatoryFramework, KnownRegulatoryFramework, } from "@pleach/core/safety"; ``` <SourceMeta subpath="@pleach/core/safety" source="{ label: "src/safety/", href: "https://github.com/pleachhq/core/tree/main/src/safety" }" /> > **Safety lives outside the cluster pattern.** Five distinct > concepts (`safety`, `scrubbers`, `fabrication-detection`, > `determinism`, `fingerprint`), each with its own mechanism — no > natural three-concept triplet. See [What lives outside the > cluster pattern](/docs/concept-clusters#what-lives-outside-the-cluster-pattern). ## Why a separate contract [#why-a-separate-contract] Five structural differences earn the split: | Difference | `PromptContribution` | `SafetyContribution` | | ------------------ | ------------------------------- | ------------------------------------------------------------------------------------------ | | Default state | Active when plugin is loaded | Inert until enabled by id at runtime construction | | Composition role | Adds prose that shapes behavior | Adds gates + refusal prose, composed LAST | | Auditability | Internal to the registry | Public — `runtime.safety.listActivePolicies()` returns a versioned `SafetyPolicySummary[]` | | Override semantics | `mode: "replace"` overrides | `replace` is structurally absent — the field doesn't exist on `SafetyContribution` | | Versioning | Implicit | Explicit semver per policy; bumps participate in the fingerprint key | The split is what lets a non-regulated deployment of a host silently skip sector-specific disclaimer policies that a regulated deployment of the same host enables — same plugin set, different active policies. ## The `SafetyContribution` shape [#the-safetycontribution-shape] ```typescript interface SafetyContribution { readonly id: SafetyPolicyId; // "<plugin>.<policy-name>" readonly version: string; // semver readonly framework?: RegulatoryFramework; readonly enforcement: SafetyEnforcement; readonly scope?: SafetyPolicyScope; // optional callClass / family / runtimeRole filter readonly content: string | ((ctx: PromptContext) => string); readonly guards?: readonly SafetyGuard[]; } ``` | Field | Purpose | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | Branded `SafetyPolicyId`. `core.*` is reserved (throws `ReservedSafetyNamespaceError`); unnamespaced ids throw `UnnamespacedSafetyIdError` | | `version` | Semver string. Bumps are cache-invalidating via `safetyPoliciesHash` | | `framework` | Open `string` field. `KNOWN_REGULATORY_FRAMEWORKS` enumerates conventional values (`HIPAA`, `GDPR`, `SOC2`, `ITAR`, `EAR`, `CWC`, `BWC`, `FDA-21CFR11`, `clinical-research-disclaimer`) for autocomplete; plugin authors are free to pass any id (e.g. `"MDR-EU"`, `"NIST-800-53"`, `"my-company.policy-v3"`). The pre-1.1 `{ custom: string }` form is deprecated — pass a plain string id instead | | `enforcement` | `advisory` / `guardrail` / `refusal` — v1.1 is prose-only across all three; the field is audit-surface even when runtime behavior is identical | | `scope` | When the policy participates — `callClass`, `family`, `runtimeRole` | | `content` | The refusal / gate / disclaimer prose; string or `(PromptContext) => string` | | `guards` | Future-facing `SafetyGuard` slot — contract surface is live; no `kind` is implemented yet. `tool-call-precondition` is the first concrete kind on the roadmap | ## Registering policies (plugin author) [#registering-policies-plugin-author] ```typescript import type { HarnessPlugin } from "@pleach/core"; const compliancePlugin: HarnessPlugin = { name: "compliance", contributeSafetyPolicies: () => [ { id: safetyPolicyId("compliance.pii-redaction"), version: "1.2.0", framework: "GDPR", enforcement: "refusal", scope: { callClass: "synthesize" }, content: "Never include raw PII in the final response. Redact email addresses, phone numbers, and identifiers.", }, { id: safetyPolicyId("compliance.export-control"), version: "1.0.0", framework: "EAR", enforcement: "guardrail", content: "Refuse export-controlled technical data unless the requester has presented a current authorization.", }, ], }; ``` Registered ≠ enabled. Plugin authors ship policies; operators opt in. ## Enabling policies (operator) [#enabling-policies-operator] The registry separates **register** (a policy is known) from **enable** (the operator opted in). Enablement is config-driven — the operator passes the id list at runtime construction via `SessionRuntimeConfig.enabledSafetyPolicies`. The constructor calls `register()` for every plugin contribution and `enable()` for every id in the config list before the runtime serves traffic. ```typescript import { safetyPolicyId } from "@pleach/core/safety"; import { SessionRuntime } from "@pleach/core"; const runtime = new SessionRuntime({ plugins: [compliancePlugin], enabledSafetyPolicies: [ safetyPolicyId("compliance.pii-redaction"), safetyPolicyId("compliance.export-control"), ], }); ``` Construction throws `UnknownSafetyPolicyError` if the config references an id no plugin registered — operator typos surface at boot, not on the first turn. A deployment that doesn't need a particular policy simply omits its id from the list. The policy stays registered (visible to `runtime.safety.listAvailablePolicies()`) but inert (zero effect on prompt composition). For interactive surfaces that need to toggle policies after construction, call `runtime.safety.getRegistry().enable(id)` / `.disable(id)`. `enable()` throws `UnknownSafetyPolicyError` for unknown ids; `disable()` is idempotent. Two further error shapes guard the registration surface: * `DuplicateSafetyPolicyError` — two policies registered with the same id. Use a versioned suffix if both must coexist. * `ReservedSafetyNamespaceError` — a plugin tried to register `core.*`. The core namespace is reserved; safety policies always originate from a plugin or compliance package. ## The fingerprint contract [#the-fingerprint-contract] The active policy list participates in the per-call fingerprint cache key as `safetyPoliciesHash` — sha256 of the canonicalized `"<id>@<version>"` list, sorted by id. Flipping a policy on or off invalidates the cache; bumping a policy's version invalidates the cache. Two runs with different active sets resolve to different cache buckets, so replay can't accidentally re-use a result that was captured under different safety constraints. The policy list in the audit ledger lets a regulator query "what was active when this call fired" without re-deriving from the plugin set. ## Listing active policies [#listing-active-policies] ```typescript const active: readonly SafetyPolicySummary[] = runtime.safety.listActivePolicies(); for (const policy of active) { console.log(policy.id, policy.version, policy.framework, policy.enforcement); } ``` `SafetyPolicySummary` is intentionally a projection — `id`, `version`, `framework`, `enforcement`, `enabled`. The `content` prose is OMITTED. Audit consumers receive the metadata, not the prose; surfacing prose through the discovery surface would couple audit tools to v1.x content shape changes. `runtime.safety.listAvailablePolicies()` returns the full shelf (enabled flag on each entry); `runtime.safety.listActivePolicies()` is the enabled subset and is the surface replay and SOC2 exports proxy. `runtime.safety.getRegistry()` returns the underlying `SafetyPolicyRegistry` when consumers need `collectActiveContributions()` for the raw prose. ## Composition order [#composition-order] Safety contributions compose **last** — after all prompt contributions. The composed system prompt ends with the safety section. The header literal is fixed: ```typescript import { SAFETY_SECTION_HEADER } from "@pleach/core/safety"; SAFETY_SECTION_HEADER; // "## Safety Policies" ``` Changing the literal value is a fingerprint-cache-invalidating event. Operators and replay tools grep for this delimiter to extract the safety portion of a composed prompt. `composeSafetyContent` is wired inside the prompt resolver (`resolvePromptForSeam`) — every seam invocation passes its registry through `host.safetyRegistry?.collectActiveContributions()` and appends the composer's output LAST. Consumers don't call the composer directly in normal flows; the example below is the shape the resolver uses when an audit or replay tool needs to re-derive the safety section out-of-band. ```typescript import { composeSafetyContent } from "@pleach/core/safety"; const { text, activePolicies } = composeSafetyContent( runtime.safety.getRegistry().collectActiveContributions(), { callClass: "synthesize", family: "anthropic", runtimeRole: "primary" }, ); ``` The composer enforces three invariants: 1. **Safety last.** Output is appended after every prompt contribution. The order is set by the resolver call site, not by the composer. 2. **Scope filtering.** Policies with a `scope` participate only when `callClass`, `family`, and `runtimeRole` match every named field. 3. **Empty-output drop.** A policy whose context-aware `content` function returns `""` is dropped from the output without an empty separator — and is dropped from `activePolicies`. Enablement is independent of per-context content. Same active set + same context produces byte-identical output. That's what keeps the safety section fingerprint-safe. ## `SafetyEnforcement` semantics [#safetyenforcement-semantics] | Level | v1.1 behavior | Intent | | ----------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | | `advisory` | Prose-only — surfaces guidance, no gate | Non-binding norms; LLM is free to comply or not | | `guardrail` | Refusal-shaped prose plus the `guards` slot for future structural checks | Operational guardrails (rate-limit refusals, tool-call preconditions) | | `refusal` | Explicit refusal prose | Hard policy — "no PII in the synthesis"; hard runtime blocks land once `kind: "tool-call-precondition"` ships | All three levels are prose-only today — LLM compliance is best-effort and `SAFETY_ENFORCEMENT_EFFECTS` routes every level through `log_only` until the `guards` slot ships a concrete `kind`. The level still appears on every audit row because downstream replay and compliance tools need the operator's stated intent, not just the runtime effect. ### Branch on the EFFECT, not the name [#branch-on-the-effect-not-the-name] `enforcement` describes operator intent — what the audit trail records. `SAFETY_ENFORCEMENT_EFFECTS` is the constant that maps each level to the runtime effect the seam commits to. v1.1 routes all three through `log_only`; v1.2+ diverges as structural guards land. ```typescript import { SAFETY_ENFORCEMENT_EFFECTS } from "@pleach/core/safety"; const effect = SAFETY_ENFORCEMENT_EFFECTS[policy.enforcement]; if (effect === "block_synthesis") { /* ... */ } ``` Plugin code that wants to react to runtime behavior should switch on the constant, not the severity name — that way a future effect-map change (when `tool-call-precondition` lands and `guardrail` / `refusal` diverge from `log_only`) is a no-op for consumers. Audit consumers continue to filter on the severity name for operator-intent queries. ### `KNOWN_REGULATORY_FRAMEWORKS` [#known_regulatory_frameworks] `framework` accepts any `string`. The exported constant lists nine conventional ids (`HIPAA`, `GDPR`, `SOC2`, `ITAR`, `EAR`, `CWC`, `BWC`, `FDA-21CFR11`, `clinical-research-disclaimer`) — render them in a picker, or use the `KnownRegulatoryFramework` literal union as an autocomplete hint. Plugin authors are free to pass any id (`"MDR-EU"`, `"NIST-800-53"`, `"my-company.policy-v3"`); the substrate doesn't hard-code one host's regulatory vocabulary. ```typescript import { KNOWN_REGULATORY_FRAMEWORKS, type RegulatoryFramework, } from "@pleach/core/safety"; for (const id of KNOWN_REGULATORY_FRAMEWORKS) { // id: KnownRegulatoryFramework } const myFramework: RegulatoryFramework = "my-company.policy-v3"; ``` ## Structural guards [#structural-guards] `SafetyContribution.guards` is the contract slot for non-prose enforcement. The slot ships today; no concrete `kind` is implemented yet — the `SafetyGuard` shape is reserved so plugin authors can declare guards now and have them activate when the runtime ships matching handlers. ```typescript interface SafetyGuard { readonly kind: "regex-refusal" | "tool-call-precondition"; readonly definition: unknown; // shape varies by kind } ``` `tool-call-precondition` is the first concrete kind on the roadmap. It lands once the prose-only shape has field experience proving the gap. Until then the slot is inert at the runtime layer — `guards` arrays parse and persist to the registry but the seam never reads them. ## Scrubber contract (redaction at write time) [#scrubber-contract-redaction-at-write-time] Safety policies on this page gate prompt-input behavior. Fabrication detection (`/docs/fabrication-detection`) inspects model output for unsupported claims. Scrubbers are a third surface: they run *before* event log rows persist, redacting payload fields at write time so the ledger never holds the unredacted shape. The substrate ships a `Scrubber` contract. `@pleach/compliance` bundles four concrete implementations — SSN-US, Luhn (card-number heuristic), US-DL, and a generic KeyedRegex — covering the common PII shapes most deployments need on day one. Host plugins register additional scrubbers through `contributeScrubbers`, the same registration pattern used elsewhere in the plugin contract. Registration is auditable; bypassing a registered scrubber requires an explicit opt-out that lands on the audit row. For the contract shape, a custom-scrubber example, and the audit gate that proves redaction happened, see `/docs/scrubbers`. ## Where to go next [#where-to-go-next] <Cards> <Card title="Plugin contract" href="/docs/plugin-contract" description="`contributeSafetyPolicies` is the registration hook." /> <Card title="Prompts" href="/docs/prompts" description="The distinct contract for capability-adding contributions." /> <Card title="AuditableCall row" href="/docs/auditable-call-row" description="The active safety set is recorded in the per-call audit row." /> <Card title="Scrubbers" href="/docs/scrubbers" description="Event-log redaction that runs before rows persist." /> <Card title="Attestation" href="/docs/attestation" description="The cryptographic surface that signs the audit row safety policies appear on." /> </Cards> --- # @pleach/sandbox (/docs/sandbox) `@pleach/sandbox` is the contract layer between `@pleach/core` runtimes and whatever process isolation a host chooses to ship — a Docker container, a Firecracker microVM, an E2B or Modal sandbox, an in-process fixture. It ships the `SandboxProvider` interface plus an in-memory fixture; vendor adapters publish under their own names (`@pleach/sandbox-<vendor>`) and slot in against the same contract. `@pleach/coding-agent` is the canonical consumer — its file-read, file-write, and shell tools all dispatch through the provider this package defines. ## What it provides [#what-it-provides] * The `SandboxProvider` contract a host implements once and reuses across every sandboxed tool — read, write, list, exec — per the four-tool sketch in `coding-agent.mdx`. * An in-memory fixture suitable for tests and replay runs where the host hasn't wired a real backing process. * The canonical name (`SandboxProvider`) plus the legacy `SandboxAdapter` retained as a `@deprecated` alias, per the status row in `packages.mdx`. * The peer-dep anchor `@pleach/coding-agent` declares at `^0.1.0` (see [Coding agent](/docs/coding-agent#locked-decisions) for the locked decisions). ## Where it fits [#where-it-fits] `@pleach/sandbox` is the seam between the runtime and the sandboxed tool surface — the low-level `SandboxProvider` contract (`execute`, `readFile`, `writeFile`, `listFiles`, plus `acquire` / `release` / `capabilities`). There is no `ctx.sandbox`: core's `ToolContext` is sandbox-agnostic and carries only `toolCallId` + `signal?`. Instead the host closes over a sandbox handle when it constructs the tools; `@pleach/coding-agent` ships a thin `SandboxClient` facade (`exec`, `readFile`, `writeFile`) built over the low-level provider, and the tool handlers call *that* — see [Coding agent](/docs/coding-agent#sandbox-tool-surface). The direction lock keeps `@pleach/core` itself sandbox-agnostic: consumers reach for `@pleach/sandbox` directly, and the coding-agent SKU composes on top of both. ## Install [#install] ```bash npm install @pleach/sandbox ``` ## API surface [#api-surface] The constructor signature, the full `SandboxProvider` method set, the option shapes for the in-memory fixture, and the `@deprecated` alias surface all live on the package's npm page: [`@pleach/sandbox`](https://www.npmjs.com/package/@pleach/sandbox). This page is placement orientation — what the SKU is for and where it slots into the substrate. The npm README is the source of truth for the contract itself; if a claim here disagrees with the published README, the README wins. ## Where to go next [#where-to-go-next] <Cards> <Card title="Coding agent" href="/docs/coding-agent" description="The canonical consumer — sandboxed tools, per-edit checkpoints, replay-as-debugger." /> <Card title="Packages" href="/docs/packages" description="The full @pleach/* SKU matrix and the contract status for @pleach/sandbox." /> <Card title="Architecture" href="/docs/architecture" description="The substrate map — where sandboxed tool dispatch sits relative to seams, the audit ledger, and the event log." /> </Cards> --- # Schema (/docs/schema) The schema bundle is the canonical persistence layer for the Supabase and Postgres-backed adapters. Twelve files, applied in order, produce a complete runtime schema with RLS policies, the shared `harness_set_updated_at()` trigger, and indexes tuned for the access patterns the adapters use. All files use `CREATE ... IF NOT EXISTS` and `DROP POLICY IF EXISTS` — re-applying is safe. Schema evolution lands as additional files; existing files are not edited in place. <SourceMeta subpath="@pleach/core/schema" source="[ { label: "src/schema/", href: "https://github.com/pleachhq/core/tree/main/src/schema" }, { label: "src/schema/postgres/", href: "https://github.com/pleachhq/core/tree/main/src/schema/postgres" }, ]" /> ## Applying the bundle [#applying-the-bundle] ```bash npx pleach init --apply --target ./supabase/migrations ``` Then your usual Postgres / Supabase migration flow. See [CLI](/docs/cli) for the flags and the manual `psql` apply path. ```bash for f in supabase/migrations/*pleach*.sql; do psql "$DATABASE_URL" -f "$f" done ``` ### Idempotent re-run [#idempotent-re-run] Every file uses `CREATE ... IF NOT EXISTS` and `DROP POLICY IF EXISTS`, so re-running the loop is safe. The shell helper below re-applies and stops on the first failure — useful after upgrading `@pleach/core` and pulling new files into the migrations directory: ```bash set -euo pipefail for f in $(ls supabase/migrations/*pleach*.sql | sort); do echo "applying $f" psql "$DATABASE_URL" --single-transaction -v ON_ERROR_STOP=1 -f "$f" done ``` `--single-transaction` rolls back a partial file on error; the next re-run picks up from a clean state. ## The 12 files [#the-12-files] | File | Table | Purpose | | ---------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `000_supabase_compat.sql` | — | Vanilla-Postgres compatibility prelude (guarded no-op on Supabase); makes the bundle apply on bare Postgres before the harness tables | | `001_harness_sessions.sql` | `harness_sessions` | Core session state — JSONB state column, version, message count | | `002_harness_checkpoints.sql` | `harness_checkpoints` | Per-channel snapshots; consumed by `SupabaseSaver` | | `003_harness_event_log.sql` | `harness_event_log` | The full observable event stream — ULID-keyed, append-only | | `004_harness_outbox.sql` | `harness_outbox` | Durable sync outbox; backs `SupabaseOutbox` | | `005_harness_errors.sql` | `harness_errors` | Structured error propagation (when `enableErrorPropagation: true`) | | `006_chat_session_links.sql` | `chat_session_links` | Provenance link from upstream chat rows to harness sessions | | `007_harness_audit_records.sql` | `harness_audit_records` | Generic audit-record table — separate from the typed `AuditableCall` ledger | | `008_harness_session_members.sql` | `harness_session_members` | Multi-user session access (for `useTeam` presence) | | `009_harness_session_comments.sql` | `harness_session_comments` | Threaded comments on sessions | | `010_auditable_calls.sql` | `harness_auditable_calls` | The typed per-call audit ledger — one row per LLM call, joinable by `tenantId` / `turnId` | | `011_spawn_event_fields.sql` | `harness_event_log` | Adds three nullable SpawnEvent topology columns projected off the JSONB `payload` so root-turn rollup queries JOIN on indexed columns | ## `harness_sessions` (001) [#harness_sessions-001] The primary table the storage adapter reads and writes. | Column | Type | Notes | | ---------------------------------------------- | -------------- | -------------------------------------- | | `id` | `UUID` PK | `gen_random_uuid()` default | | `user_id` | `TEXT` | Owner — RLS keys here | | `organization_id` | `UUID?` | Multi-tenant scope | | `title` | `TEXT?` | User-visible session title | | `version` | `INTEGER` | Optimistic-concurrency version counter | | `schema_version` | `INTEGER` | Row-shape version | | `state` | `JSONB` | The full `SessionState` payload | | `message_count` | `INTEGER` | Denormalized for sidebar rendering | | `created_at` / `updated_at` / `last_active_at` | `TIMESTAMPTZ` | Lifecycle timestamps | | `deleted_at` | `TIMESTAMPTZ?` | Soft-delete marker | Indexes: `(user_id)`, `(organization_id)`, `(user_id, last_active_at DESC)` filtered to non-deleted, `(id, version)` for optimistic-concurrency reads. ## `harness_checkpoints` (002) [#harness_checkpoints-002] Each row is one channel snapshot at a stage boundary. Composite key on `(session_id, checkpoint_id, channel_name)`. The checkpointer reads all rows for a checkpoint id and rebuilds the session state from the union. ## `harness_event_log` (003) [#harness_event_log-003] Append-only event stream. `record_id TEXT PRIMARY KEY` (ULID) so lexicographic order matches creation order — cursor pagination without a separate timestamp index. Indexes: `(session_id, record_id)` for per-session reads, `(session_id, event_type, record_id)` for filtered reads (e.g. "all `tool.failed` since cursor X"). ## `harness_outbox` (004) [#harness_outbox-004] The durable sync outbox. Rows hold operation payloads buffered for transmission to the backend. The worker claims a row, attempts the round-trip, and either commits or fails with retry semantics — `next_retry_at` drives the exponential backoff. | Column | Type | Notes | | --------------------------- | ------------- | -------------------------------------------------------- | | `id` | `UUID` PK | `gen_random_uuid()` default | | `session_id` | `UUID` | The session the row mutates | | `operation_type` | `TEXT` | `create` / `update` / `delete` / `append-event` | | `payload` | `JSONB` | Operation-specific body (row to upsert, event to append) | | `retry_count` | `INTEGER` | Bumped by the worker on each failure | | `next_retry_at` | `TIMESTAMPTZ` | Backoff cursor — the poll index keys on this | | `last_error` | `TEXT` | Last failure message; `NULL` on success | | `status` | `TEXT` | `pending` / `in-flight` / `committed` / `failed` | | `user_id` | `TEXT` | RLS scope | | `created_at` / `updated_at` | `TIMESTAMPTZ` | Lifecycle timestamps | Two indexes carry the access patterns: `(status, next_retry_at)` filtered to `pending`/`in-flight` for the worker poll, and `(session_id, created_at DESC)` for per-session debug listing. ## `harness_errors` (005) [#harness_errors-005] Structured errors persisted when `enableErrorPropagation: true` on the runtime. Carries the same shape as `HarnessError` — code, message, recovery hint, cause — plus the session and turn it fired against. Useful for cross-session error analytics. ## `chat_session_links` (006) [#chat_session_links-006] Provenance link between an upstream chat row (the host application's chat schema) and the harness session. Set via `SessionRuntimeConfig.chatId`. Lets billing or product analytics join the harness ledger back to a customer-facing chat surface. `chat_id UUID PRIMARY KEY` — one chat binds to at most one session at a time, last write wins. `session_id` is intentionally NOT a foreign key to `harness_sessions(id)` so the link can be re-bound after a hard-delete. Reverse lookup uses `(session_id, created_at DESC)`. ## `harness_audit_records` (007) [#harness_audit_records-007] Generic audit-record table for events that don't fit the typed `AuditableCall` shape. Used by `@pleach/compliance` and custom audit hooks for cross-cutting records that don't map to a single LLM call. ## `harness_session_members` (008) [#harness_session_members-008] Multi-user session access. Rows associate a `user_id` with a `session_id` plus a role (`owner` / `editor` / `viewer`). Consumed by `useTeam` presence and RLS policies that allow shared read. ## `harness_session_comments` (009) [#harness_session_comments-009] Threaded comments on sessions — replies, mentions, reactions. Optional surface; the runtime itself doesn't consume it. ## `harness_auditable_calls` (010) [#harness_auditable_calls-010] The typed per-call audit ledger. See [AuditableCall row](/docs/auditable-call-row) for the column-by-column walk and the join patterns. Key invariants: * `record_id TEXT PRIMARY KEY` (ULID; lex-sortable) * `session_id UUID REFERENCES harness_sessions(id) ON DELETE RESTRICT` — deleting a session with audit history requires an explicit retention policy decision * `stage_id` constrained to `{anchor-plan, tool-loop, synthesize, post-turn}` * `actor_kind` constrained to `{user, guest, system, scheduled}` * `payload JSONB` — typed slots for `cacheHit` / `family-lock-resolution` / etc. ## `harness_config_manifest` (rolling out) [#harness_config_manifest-rolling-out] <StatusBadge status="in-flight"> additive · rolling out </StatusBadge> A content-addressable snapshot of the runtime substrate — the plugin set, system prompts, graph node bodies, channel definitions, and post-stage filters active in a session. The primary key is the snapshot's content hash, so identical substrates share a row. It lands as an additive file (`012_harness_config_manifest.sql`) alongside a nullable `manifest_hash` column on `harness_event_log`, so existing rows stay valid through the rollout. See [Config manifest](/docs/config-manifest) for the column walk, the Merkle hash algorithm, retention, and the query shapes. ## RLS template [#rls-template] Every table ships the same two-policy template: ```sql -- 1. Service role has full access (for server-side adapters). CREATE POLICY <table>_service_role ON <table> FOR ALL TO service_role USING (true) WITH CHECK (true); -- 2. Owner-scoped policies for anon clients. CREATE POLICY <table>_owner_select ON <table> FOR SELECT USING (user_id = auth.uid()::text); CREATE POLICY <table>_owner_insert ON <table> FOR INSERT WITH CHECK (user_id = auth.uid()::text); -- update + delete policies follow the same shape ``` Service-role clients bypass RLS by construction. Anon clients must match the policy — passing `userId` on the runtime + a correctly-signed JWT is what threads the request through. For shared sessions (per `harness_session_members`), the owner policy widens to "user\_id is owner OR member\_id is the current user." The bundle ships the simple case; layer your own multi-user policies on top. ### Multi-tenant policy keyed on `organization_id` [#multi-tenant-policy-keyed-on-organization_id] When sessions belong to an org rather than a single user, gate reads on the `organization_id` column and the JWT claim that carries it. The same shape works for every table with an `organization_id`: ```sql DROP POLICY IF EXISTS harness_sessions_org_select ON harness_sessions; CREATE POLICY harness_sessions_org_select ON harness_sessions FOR SELECT USING ( organization_id::text = auth.jwt() ->> 'organization_id' AND deleted_at IS NULL ); ``` Pair this with a JWT signer that stamps `organization_id` on every token — Supabase Auth hooks or your own session issuer. ## The shared trigger function [#the-shared-trigger-function] Every table that has an `updated_at` column uses one shared trigger: ```sql CREATE OR REPLACE FUNCTION harness_set_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; ``` Created once (in file 001); reused everywhere. Don't redefine it in your own migrations — the bundle's version is the one the adapters expect. ## Custom columns [#custom-columns] Adding columns is safe — the adapters serialize/deserialize the `state` JSONB, not the row schema. To add fields the adapters read directly (like a custom denormalized `last_synced_at`), use an additive migration that lands after the bundle and update the adapter via a custom subclass. ## Where to go next [#where-to-go-next] <Cards> <Card title="CLI" href="/docs/cli" description="`pleach init` for scaffolding the bundle into a target directory." /> <Card title="Storage" href="/docs/storage" description="The adapters that consume this schema." /> <Card title="AuditableCall row" href="/docs/auditable-call-row" description="File 010 in detail — the column-by-column reference." /> <Card title="Event log" href="/docs/event-log" description="File 003 in detail — `EventLogWriter` and projections." /> </Cards> --- # Scrubbers — redaction at write time (/docs/scrubbers) Scrubbers are one concept in the **safety & determinism** [thematic island](/docs/concept-clusters#what-lives-outside-the-cluster-pattern) — siblings of [safety](/docs/safety), [fabrication detection](/docs/fabrication-detection), [determinism](/docs/determinism), and [fingerprint](/docs/fingerprint). A `Scrubber` gates writes into `harness_event_log`. When a row would persist, every registered scrubber runs against the payload first; matched spans get rewritten before the row reaches storage. The redacted form is what hits disk — the original payload never lands. Not prompt-input scrubbing — that's [`/docs/safety`](/docs/safety). Not fabrication detection — that's [`/docs/fabrication-detection`](/docs/fabrication-detection). Scrubbers sit downstream of synthesis, on the persistence boundary; safety and fabrication detectors sit upstream, on the model boundary. <SourceMeta subpath="@pleach/core/scrubbers" source="{ label: "src/scrubbers/", href: "https://github.com/pleachhq/core/tree/main/src/scrubbers" }" /> ## The `Scrubber` contract [#the-scrubber-contract] A scrubber is small: an `id` and a `scrub(input, ctx)` method. It takes a single string plus structural context and returns the redacted string plus attribution metadata. Scrubbers do **not** declare which event types they apply to — scoping is global (see below). ```typescript import type { Scrubber } from "@pleach/core/scrubbers"; ``` Scoping is global, not per-scrubber. A single allowlist — `SCRUBBABLE_FIELDS`, keyed by event type — names the dotted field paths each event type exposes to redaction. The chain applies *every* registered scrubber to *every* allowlisted field; an individual scrubber never names an event type. The runtime still requires that every event type the substrate writes has an entry in that allowlist (see the coverage rule below). `scrub` returns a `ScrubResult`: `{ redacted, matchCount, matchedScrubberIds }`. `redacted` is the post-scrub string (equal to the input when nothing matched); `matchCount` is the number of redacted matches; `matchedScrubberIds` is the required list of scrubber ids that fired (`[]` when none did). These feed the `event.redacted` audit meta-event — downstream consumers (a security dashboard, a SOC2 evidence export) count redactions per type without re-running the regex against persisted rows. No original matched text, offsets, or spans are returned. ## The four bundled scrubbers [#the-four-bundled-scrubbers] These four ship in `@pleach/compliance` (see [`/docs/compliance`](/docs/compliance) for the package surface). Each independently implements the `Scrubber` contract. | Scrubber | Pattern | Notes | | ------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | | `SsnUsScrubber` | US Social Security numbers | Redacts the matched span; the redaction key identifies the pattern family for audit | | `CreditCardScrubber` | Credit card numbers validated via the Luhn check digit | Validation cuts the false-positive rate of naive 16-digit regex matches against arbitrary numeric content | | `UsDriverLicenseScrubber` | US driver's license numbers | Per-state heuristics — the format varies; the scrubber composes a state-keyed pattern table | | `KeyedRegexScrubber` | Generic pattern + key adapter | The shape hosts use for custom regulated identifiers (medical record numbers, tax IDs, internal employee ids) | The first three are independent implementations, not configurations of a shared base — `SsnUsScrubber` carries SSA invalid-range guards, `CreditCardScrubber` gates on a Luhn checksum plus an IIN range allowlist, and `UsDriverLicenseScrubber` anchors on per-state formats. `KeyedRegexScrubber` is the separate consumer-extension shape: hosts that need a new regulated identifier reach for it directly rather than waiting for an upstream addition. ## The `contributeScrubbers` plugin hook [#the-contributescrubbers-plugin-hook] Hosts register additional scrubbers through the `HarnessPlugin` contract. The hook returns an array; the runtime composes contributions across every loaded plugin into the registry the event log writer consults. ```typescript // lib/plugins/tenantCompliancePlugin.ts import type { HarnessPlugin } from "@pleach/core"; import type { Scrubber } from "@pleach/core/scrubbers"; const tenantIdentifierScrubber: Scrubber = { /* shape defined below in the custom example */ }; export const tenantCompliancePlugin: HarnessPlugin = { name: "tenant-compliance", contributeScrubbers: () => [tenantIdentifierScrubber], }; ``` Registration order matches the plugin registration order at runtime construction. Scrubbers compose by chaining — the redacted output of one scrubber is the input to the next. Two scrubbers that match overlapping spans should be sequenced explicitly at the registration site; the runtime won't reorder them. ## Event-type allowlist coverage [#event-type-allowlist-coverage] Every event type the substrate writes must have an entry in `SCRUBBABLE_FIELDS`. An empty entry counts — the requirement is *coverage*, not *redaction*. The point is to force an allowlisting decision for every event type, so a new type can't sneak past the redaction surface. The CI gate `audit:c8-event-type-allowlist-coverage` enforces this on the core repo: it scans the `SCRUBBABLE_FIELDS` keys against the event union, and a new event type that lands without an entry fails the audit. The same rule applies to consumer code: plugins that declare new event types through `contributeEventTypes` should ship a corresponding allowlist entry. If you genuinely want a type to write unredacted, give it an empty field list (`[]`) — the exported `SCRUB_NONE_AUDITED` sentinel is the grep-able way to spell it. The entry is the load-bearing artifact: `[]` means "audited, no scrubbable fields," distinguished from a missing entry (which fires `[C8:unscrubbed-event-type]`). ## `EventLogWriter` integration [#eventlogwriter-integration] The writer applies scrubbers automatically when persisting. Consumer code that calls into the event log doesn't reach for the scrubber registry directly — the redaction step is structural, not opt-in. ```typescript import { EventLogWriter } from "@pleach/core/eventLog"; // Consumer code is unchanged by scrubber registration. // The writer consults the registry at persist time. await writer.append({ type: "tool.completed", sessionId, turnId, payload: { /* ... */ }, }); ``` The single write surface is what keeps the gate honest. There's no "raw" write path that skips scrubbers — bypassing redaction would mean bypassing the writer, which would also bypass the hash chain, the tenant scoping, and the append-only invariant. The scrubber pass is part of the same atomic step that lays down the row. ## Production-path adoption [#production-path-adoption] The scrubber hook runs at the `EventLogWriter` boundary, but a hook that nothing calls isn't a gate — it's a contract. Production adoption is what makes it load-bearing. Earlier in the substrate, the canonical browser-event POST surface ran an inline row-builder and a direct insert into storage. The writer was in the codebase, but the route bypassed it. Any new event-type the writer's scrubber chain would have gated could land in the table unredacted simply because the route never asked the chain to run. That bypass is now closed. The production route calls the writer's `write` and `flush` directly; the inline insert is retired. The scrubber chain runs against every row that lands through the canonical surface — there's no "stale code path" alternative for a host to accidentally pick. The practical consequence: registering a scrubber against an event type the route writes is now sufficient. Previously, adoption took a second step (route refactor); now it doesn't. Hosts that wired scrubbers expecting production gating finally get it. ## What scrubbers do NOT do [#what-scrubbers-do-not-do] * **They don't redact prompt inputs sent to the model.** That's a synthesis-time concern handled by safety policies — see [`/docs/safety`](/docs/safety). A scrubber runs after the model has already seen the data; redacting at write time protects the *persisted* surface, not the *model* surface. * **They don't detect fabricated output.** Fabrication is a content truthfulness signal, not a pattern match — see [`/docs/fabrication-detection`](/docs/fabrication-detection). * **They don't replace tamper-evidence.** The hash chain (see [`/docs/hash-chain`](/docs/hash-chain)) proves a row hasn't changed since write; scrubbers shape what the row contains *at* write. Different jobs, different contracts. * **They don't dedupe.** A value redacted on one write site stays redacted on every other write site that matches the same pattern. Scrubbers are stateless across rows; the same input produces the same output every time. ## Custom scrubber example [#custom-scrubber-example] A tenant with internal employee identifiers shaped `EMP-` followed by six digits can register a `KeyedRegexScrubber` for them. The scrubber takes an `id` plus one or more `entries` — each an `(name, pattern, replacement)` tuple. The consumer supplies the replacement string; `KeyedRegexScrubber` does not auto-format one. Note there is no `eventTypes` field — scoping comes from `SCRUBBABLE_FIELDS`, not the scrubber: ```typescript // lib/plugins/employeeIdScrubber.ts import { KeyedRegexScrubber } from "@pleach/compliance/scrubbers"; import type { HarnessPlugin } from "@pleach/core"; const employeeIdScrubber = new KeyedRegexScrubber({ id: "internal-pii", entries: [ { name: "employee-id", pattern: /\bEMP-\d{6}\b/g, replacement: "[REDACTED:EMPLOYEE_ID]", }, ], }); export const tenantCompliancePlugin: HarnessPlugin = { name: "tenant-compliance", contributeScrubbers: () => [employeeIdScrubber], }; ``` Register the plugin once at runtime construction. Every subsequent write through `EventLogWriter` runs the scrubber against the fields `SCRUBBABLE_FIELDS` allowlists, and rows persist with the supplied `replacement` — here `[REDACTED:EMPLOYEE_ID]` — in place of the matched text. The entry `name` is what shows up in the `matchedScrubberIds` attribution (as `<id>:<name>`) and in any downstream audit consumer. Pick a name that reads in a compliance review — `employee-id`, not `EMP_REGEX_V2`. ## What CI checks when you add a scrubber [#what-ci-checks-when-you-add-a-scrubber] A scrubber registered through `contributeScrubbers` adds redaction coverage; the gate that fails is the one on the *event type* it gates: * `audit:c8-event-type-allowlist-coverage` — every event type the substrate writes has an entry in `SCRUBBABLE_FIELDS`, empty entries included. A plugin that declares a new type through `contributeEventTypes` ships its allowlist entry in the same plugin. Run `npm run audit:c8-event-type-allowlist-coverage`. The scrubber — the "clearer" — is one row in the [extension map](/docs/extending), which pairs every extension point with the gate that guards it. ## Where to go next [#where-to-go-next] <Cards> <Card title="Safety policies" href="/docs/safety" description="Prompt-input scrubbing and refusal contracts — the synthesis-time counterpart to write-time scrubbers." /> <Card title="Compliance" href="/docs/compliance" description="Where the four bundled scrubbers (SSN-US, Luhn, US-DL, KeyedRegex) ship from." /> <Card title="Plugin contract" href="/docs/plugin-contract" description="The HarnessPlugin surface that exposes the contributeScrubbers hook." /> <Card title="Audit ledger" href="/docs/audit-ledger" description="The audit row every redacted event is joinable against by turnId." /> </Cards> --- # Seams (/docs/seams) Every LLM call in `@pleach/core` routes through exactly one of four seams. The seam factory carries the `callClass` literal that downstream code threads as a type parameter, so a node consuming a seam never re-introduces the literal at the call site. The synthesize seam is a per-runtime singleton, which means the rendered string and the audited string are the same string by construction. A seam is the routing cluster's entry point — it carries the [CallClass](/docs/call-classes) literal into resolution and dispatches the result against the session's [family lock](/docs/family-lock#the-routing-cluster). See [Family-lock → the routing cluster](/docs/family-lock#the-routing-cluster) for the cluster framing. <SourceMeta subpath="@pleach/core" source="{ label: "src/graph/seams/", href: "https://github.com/pleachhq/core/tree/main/src/graph/seams" }" /> ## The four seams [#the-four-seams] | Seam | File | Role | | ---------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `synthesizeSeam` | `graph/seams/synthesizeSeam.ts` | User-facing answer; exactly one per turn | | `reasoningSeam` | `graph/seams/reasoningSeam.ts` | **Reserved/planned** — the holder is initialized per runtime but no node in the default runtime routes through it today; the contract reserves `reasoning` for future answer-sufficiency judging, plan compose/revise, and tool-result interpretation | | `utilitySeam` | `graph/seams/utilitySeam.ts` | Internal classification (intent, planner, cache routing) | | `converseSeam` | `graph/seams/converseSeam.ts` | Short user-facing prose (refusal hints, retry narration) | A seam IS the per-call-class factory that builds a `ProviderSeam<C>`. The factory binds the locked `callClass`, the resolved transport, and the per-runtime stream-observer registry into one entry point; nodes invoke that entry point without seeing the underlying provider plumbing. The type parameter `C` carries the call class through to the return shape — a `synthesizeSeam` call returns a `ProviderSeamResult<"synthesize">`, distinct at compile time from a `utilitySeam` result. A seam is NOT a provider adapter — that's the transport layer covered in [Providers](/docs/providers). A seam is also NOT model resolution — the `(family × callClass)` matrix lives in [Family lock](/docs/family-lock). The seam sits above both: it holds the call class, calls into resolution, and dispatches the observer ladder around the resulting stream. <Callout type="info" title="Coming from LangGraph?"> LangGraph has no seam concept. A LangGraph node calls a model directly (`await model.invoke(messages)`) and the LLM is just another resource. Pleach factors that call out so the **call class** (`synthesize`, `reasoning`, `utility`, `converse`) is a type-level invariant — a node can't accidentally fire a `synthesize` call during the `tool-loop` stage because the decision node there consumes a `ProviderSeam<"utility">` and the synthesize seam is a different type. The translation: where LangGraph's nodes know about the model, pleach's nodes know about the *kind* of model call they need. The seam carries the binding. The benefit: one runtime can route the four call classes to four different models (utility → cheap; synthesize → flagship) without the node knowing. </Callout> ## The singleton synthesize seam [#the-singleton-synthesize-seam] The user sees the synthesis. If the runtime fires two synthesize calls and renders one of them, the audit ledger records one string and the UI shows a different one. Capping at exactly one synthesize per turn means the audited string and the rendered string are the same string. The cap is enforced by two pieces. `SynthesizeSeamHolder` is a per- runtime singleton, and the synthesizer node — plus the recovery path that stands in for it when a provider fails — is the only consumer that asks the holder for the seam. Nothing else reaches it. The tool-loop's continuation call, the "call another tool or finish?" decision, is `utility`-class and consumes a separate utility seam, so it physically can't bump the synthesize counter. That separation is what makes the cap structural rather than policed: "exactly one synthesize per turn" holds because exactly one node class reaches the synthesize seam, by construction. `TurnSynthesizeCounter` is idempotent on `messageId`; a second synthesize call for the same message is a no-op against the counter and never reaches the provider. `npm run test:graphnoderef-wire-check` is the wire-check that catches code reaching around the holder. A node that constructs its own `synthesizeSeam` instead of consuming the shared one fails the check before CI green. The check walks the compiled graph, collects every node whose `acceptsSeam` is `"synthesize"`, and asserts they share one underlying `ProviderSeam<"synthesize">` identity. See [Architecture § Call classes](/docs/architecture#2-call-classes) for the broader invariant the singleton pins down. ## The callClass lint [#the-callclass-lint] The `callClass` literal — the strings `"utility"`, `"reasoning"`, `"converse"`, `"synthesize"` — appears only inside the four seam factories and the matrix module. Outside those files, code resolves a model through `AgentAdapter.resolveModel<C>()`, and the locked call class threads through as a type parameter without a literal. ```typescript import type { CallClass } from "@pleach/core/modelfamily" // inside a node — no "synthesize" literal at the call site async function synthesizer<C extends CallClass>( ctx: NodeContext<C>, ): Promise<Partial<State>> { const model = await ctx.adapter.resolveModel<C>() return { /* ... */ } } ``` `npm run lint:callclass-literals` is the CI gate. A literal that escapes the seam factories fails the lint. See [Architecture § Call classes](/docs/architecture#2-call-classes) for the rationale and [Call classes](/docs/call-classes) for the four-class taxonomy. ## Stream-observer ladder [#stream-observer-ladder] Each seam dispatches a per-chunk observer ladder on the inbound stream. For every chunk, every registered observer returns one verdict: | Verdict | Effect | | ---------- | ------------------------------------------------------------------------------------------------------ | | `continue` | No-op; pass the chunk through | | `amend` | Replace the chunk content 1:1 — strict, no multiplex | | `emit` | Forward the chunk verbatim AND emit a named-channel envelope | | `stop` | Stop the stream; the downstream consumer reads the stop sentinel | | `buffer` | Hold the chunk; the seam suppresses the yield and accumulates state | | `release` | Flush previously-buffered chunks in order; optionally strip N chars from the seam's accumulated mirror | Observers register through `HarnessPlugin.contributeStreamObservers`. The seam queries the per-runtime `StreamObserverRegistry` at invocation time and invokes each observer's `onChunk(chunk, ctx)` in registration order. See [Plugin contract](/docs/plugin-contract) for the registration shape and [Stream events](/docs/stream-events) for how verdicts surface to the consumer. `onChunk` is sync only — no `Promise<ObserverChunkVerdict>` overload. Two async observers resolving in a different order on replay than on record would race; the runtime would then schedule a different next superstep and the diff harness would flag the divergence. Keeping the verdict sync is the armor that holds the replay-determinism property. `amend` being 1:1 is the matching armor on the chunk side. An observer that returned multiple chunks for one input would break byte-replay: the chunk count itself is part of the recorded stream. Plugins that need fan-out emit named-channel envelopes via the `emit` verdict — the main stream stays untouched, the side channel carries the structured side-effect. The `buffer` / `release` pair is the additive extension for observers that need to hold chunks until a boundary settles — e.g. an early-coherence observer that buffers until it can prove the prefix is clean, then releases the held chunks in order. `release` carries an optional `stripAccumulated` count so the seam trims its own accumulated-content mirror; the outer consumer loops subscribe to that envelope to keep their mirrors in sync. ## Plugin observers are canonical [#plugin-observers-are-canonical] The chunk-time detection surface lives in plugin observers, not in the seam itself. Hosts register factories via `HarnessPlugin.contributeStreamObservers`; the per-runtime `StreamObserverRegistry` collects them at construction, and the seam queries the registry at invocation time and dispatches every chunk through the verdict ladder above. A reference host ships observers as a worked example — force- synthesis echo, same-tool repetition, markup sanitization, halluci- nated tool id, plus emit-only detectors for phrase-loop, fim- token, substring-repetition, prefix-garble, early-coherence, and body-garble. `@pleach/core` provides the contract and the registry; every host ships its own observers. The historical helper-barrel dispatchers in `runDecisionBody` (`dispatchHallucinationDetector`, `dispatchPhraseLoopDetector`, `dispatchFimTokenDetector`, `dispatchSubstringRepetition`, `dispatchPrefixGarbleDetector`, `dispatchEarlyCoherenceDetector`, `dispatchDsmlDetector`) retired in the Track 3 cutover. Plugin observers carry the same predicates and route their verdicts onto named channels; the flag-router consumer maps each channel back into the same graph-state fields the dispatchers wrote. The body-garble dispatcher break-path is the deliberate exception and stays in `createLlmDecisionNode.ts`. The predicate is time- dependent — it needs mid-stream chunks to settle into legitimate technical notation, CJK, and foreign-language compound names before it can distinguish garble from valid content. A stop-at-chunk verdict can't carry that asymmetry; an observer that fired at the wrong moment would false-positive on every long technical token. The kept dispatcher is the regression-detection armor against that class of false positive, and the asymmetric close is the documented shape — chunk-time observers for everything time-invariant, the inline dispatcher for the one time-dependent case. ## How a node consumes a seam [#how-a-node-consumes-a-seam] A node declares which seam it consumes via `acceptsSeam` in its metadata. The field is `CallClass | null` — a call-class literal for nodes that reserve a seam, `null` for pure state transforms and deterministic anchor builders that take no LLM call. The reservation is the seam-attachment contract; future LLM growth attaches without re-typing the node signature. The substrate threads the bound seam into the node's signature, and the node never imports a seam factory directly. ```typescript const toolLoopMetadata = { stageId: "tool-loop", acceptsSeam: "reasoning", subscribes: ["messages"], writes: ["messages"], } async function toolLoop(state, ctx) { // ctx.seam is the bound reasoningSeam — no factory import const result = await ctx.seam.invoke({ messages: state.messages }) return { messages: [...state.messages, result.message] } } graph.addNode("toolLoop", toolLoop, toolLoopMetadata) ``` `npm run lint:harness-boundary` is the CI gate. A node that imports from `src/graph/seams/` fails the lint — the bound seam on the node context is the only path. See [Nodes](/docs/nodes) for the full metadata shape and [Graph](/docs/graph) for how the substrate wires the binding. ## Determinism contract [#determinism-contract] Seams compose with the rest of the determinism story. The observer dispatch is sync, so the verdict ladder produces the same output shape on replay as on record. The call returns a stable `ProviderSeamResult<C>` whose fields are typed by call class. The provider response feeds channel reducers that are commutative and associative, so concurrent writes from a fan-out resolve the same way on every replay. The byte-replay property — that a recorded turn replayed against the same package version + the same input produces a byte-identical fingerprint stream — depends on every link in this chain. See [Determinism](/docs/determinism) for the full five-contract set. ## Where to go next [#where-to-go-next] <Cards> <Card title="Call classes" href="/docs/call-classes" description="The four-class taxonomy — utility, reasoning, converse, synthesize — and per-turn budgets." /> <Card title="Family lock" href="/docs/family-lock" description="The (family × callClass) matrix the seam resolves against and the family-strict cascade." /> <Card title="Providers" href="/docs/providers" description="The transport layer beneath the seam — native, openrouter, byok-native, byok-openrouter." /> <Card title="Architecture" href="/docs/architecture" description="Where seams sit in the substrate — stage lattice, call classes, audit ledger, event log." /> </Cards> --- # Security (/docs/security) The runtime takes a strong default-deny posture: [storage](/docs/storage) adapters are RLS-aware, the [audit ledger](/docs/audit-ledger) is append-only, the [fingerprint](/docs/fingerprint) isolates tenants, and the plugin contract structurally prevents a plugin from rewriting the lattice or bypassing the synthesize seam. This page documents what the substrate enforces and what you're still on the hook for. ## What the substrate enforces [#what-the-substrate-enforces] | Property | Enforcement | | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | Tenant isolation in the [cache](/docs/cache) | `tenantId` is in the fingerprint key — no shared cache buckets across tenants | | Append-only audit | `ProviderDecisionLedger` interface has no update / delete — adapters cannot offer mutation | | RLS-bound storage (anon clients) | Schema bundle ships owner-scoped policies on every `harness_*` table | | Plugin can't rewrite the lattice | `audit:graph-stages` CI gate — out-of-lattice edges fail build | | Plugin can't bypass the synthesize seam | `SynthesizeSeamHolder` + `TurnSynthesizeCounter` runtime invariants | | Plugin can't reach the modelfamily matrix | `lint:harness-boundary` CI gate | | [Sync](/docs/sync) conflicts surface, never silently overwrite | Version-vector comparison gates the merge | | Replay [determinism](/docs/determinism) | Sync-only observers, deterministic reducers, fingerprint quantization | These are structural — getting them wrong fails CI or throws at runtime. They're not posture; they're contracts. ## What you're on the hook for [#what-youre-on-the-hook-for] | Concern | Where it lives | | ------------------------------------------ | ------------------------------------------------------------------------- | | Auth (who the request is) | Your handler — `authToken` and `userId` come from there | | Service-role key handling | Server-only code paths; never reach the browser bundle | | RLS policy correctness for shared sessions | Extend the bundle's owner-scoped policies for membership | | Tenant-scoped runtime construction | `organizationId` passed on every `new SessionRuntime` | | Tool execution sandboxing | Tools that touch the filesystem / shell / network are your responsibility | | Plugin trust model | Plugins run in-process; vet what you install | | Provider key handling | BYOK / gateway pattern keeps tenant credentials scoped | ## Auth: `authToken` and `userId` [#auth-authtoken-and-userid] Two related but distinct fields on `SessionRuntimeConfig`: * `userId` — the row owner. Drives RLS in storage and tenant attribution in the audit ledger. * `authToken` — bearer token forwarded to outbound API calls (provider, gateway, sync endpoints). Substrate-internal — not exposed to plugin code. A typical authed request resolves both before runtime construction: ```typescript const session = await auth.verifyRequest(req); if (!session) return new Response("Unauthorized", { status: 401 }); const runtime = new SessionRuntime({ storage, userId: session.userId, organizationId: session.orgId, authToken: session.bearerToken, }); ``` Never default `userId` to `"anonymous"` in production. The runtime accepts it for tutorials and mock mode; it's a tenant-attribution leak in any real deployment. ## Service-role keys [#service-role-keys] The Supabase service-role key bypasses RLS. The substrate uses it for server-side adapters; the rule is: **service-role clients never appear in browser bundles.** Three patterns to enforce the rule: 1. **Server-only subpaths.** Import service-role clients only in `app/api/*` (Next.js), `server/*` (Remix), or equivalent. Browser entry points reach the runtime through HTTP, not by importing it directly. 2. **Build-time check.** Tools like `esbuild-plugin-resolve-extensions` or `next-config-with-validation` can fail builds that bundle server-only imports into client chunks. 3. **The query subpath is server-only.** `@pleach/core/query` bypasses RLS by design. Importing it from a client component is a documented anti-pattern; CI lint should flag it. ## RLS extensions for shared sessions [#rls-extensions-for-shared-sessions] The default policies are owner-scoped — `user_id = auth.uid()`. For multi-user sessions within a tenant, extend with membership: ```sql DROP POLICY harness_sessions_owner_select ON harness_sessions; CREATE POLICY harness_sessions_tenant_select ON harness_sessions FOR SELECT USING ( user_id = auth.uid()::text OR id IN ( SELECT session_id FROM harness_session_members WHERE user_id = auth.uid()::text ) ); ``` Apply analogous policies on `harness_checkpoints` and `harness_auditable_calls`. The audit table additionally needs a tenant filter — leaking audit rows across tenants is a real security event regardless of session membership. <Callout type="warn"> Per-**user** isolation on `harness_event_log` is **chat-ownership**, not per-event `actor_id`. A single chat's event log carries mixed `actor_id` (the user for message/tool rows, `system` / `NULL` for session/cost/error/interrupt rows, `subagent:{id}` for delegated work), so a `WHERE actor_id = <caller>` read filter would hide the non-user rows from the owner and break reconstruction, export, and cost projections. Scope reads by **who owns the chat** (`ai_chats.created_by = <caller>`), checked in the API route *before* the event-log query — never by `actor_id`. The user gets their chat's complete trail iff they own the chat. </Callout> ## Tool sandboxing [#tool-sandboxing] The substrate doesn't sandbox tool execution. A tool that reads the filesystem, spawns a process, or makes outbound HTTP requests runs with the same privileges as the host process. Mitigations, in order of strength: 1. **Schema-validate every input.** `defineTool` enforces this via Zod; the validation runs before `execute` fires. 2. **Honor `ToolContext.signal`.** Tools that ignore it keep running after the user aborts — a DoS vector if the tool spawns expensive work. 3. **Use the per-tool interrupt pattern for destructive actions.** Configure `InterruptConfig.perToolApproval` to require explicit approval for tools that write or delete. 4. **Move untrusted code execution into [`@pleach/coding-agent`](/docs/coding-agent).** The sandboxed code-execution surface runs guest-supplied code in a Firecracker microVM (Vercel Sandbox or equivalent), not in the host process. ## Plugin trust model [#plugin-trust-model] `HarnessPlugin` is an in-process extension contract. Plugins run with the runtime's privileges; a malicious plugin can read every prompt, intercept every stream chunk, and emit fabricated events. This is the same trust model as any in-process library. Practical guidance: * Only install plugins you'd be willing to install as ordinary npm dependencies. * Pin versions in `package.json`; review changelogs on upgrade. * For untrusted plugins, the substrate has no isolation primitive — those are out of scope. If your threat model requires it, run different agent instances in different processes / containers and don't share plugin sets. ## Provider key handling (BYOK) [#provider-key-handling-byok] Two patterns documented in [Multi-tenant](/docs/multi-tenant): * **BYOK via runtime construction** — tenant key threaded into the provider at runtime build. Simplest; works when one tenant = one provider. * **Gateway plugin** — `@pleach/gateway` reads tenant context off the runtime and routes per its own credentials store. Use when tenants share a provider but have different rate limits or fallback chains. Either way: provider keys never reach the browser bundle, never appear in audit-ledger payloads (the ledger records `family`, `modelId`, `transport` — never the credential), and never appear in logs. ## Audit ledger as evidence [#audit-ledger-as-evidence] The append-only contract means: a regulator can verify that a ledger row hasn't been tampered with by inspecting the schema and the RLS policies. The `@pleach/compliance` SKU layers a [hash chain](/docs/hash-chain) on top — each row's hash chains to the previous, so any single-row mutation breaks the chain and the verify pass reports the broken index. For SOC 2 / HIPAA / 21 CFR Part 11 deployments, this is the foundation. Compliance isn't shipped automatically — you have to enable the hash-chain plug-point — but the substrate doesn't get in the way. ## Sync conflicts and confused-deputy [#sync-conflicts-and-confused-deputy] The version-vector sync detects conflicts at write time. The default merger picks `merged` when fields don't overlap and `local` when they do. If your application has fields where `local` is the wrong default (e.g. a server-side compliance flag the client shouldn't be able to override), pass a custom merger in `SyncCoordinatorConfig.merger` that explicitly picks `remote` for those fields. A misconfigured default merger is the closest the substrate comes to a confused-deputy issue. The merger is a single function; the audit value is high. ## Secrets in environment variables [#secrets-in-environment-variables] The runtime reads env vars at startup; the [Env vars](/docs/env-vars) page enumerates them. Two patterns to keep secrets out of process memory longer than necessary: * Read `process.env.X` once at runtime construction; pass the resolved value as config. Never keep the env var live for later reads. * For per-request secrets (a BYOK key resolved from a session), build the runtime per-request as documented in [Multi-tenant](/docs/multi-tenant). The runtime is GC'd after the request; the key goes with it. ## Logging and PII [#logging-and-pii] The substrate's default loggers write event types and ids, not payloads. Plugins can log whatever they choose; vet them. For PII redaction inside the audit ledger payload itself, install `@pleach/compliance` and wire its `PIIRedactor` plug-point — see [scrubbers](/docs/scrubbers). The no-op default ships pass-through; production deployments running without the redaction wired are recording raw user input into the ledger. ## Production checklist [#production-checklist] * [ ] Service-role keys server-only; CI enforces no import in client bundles * [ ] `userId` and `organizationId` always passed; never defaulted * [ ] Schema bundle applied; RLS extended for membership if needed * [ ] `PIIRedactor` wired (via `@pleach/compliance`) for regulated deployments * [ ] `TamperEvidence` wired for evidence-required deployments * [ ] Plugin set audited; versions pinned * [ ] Tool inputs Zod-validated (automatic; verify no `as any` escapes) * [ ] `InterruptConfig.perToolApproval` configured for destructive tools * [ ] Sync merger reviewed for confused-deputy-safe defaults * [ ] Mock mode flag (`HARNESS_MOCK_MODE`) cannot be set in production env * [ ] DevTools (`useHarnessDevTools`) gated behind `NODE_ENV !== "production"` ## Security-shaped substrate features [#security-shaped-substrate-features] The security posture isn't only what's documented in the auth / RLS / secret-handling sections above. Several substrate features ship security-relevant behavior by default, and a reader on this page shouldn't have to discover them through cross-reference. * **Scrubber gate at write time** — every event log row is gated through registered `Scrubber` instances before persistence. `@pleach/compliance` ships four (`SSN-US`, `Luhn`, `US-DL`, `KeyedRegex`); hosts add more via `contributeScrubbers`. The CI gate `audit:c8-event-type-allowlist-coverage` enforces coverage. See [Scrubbers](/docs/scrubbers), [Compliance](/docs/compliance). * **Tamper-evident hash chain** — `harness_event_log` carries `prev_hash` and `row_hash` columns that catch silent backfills, row reorders, and row removals after the fact. Verification walks the chain from a known root. The schema ships today; writer-side stamping is in soak. See [Hash chain](/docs/hash-chain). * **Multi-tenant scoping by construction** — `runtime.tenant` stamps `tenant_id` on every event log row, every audit ledger row, every OTel span, and (via `withTenantHeader`) every outbound HTTP request. RLS at the database layer enforces; the facet provides the value. Two CI gates (`audit:tenant-scoping`, `audit:harness-event-log-tenant-id-required`) flag write sites that bypass the facet. See [Tenant facet](/docs/tenant-facet). * **Audit-record version log** — `AUDIT_RECORD_VERSION_HISTORY` exposes the additive-promotion contract over the five typed records. A consumer reading the version log knows which fields changed in which substrate release and can pin its alert thresholds accordingly. See [Typed records](/docs/typed-records). * **OTel `pleach.tenant_id` attribute** — set automatically on every emitted span when `runtime.tenant` is configured. The load-bearing field for per-tenant trace queries and incident scoping. See [OTel observability](/docs/otel-observability). Each of these features layers on top of the auth / RLS / secret-handling story documented above — substrate-level features don't replace consumer-side discipline. They reduce the surface where that discipline can silently slip. ## Where to go next [#where-to-go-next] <Cards> <Card title="Multi-tenant" href="/docs/multi-tenant" description="Tenant-isolation patterns and the production checklist." /> <Card title="Schema" href="/docs/schema" description="The RLS template and how to extend it." /> <Card title="Audit ledger" href="/docs/audit-ledger" description="The compliance plug-points (`TamperEvidence`, `PIIRedactor`, `GDPRSoftDelete`)." /> <Card title="Env vars" href="/docs/env-vars" description="What the runtime reads from the environment." /> <Card title="Scrubbers" href="/docs/scrubbers" description="Write-time PII gate with four shipped detectors and host extension." /> <Card title="Hash chain" href="/docs/hash-chain" description="Tamper-evident chaining over `harness_event_log` rows." /> </Cards> --- # HarnessServer (/docs/server) `HarnessServer` is one surface in the **frontend integration** [thematic island](/docs/concept-clusters#what-lives-outside-the-cluster-pattern) — siblings of [react](/docs/react), [api-routes](/docs/api-routes), [query](/docs/query), and [devtools](/docs/devtools). Wiring surfaces, not concept triplets. `HarnessServer` is a set of pure request-to-response handlers that wrap a `SessionRuntime`. It does not bind a port. Each handler mounts into whatever HTTP framework already owns your transport — Next.js route segments, Express middleware, Hono routes, raw `node:http`. <Callout type="warn"> **`@pleach/core/server` is not a published subpath today.** `HarnessServer` + `ROUTES` are real classes, but they are an internal substrate surface — the `./server` export key is not yet in `@pleach/core`'s `package.json`, so the imports below resolve only inside the monorepo, not for an external consumer. For a published, ready-to-mount route handler use `createPleachRoute` from [`@pleach/core/quickstart`](/docs/api-routes) (a single Web-standard POST handler). This page documents the fuller handler set for reference and for the day the subpath is promoted. </Callout> ```typescript // Internal substrate path — not a published @pleach/core subpath yet. import { HarnessServer, ROUTES, type HarnessServerConfig, } from "@pleach/core/server"; ``` `HarnessServer` is the substrate-level handler set; the Next.js adapter that ships at [`/docs/api-routes`](/docs/api-routes) is one mount. The route paths and shapes match — picking between them is about which framework owns your HTTP layer. <SourceMeta source="{ label: "src/server/", href: "https://github.com/pleachhq/core/tree/main/src/server" }" /> ## Configuration [#configuration] ```typescript interface HarnessServerConfig { provider: ServerProvider; // executes messages, yields stream events storage: ServerStorage; // session CRUD checkpointer?: ServerCheckpointer; // enables checkpoints + rollback routes auth?: ServerAuthProvider; // surfaces on /health features map tools?: ToolRegistry; // enables /tools + /tools/:name port?: number; // informational only hostname?: string; // informational only cors?: { origins, methods?, headers? }; } ``` `provider` is the seam the execute routes call into — typically a thin adapter that calls `runtime.executeMessage(...)` and yields each stream event. `storage` mirrors the `StorageAdapter` shape but with loosened types so the server stays decoupled from the full `SessionState` envelope. Calling `start()` flips an `isRunning()` flag and nothing else; it's useful for health-check gating but binds no socket. ## The `ROUTES` constant [#the-routes-constant] `ROUTES` is the canonical path table. Mount handlers against these strings so a client built from `ROUTES` and a server built from `ROUTES` stay in lockstep. | Constant | Path | Handler | | --------------------- | ---------------------------------------------- | -------------------------------------------- | | `ROUTES.HEALTH` | `/health` | `handleHealth` | | `ROUTES.SESSIONS` | `/sessions` | `handleCreateSession` / `handleListSessions` | | `ROUTES.SESSION` | `/sessions/:sessionId` | `handleGetSession` / `handleDeleteSession` | | `ROUTES.EXECUTE` | `/sessions/:sessionId/execute` | `handleExecuteMessage` (SSE) | | `ROUTES.EXECUTE_SYNC` | `/sessions/:sessionId/execute/sync` | `handleExecuteMessageSync` (buffered JSON) | | `ROUTES.INTERRUPT` | `/sessions/:sessionId/interrupts/:interruptId` | `handleResolveInterrupt` | | `ROUTES.CHECKPOINTS` | `/sessions/:sessionId/checkpoints` | `handleListCheckpoints` | | `ROUTES.ROLLBACK` | `/sessions/:sessionId/rollback` | `handleRollback` | | `ROUTES.TOOLS` | `/tools` | `handleListTools` | | `ROUTES.TOOL` | `/tools/:toolName` | `handleGetTool` | The Next.js handlers under `/api/harness/*` add an extra `sync` route for version-vector merge that `HarnessServer` does not ship. If sync is load-bearing, mount the Next.js adapter directly or proxy the sync endpoint to your own implementation. ## Handler signatures [#handler-signatures] Every handler returns one of two shapes: ```typescript interface HandlerResponse { status: number; body: unknown; headers?: Record<string, string>; } interface SSEResponse { status: number; headers: Record<string, string>; stream: AsyncIterable<string>; // already-formatted SSE frames } ``` `HandlerResponse` is for buffered JSON; `SSEResponse` is the streaming path. The frame format the server emits is one of: ``` event: <type> data: <json> event: done data: {} ``` `event: done` is yielded at end-of-stream so a client can distinguish clean close from disconnect without inspecting the underlying socket. ### Session handlers [#session-handlers] | Handler | Input | Returns | | ------------------------------------------ | ------ | --------------------------- | | `handleCreateSession({ userId, config? })` | body | 201 + seeded `SessionState` | | `handleGetSession({ sessionId })` | params | 200 + state, or 404 | | `handleListSessions({ userId?, limit? })` | query | 200 + array | | `handleDeleteSession({ sessionId })` | params | 204 | `handleCreateSession` seeds the envelope (`id`, `version: 1`, empty arrays for messages / tool calls / jobs / artifacts) and merges `config` last. The id is a fresh `crypto.randomUUID()`. ### Execution handlers [#execution-handlers] | Handler | Response | | -------------------------- | ------------------------------------------------- | | `handleExecuteMessage` | SSE stream of events from `provider.execute` | | `handleExecuteMessageSync` | Buffered JSON: `{ events, message, toolResults }` | `handleExecuteMessageSync` drains the provider stream and extracts the `message.complete` event into `message` and every `tool.completed` event into `toolResults`. Use it for non-streaming clients (cron jobs, batch workers, integration tests). A buffered call from a batch worker, no SSE wiring: ```typescript const result = await server.handleExecuteMessageSync( { sessionId }, { message: "Summarize today's queue." }, ); if (result.status === 200) { const body = result.body as { message: unknown; toolResults: unknown[] }; await writeReport(sessionId, body.message, body.toolResults); } ``` ### Checkpoint handlers [#checkpoint-handlers] Both checkpoint routes return `501` when `checkpointer` is not configured — the body is `{ error: "Checkpointer not configured" }`, not a generic 500. | Handler | Notes | | ------------------------------------------------- | ------------------------------------------------------------------------------- | | `handleListCheckpoints({ sessionId })` | Drains the checkpointer's `list` async iterable into an array | | `handleRollback({ sessionId }, { checkpointId })` | Reads the checkpoint, writes `checkpoint.state` back to `storage.updateSession` | The rollback here is the wire-level operation — it replays the stored state without the in-process bookkeeping (version-vector bump, `source: "rollback"` checkpoint write) that `runtime.checkpoints.rollback` does. If you need that bookkeeping, mount the Next.js handler or call the runtime method directly behind your own route. ## Mounting examples [#mounting-examples] ### Next.js App Router [#nextjs-app-router] ```typescript // app/api/harness/[...path]/route.ts // Internal substrate path — not a published @pleach/core subpath yet. import { HarnessServer, ROUTES } from "@pleach/core/server"; const server = new HarnessServer({ provider, storage, checkpointer }); server.start(); export async function POST(req: Request, { params }: { params: { path: string[] } }) { const [resource, sessionId, action] = params.path; if (resource === "sessions" && !sessionId) { const body = (await req.json()) as { userId: string; config?: Record<string, unknown> }; const r = await server.handleCreateSession(body); return Response.json(r.body, { status: r.status }); } if (resource === "sessions" && action === "execute") { const body = (await req.json()) as { message: string; options?: Record<string, unknown> }; const r = await server.handleExecuteMessage({ sessionId }, body); return new Response(toReadableStream(r.stream), { status: r.status, headers: r.headers }); } // ... etc } ``` ### Express [#express] ```typescript import express from "express"; // Internal substrate path — not a published @pleach/core subpath yet. import { HarnessServer, ROUTES } from "@pleach/core/server"; const app = express(); const server = new HarnessServer({ provider, storage }); app.post(ROUTES.SESSIONS, async (req, res) => { const r = await server.handleCreateSession(req.body); res.status(r.status).json(r.body); }); app.post(ROUTES.EXECUTE, async (req, res) => { const r = await server.handleExecuteMessage({ sessionId: req.params.sessionId }, req.body); res.writeHead(r.status, r.headers); for await (const frame of r.stream) res.write(frame); res.end(); }); ``` ### Hono [#hono] ```typescript import { Hono } from "hono"; import { streamSSE } from "hono/streaming"; const app = new Hono(); app.post(ROUTES.EXECUTE, (c) => streamSSE(c, async (stream) => { const r = await server.handleExecuteMessage({ sessionId: c.req.param("sessionId") }, await c.req.json()); for await (const frame of r.stream) await stream.write(frame); }), ); ``` The pattern is the same in every framework: route the framework's `(req, params, body)` into the matching `handle*` method, then serialize the `HandlerResponse` or `SSEResponse` back into whatever the framework expects. ## Where to go next [#where-to-go-next] <Cards> <Card title="API routes" href="/docs/api-routes" description="The Next.js reference handlers with the full route catalog and SSE wire format." /> <Card title="React" href="/docs/react" description="The client-side hooks that consume these handlers over HTTP + SSE." /> <Card title="Query" href="/docs/query" description="Server-side read API over persisted harness data — pairs with these write-path handlers." /> <Card title="DevTools" href="/docs/devtools" description="Browser-console surface for inspecting what the server returns." /> </Cards> --- # Session lifecycle (/docs/session-lifecycle) A session is the unit of persistence and identity. It carries the locked provider and model, the message history, the channel state, and the checkpoint chain. The runtime is per-process; the session is per-conversation. Every turn runs against one session id, and every audit row joins back to it. This page walks the session through its lifecycle. Per-turn execution flow — the stream events, the tool loop, the synthesis stage — lives on [Turn lifecycle](/docs/turn-lifecycle). ## The runtime-lifecycle cluster [#the-runtime-lifecycle-cluster] Session lifecycle is one of three concepts paired with [Turn lifecycle](/docs/turn-lifecycle) (the per-message arc) and [Event log](/docs/event-log) (the append-only stream both arcs write into). Sessions outlive turns, turns outlive their stream frames, and the event log outlives both. The full triplet framing lives at [Concept clusters → Runtime-lifecycle](/docs/concept-clusters#runtime-lifecycle-cluster); the rest of this page is the deep dive on the session arc itself. ## Minting a session [#minting-a-session] ```typescript const session = await runtime.createSession({ provider: { type: "anthropic" }, model: { id: "claude-sonnet-4-20250514" }, tools: { enabled: ["search", "calculator"] }, }) ``` `createSession` takes a `Partial<SessionConfig>` and writes the new session to the storage adapter before resolving. The returned `Session` is your handle — `session.id` is a UUIDv7 the runtime generates, and it's what every subsequent call takes. Three things land on storage during `createSession`: * The session row itself (id, owner, createdAt, locked provider+model). * An initial version-vector entry keyed on the runtime's `clientId`. * An empty channel set ready for the first turn's writes. Provider and model are locked at this point. A session keeps the same `(provider, model)` pair for its lifetime — switching models mid-conversation means a new session, not a mutation. See [SessionRuntime](/docs/session-runtime#creating-a-session) for the full config table and [`POST /api/harness/sessions`](/docs/api-routes) for the HTTP wire shape. ## Resuming a session [#resuming-a-session] ```typescript const session = await runtime.resumeSession(sessionId) ``` Resume rebuilds a session into the runtime from three layers: 1. **The storage row.** The adapter reads the session record — owner, locked provider/model, version vector, last-active timestamp, and the durable `SessionState` (messages, pending tool calls, artifacts, jobs). This is the base state. 2. **The latest checkpoint.** When a `checkpointer` is wired and holds a snapshot **at least as recent as the storage row**, the runtime overlays it — the recovery path for a crash that landed a checkpoint but lost the row write. The version guard means an older checkpoint never rolls back a fresher row; the checkpoint snapshot is authoritative when it wins. (Making the event log the canonical *message/state* reader is a separate forward-looking direction — that projection currently runs as a behavior-free shadow, gated off by default.) 3. **The event log (server only).** When the runtime can read the persisted log (a service-role server adapter), `hydrateFromEvents` rebuilds the **ephemeral card-lifecycle state** the `SessionState` row does not carry — resolved interrupts, subagents, sandbox exports, and user cards. Because that state is *derived* from the durable log, it rides the returned Session's transient `hydratedHarnessState` field and is **not** written back to the row. The browser / in-memory path skips this layer. See [Event log](/docs/event-log#hydratefromevents) for the walk. Resume reconstructs only what storage durably held. The default in-memory adapter keeps everything in this process's heap — restart the process (or land on a fresh serverless instance) and there is nothing to resume. Durability is the host's storage-adapter choice, not an automatic guarantee; wire a durable adapter (Supabase / Postgres / Redis) for resume to survive a restart. See [Storage backends](#storage-backends) below. Resume is idempotent. Calling it twice with the same id returns two `Session` instances pointing at the same persisted state — the later call wins as the runtime's `getActiveSession()`. A resumed session keeps its original `id`, its `clientId` lineage, and every prior audit row. The audit ledger doesn't fork on resume; it appends. ## Aborting an in-flight turn [#aborting-an-in-flight-turn] ```typescript const ctrl = new AbortController() setTimeout(() => ctrl.abort(), 30_000) for await (const event of runtime.executeMessage( session.id, prompt, { abortSignal: ctrl.signal }, )) { // ... } ``` The `AbortController` propagates into the per-call provider stream and into every active subagent under the turn. The runtime cancels in-flight network calls, unwinds the tool loop, and flushes a final audit row with `outcome.status: "user-aborted"` — recorded distinctly from `provider-error` at both the tool-loop decision call and the synthesis terminal, so cost rollups and compliance can tell caller cancellation apart from real provider failure. Aborting does not delete the session. The session row stays; the partial turn lands an audit row carrying the same `(sessionId, turnId, stageId, seqWithinTurn)` identity tuple as a successful row — so per-turn cost rollups still count the prefill tokens the provider already consumed. The next `executeMessage` on the same `sessionId` continues from the post-abort state. See [SessionRuntime · Aborting a turn](/docs/session-runtime#aborting-a-turn) for the code path inside the runtime. ## Time-travel and rollback [#time-travel-and-rollback] The rollback API itself and the rollback-vs-fork choice live on [Checkpointing](/docs/checkpointing#rolling-back-to-a-checkpoint). Two session-level invariants matter here: * **Audit rows are never rolled back.** The ledger is append-only by contract — `ProviderDecisionLedger` has no `update` or `delete` primitive. A rolled-back turn's calls stay in the ledger; the rollback is a new event downstream of them. * **Subagent provenance is preserved.** Audit rows for subagents nested under the rolled-back point keep their `parentTurnId` and `subagentDepth`, so a rollback can be replayed and diffed against the original branch. Fork is the non-destructive sibling — it mints a new `sessionId` pointing at the same checkpoint, so the original transcript stays intact. See [Checkpointing](/docs/checkpointing) for the rollback and fork APIs, and [Time travel](/docs/time-travel) for the eval and replay use cases. ## Conflict resolution (multi-client) [#conflict-resolution-multi-client] Two clients writing the same session bump their respective version-vector entries. When one pushes to the server, the coordinator compares vectors — a `concurrent` outcome is settled by `SyncCoordinator.resolveConflicts(local, remote)` using last-writer-wins on `updatedAt`, rather than clobbering by arrival order. This is the self-host resolution path in open `@pleach/core`. Interactive conflict resolution is enterprise-tier / planned, not shipped in the open package: ```typescript // Enterprise-tier / planned — NOT functional in open @pleach/core. await runtime.resolveConflict(sessionId, conflictId, "local") // or "remote" ``` In `@pleach/core`, `runtime.resolveConflict` is a stub: it logs and returns a receipt without merging or persisting, and no `sync.conflict` stream event is emitted to hand it a `conflictId`. CRDT merge and the interactive conflict surface are the enterprise upgrade. For the working self-host path, call `SyncCoordinator.resolveConflicts(local, remote)` on your push handler. See [Sync](/docs/sync) for the version-vector math, the durable outbox, and the 3xxx error code range. ## Deleting a session [#deleting-a-session] ```typescript await runtime.deleteSession(sessionId) ``` Delete cascades into the checkpointer when one is wired — every checkpoint for the session drops. The session row is removed from storage and `getActiveSession()` returns `null` if it pointed at the deleted id. The audit ledger is the exception. Audit rows survive delete because the ledger is append-only by contract; that's what makes `ProviderDecisionLedger` replayable and what gives finance and compliance a row set that can't be silently retroactively edited. Subject-key-derived deletion of audit data goes through the `GDPRSoftDelete` plug-point. It leaves the `recordId` in place (so the hash chain holds) and clears the identifying fields under the subject key — satisfying a deletion request without breaking the append-only invariant. See [Audit ledger · The three compliance plug-points](/docs/audit-ledger#the-three-compliance-plug-points). ## Storage backends [#storage-backends] The storage adapter is what `createSession` writes to and what `resumeSession` reads from. Pick by environment. | Adapter | Environment | Use for | | ------------------ | ------------- | ----------------------------------------------------------- | | `MemoryAdapter` | Any | Tests, local dev, ephemeral demos | | `IndexedDBAdapter` | Browser | Offline-first apps, browser extensions, multi-device drafts | | `SupabaseAdapter` | Server (Node) | Persistent storage, multi-tenant RLS, production | `MemoryAdapter` is the zero-config default — and it is **non-durable**: session state (messages, checkpoints, event log) lives in the process heap, is lost on restart, and is not shared across instances. On serverless, each cold start or new instance begins from empty state. The quickstart route (`createPleachRoute`) emits a one-time startup warning when it falls back to this adapter. Production hosts should supply a durable adapter — durability is the host's storage-adapter choice, not something the runtime provides automatically. All three implement the same `StorageAdapter` interface, so swapping is a one-line constructor change. See [Storage](/docs/storage) for the per-adapter config and the RLS template that `SupabaseAdapter` parameterizes. ## Lifecycle method index [#lifecycle-method-index] | Method | Signature | When to reach for it | | ---------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `createSession` | `(config?: Partial<SessionConfig>) => Promise<Session>` | Starting a new conversation — locks provider and model | | `resumeSession` | `(sessionId: string) => Promise<Session>` | Loading a session from a prior process or another tab | | `saveSession` | `(state: SessionState) => Promise<void>` | Flushing an explicit state mutation outside the normal turn write path | | `deleteSession` | `(sessionId: string) => Promise<void>` | Removing the session and its checkpoints (audit rows stay) | | `getActiveSession` | `() => Session \| null` | Reading the last session the runtime loaded | | `rollbackToCheckpoint` | `(sessionId, checkpointId) => Promise<Session>` | Rewinding a session in place (deprecated alias for `runtime.checkpoints.rollback`) | | `resolveConflict` | `(sessionId, conflictId, "local" \| "remote") => Promise<…>` | Enterprise-tier / planned — a stub in open `@pleach/core` (logs, no merge). Use `SyncCoordinator.resolveConflicts` for the working last-writer-wins path | | `destroy` | `() => Promise<void>` | Tearing the runtime down — stream manager, interrupt manager, listeners | The same flat-method-to-facet deprecation contract applies here: `rollbackToCheckpoint` redirects to `runtime.checkpoints.rollback`, and flat methods stay callable through one minor cycle before the next major drops them. See [SessionRuntime · Session lifecycle methods](/docs/session-runtime#session-lifecycle-methods) for the canonical signature reference. ## Where to go next [#where-to-go-next] <Cards> <Card title="Turn lifecycle" href="/docs/turn-lifecycle" description="What happens inside one executeMessage call — stages, stream events, the tool loop." /> <Card title="SessionRuntime" href="/docs/session-runtime" description="The constructor config, every accessor method, and the full strategy slot table." /> <Card title="API routes" href="/docs/api-routes" description="The HTTP + SSE wire shapes for create / get / put / delete / sync." /> <Card title="Checkpointing" href="/docs/checkpointing" description="Snapshot writes, the rollback and fork APIs, and the Checkpointer contract." /> <Card title="Sync" href="/docs/sync" description="Version vectors, the durable outbox, and the 3xxx error code range." /> </Cards> --- # SessionRuntime (/docs/session-runtime) A `SessionRuntime` owns one user's sessions, the per-turn graph, the stream pipeline, the `AuditableCall` ledger, and the plugin set — it's the one class you instantiate from `@pleach/core`. Everything else is configuration passed to its constructor or methods called on the instance. See [Storage](/docs/storage) for adapter choices and [Stream events](/docs/stream-events) for what `executeMessage` yields. `SessionRuntime` is the implementation surface of the [runtime-lifecycle cluster](/docs/session-lifecycle#the-runtime-lifecycle-cluster) — it hosts the three lifecycle concepts (session lifecycle, turn lifecycle, event log) under one constructor. ```typescript import { SessionRuntime } from "@pleach/core"; ``` <SourceMeta source="[ { label: "src/SessionRuntime.ts", href: "https://github.com/pleachhq/core/blob/main/src/SessionRuntime.ts" }, { label: "src/runtime/", href: "https://github.com/pleachhq/core/tree/main/src/runtime" }, ]" /> ## Construction [#construction] ```typescript const runtime = new SessionRuntime(config); ``` All fields are optional. With no config, you get an in-memory runtime with `userId: "anonymous"` — fine for a tutorial, never fine for production. ### `SessionRuntimeConfig` fields [#sessionruntimeconfig-fields] | Field | Type | Default | Purpose | | --------------------------- | --------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `storage` | `StorageAdapter` | fresh `MemoryAdapter` | Where sessions persist. See [Storage](/docs/storage). | | `checkpointer` | `Checkpointer` | none | Where checkpoints persist. See [Checkpointing](/docs/checkpointing). | | `eventLogWriter` | `HarnessEventWriter` | none | Write side of the durable event log. | | `eventReader` | `HarnessEventReader` | none | Read side of the durable event log — backs `runtime.events.iterate` / `.fold` and `resumeSession`'s Layer-3 hydration. Store-agnostic; the host casts its store into it at construction. See [Backing the reader](/docs/event-log-projections#backing-the-reader). | | `cache` | `StorageAdapter` | none | Optional hot-data cache adapter | | `userId` | `string` | `"anonymous"` | Session owner. Production deployments must pass this. | | `organizationId` | `string` | – | Multi-tenant scoping | | `clientId` | `string` | generated | Sync version-vector key | | `provider` | `AgentProvider` | – | LLM provider implementation | | `plugins` | `HarnessPlugin[]` | `[]` | Domain extensions. See [Plugin contract](/docs/plugin-contract). | | `metaToolNames` | `Set<string>` | empty set | Meta-tool names the runtime treats as flow-control (e.g. `set_step_complete`, `wait_for_jobs`) | | `enabledSafetyPolicies` | `readonly SafetyPolicyId[]` | `[]` | Operator opt-in for safety policies plugins registered. Canonical read surface is `runtime.safety.{listAvailable, getActive, getRegistry}`. | | `seedCoreDefaults` | `boolean` | `true` | Auto-seed the 11 `core.*` prompt fragments | | `interrupt` | `InterruptConfig` | – | Human-in-the-loop configuration | | `enableSubagentConcurrency` | `boolean` | `false` | Allow parallel subagents | | `maxConcurrentSubagents` | `number` | `4` | Concurrency cap when enabled | | `chatId` | `string` | – | Provenance link to an upstream chat row | | `isGuest` / `guestId` | `boolean` / `string` | – | Guest-mode session ownership | | `authToken` | `string` | – | Bearer token forwarded to API calls | | `jobDispatchEndpoint` | `string` | – | URL the runtime POSTs queued jobs to | `enabledSafetyPolicies` participates in the fingerprint cache key — toggling a policy invalidates the cache automatically. `metaToolNames` is load-bearing after R-1.A. Hosts that don't pass it see a one-shot `[UXParity:metaToolNames-config-missing]` probe plus a startup warning, and continuation guards silently disable. The continuation guards are the runtime nodes that decide whether a named flow-control tool (`set_step_complete`, `wait_for_jobs`) signals "this step is done" or "this is just another tool call"; when the guards disable, the tool loop keeps iterating on what should have been a terminal signal, which is the symptom most hosts notice before they read the probe. ## Strategy injection [#strategy-injection] `SessionRuntimeConfig` also carries a second set of fields the substrate calls **strategies** — typed slots the host fills with domain code, each with one well-defined shape and one consumer. A strategy that's unset gracefully degrades; the runtime stays functional and the strategy-backed behavior goes inert. Representative slots include `summaryExtractor`, `toolCatalog`, `subagentExecutor`, `fabricationGuardStrategy`, `hallucinationDetectorFactory`, and the orchestrator-side provider-fallback registry. The full surface, the wiring patterns, and the per-turn vs. per-runtime allocation rule live on [Runtime strategies](/docs/runtime-strategies). ## Facets [#facets] Each facet returns a typed slice of the runtime — `runtime.sessions` for session lifecycle, `runtime.events` for the event log, `runtime.sync` for bidirectional sync, `runtime.dev` for diagnostics. Reading a facet gets you the methods grouped under that name and the typed receipts those methods return. Runtime-level facet inventory: | Facet | Covers | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `runtime.sessions` | Session lifecycle — `create`, `resume`, `find`, `save`, `delete`, `updateProviderModel` | | `runtime.sync` | Bidirectional sync — `execute`, `resolveConflict`, `subscribeToStream`. Callable: `runtime.sync(sessionId)` preserves the legacy shape. | | `runtime.events` | Event-log iteration + fold + the live event bus (`on`, `once`, `off`) | | `runtime.spans` | Span `start` / `flush` / `shutdown` plus `inFlightCount`, `isShutdown`, `snapshot` introspection | | `runtime.tenant` | Tenant identity — frozen at construction; identity-stable across reads | | `runtime.plugins` | `PluginManager` access plus the full `collect*` cohort and registry lookups | | `runtime.prompts` | Prompt-contribution introspection (`list`, `get`, `getAll`, `listByOrigin`) | | `runtime.safety` | `listActivePolicies`, `listAvailablePolicies`, `getRegistry` | | `runtime.tools` | Registered tool catalog introspection | | `runtime.observerRouter` | Late-binding stream-observer registration; receipts on `unregister` | | `runtime.degradation` | Model degradation reads; `getDegradedModelRecord` returns a receipt | | `runtime.checkpoints` | `rollback`, `list` | | `runtime.timeTravel` | Time-travel API accessor | | `runtime.async` | Async-task manager, subagent spawn, result lookup | | `runtime.interrupts` | Interrupt manager; `resolve` returns an `InterruptResolveReceipt` | | `runtime.adapter` | Orchestrator-adapter wiring + capability registry | | `runtime.diagnostics` | Boot-time readiness probes for the host-extension bag | | `runtime.dev` | Diagnostic sub-API — store, stream manager, repetition-guard wiring | | `runtime.graph.recovery` | Per-turn recovery hooks | | `runtime.graph.heuristics` | Tunable graph heuristics | | `runtime.graph.config` | Graph-build configuration | | `runtime._internal` | INTERNAL graph-consumption surface — substrate-only, do not call | ### Receipt-shaped returns [#receipt-shaped-returns] Mutating accessors across `sessions`, `sync`, `observerRouter`, `degradation`, and `interrupts` return typed receipts rather than booleans or `void`. The pattern is deliberate: every call site that mutates runtime state echoes a structured outcome callers can branch on, audit, or log without re-derivation. Examples: * `runtime.sessions.delete(sessionId)` → `SessionDeleteReceipt` (`deleted`, `sessionId`, `removedFromCache`). * `runtime.sync.flushOutbox()` → `OutboxFlushReceipt` with a four-verdict `outcome` (`"all-flushed" | "partial" | "all-stuck" | "none-pending"`) plus `failedEntries`, `flushedAt`, and the pre-widening counts. * `runtime.sync.resolveConflicts(...)` includes a `conflictedPaths` array describing every diverged field (`path`, `localValue`, `remoteValue`, `chosenSide`). * `runtime.sync.resolveConflict(...)` → `SyncConflictResolveReceipt` (`resolved`, `sessionId`, `conflictId`, `resolution`, `resolvedAt`). * `runtime.observerRouter.unregister(reg)` → `{ removed, wasRegistered }`. * `runtime.degradation.getDegradedModelRecord(modelId)` → receipt with the active routing entry or `null` when none is held. * `runtime.interrupts.resolve(id, decision)` → `InterruptResolveReceipt`. ### Deprecation contract [#deprecation-contract] Existing flat methods stay around with `@deprecated` JSDoc that points to the facet. For example, `runtime.getTenantId()` redirects readers to `runtime.tenant.getTenantId()`, and `runtime.getSafetyRegistry()` redirects to `runtime.safety.getRegistry()`. The minor release that adds the facet also marks the flat method `@deprecated`; the next major removes the flat method. Flat methods are never removed in a minor bump. For the full facet inventory, the `TurnOrchestrator.*` facet set, and the audit gates that enforce coverage, see [Facets](/docs/facets). ## Creating a session [#creating-a-session] ```typescript const session = await runtime.createSession({ provider: { type: "anthropic" }, model: { id: "claude-sonnet-4-20250514" }, tools: { enabled: ["search", "calculator"] }, }); ``` `createSession` accepts a `Partial<SessionConfig>`. Defaults: | Config field | Default | | -------------------------- | ------------------------------------ | | `provider` | `{ type: "anthropic" }` | | `model` | `{ id: "claude-sonnet-4-20250514" }` | | `tools` | `{ enabled: [] }` | | `projectId` / `campaignId` | inherited from runtime if set | Returns a `Session` instance bound to a generated UUIDv7 id — `session.id` is what `executeMessage` takes. The session is written to storage before this resolves. ## Executing a message [#executing-a-message] `executeMessage` is an async generator. Iterating it streams the turn; every yielded value is a `StreamEvent`. ```typescript for await (const event of runtime.executeMessage(session.id, "Hello")) { switch (event.type) { case "message.delta": process.stdout.write(event.delta); break; case "tool.completed": console.log("tool finished:", event.toolCall.name); break; case "error": console.error(event.code, event.error); break; } } ``` ### Signature [#signature] ```typescript runtime.executeMessage( sessionId: string, content: string, options?: { fileReferences?: unknown[]; abortSignal?: AbortSignal; }, ): AsyncGenerator<StreamEvent>; ``` ### Aborting a turn [#aborting-a-turn] Pass an `AbortSignal`. The runtime cancels in-flight provider calls, unwinds the tool loop cleanly, and flushes a final ledger row with `outcome.status: "user-aborted"`. The user-aborted row carries the same `(sessionId, turnId, stageId, seqWithinTurn)` identity tuple as a successful row, which is what lets a per-turn cost rollup count an abort against the same `turnId` as the calls that ran before it — the aborted call still consumed input tokens through the provider's prefill, and the row exists so that spend isn't invisible. ```typescript const ctrl = new AbortController(); setTimeout(() => ctrl.abort(), 30_000); for await (const event of runtime.executeMessage( session.id, prompt, { abortSignal: ctrl.signal }, )) { // ... } ``` ## Deleting a session [#deleting-a-session] ```typescript await runtime.deleteSession(session.id); ``` Cascades into the checkpointer when one is wired. Audit-ledger rows are not deleted — the ledger is append-only by contract, which is why `ProviderDecisionLedger` has no `update` or `delete` primitive in any conforming language. The `GDPRSoftDelete` plug-point on the ledger interface (no-op default; production wiring lands in `@pleach/compliance@0.1.0` alongside its scrubber cohort and attestation surface) is the path for subject-key-derived redaction: it leaves the `recordId` in place (so the hash chain holds) and clears the identifying fields under the subject key, satisfying a deletion request without breaking the append-only invariant. Wire your own implementation today via `HarnessPlugin`. ## Flat method surface (legacy) [#flat-method-surface-legacy] The tables below mirror the facets above as flat methods on the runtime; the methods are `@deprecated` and forward to their facet equivalents — prefer [Facets](#facets) for new code. ### Session lifecycle methods [#session-lifecycle-methods] | Method | Signature | Use | | ---------------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `createSession` | `(config?: Partial<SessionConfig>) => Promise<Session>` | Mint a new session, persist to storage | | `resumeSession` | `(sessionId: string) => Promise<Session>` | Load an existing session from storage | | `saveSession` | `(state: SessionState) => Promise<void>` | Flush an explicit state mutation to storage | | `deleteSession` | `(sessionId: string) => Promise<SessionDeleteReceipt>` | Remove the session; cascades to checkpointer. Returns a receipt with `deleted`, `sessionId`, and `removedFromCache`. | | `getActiveSession` | `() => Session \| null` | Last session loaded into the runtime | | `rollbackToCheckpoint` | `(sessionId, checkpointId) => Promise<Session>` | Restore a session from a named checkpoint | | `resolveConflict` | `(sessionId, conflictId, "local" \| "remote") => Promise<SyncConflictResolveReceipt>` | Resolve a sync conflict surfaced via `sync.conflict`. Returns a per-path receipt. | | `destroy` | `() => Promise<void>` | Tear down stream + interrupt managers, unsubscribe listeners | ### Stream subscription [#stream-subscription] Two ways to consume events. `executeMessage` is the per-turn async generator above. For cross-cutting subscribers, the runtime also exposes a mode-filtered subscription via its embedded `StreamManager`. ```typescript const unsubscribe = runtime.subscribeToStream("messages", (event) => { // event is StreamEvent — already filtered to the mode }); ``` | Mode | Includes | | ------------ | -------------------------------------------------------------------------------------- | | `"values"` | `session.created`, `session.resumed`, `checkpoint.created` | | `"updates"` | `message.added`, `message.complete`, tool lifecycle, job lifecycle, `artifact.created` | | `"messages"` | `message.delta`, `message.complete`, `thinking.delta`, `thinking.complete` | | `"custom"` | Any event whose `type` starts with `custom.` | | `"debug"` | All events — unfiltered | | `"all"` | All events — unfiltered | `getStreamManager()` returns the underlying manager when you need to set a throttle or an event-type allowlist directly: `manager.setThrottleInterval(ms)`, `manager.setEventFilter(types)`. ### Accessor methods [#accessor-methods] The runtime exposes its internal coordinators behind named getters. Returns are `undefined` when the coordinator wasn't wired at construction. | Method | Returns | | -------------------------------- | -------------------------------------------------------------------------------------------------- | | `getInterruptManager()` | `InterruptManager \| undefined` — present when `interrupt.enabled: true` | | `resolveInterrupt(id, decision)` | `InterruptResolveReceipt` — submit an `ApprovalDecision` and receive a receipt echoing the outcome | | `getTimeTravelApi()` | Checkpoint list / restore API (see [Checkpointing](/docs/checkpointing)) | | `getAsyncTaskManager()` | Background job manager (see [Stream events](/docs/stream-events#async-jobs)) | | `getPluginManager()` | Registered plugin set | | `getStreamManager()` | `StreamManager` — throttle + filter the live stream | | `getStore()` | The storage adapter passed at construction | | `getSubagentManager()` | `SubagentManager \| undefined` when concurrency is enabled | | `getCapabilityRegistry()` | Registered capability strategies | | `getSafetyRegistry()` | `SafetyPolicyRegistry` | | `listActiveSafetyPolicies()` | `readonly SafetyPolicySummary[]` | | `getDegradedModels()` | Models the degradation tracker is routing around | | `isModelDegraded(modelId)` | Boolean check for a single model | | `getPlanningContext()` | Current planning context (advanced) | The published `.d.ts` is the canonical signature reference; your IDE autocompletes from it. ### Lifecycle events [#lifecycle-events] `SessionRuntime` extends `EventEmitter`. The same shapes that come out of `executeMessage` also fire as events, so a long-lived runtime can attach cross-cutting subscribers without owning the async iterator: ```typescript runtime.on("checkpoint.created", (event) => { metrics.increment("checkpoints", { sessionId: event.checkpoint.sessionId }); }); ``` The full event union is the `StreamEvent` type — see [Stream events](/docs/stream-events) for the catalog. ## Plugin contributions [#plugin-contributions] `runtime.plugins` is the canonical read surface for everything a plugin contributed. Beyond `PluginManager` access, the facet exposes a `collect*` cohort that mirrors `PluginManager.collect*` one-for-one — roughly two dozen accessors covering retry policy, continuation policy, fabrication detectors, finalization passes, intent classifiers, tool-coupling hints, intent-tool maps, sandbox bridge, interrupt UI handlers, citation rules, manifest providers, fabrication guard, guest-denied tools, stream observers, hallucinated-tool detectors, stream-chunk handlers, middleware, and runtime-aware middleware. The full hook list lives on [Plugin contract](/docs/plugin-contract). ```typescript const retryPolicies = runtime.plugins.collectRetryPolicy(); const continuationPolicies = runtime.plugins.collectContinuationPolicy(); const detectors = runtime.plugins.collectFabricationDetectors(sessionId); ``` Two convenience accessors round out the facet: * `runtime.plugins.listAvailablePromptContributions()` — every prompt contribution every plugin registered, as a snapshot array. * `runtime.plugins.getSafetyRegistry()` — the active `SafetyPolicyRegistry`. Equivalent to `runtime.safety.getRegistry()`. Collection-shaped accessors return `[]` when no plugin contributed; capability-shaped accessors return `null`. Empty results never throw. ## Event-log projections [#event-log-projections] `runtime.events.iterate(...)` returns a paginated async-iterable over `harness_event_log` rows ordered by `(chat_id, sequence_number, created_at, id)`. The accessor reads the durable Supabase log server-side; PA-1 Phase A.2 promoted this from a stub to a real projection surface. ```typescript for await (const row of runtime.events.iterate({ chatId })) { // EventLogRow — id, session_id, event_type, payload, sequence_number, … } const result = await runtime.events.fold(myProjection, { chatId }); // result.state, result.rowsProcessed, result.projection ``` `runtime.events.fold(projection, options?)` composes a `GraphProjection<T>` over the iteration. `options.fromSequenceNumber` skips at the SQL layer — rows with `sequence_number IS NULL` (pre-PA-1 legacy) are always included for back-compat. See [Event-log projections](/docs/event-log-projections) for the projection contract and the canonical bundled projections. The live event bus (`runtime.events.on`, `once`, `off`) is co-located on the same facet but is unrelated to the persisted log — ordering between the two is not guaranteed; treat the bus as a hot stream. ## Span introspection [#span-introspection] `runtime.spans` exposes the OTel-shaped exporter the runtime threads through every turn: ```typescript runtime.spans.start({ name: "host.custom", kind: "host.custom" }); await runtime.spans.flush(); // call from a waitUntil tail runtime.spans.inFlightCount(); // 0 when the exporter doesn't track runtime.spans.isShutdown(); // false when the exporter doesn't track runtime.spans.snapshot(); // { inFlightCount, isShutdown } in one read ``` `inFlightCount`, `isShutdown`, and `snapshot` are introspection methods backed by optional `getInFlightCount?()` / `isShutdown?()` hooks on the exporter contract. When a host-supplied exporter doesn't implement them, the facet returns `0` / `false` — documented as "unknown — assume zero / false". The default `NoopOtelExporter` and the reference `CapturingOtelExporter` both track accurately. ## cacheBackend default-policy promotion [#cachebackend-default-policy-promotion] `memoryCacheBackend` is the substrate default for `cacheBackend`. The runtime constructor default-constructs one when `config.cacheBackend` is `undefined`; the seam factories receive the backend automatically via `createSeam.ts`. Previously the field had to be configured explicitly; today it is always-on with a default cap of 1000 entries and 64 MB. The change is observable in `runtime.cache?.metricsSnapshot()` on day zero. No consumer action is required. To override, pass a custom `cacheBackend` field on `SessionRuntimeConfig`. Set it to `null` to disable caching entirely. For the contract, the two read modes, and the 4-gap fingerprint thread, see [Cache](/docs/cache). ## Event-log hash chain [#event-log-hash-chain] The persistence layer can stamp a per-chat hash chain onto every `harness_event_log` row. The wire-in lives on `EventLogWriter` and is gated by the `c9PhaseBEnabled` flag; when on, the writer maintains a `prevHashCache` keyed by `chat_id`, links each new row to the previous `row_hash`, and surfaces `verifyChainForChat(...)` and `generateProof(...)` from `@pleach/core/eventLog` for downstream audit. Hosts that don't care about the chain leave the flag off and the writer behaves exactly as before. ## Where to go next [#where-to-go-next] <Cards> <Card title="Stream events" href="/docs/stream-events" description="Every event type executeMessage can yield, with the payload shape." /> <Card title="Storage" href="/docs/storage" description="MemoryAdapter, IndexedDBAdapter, SupabaseAdapter — pick one per environment." /> <Card title="Checkpointing" href="/docs/checkpointing" description="Snapshot, restore, time-travel — the per-channel checkpoint API." /> <Card title="React" href="/docs/react" description="HarnessProvider, useHarness, and the hook surface for browser apps." /> </Cards> --- # Skills (/docs/skills) A skill is a markdown file with YAML frontmatter. The runtime loads it, indexes it by intent, and injects its body into the system prompt when the user's intent matches. The variable surface is the markdown body and the activation policy in the frontmatter; the loader, the cache, and the intent matching are structural. See [Prompts](/docs/prompts) for the contribution shape the skill body becomes, and [Subagents](/docs/subagents) for how a skill with an `execution` block delegates to a child runtime. Skill is one of three concepts in the [composing-agents cluster](/docs/agents#the-composing-agents-cluster), alongside the subagent spawn that executes it and the agent profile that scores it. The skill carries the spec name; a matching subagent invocation writes one entry into the profile keyed by that same name. Ships from the `@pleach/core/skills` barrel — a real, emitted subpath export (`SkillLoader`, `parseSkillFrontmatter`, and the `Skill` / `SkillFrontmatter` types all import from it). There are no `/loader`, `/parser`, or `/types` sub-subpaths; import everything from the `@pleach/core/skills` barrel. ```typescript import { SkillLoader, parseSkillFrontmatter } from "@pleach/core/skills"; import type { Skill, SkillFrontmatter } from "@pleach/core/skills"; ``` <SourceMeta source="{ label: "src/skills/", href: "https://github.com/pleachhq/core/tree/main/src/skills" }" /> ## `Skill` shape [#skill-shape] | Field | Type | Notes | | ----------------- | ---------------------------------- | --------------------------------------------------------------- | | `name` | string | kebab-case identifier | | `description` | string | One-line summary | | `version` | string \| undefined | Semver, optional | | `author` | string \| undefined | Email or identifier | | `allowedTools` | string\[] \| undefined | Whitelist | | `disallowedTools` | string\[] \| undefined | Blacklist | | `toolMode` | `"inherit" \| "none"` \| undefined | Parent's resolved tools, or pure reasoning | | `intents` | string\[] \| undefined | Keywords from the intent classifier | | `activation` | enum | `auto` / `manual` / `intent-matched` (default `intent-matched`) | | `content` | string | Markdown body, injected into the prompt | | `source` | enum | `builtin` / `org` / `user` | | `path` | string \| undefined | Filesystem path (builtin skills) | | `execution` | object \| undefined | `maxSteps`, `timeout`, `canDelegate` | `activation` decides how the loader surfaces the skill. `auto` and `intent-matched` are both returned by `getByIntent(intent)`; `manual` skills are returned only by `getByName(name)`. ## Loader sources [#loader-sources] `SkillLoader.loadAll(orgId?, userId?)` walks three sources and unions the results into a single cache. Builtin first, then store, then subagent-spec migration; the loader returns the merged list and caches by name. | Order | Source | Where it reads | Notes | | ----- | ---------------- | ---------------------------------------------------- | -------------------------------------------------------------------------- | | 1 | Builtin | `skills/builtin/<name>/SKILL.md` (server only) | Skipped in the browser; missing dir is fine | | 2 | Org / user store | `[orgId, "skills"]` then `[orgId, userId, "skills"]` | Up to 50 per namespace | | 3 | Subagent specs | The `subagentSpecs` array passed to the constructor | Skipped if a skill with the same `name` already exists from sources 1 or 2 | The constructor takes the store and the spec list: ```typescript const loader = new SkillLoader({ store, // anything implementing { search } subagentSpecs: [ { name: "researcher", description: "Fan out searches.", tools: ["search_corpus", "search_news"], maxSteps: 5, timeout: 120_000, systemPromptSuffix: "You are a research subagent.", }, ], }); await loader.loadAll(orgId, userId); ``` A migrated subagent spec lands as a builtin skill with `activation: "manual"` — the spec was already gated by an explicit delegation, not by intent matching. ## Lookups [#lookups] | Method | Returns | Effect | | -------------------------- | -------------------- | -------------------------------------------------------------------------------------------- | | `loadAll(orgId?, userId?)` | `Skill[]` | Walks the three sources, clears and repopulates the cache | | `getByIntent(intent)` | `Skill[]` | Skills whose `intents` include `intent` and whose `activation` is `auto` or `intent-matched` | | `getByName(name)` | `Skill \| undefined` | Exact-match cache lookup | | `getAll()` | `Skill[]` | Every cached skill | | `size` | number | Cache size | `loadAll` is the only async method; everything else reads from the in-memory cache the load populated. The lookup path is the synchronous half — call `loadAll` once at session start, then route per-turn intents through the cache. ```typescript await loader.loadAll(orgId, userId); const matched = loader.getByIntent("vendor_evaluation"); for (const skill of matched) { systemPrompt.append(skill.content); } const fallback = loader.getByName("researcher"); if (fallback) await delegateToSubagent(fallback); ``` `getByIntent` filters to `auto` and `intent-matched` activations. `getByName` is the only path to a `manual` skill. ## SKILL.md format [#skillmd-format] ```markdown --- name: vendor-evaluation description: Score vendors against a fixed rubric. version: 1.0.0 author: ops@example.com allowed-tools: - search_web - fetch_url intents: - vendor_evaluation - rfp_scoring activation: intent-matched execution: maxSteps: 8 timeout: 180000 --- You are a vendor-evaluation agent. Score each candidate against the fixed rubric in the user's request. Cite every claim with the URL you pulled it from. ``` `parseSkillFrontmatter(fileContent, source, filePath?)` is exported directly for callers that want to parse a SKILL.md without going through the loader (tests, custom store adapters). The parser is intentionally a lightweight YAML reader — keys, scalars, inline arrays, indented arrays, and one level of nested objects. Anything more elaborate belongs in a real YAML library. A direct parse, no loader involved: ```typescript import { parseSkillFrontmatter } from "@pleach/core/skills"; const raw = await readFile("./skills/builtin/researcher/SKILL.md", "utf-8"); const skill = parseSkillFrontmatter(raw, "builtin", "./skills/builtin/researcher/SKILL.md"); console.log(skill.name, skill.activation, skill.allowedTools); ``` ## Where to go next [#where-to-go-next] <Cards> <Card title="Subagents" href="/docs/subagents" description="A skill with an `execution` block can be delegated as a subagent." /> <Card title="Prompts" href="/docs/prompts" description="The skill's markdown body lands as a system-prompt section contribution." /> <Card title="Tools" href="/docs/tools" description="`allowedTools` / `disallowedTools` / `toolMode` gate the tool registry per skill." /> <Card title="Subpath exports" href="/docs/subpath-exports" description="`@pleach/core/skills` is a real emitted barrel — import SkillLoader, parseSkillFrontmatter, Skill, and SkillFrontmatter from it." /> </Cards> --- # Storage (/docs/storage) `@pleach/core` ships storage adapters that all implement the same `StorageAdapter` interface. The application code that calls `runtime.createSession()` or `runtime.executeMessage()` is the same regardless of which adapter is wired. ```typescript import { MemoryAdapter, IndexedDBAdapter, SupabaseAdapter, createSupabaseAdapter, type SupabaseAdapterConfig, // Provider-agnostic Postgres — inject any pg-shaped client PgStorageAdapter, createPgStorageAdapter, type PgStorageAdapterConfig, type PgClientLike, } from "@pleach/core/sessions"; ``` <Callout type="warn" title="Cross-restart durability requires wiring an adapter"> The runtime **defaults to `MemoryAdapter`** — everything lives in process memory and disappears on restart. Session restore/resume across a restart only works once you wire a **durable** `StorageAdapter` into `SessionRuntimeConfig.storage`. The recommended provider-agnostic default is [`PgStorageAdapter`](#pgstorageadapter-provider-agnostic-postgres) (inject your own Postgres client); `SupabaseAdapter` is the hosted-Supabase option and `IndexedDBAdapter` the browser option. Durable persistence of the **event log** is a separate wiring step — pair with [`createPgEventLogWriter`](#durable-event-log-writer) as `eventLogWriter`. </Callout> `createSupabaseAdapter(config)` is the preferred way to wire the Supabase adapter — it routes through the chat-session-link registry so RLS and JWT plumbing land correctly. Reach for `new SupabaseAdapter(config)` only when you're explicitly opting out of the registry. <SourceMeta subpath="@pleach/core/sessions" source="[ { label: "src/sessions/", href: "https://github.com/pleachhq/core/tree/main/src/sessions" }, { label: "src/store/", href: "https://github.com/pleachhq/core/tree/main/src/store" }, ]" /> ## The state-and-persistence cluster [#the-state-and-persistence-cluster] Storage adapter is one of three concepts paired with [checkpointing](/docs/checkpointing) (the rewind axis) and [sync version vector](/docs/sync) (the concurrent-writer axis). The cluster sits below the execution graph and above the schema bundle; together the three carry session state across restarts, rewinds, and concurrent writers. The full triplet framing lives at [Concept clusters → State-and-persistence](/docs/concept-clusters#state-and-persistence-cluster); the rest of this page is the deep dive on the adapter. ## Picking an adapter [#picking-an-adapter] | Adapter | Environment | When to reach for it | | ------------------ | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MemoryAdapter` | Any | Tests, local dev, ephemeral demos — **non-durable** (default; lost on restart) | | `IndexedDBAdapter` | Browser | Offline-first PWAs, browser extensions, multi-device drafts | | `PgStorageAdapter` | Server (Node) | **Recommended durable default.** Any Postgres — node-postgres, Neon, RDS, pglite, Supabase's underlying pg — via an injected `PgClientLike`. See [`PgStorageAdapter`](#pgstorageadapter-provider-agnostic-postgres). | | `SupabaseAdapter` | Server (Node) | Hosted Supabase specifically — PostgREST client, Realtime, RLS + JWT registry plumbing | | pglite (local) | Server (Node) | Durable *local* dev — chats survive a restart with no cloud account. Built into `pleach dev --sql`; see [Durable local dev](#durable-local-dev). | All three implement the same interface — swapping is a one-line change at runtime construction. The line is the `storage` field on `SessionRuntimeConfig`: replace `new MemoryAdapter()` with `createSupabaseAdapter({ client: supabase })` and every call site that already runs against `runtime.createSession()` or `runtime.executeMessage()` keeps working. The `StorageAdapter` interface is the contract that makes this swap safe — see [Common interface](#common-interface) below for the exact shape. ## `MemoryAdapter` [#memoryadapter] Zero config; everything lives in process memory and disappears on restart. ```typescript import { SessionRuntime } from "@pleach/core"; import { MemoryAdapter } from "@pleach/core/sessions"; const runtime = new SessionRuntime({ storage: new MemoryAdapter(), userId: "user_123", }); ``` This is what `HARNESS_MOCK_MODE=true` wires automatically — if you've set that env var, you don't need to pass `storage` at all. Concretely: state lives in a `Map<sessionId, SessionState>` on the adapter instance, `listSessions` is an iteration over that map's values with the filter predicate applied in-process, and the next restart starts from empty. Use it for unit tests that need a clean session per `beforeEach` without a database round trip. ## `IndexedDBAdapter` [#indexeddbadapter] Persists sessions to the browser's IndexedDB store. Survives page reloads; available offline. ```typescript import { SessionRuntime } from "@pleach/core"; import { IndexedDBAdapter } from "@pleach/core/sessions"; const runtime = new SessionRuntime({ storage: new IndexedDBAdapter({ dbName: "pleach-sessions" }), userId: currentUser.id, clientId: getOrCreateClientId(), }); ``` `dbName` is the only constructor option; the schema version of the IndexedDB store is managed internally. Default is `"harness-sessions"`. The adapter opens IndexedDB with a 5-second timeout — if another tab holds an open connection with a different schema, the open fires `onblocked` and the next call rejects with `IndexedDB open timed out — database may be blocked or unavailable`. The store auto-indexes on `userId`, `organizationId`, `updatedAt`, and `lastActiveAt` so `listSessions` filters resolve without a table scan. Pair with `IndexedDBSaver` for offline checkpointing and the client-side sync coordinator to push changes to a server adapter when the network returns — see [Checkpointing](/docs/checkpointing) and `@pleach/core/sync`. ## `SupabaseAdapter` [#supabaseadapter] Persists sessions to a Postgres database via the Supabase client. RLS-bound when constructed from a per-request client. ```typescript import { createClient } from "@supabase/supabase-js"; import { SessionRuntime } from "@pleach/core"; import { SupabaseAdapter } from "@pleach/core/sessions"; const supabase = createClient( process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, ); const runtime = new SessionRuntime({ storage: createSupabaseAdapter({ client: supabase }), userId: req.user.id, organizationId: req.user.orgId, }); ``` `SupabaseAdapterConfig` accepts three optional fields: | Field | Default | Effect | | ----------------- | -------------------- | ----------------------------------------------------------------------------------- | | `tableName` | `"harness_sessions"` | Override if you renamed the table in your migrations | | `enableRealtime` | `false` | Subscribe via Supabase Realtime for cross-process change fan-out | | `chatSessionLink` | registry lookup | Inject a `ChatSessionLink` to mirror `chatId → sessionId` into `chat_session_links` | ### Schema [#schema] The adapter expects the harness schema bundle to be applied. Scaffold it once per project: ```bash npx pleach init --apply --target ./supabase/migrations ``` Then run your usual Supabase migration flow (`supabase db push`, or apply each `*.sql` by hand with `psql`). The bundle ships `CREATE ... IF NOT EXISTS` DDL so re-running is safe. ### Browser vs server clients [#browser-vs-server-clients] | Constructed from | Authority | Use case | | --------------------------- | ------------------------------ | --------------------------------- | | `SUPABASE_ANON_KEY` | RLS-bound to the signed-in JWT | Direct-from-browser writes | | `SUPABASE_SERVICE_ROLE_KEY` | Bypasses RLS | API routes, server-rendered pages | `userId` and `organizationId` on the runtime are the scoping fields the RLS policies in the schema bundle key against. Service-role clients still need to set them so the audit ledger attributes rows correctly. ## `PgStorageAdapter` — provider-agnostic Postgres [#pgstorageadapter--provider-agnostic-postgres] The recommended durable default when you are **not** on hosted Supabase. `PgStorageAdapter` implements the same `StorageAdapter` interface as `SupabaseAdapter`, but talks raw SQL through an injected **`PgClientLike`** — so it runs on *any* Postgres provider. `PgClientLike` is a minimal structural type; anything with a parameterized `query(sql, params)` that resolves to `{ rows }` satisfies it: ```typescript interface PgClientLike { query<Row>(sql: string, params?: readonly unknown[]): Promise<{ rows: Row[] }>; } ``` That one seam is the "tune to any provider" mechanism — you construct your own driver and pass it in. `@pleach/core` adds **no** Postgres driver as a dependency: you own the client. ```typescript title="(a) node-postgres" import { Pool } from "pg"; import { SessionRuntime } from "@pleach/core"; import { createPgStorageAdapter } from "@pleach/core/sessions"; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const runtime = new SessionRuntime({ storage: createPgStorageAdapter(pool), userId: req.user.id, }); ``` ```typescript title="(b) Neon serverless" import { Pool } from "@neondatabase/serverless"; import { createPgStorageAdapter } from "@pleach/core/sessions"; const storage = createPgStorageAdapter( new Pool({ connectionString: process.env.DATABASE_URL }), ); ``` ```typescript title="(c) Supabase's underlying pg (or any pg-shaped client)" import { createPgStorageAdapter } from "@pleach/core/sessions"; // Bring your own client — RDS Data-API shim, a pooled pg client, pglite, … const storage = createPgStorageAdapter(myPgClient, { tableName: "harness_sessions", // optional override schemaName: "public", // optional schema qualifier }); ``` `PgStorageAdapterConfig` accepts: | Field | Default | Effect | | ------------ | -------------------- | --------------------------------------------------------------- | | `client` | *(required)* | The injected `PgClientLike` — your Postgres driver | | `tableName` | `"harness_sessions"` | Override if you renamed the table | | `schemaName` | *(unqualified)* | Qualify the table (e.g. `"pleach"`) for a namespaced deployment | Semantics match `SupabaseAdapter`: full `SessionState` persisted as a JSONB `state` column, soft delete via `deleted_at`, and optimistic concurrency via `conditionalUpdate` (a version-guarded `UPDATE … RETURNING id`, so it is portable across drivers regardless of their affected-row-count conventions). ### Schema [#schema-1] `PgStorageAdapter` targets the **shipped** `harness_sessions` DDL — `src/schema/postgres/001_harness_sessions.sql` in the `@pleach/core` repo, the same vanilla-Postgres-portable bundle `pleach init` scaffolds. Apply it once (the DDL is `CREATE … IF NOT EXISTS`, so re-running is safe), then point the adapter at it. Do not hand-write a divergent schema. The shipped DDL types `id` (and `organization_id`) as `UUID`, so `session.id` and `organizationId` must be UUID-shaped strings — the runtime mints UUIDv7 session ids by default. If you need non-UUID ids, point the adapter at a table whose id columns are `TEXT`. ### Durable event-log writer [#durable-event-log-writer] Persisting sessions is one axis; persisting the **event log** (the source of truth for restore, replay, and audit) is a separate one. Pair the adapter with `createPgEventLogWriter` — the provider-agnostic sibling of the Supabase-bound `EventLogWriter`, backed by the *same* `PgClientLike`: ```typescript import { createPgStorageAdapter } from "@pleach/core/sessions"; import { createPgEventLogWriter } from "@pleach/core/eventLog"; import { SessionRuntime } from "@pleach/core"; const runtime = new SessionRuntime({ storage: createPgStorageAdapter(pool), eventLogWriter: createPgEventLogWriter(pool, { tenant: { id: "acme" }, // optional multi-tenant attribution }), userId: req.user.id, }); ``` It writes append-only rows to the shipped `harness_event_log` table (`src/schema/postgres/003_harness_event_log.sql`), stamping a per-chat `sequence_number` (cold-start `MAX + 1`), `payload_hash`, `tenant_id`, `domain`/`kind`, and `severity`. Writes are fire-and-forget (`write()` never throws into a hot path; failures route to `onError`); `flush()` awaits the serialized write tail. It is a lean durable writer — it does **not** compute the C9 tamper-evidence `prev_hash`/`row_hash` chain (those columns stay NULL); wire the full `EventLogWriter` if you need the hash chain. <Callout type="warn" title="Single writer per chat"> `sequence_number` is allocated in-process (cold-start `MAX + 1`), which is race-free within one writer instance but **not across instances**. Two concurrent writers for the same chat — e.g. two serverless invocations — can cold-start from the same `MAX` and emit duplicate ordinals silently. In a serverless / multi-pod deployment, guarantee one writer per chat, or use the Supabase-bound `EventLogWriter` with its atomic per-chat sequence RPC. The `chat_id`/`session_id` you pass must be UUID-shaped (the shipped `003` columns are `UUID`). </Callout> ## Common interface [#common-interface] Every adapter implements the same shape. You rarely call it directly — `SessionRuntime` does — but the type is exported for testing and for writing custom adapters: ```typescript import type { StorageAdapter } from "@pleach/core"; interface StorageAdapter { createSession(session: SessionState): Promise<void>; getSession(id: string): Promise<SessionState | null>; updateSession(id: string, session: SessionState): Promise<void>; deleteSession(id: string): Promise<void>; listSessions(filter?: SessionFilter): Promise<SessionSummary[]>; conditionalUpdate?( id: string, session: SessionState, expectedVersion: number, ): Promise<boolean>; } ``` `listSessions` returns `SessionSummary[]` — the light shape for sidebar rendering, not the full `SessionState`. The summary carries `id`, `title`, `userId`, `organizationId`, `updatedAt`, and `lastActiveAt`; rendering 200 entries in a sidebar reads no message bodies and no channel state. `conditionalUpdate` is the optimistic-concurrency hook; adapters that implement it let the runtime detect lost-update races on `(id, version)` and surface them rather than overwrite blindly. When `conditionalUpdate` returns `false`, the runtime treats it as a `SYNC_VERSION_MISMATCH` (code `3003`) — the application reaches for the rollback path or pulls the remote version before retrying. Event log and checkpoint writes go through their own interfaces (`EventLogWriter`, `Checkpointer`), not through `StorageAdapter`. For **any Postgres** store, reach for [`PgStorageAdapter`](#pgstorageadapter-provider-agnostic-postgres) first — inject your driver rather than reimplementing the interface. Build your own adapter (Redis, DynamoDB, SQLite) only for non-Postgres stores by implementing this interface against your store of choice. Pass it as `storage` on the runtime config — application code stays unchanged. ## Cache adapter [#cache-adapter] The optional `cache` field accepts a second `StorageAdapter`. The runtime reads from cache first and falls back to the primary store on miss. Typical pairing: `SupabaseAdapter` as primary, an `IORedisAdapter` (BYO) as cache. The cache is invalidated by the runtime on every write — no manual `cache.del()` calls. A concrete walk-through for a knowledge-base assistant: a user opens a chat sidebar; the React layer reads `listSessions({ userId: "user-7", organizationId: "org-acme" })`, the cache returns 50 summaries in \~2ms, and `getSession("session-018f-...")` hits the cache for the active session without a Postgres round trip. When the next `executeMessage` mutates the session, the runtime writes to Supabase and invalidates the cache key — the sidebar's next read pulls the new `updatedAt` automatically. ## Durable local dev [#durable-local-dev] You don't need a cloud account to see durability work. `pleach dev --sql` backs the playground route with a local [pglite](https://pglite.dev) (Postgres-in-WASM) store — so chats and checkpoints **survive a server restart**, with no Docker and no Supabase project: ```bash npm install @electric-sql/pglite # optional peer, one time npx pleach dev --sql # store lives under ./.pleach-dev-sql ``` Because pglite is real Postgres, this is the only local option that demonstrates the *enforced* multi-tenant boundary end-to-end: the same `current_tenant()` GUC function + row-level-security policy that `SupabaseAdapter` relies on in production runs in-process, so a cross-tenant read is **refused by the database**, not just filtered in application code. The same store backs the value-prop battery (`npm run ci:devharness-sql` in the `@pleach/core` repo), which asserts nine durable properties the in-memory default cannot show headlessly: 1. **Restart-restore reconstructed in-graph** — a fresh `SessionRuntime` pointed at the durable store calls `resumeSession`, then a follow-up turn continues the conversation through real graph execution (not a detached `reconstructSessionState` fold). 2. **Per-tenant SQL cost rollup** — `GROUP BY tenant_id` over the event log. 3. **RLS tenant isolation** — the database refuses a cross-tenant read. 4. **Durable checkpoints** — survive a restart at `_durability.level: "disk"`. 5. **`@pleach/observe` `postgres()` destination** — each provider call recorded as a SQL-queryable row in `pleach_observe_calls`. 6. **Time-travel fork** — `TimeTravelAPI.fork` cross-session lineage survives a restart. 7. **SQL-queryable audit ledger** — `harness_auditable_calls` FinOps (`GROUP BY tenant_id`) + SOC2 (every call classified) rollups. 8. **Tamper-evident hash chain** — a recursive-CTE linkage verifier catches a deleted/reordered row; a content-hash recompute catches a mutated payload. 9. **Deterministic replay** — `@pleach/replay`'s `ReplayClient` folds the durable log over the injected reader; fold-from-DB byte-equals `hydrateFromEvents`, and `createStrictHandleReplay` confirms independent replays don't diverge. It's the executable reference for wiring your own SQL `StorageAdapter` + `Checkpointer` + event-log writer + **event reader** (the agnostic [`HarnessEventReader`](/docs/event-log-projections#backing-the-reader) behind `runtime.events.iterate`) + audit ledger + observe destination. For a non-Postgres engine, the [Custom storage adapter recipe](/docs/recipes#3-custom-storage-adapter-sqlite) shows the same shape against SQLite (`better-sqlite3`) — note that SQLite can attribute cost per tenant but cannot *enforce* RLS isolation; that needs Postgres/pglite. ## Where to go next [#where-to-go-next] <Cards> <Card title="SessionRuntime" href="/docs/session-runtime" description="Construct the runtime, create sessions, execute messages." /> <Card title="Checkpointing" href="/docs/checkpointing" description="Pair the storage adapter with a checkpointer for rollback." /> <Card title="CLI" href="/docs/cli" description="`pleach init` for scaffolding the schema bundle." /> <Card title="Subpath exports" href="/docs/subpath-exports" description="What `@pleach/core/sessions` and `@pleach/core/schema` actually export." /> </Cards> --- # Stream events (/docs/stream-events) `runtime.executeMessage()` is an async generator. Every yielded value is a `StreamEvent` — a discriminated union keyed on `type`. Browser UIs render from these; server consumers forward them over SSE. See [SessionRuntime](/docs/session-runtime) for the generator's signature and [Event log](/docs/event-log) for how these shapes persist as durable rows. ```typescript import type { StreamEvent } from "@pleach/core"; for await (const event of runtime.executeMessage(sessionId, prompt)) { // event is a StreamEvent — switch on event.type } ``` Every event also carries an optional `namespace: string[]` field — empty for the root orchestrator, populated for events emitted from inside a subagent. Use it to route subagent output into its own UI surface. <Callout type="info" title="Coming from LangGraph?"> LangGraph's `graph.stream(input, { streamMode: "updates" | "messages" | "values" | "custom" | "tools" | "debug" | "checkpoints" | "tasks" })` returns different shapes per mode. Pleach has a single discriminated union — `StreamEvent` — where every shape ships in every stream and consumers filter on `event.type`. The rough mapping: | LangGraph `streamMode` | Pleach equivalent | | ---------------------- | ------------------------------------------------------------------------------------ | | `"updates"` | `session.updated` events | | `"messages"` | `message.delta` + `message.complete` | | `"values"` | Re-read the session via `runtime.sessions.resume(sessionId)` after `session.updated` | | `"custom"` | Emit a `custom.event` via `runtime.events.emit()` from inside a node | | `"tools"` | `tool.started` / `tool.completed` / `tool.failed` | | `"debug"` | The full untouched stream; pleach has no filter | | `"checkpoints"` | `checkpoint.created` | | `"tasks"` | `task.scheduled` / `task.completed` (subagent + async) | LangGraph's `streamMode: ["updates", "custom"]` tuple-yielding form has no pleach analog — the discriminated union plays the same role with one `switch (event.type)`. The array-tuple form trades one extra type-narrow step for the convenience of multi-mode iteration; we picked the discriminated-union shape because it composes better with downstream `flatMap` / `filter` / `tap` pipelines. </Callout> <SourceMeta source="{ label: "src/streaming/", href: "https://github.com/pleachhq/core/tree/main/src/streaming" }" /> ## Session lifecycle [#session-lifecycle] <TypeTable type="{ 'session.created': { type: '{ session: SessionState }', description: 'New session minted at turn start.' }, 'session.resumed': { type: '{ session: SessionState }', description: 'Existing session loaded for the turn.' }, 'session.updated': { type: '{ session: SessionState }', description: 'State mutation propagated to subscribers.' }, }" /> ## Message stream [#message-stream] The main user-facing surface — what gets rendered into the chat viewport. | Type | Payload | When | | ------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `message.added` | `{ message: Message }` | Full message appended (user or assistant) | | `message.delta` | `{ delta: string }` | Streaming chunk; concatenate to render | | `message.complete` | `{ message: Message }` | Final assembled assistant message | | `message.citations` | `{ citations: MessageCitation[]; section: string }` | Citation set attached to a message section | | `message.entities` | `{ toolCallId, toolName, entities: Entity[] }` | Structured entities extracted from a tool result | | `reasoning.delta` | `{ delta: string }` | A chunk of the model's reasoning (chain-of-thought), surfaced from the provider as it arrives — **never answer content** | | `thinking.delta` | `{ delta: string }` | Streaming reasoning trace (when the model emits one) | | `thinking.complete` | `{ thinking: string }` | Full reasoning trace assembled | Render `message.delta` and `thinking.delta` as they arrive; treat `message.complete` as the authoritative final text. ### Reasoning traces (`reasoning.delta`) [#reasoning-traces-reasoningdelta] Reasoning models (DeepSeek-R1, etc.) stream a chain-of-thought *before* the answer. Pleach surfaces that as `reasoning.delta` events — a provider-agnostic side-channel that is **never** concatenated into the answer text. Two reasons it exists: 1. **Liveness.** A long reasoning phase (tens of seconds with no answer token) would otherwise look like a stalled stream. `reasoning.delta` keeps the no-content watchdog from false-firing while the model thinks. 2. **UX.** Render a "Thinking…" indicator, or accumulate the deltas into a collapsible trace next to the final answer. This is **distinct from the `reasoning` call class** (a type-level routing invariant on the seam — see [Seams](/docs/seams)). `reasoning.delta` is *runtime trace content*; the `reasoning` call class is *which seam a node consumes*. The `@pleach/react` `useSessionMessageStream` hook exposes `isReasoning` + `reasoningText` so you don't have to accumulate the deltas yourself — see [React](/docs/react). ## Tool calls [#tool-calls] | Type | Payload | When | | ---------------- | ----------------------------------------- | ----------------------------------------------- | | `tool.started` | `{ toolCall: ToolCall }` | Tool invocation dispatched | | `tool.delta` | `{ toolCallId: string; delta: string }` | Streaming argument assembly (e.g. partial JSON) | | `tool.completed` | `{ toolCall: ToolCall; result: unknown }` | Tool returned a result | | `tool.failed` | `{ toolCall: ToolCall; error: string }` | Tool threw or rejected | Pair `tool.started` with `tool.completed` / `tool.failed` on `toolCall.id` to render per-tool spinners and result cards. A worked sequence for a single `search_corpus` call in a knowledge-base assistant: 1. `tool.started` — `{ toolCall: { id: "tc-018f-7a-1", name: "search_corpus", args: { query: "indexing strategies" } } }` 2. `tool.delta` × N — streaming arg assembly while the model emits JSON; concatenate into a per-`toolCallId` buffer 3. `tool.completed` — `{ toolCall: { id: "tc-018f-7a-1", … }, result: [{ id: "doc-abc123", score: 0.91 }, …] }` 4. `message.entities` — `{ toolCallId: "tc-018f-7a-1", toolName: "search_corpus", entities: [{ kind: "document", id: "doc-abc123" }] }` 5. `message.citations` — citations attached to the synthesized answer's body section once the LLM references the hits Steps 1 and 3 are the spinner bracket; step 4 is what hydrates a "sources" sidebar; step 5 is what underlines the cited sentence in the rendered transcript. ## Async jobs [#async-jobs] For tools that dispatch work to a queue and return later. | Type | Payload | When | | ---------------- | ------------------------------------------------------------------------------------ | -------------------------------------- | | `job.dispatched` | `{ job: PendingJob }` | Job submitted to the dispatch endpoint | | `job.progress` | `{ jobId, progress, phase?, phaseProgress?, statusMessage?, estimatedRemainingMs? }` | Worker progress update | | `job.completed` | `{ job: CompletedJob }` | Worker finished successfully | | `job.failed` | `{ jobId: string; error: string }` | Worker errored | | `jobs.pending` | `{ jobs: PendingJob[] }` | Snapshot of all currently-pending jobs | `progress` is 0–1. Use `phaseProgress` for multi-phase jobs (e.g. `download` then `process` then `upload`). ## Interrupts (human-in-the-loop) [#interrupts-human-in-the-loop] | Type | Payload | When | | --------------------- | ----------------------------------------------------- | --------------------------------------------- | | `interrupt.requested` | `{ interrupt: PendingInterrupt }` | Runtime is blocked waiting on a user decision | | `interrupt.resolved` | `{ interruptId: string; decision: ApprovalDecision }` | Decision submitted; turn resumes | The `interrupt.requested` event is when you'd surface an approval modal in your UI; the runtime pauses until `decision` lands. ## Execution lifecycle [#execution-lifecycle] Agnostic signals an external `@pleach/core` consumer subscribes to via `useChat({ onEvent })` (or any `StreamEvent` driver) to observe *how* a turn ran — stage transitions, recovery, retries, stream timing — without inheriting the host's console-grep idiom. They fire from graph-internal sites alongside the runtime's own diagnostics; the values reuse the canonical core unions (`StageId`, `RecoveryDispatchArm`) and carry no domain concepts. | Type | Payload | When | | ------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `turn.started` | `{}` | A turn begins (brackets graph + imperative paths uniformly) | | `turn.completed` | `{ outcome: "ok" \| "error" \| "interrupted"; durationMs: number }` | The turn ends; `outcome` is the terminal state | | `stage.transition` | `{ from: StageId \| null; to: StageId }` | The compiled graph moves stage; `from` is `null` on graph entry | | `stage.lattice_violation` | `{ from: StageId; to: StageId }` | A runtime transition was NOT in `ALLOWED_EDGE_PATTERNS` — a structural-conformance canary (fail-soft) | | `stage.post_turn_fan_out` | `{ writer: string }` | A post-turn parallel writer fired (`"sessionMemoryWrite"` / `"costRollup"`); both per turn means the fan-out held | | `recovery.fired` | `{ arm: RecoveryDispatchArm }` | A post-turn recovery filter dispatched; `arm` ∈ `"zeroToolRecovery"` \| `"allToolsFailedMissingParams"` \| `"maxStepsHit"` | | `retry.attempted` | `{ reason: string; attempt: number }` | The turn re-entered the model loop (e.g. tool re-resolution) | | `stream.first_chunk` | `{ ttfbMs: number }` | First content delta arrived; `ttfbMs` is time-to-first-byte from invoke | | `stream.completed` | `{ chunks: number; durationMs: number }` | The model stream finished | Use `stage.transition` to reconstruct the per-turn lattice walk (`anchor-plan → tool-loop → synthesize → post-turn`); a `stage.lattice_violation` is your signal that execution diverged from the advertised lattice (see [Architecture](/docs/architecture) for the nine-pattern lattice the same `ALLOWED_EDGE_PATTERNS` constant defines). `stream.first_chunk` / `turn.completed` give you TTFB and total latency without a separate metrics pipeline. <Callout type="info" title="Per-call cost & latency live on a different surface"> These nine are *stream* events (`executeMessage` / `useChat`). The per-LLM-call cost/latency signal — `model.called` (`{ provider, model, callClass, inputTokens, outputTokens, costUSD, latencyMs }`) — fires on the durable `runtime.events` bus, not this stream, because cost attribution is a long-lived concern. Wire it straight into a destination with the observe bridge: ```typescript import { observeSink } from "@pleach/observe"; runtime.events.on("model.called", observeSink({ destinations: [store] })); ``` `model.called` maps one-to-one to an `ObserveRow`. See [Observe](/docs/observe) for destinations, redaction, and sampling. </Callout> ## Artifacts [#artifacts] | Type | Payload | When | | ------------------ | --------------------------- | ------------------------------------------------------- | | `artifact.created` | `{ artifact: ArtifactRef }` | New artifact (file, image, structured doc) materialized | ## Checkpoints and sync [#checkpoints-and-sync] | Type | Payload | When | | -------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------- | | `checkpoint.created` | `{ checkpoint: Checkpoint }` | Stage boundary crossed; snapshot written | | `sync.started` | `{ sessionId: string }` | Sync coordinator begins push/pull cycle | | `sync.completed` | `{ sessionId: string }` | Sync cycle landed cleanly | | `sync.conflict` | `{ sessionId, resolution: "local" \| "remote" \| "merged" }` | Version-vector conflict surfaced; `resolution` reports what the merger chose | ## Subagents [#subagents] When `enableSubagentConcurrency: true` and the orchestrator spawns parallel workers. | Type | Payload | When | | -------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------- | | `subagent.spawned` | `{ subagentId, task, specName?, context? }` | New subagent created | | `subagent.progress` | `{ subagentId, progress, message?, activeToolName? }` | Subagent progress update | | `subagent.completed` | `{ subagentId, content, toolsUsed, toolCallDetails? }` | Subagent finished | | `subagent.failed` | `{ subagentId, error, terminalStatus? }` | Subagent errored; `terminalStatus` discriminates `cancelled` / `failed` / `timeout` | Subagent-emitted events carry `namespace` so a UI can route them into a separate panel. ## Sandbox [#sandbox] For tools that allocate a sandboxed execution environment. The event shapes below are stable contracts in `@pleach/core`. `@pleach/sandbox@0.1.0` ships the `SandboxProvider` canonical adapter and `@pleach/coding-agent@0.1.0` consumes these events — its `start()` / `stop()` / `executeStep()` bodies all land at the `0.1.0` cut; `executeStep()` throws `PACK_270_D3_EXECUTE_STEP_NOT_STARTED_MESSAGE` only when called before `start()`. Hosts can implement their own sandbox adapter against the same event surface. | Type | Payload | When | | -------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------ | | `sandbox.created` | `{ sandboxId, tunnelUrl }` | Sandbox booted; `tunnelUrl` is reachable for preview | | `sandbox.terminated` | `{ sandboxId, exportedFiles }` | Sandbox torn down; `exportedFiles` is the count of files saved | | `result.offloaded` | `{ toolName, filename, sandboxPath, sizeBytes, rowCount? }` | Large tool result written to sandbox filesystem instead of inlined | ## Stream control [#stream-control] | Type | Payload | When | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | `stream.truncated` | `{ reason: "entropy_collapse" \| "phrase_loop" \| "substring_repetition"; partialLength }` | Output was truncated by a degeneration guard | | `stream.reconnecting` | `{ attempt, maxRetries, disconnectedAt }` | SSE reconnect cycle in progress | | `stream.disconnected` | `{ maxRetries }` | SSE reconnect budget exhausted | | `context.summarized` | `{ originalMessageCount, compressedTokens, historyPath? }` | History was compacted into a summary before the next call | | `content.correction` | `{ correctedContent, reason, materializerPreviousLen? }` | Post-stream fabrication / leakage guard replaced already-streamed content | | `content.reset` | `{ reason: "providerError" \| "garbledNarration" \| "synthesisReplacementRetry" \| "synthesisHeaderOverlap"; failedModel?; clearedDeltaLength? }` | Drop streamed-so-far content; `clearedDeltaLength` tail-slices instead of full-clears when present | For `content.correction`, the renderer should treat `correctedContent` as the authoritative final text — replace the last assistant message wholesale. A common case: the model streamed a sentence claiming it had called `fetch_document` when no such tool call landed in the turn; the fabrication guard fires `content.correction` with `reason: "phantom_tool_reference"` and a `correctedContent` payload that drops the false claim. The UI's message buffer for that `messageId` is replaced wholesale, not appended to. ## Provider cascade and LLM turn [#provider-cascade-and-llm-turn] Emitted by the fallback executor and the graph LLM node — surface per-model progress, model transitions, and token usage. | Type | Payload | When | | ------------------------ | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | | `provider.cascade` | `{ model?, provider?, error?, failedModels?, attempt?, isForceSynthesis? }` | A model in the cascade failed; UI shows "(N failed)" | | `provider.cascade.reset` | – | New graph turn started; cascade counter resets | | `llm.turn` | `{ model, provider?, input_tokens?, output_tokens?, tool_call_count?, depth?, modelSwitchReason?, requestedModel? }` | LLM turn completed; carries per-turn model + token metadata | `modelSwitchReason` discriminates `"provider_fallback"`, `"zero_tool_recovery"`, `"forceSynthesis_escalation"`, `"forceSynthesis_garble_cascade"`. Use `llm.turn.depth` to attribute spend across subagent depth levels — `depth: 0` is the root turn, `depth: 1` is a direct subagent, `depth: 2` is a grandchild, and the hard ceiling is `SUBAGENT_LIMITS.maxDepth = 3` (see [Subagents](/docs/subagents#subagent-limits-and-depth-tracking)). A `GROUP BY depth` on the per-call ledger gives you "how much did fan-out cost relative to the root turn." ## Domain events [#domain-events] | Type | Payload | When | | -------------- | --------------------------------------- | ----------------------------------------------- | | `domain.event` | `{ domainType, domain, kind, payload }` | Plugin-namespaced event yielded onto the stream | Plugins emit `domain.event` to surface arbitrary structured events to the consumer without inventing a new variant per plugin. The `domain` namespaces the plugin (e.g. `compliance.audit`, `gateway.cost`), `kind` is the event kind, `payload` carries the data. ## Step lifecycle [#step-lifecycle] | Type | Payload | When | | ------------ | ------------------ | --------------------------------------------------------- | | `step.start` | `{ step: string }` | Named step (`anchor-plan`, `tool-loop-iter`, etc.) begins | | `step.end` | `{ step: string }` | Step completes | ## Errors [#errors] | Type | Payload | When | | ------- | ---------------------------------- | ------------------------------------------- | | `error` | `{ error: string; code?: string }` | Recoverable or terminal error in the stream | The `code` field is the structured error code (1xxx–7xxx) — see [Error codes](/docs/error-codes) for the catalog. ## Forwarding over SSE [#forwarding-over-sse] The stream maps directly onto Server-Sent Events. The reference Next.js route in the package looks roughly like: ```typescript export async function POST(req: Request) { const { sessionId, content } = await req.json(); const stream = new ReadableStream({ async start(controller) { for await (const event of runtime.executeMessage(sessionId, content)) { controller.enqueue(`data: ${JSON.stringify(event)}\n\n`); } controller.close(); }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream" }, }); } ``` Each event becomes one SSE `data:` line. Clients re-hydrate the union by parsing `JSON.parse(line)` and switching on `type`. ## Mode-filtered subscription [#mode-filtered-subscription] `SessionRuntime` also exposes a non-iterator subscription that filters by `StreamMode`. Use this for cross-cutting subscribers that don't own the turn loop. ```typescript const unsubscribe = runtime.subscribeToStream("messages", (event) => { // only message.delta / message.complete / thinking.* land here }); ``` `"values"`, `"updates"`, `"messages"`, `"custom"`, `"debug"`, and `"all"` are the legal modes — see [SessionRuntime → Stream subscription](/docs/session-runtime#stream-subscription) for the per-mode event sets. ## `content.delta` [#contentdelta] `content.delta` is the canonical streaming-chunk event type. The substrate emits one per token-or-chunk during model output for a turn. It pairs with the chunk-loop that folds deltas into a terminal message at end-of-turn. See [Event log](/docs/event-log) § `content.delta streaming chunks` for the persisted row shape, and [Event log projections](/docs/event-log-projections) for the fold that reconstructs the final message. Consumers subscribing to `content.delta` should treat each chunk as forward-only. Don't try to "undo" a delta — the terminal message is the canonical reconstructed state. ## tool.execution span emit [#toolexecution-span-emit] The substrate emits a `tool.execution` OTel span per tool dispatch. The span brackets the same lifecycle moment as the `tool.completed` stream event above. The two are paired surfaces for the same dispatch: the span feeds traces and observability backends; the stream event feeds in-process subscribers rendering the turn. See [OTel observability](/docs/otel-observability) for the full span surface and attribute catalog. ## Where to go next [#where-to-go-next] <Cards> <Card title="SessionRuntime" href="/docs/session-runtime" description="The executeMessage signature and abort handling." /> <Card title="Async tasks" href="/docs/async-tasks" description="Long-running tool calls fire `job.*` events on the same stream." /> <Card title="Interrupts" href="/docs/interrupts" description="`interrupt.requested` / `interrupt.resolved` — the human-in-the-loop pause surface." /> <Card title="Checkpointing" href="/docs/checkpointing" description="What `checkpoint.created` events bracket — stage-boundary snapshots." /> <Card title="Sync" href="/docs/sync" description="The sync coordinator behind `sync.started` / `sync.completed` / `sync.conflict`." /> </Cards> --- # streamSingleTurn (/docs/stream-single-turn) `streamSingleTurn` is the per-turn body. It owns the path from "the runtime entered a turn" to "the synthesis stream closed" — provider call, tool dispatch, continuation gate, fallback chain, hallucination check, repetition guard, synthesis. The body is generic substrate; everything host-specific exits through typed strategy slots and plugin hooks. The body lives in the package's internal `strategies/streamSingleTurn/` tree — an implementation detail, not a public import surface — and is the default body the runtime threads through when a host constructs `SessionRuntime` without a custom strategy. This is its current home as of the **PO-A1** lift (commit `0a1fde560`); previously it lived at `src/lib/orchestrator/streamSingleTurn.ts` on the host side. Hosts that need a non-default body register one through the module-loader seam — the runtime resolves the body via `dynamicImportApp("orchestrator.streamSingleTurn")` (wired with `setHarnessModuleLoader`), so a registered host body replaces the default at that key. <SourceMeta subpath="@pleach/core/strategies" source="{ label: "src/strategies/streamSingleTurn/turnBody.ts", href: "https://github.com/pleachhq/core/tree/main/src/strategies/streamSingleTurn/turnBody.ts" }" /> ## Where the body sits [#where-the-body-sits] `streamSingleTurn` is one of two execution paths the runtime exposes. The body runs the imperative arc — call provider, dispatch tools, continuation-or-synthesize, write the ledger — and hands the [`OrchestratorClient`](/docs/orchestrator-client) handle to every strategy slot and plugin hook it consumes. The declarative [graph path](/docs/graph) walks the four-stage lattice when the turn shape needs the enrichment slots. Both paths write the same `AuditableCall` rows and honor the same family-lock; the choice is per-host, not per-turn. A turn enters the body through `runtime.executeMessage(sessionId, content)`. The runtime allocates a per-turn `OrchestratorClient`, resolves the registered strategy, and dispatches. The body streams `StreamEvent` frames as it runs; the runtime forwards them to the SSE consumer. See [Turn lifecycle](/docs/turn-lifecycle) for the wall-clock arc and [Stream events](/docs/stream-events) for the per-frame shape. ## What the body consumes [#what-the-body-consumes] The body is a generic per-turn driver. Every host-specific behavior — model fallback, hallucination detection, repetition budgeting, continuation gate exemptions, runaway-directive carry-forward — enters through optional `harnessRuntime?.getX()` accessors on `OrchestratorConfig.harnessRuntime`. A host that wires zero accessors gets a working body: every read chains with `?.` and falls back to a no-op default and the substrate emits its own baseline behavior. The body reads four such accessors today — the **C1–C4** dep-inversion cohort landed alongside the PO-A1 body lift. Each read sits inline at the point of use: | Accessor (read site) | What the body asks it | | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `harnessRuntime?.getDomainContextStrategy?.()` (C1; turnBody.ts \~L1506, L1787, L2119) | Per-turn domain-context strategy supplying the science-context / short-content-garble predicates the body's degradation guards consume. Default: no-op strategy. | | `harnessRuntime?.getHallucinationDetectorFactory?.()` (C2; turnBody.ts \~L835, L1795) | Build a per-turn detector. Body calls `feed(chunk)` on every content delta and reads `hallucinationDetected` / `narratedWithoutExecution` after the turn settles. Default: `createNoOpHallucinationDetector`. | | `harnessRuntime?.getRepetitionGuard?.()` (C3; turnBody.ts \~L419) | Per-session repetition tracker. Body calls `setPlanActive`, `getPriorTurnRunawayDirective`, `clearPriorTurnRunawayDirective` if defined. Default: no-op; the runaway-directive arm stays silent. | | `harnessRuntime?.getContinuationMetaToolNames?.()` (C4; turnBody.ts \~L410) | Names exempted from tool-call counting in the depth-zero continuation gate. Default: empty set; every tool counts. | `EMPTY_SINGLE_TURN_FLAGS` from `@pleach/core/types/singleTurn` is the four-boolean reset baseline (`truncated`, `modelFallback`, `contextOverflow`, `hallucinationDetected`) that the body spreads at turn boundaries. The accessor table above is the body-facing view of the surface documented in full at [Runtime strategies § Newer injection hooks](/docs/runtime-strategies#newer-injection-hooks). The full strategy inventory — `summaryExtractor`, `toolCatalog`, `subagentExecutor`, and the other singletons — lives in the same page. ### Generic-by-injection [#generic-by-injection] The body itself contains no host-specific logic. Domain behavior (host vocabulary, vendor backends, sandbox tool prefixes, domain phrasing — for example a host's domain-specific input handling, third-party integrations, or safety directives) arrives via `harnessRuntime.collectX()` and `harnessRuntime.getX()` calls into the registered plugin surface. The body asks; the plugin answers; nothing is hardcoded. This is the property that lets the same body drive arbitrary domains. ## What the body never sees [#what-the-body-never-sees] The body is generic substrate; the [domain-string purity gate](/docs/architecture#10-boundary-rules) asserts it stays generic. Five categories of literal are forbidden from `packages/core/src/**` and therefore from the body itself: * Host vocabulary (the consumer's domain terms) * Vendor backend names (specific cloud / execution platforms) * Sandbox tool prefixes (the host's tool namespace convention) * Identity discriminators (host-specific account or org strings) * Domain phrasing (host-specific prompt fragments) A leak fails `audit:domain-string-purity` in CI. The body stays domain-agnostic by structure, not by review discipline. Plugins remain the only legitimate channel for any of the five categories; the body asks the plugin surface for the value it needs and ships none of it inline. See [Language-agnostic contract § How the contract stays honest](/docs/language-agnostic-contract#how-the-contract-stays-honest) for why the gate is load-bearing. ## The helpers barrel (internal) [#the-helpers-barrel-internal] `streamSingleTurn` groups its helpers behind a barrel in the package's internal `strategies/streamSingleTurn/helpers/` tree. This barrel is an **implementation detail — not part of the public API**. It sits under the package's `internal/` namespace precisely to signal that its paths can move or change shape between releases with no deprecation cycle. Do not import it directly. The barrel groups both the typed contracts the body consumes — `TurnAccumulator`, `OrchestratorMessage`, `ProviderFallbackConfig`, `ToolDefinition` — and a small set of runtime utilities the body uses inline (`safeStringifyArgs`, `parseToolArgs`, `dispatchForceSynthesisEchoDetector`, `buildImperativeProviderAttemptCallback`). They exist for the body's own use, not for consumer code. For the public, supported types a strategy needs, reach for the named type subpaths instead. The generic `ToolDefinition` structural contract, for example, ships from `@pleach/core/types/generic`: ```typescript import type { ToolDefinition } from "@pleach/core/types/generic"; ``` A host does not import the body's internal helpers to wire a custom strategy. It registers a replacement body through the module-loader seam (see [Wiring a custom body](#wiring-a-custom-body)) and consumes the typed strategy slots the runtime threads in. ### Consumer-surface non-overlap [#consumer-surface-non-overlap] The body lives at `turnBody.ts` and the chunk-time observer cohort lives at `streamSingleTurn/consumers/*`. These are locked as disjoint import territories: a single file may import from one or the other, never both. `audit:streamSingleTurn-consumer-surface` runs at PR time and fails any host file that overlaps the two. If a host genuinely needs both, instantiate two separate consumer surfaces — one for the body, one for the chunk-time observers — and keep their import graphs disjoint. ## Wiring a custom body [#wiring-a-custom-body] The default `streamSingleTurn` body handles the linear-turn case without configuration. A host that needs a different shape — typically because the turn needs to interleave a custom planner or a non-standard tool-dispatch order — registers its own body at the `orchestrator.streamSingleTurn` module-loader key. The wiring goes through `setHarnessModuleLoader` (see [Host adapter](/docs/host-adapter)), not a `SessionRuntimeConfig` field; the runtime resolves the registered body via `dynamicImportApp("orchestrator.streamSingleTurn")` and threads the same per-turn `OrchestratorClient` into it. A custom body is a sharp tool. The default body is what the substrate's parity tests run against — replacing it is opting out of the byte-replay property until the custom body passes the same fixtures. See [Determinism](/docs/determinism) for the five contracts the default body holds. ## Where to go next [#where-to-go-next] <Cards> <Card title="Runtime strategies" href="/docs/runtime-strategies" description="The full strategy slot inventory the body consumes." /> <Card title="OrchestratorClient" href="/docs/orchestrator-client" description="The per-turn handle the body threads into every strategy slot." /> <Card title="Turn lifecycle" href="/docs/turn-lifecycle" description="The wall-clock arc of one turn — what the body does between executeMessage and the final SSE frame." /> <Card title="Plugin contract" href="/docs/plugin-contract" description="The contribution surface that supplies the body's domain inputs." /> </Cards> --- # Subagents (/docs/subagents) A subagent is a child runtime spawned from a tool call. The parent turn pauses on the subagent's tool, the subagent runs its own anchor-plan → tool-loop → synthesize → post-turn cycle, and the result bubbles back to the parent as the tool result. Every call the child makes lands on the same audit ledger with `parent_turn_id` and `subagent_depth` columns set, so a `GROUP BY parent_turn_id` rolls nested fan-out back to the user message that caused it. See [Event log](/docs/event-log) for the broader projection surface. Subagent is one of three concepts in the [composing-agents cluster](/docs/agents#the-composing-agents-cluster), alongside the skill that defines the spec and the agent profile that records every invocation. The subagent is the runtime spawn; the skill is the durable definition; the profile is the rolling signal that decides whether the spec gets routed to again. Subagents are off by default — the substrate runs strictly serial. Enable them when the parent agent decomposes work into independent subtasks (research fan-out, multi-document analysis, parallel benchmark runs). ```typescript import { SubagentManager, createSubagentManager, SUBAGENT_LIMITS, RESULT_PREVIEW_MAX_CHARS, buildDependencyGraph, topologicalSort, } from "@pleach/core"; import type { Subagent, SubagentStatus, SubagentTask, SubagentSpawnConfig, SubAgentConfig, SubAgentHandle, SubAgentResult, SubAgentToolCall, SubagentSessionFlags, } from "@pleach/core"; // LightweightSessionRuntime, the workspace-context helpers, and the // WorkspaceContext type ship only from the `@pleach/core/subagents` // subpath — they are not re-exported from the root barrel. import { LightweightSessionRuntime, fetchWorkspaceContext, formatWorkspaceContextPrompt, } from "@pleach/core/subagents"; import type { WorkspaceContext } from "@pleach/core/subagents"; ``` <SourceMeta source="{ label: "src/subagent/", href: "https://github.com/pleachhq/core/tree/main/src/subagent" }" /> ## Enabling subagents [#enabling-subagents] ```typescript import { SessionRuntime } from "@pleach/core"; const runtime = new SessionRuntime({ storage: supabaseAdapter, enableSubagentConcurrency: true, maxConcurrentSubagents: 4, userId: "user_123", }); ``` | Config field | Default | Effect | | --------------------------- | ------- | --------------------------------------------------------- | | `enableSubagentConcurrency` | `false` | Master switch | | `maxConcurrentSubagents` | `4` | Cap on the `SubagentManager` (the host-driven queue path) | <Callout type="warn" title="Bare core needs a host-supplied executor"> The flags above turn the surface on; they don't make a subagent run on bare `@pleach/core`. The runtime's default executor is a no-op that returns an "executor not configured" failure, so the spawn emits `subagent.spawned` then `subagent.failed` with no work done. To actually run subagents the host supplies a `subagentExecutor` on `SessionRuntimeConfig` (the LLM call is bring-your-own) and/or drives the `SubagentManager` itself. Without that wiring the model is offered no delegation tool and spawns don't execute. `@pleach/core` provides scheduling and lifecycle, not the model call. </Callout> Two concurrency paths exist, and they cap differently. The imperative spawn path (`runtime.async.spawn` / `spawnAgent`) caps on the hard `SUBAGENT_LIMITS.maxConcurrent` constant (`3`), not on `maxConcurrentSubagents`. When the active subagent count is already at that limit, the spawn does not queue or wait — it returns a failed `SubAgentResult` (`status: "failed"`, error `Sub-agent concurrency limit reached`) and emits `subagent.spawned` immediately followed by `subagent.failed`. Depth behaves the same way: exceeding `SUBAGENT_LIMITS.maxDepth` (`3`) rejects the spawn rather than queuing it. `maxConcurrentSubagents` (default `4`) caps the `SubagentManager` — the queue/DAG fan-out component. The runtime constructs a `SubagentManager` when `enableSubagentConcurrency` is set, but does not drive it on the imperative spawn path. The queue, `buildDependencyGraph`, and `topologicalSort` are a host-driven surface: a host that wants queue-on-cap (rather than fail-on-cap) fan-out orchestrates the `SubagentManager` directly. ## How a subagent gets spawned [#how-a-subagent-gets-spawned] Spawns are imperative in bare `@pleach/core` — there is no metadata-flag auto-escalation. The entry points are: * `runtime.async.spawn(config)` (or the `@deprecated` `runtime.spawnAgent(config)`) — the host or a tool calls it directly with a `SubAgentConfig`. * The `SubagentManager` API — `createSubagentManager(...)` then its spawn/queue methods, for host-driven fan-out. * A host `OrchestratorAdapter` that surfaces delegation tools (the Ivy host uses the `delegate_to_*` prefix; the prefix is host-configurable via `delegationToolPrefix`). The model calling one of those tools routes through the host's `subagentExecutor`. There is no `spawnsSubagent: true` tool-metadata flag in core (the field does not exist), and bare core wires neither a planner-emitted subagent task nor an intent-classifier route to a spec — those are host responsibilities. When a spawn runs, the runtime allocates a `LightweightSessionRuntime` and dispatches through the configured `subagentExecutor`. ## Namespacing in the event stream [#namespacing-in-the-event-stream] Every event a subagent emits carries `namespace: string[]`. Empty array for the root orchestrator; populated for events emitted from inside a subagent. ```typescript for await (const event of runtime.executeMessage(sessionId, prompt)) { if (event.namespace && event.namespace.length > 0) { // Subagent event — route to a sub-panel renderInSubagentPanel(event.namespace, event); } else { renderInRootPanel(event); } } ``` The namespace path is the subagent tree — a subagent that itself spawns a subagent emits events with `namespace: ["parent", "child"]`. UIs render this as a tree of nested transcripts. ## Subagent lifecycle events [#subagent-lifecycle-events] Four stream events bracket a subagent's life: | Event | Payload | When | | -------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------- | | `subagent.spawned` | `{ subagentId, task, specName?, context? }` | Allocated; runtime is starting it | | `subagent.progress` | `{ subagentId, progress, message?, activeToolName? }` | Periodic progress update | | `subagent.completed` | `{ subagentId, content, toolsUsed, toolCallDetails? }` | Finished cleanly | | `subagent.failed` | `{ subagentId, error, terminalStatus? }` | Errored; `terminalStatus` discriminates `cancelled` / `failed` / `timeout` | `progress` is 0–1. `activeToolName` lets a UI show what the subagent is currently doing without subscribing to its full event stream. ## `WorkspaceContext` [#workspacecontext] Live workspace state a subagent can inherit when its spec is `workspaceAware`. Queried from a sandbox-style execution environment at spawn time so the subagent doesn't burn a turn asking what's installed. ```typescript interface WorkspaceContext { pythonVersion: string; packages: Array<{ name: string; version: string }>; cliTools: string[]; disk?: { total: string; used: string; available: string; usePct: string }; fileCount: number; files?: string[]; } ``` `fetchWorkspaceContext(sandbox)` queries a sandbox connection and returns the snapshot (or `null` if the sandbox isn't active — callers proceed without context rather than failing the spawn). `formatWorkspaceContextPrompt(ctx)` renders the snapshot as a prompt section. `setDomainClassifierPatterns(patterns)` configures the file-classification heuristic that picks which files surface in `files`. A workspace-aware subagent needs the host to supply the sandbox connection. Hosts wire one in via the `contributeSandboxBridge` plugin hook — see [Plugin contract](/docs/plugin-contract) for the bridge surface. ## `SubAgentConfig` and the spawn surface [#subagentconfig-and-the-spawn-surface] ```typescript interface SubAgentConfig { task: string; name?: string; // spec name; default "general-purpose" tools?: string[] | "inherit" | "none"; context?: { conversationSummary?: string; variables?: Record<string, unknown> }; maxSteps?: number; // default 5 maxDepth?: number; // checked at spawn time timeout?: number; // default 120_000ms streamProgress?: boolean; systemPromptSuffix?: string; resumeId?: string; // resume a prior subagent's transcript } interface SubAgentHandle { id: string; status: "cancelled" | "completed" | "failed" | "pending" | "running"; progress?: number; // 0–100 } interface SubAgentResult { id: string; status: "cancelled" | "completed" | "failed" | "timeout"; content: string; toolCalls: SubAgentToolCall[]; error?: string; metrics: { totalSteps: number; durationMs: number; inputTokens?: number; outputTokens?: number; }; transcript?: Array<{ role: string; content: unknown }>; agentName?: string; checkpointId?: string; turnMetrics?: { successfulToolNames: string[]; failedToolNames: string[]; turnHadFailedTools: boolean; retryCount: number; totalToolCalls: number; }; } ``` <Callout type="warn" title="`context.variables` is currently inert"> `context.variables` is accepted on `SubAgentConfig` but the runtime does not consume it — when a spawn supplies it, the runtime logs a warning (`context.variables provided but executor does not consume them; embed into context.conversationSummary or task`) and proceeds without it. Put anything the child needs into `context.conversationSummary` or `task` instead. </Callout> `SubagentSessionFlags` propagates parent-session state into the child so the subagent doesn't re-offer tools the parent already knows are unavailable: ```typescript interface SubagentSessionFlags { sandboxUnavailableInSession?: boolean; filteredToolNames?: string[]; } ``` ## `LightweightSessionRuntime` [#lightweightsessionruntime] Subagents run on `LightweightSessionRuntime` — a slimmer `SessionRuntime` variant that shares the parent's storage, checkpointer, and plugin set but isolates its channels and event stream. It is exported from the `@pleach/core/subagents` subpath, not the root barrel. ```typescript import { LightweightSessionRuntime } from "@pleach/core/subagents"; // Usually you don't construct this directly — the SubagentManager does. // Direct construction is for testing or custom orchestration: const sub = new LightweightSessionRuntime({ parentSessionId: runtime.sessionId, agentName: "research-fanout", parentPluginManager: pluginManager, // optional store: baseStore, // optional (BaseStore) sessionFlags: { // optional sandboxUnavailableInSession: false, filteredToolNames: [], }, }); ``` The lightweight runtime inherits: | From parent | What | | --------------- | ---------------------------------------------------------------------- | | Storage adapter | Reads + writes the same DB; isolated `subagent_id` column scope | | Checkpointer | Subagent checkpoints land in the same store with parent-id provenance | | Plugin set | Plugins are shared; per-subagent contributions are filtered by `scope` | | Audit ledger | Subagent calls write to the same ledger with the subagent's id | What's isolated: * Channels — the subagent has its own per-channel state * Event log namespace — events tagged with the subagent's path * Tool registry filter — subagents typically run a narrower tool set than their parent ## `SUBAGENT_LIMITS` and depth tracking [#subagent_limits-and-depth-tracking] Hard limits the substrate enforces independently of consumer config: ```typescript const SUBAGENT_LIMITS = { maxDepth: 3, // parent → child → grandchild maxConcurrent: 3, // concurrent subagents per session maxPerTurn: 5, // subagents spawned in one turn timeoutMs: 120_000, // default timeout } as const; const RESULT_PREVIEW_MAX_CHARS = 400; ``` Depth is checked at spawn time — exceeding `maxDepth` rejects the spawn rather than proceeding into a fourth nested runtime. `SubAgentToolCall.resultPreview` is truncated at `RESULT_PREVIEW_MAX_CHARS` so the per-step records on the parent's `SubAgentResult.toolCalls` stay bounded. `turnDepth` on `RuntimeStateSnapshot` (passed to runtime-aware prompt contributions) reports the same value the subagent sees. The depth value is also what lands on every `AuditableCall` row emitted from inside a subagent — `payload.subagentDepth` carries `1` for a direct child, `2` for a grandchild, and the audit ledger keeps the attribution distinct so a `GROUP BY subagentDepth` query answers "how much did the fan-out tier cost relative to the root turn." The same field threads through `llm.turn.depth` on the stream, so a UI panel can colour-code per-tier spend without joining back to the ledger. ## Authoring subagent specs [#authoring-subagent-specs] Subagent specs are authored as skills — markdown files with YAML frontmatter, loaded by `SkillLoader` from `@pleach/core/skills`. A skill's `execution` block (`maxSteps`, `timeout`, `canDelegate`) marks it as subagent-capable. The orchestrator's intent classifier routes to it via the `intents` field. A minimal skill file at `skills/builtin/research-fanout/SKILL.md`: ```markdown --- name: research-fanout description: Fan out searches across multiple sources in parallel. allowed-tools: - search_corpus - search_news intents: - research - literature_review activation: intent-matched execution: maxSteps: 6 timeout: 120000 --- You are a research subagent. Run each search in parallel and synthesise the results into a single structured report. ``` Legacy in-code subagent specs — the `subagentSpecs` array passed to the `SkillLoader` constructor — are migrated to builtin skills automatically at load time. A spec whose `name` matches an existing skill file is skipped. Migration is the backward-compatibility path, not the authoring target. See [Skills](/docs/skills) for the full `Skill` shape, the three-source merge order, and the `getByIntent` / `getByName` loader API. ## Aborting a subagent [#aborting-a-subagent] The parent's `AbortSignal` propagates to every active subagent — aborting the parent turn cancels every running child. That's the primary cancel path today: cancel the parent and every active child unwinds. Per-subagent cancellation is partially wired. `SubagentManager` marks the subagent `"cancelled"` and emits `subagent.cancelled`, but the async-task path ([Async tasks](/docs/async-tasks)) currently logs a warning rather than tearing down the running worker — cancellation is cooperative, not preemptive. A subagent mid-tool-call won't stop until that call returns. The aborted subagent emits `subagent.failed` with `terminalStatus: "cancelled"`. The discriminator matters for dashboards: `"cancelled"` is user-driven, `"failed"` is an error inside the subagent's own runtime (a tool threw, a provider call exhausted its cascade), and `"timeout"` is the `SubAgentConfig.timeout` ceiling firing — typically `120_000`ms. Alert on `"failed"`; don't alert on `"cancelled"`. ## Where to go next [#where-to-go-next] <Cards> <Card title="Stream events" href="/docs/stream-events" description="`subagent.spawned` / `progress` / `completed` / `failed` payloads." /> <Card title="Skills" href="/docs/skills" description="`SkillLoader` — define subagent specs as markdown skills; the `execution` block makes a skill delegatable." /> <Card title="SessionRuntime" href="/docs/session-runtime" description="`enableSubagentConcurrency` and `maxConcurrentSubagents` config fields." /> <Card title="Auditable call row" href="/docs/auditable-call-row" description="`parent_turn_id` and `subagent_depth` columns roll fan-out back to the user turn." /> </Cards> --- # Subpath exports (/docs/subpath-exports) `@pleach/core` ships an ESM-first export map with both `import` and `require` entries (the package is `type: module`; CommonJS consumers get `.cjs` builds). Below: the named subpaths consumers should rely on, plus the wildcard escape hatch and what it costs you. Snapshot is for `@pleach/core@1.x`. The canonical list lives in the package's `package.json` and is watched by `audit:package-export-validation` upstream. ## Named subpaths [#named-subpaths] `stable` means the path is named in the published `exports` map and watched by the upstream package-export audit. Removal or rename goes through a deprecation cycle. Each subpath ships ESM (`.js`), CJS (`.cjs`), and types (`.d.ts`) — see [Dual-format builds](#dual-format-builds). ### Substrate runtime [#substrate-runtime] | Import | Ships | Stability | | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | | `@pleach/core` | Root barrel — `SessionRuntime`, factories, runtime types | stable | | `@pleach/core/SessionRuntime` | `SessionRuntime` class, direct entry (barrel sidesteps tree-shaking) | stable | | `@pleach/core/runtime` | Process-scoped registries + module-loader indirection | stable | | `@pleach/core/runtime/createPleachRuntime` | `createPleachRuntime`, `TurnOrchestrator`, host-injection types | stable | | `@pleach/core/quickstart` | Zero-config starter surface — `createPleachRoute` (fetch-handler factory), `useChat` (Pleach-native React hook), `<ChatBox />` (unstyled, accessible), `defaultPlugin`, `createBenchmarkPlugin`, `providerDetection` (`detectProvider`, `detectAvailableProviders`, `DEFAULT_PROVIDER_ENV_VARS`, `DEFAULT_PROVIDER_PRIORITY`). What `npx pleach init` scaffolds against. See [The quickstart subpath](/docs/getting-started#the-quickstart-subpath). | stable | ### Type subpaths [#type-subpaths] Narrowed deep-path type entries landed via the pa3prep cluster 15 sweep — type-only deep paths were promoted from wildcard reach to named entries so consumers don't pay the wildcard's instability cost on contracts. | Import | Ships | Stability | | -------------------------------------- | ------------------------------------ | --------- | | `@pleach/core/types` | Public type definitions (re-exports) | stable | | `@pleach/core/types/command` | `Command` + `isCommand` brand | stable | | `@pleach/core/types/dynamicAppModules` | Dynamic-app-module type registry | stable | | `@pleach/core/types/generic` | Generic runtime types | stable | | `@pleach/core/types/singleTurn` | Single-turn body contract types | stable | | `@pleach/core/types/strategies` | Strategy registration types | stable | ### Persistence + state [#persistence--state] | Import | Ships | Stability | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | | `@pleach/core/sessions` | Session-state adapters — `MemoryAdapter`, `IndexedDBAdapter`, `SupabaseAdapter` | stable | | `@pleach/core/checkpointing` | Checkpoint savers — `MemorySaver`, `IndexedDBSaver`, `SupabaseSaver` | stable | | `@pleach/core/sync` | Version-vector sync utilities | stable | | `@pleach/core/store` | KV store — `BaseStore`, `MemoryStore`, `SupabaseStore`, `MemoryNamespaces` | stable | | `@pleach/core/cache` | In-memory cache backend (`createMemoryCacheBackend`) + content-hash deriver (`deriveContentHashKey`, `canonicalizeKeyInput`, `ContentHashKeyInput` — re-exported by `@pleach/replay/cache`) | stable | | `@pleach/core/schema` | Generated row-shape types + SQL bundle | stable | | `@pleach/core/schema/postgres/*.sql` | Raw SQL migrations (asset import) | stable | ### Event log + projections [#event-log--projections] | Import | Ships | Stability | | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | | `@pleach/core/eventLog` | `EventLogWriter`, `EventLogClient`, `hydrateFromEvents`, `GraphProjection<T>` | stable | | `@pleach/core/manifest` | Chat-manifest — `createDefaultChatManifestProvider` (the `[Session Manifest]` ledger + section render), checkpoint/reload persistence (`stampManifestIntoState`, `restoreManifestFromState`), and `chatManifestProjection` / `foldChatManifest` (the manifest as an event-log projection) | experimental | ### Audit, attestation, scrub [#audit-attestation-scrub] | Import | Ships | Stability | | ------------------------------------ | ------------------------------------------------------------------------ | --------- | | `@pleach/core/audit` | `AuditableCall` ledger primitives + no-op plug-points | stable | | `@pleach/core/attestation` | Attestation module — signer, verifier, canonical envelope | stable | | `@pleach/core/attestation/keyStores` | Pluggable key-store backends (memory, env, KMS adapters) | stable | | `@pleach/core/attestation/testing` | Test fixtures + harness for attestation flows | stable | | `@pleach/core/scrubbers` | C8 redaction surface — `SCRUBBABLE_FIELDS` allowlist + scrubber registry | stable | ### Prompts + prompt-builder [#prompts--prompt-builder] | Import | Ships | Stability | | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------- | | `@pleach/core/prompts` | C12 prompt-contribution contract — `PromptContributionRegistry`, `PromptContribution`, 41 Phase H universal templates | stable | | `@pleach/core/prompts/examples` | Reference prompt contributors | stable | | `@pleach/core/prompt-builder` | Phase F generic composition — `composeBudgetedPrompt` + diagnostics | stable | | `@pleach/core/prompt-builder/sections` | Section primitives | stable | | `@pleach/core/prompt-builder/examples` | Reference section assemblies | stable | ### Safety [#safety] | Import | Ships | Stability | | ------------------------------ | --------------------------------------------------------------------- | --------- | | `@pleach/core/safety` | `SafetyPolicyRegistry`, `composeSafetyContent`, contribution registry | stable | | `@pleach/core/safety/examples` | Reference safety contributors | stable | ### Lineage + fingerprint + observability [#lineage--fingerprint--observability] | Import | Ships | Stability | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | | `@pleach/core/lineage` | `LineageTracker` + 6 lineage types (Layer-4 lineage primitives) | stable | | `@pleach/core/fingerprint` | `computeFingerprint` — platform-uniform pure hash | stable | | `@pleach/core/otel` | OpenTelemetry no-op + adapter seams | stable | | `@pleach/core/inspector` | `inspectRuntime`, `InspectionReport`, `CapabilityReport`, `PluginReport`, `CapabilityDescriptor`, the 35 known-capability list (31 modern + 4 deprecated) | stable | | `@pleach/core/harness` | Turn-report folding for the headless self-observation loop (the `harness` chatinit flag + `pleach dev` plugin-test harness) — `foldTurnReport`, `foldTurnReportFromEvents`, `createHarnessCapture`, `projectInspector`, `diffReports`, `evaluateAssertions`, `buildReportMarkdown` (`{ verbose }` for the audit-ledger/runtime-log/tool-card sections), `TurnReport`. Also the answer-quality battery `detectEdgeCases` (+ `EdgeCaseFinding`, `hasSubstantiveAnswerBody`, `PLAN_NARRATION_PATTERNS`), the default `defaultAnswerSufficiencyBackstop`, and the headless-diagnostics pub/sub (`getDiagnosticsBus`, `DiagnosticsBus`). See [Playground & DevTools](/docs/playground-and-devtools). | stable | | `@pleach/core/instrumentation` | UX-parity probes + observer-vs-legacy divergence telemetry | stable, internal-leaning | ### Strategies + streaming [#strategies--streaming] | Import | Ships | Stability | | ----------------------------------------------------- | ---------------------------------------------------------------------- | --------- | | `@pleach/core/strategies` | Strategy barrel — fabrication detector + named detectors + guard types | stable | | `@pleach/core/strategies/fabricationDetector` | Direct fabrication detector entry | stable | | `@pleach/core/strategies/fabricationDetectors` | Named fabrication detector cohort | stable | | `@pleach/core/strategies/fabricationGuardTypes` | Guard type contracts | stable | | `@pleach/core/strategies/hallucinationDetector` | Hallucinated tool-call detector | stable | | `@pleach/core/streaming` | Producer side — `StreamManager`, `asyncEventChannel` | stable | | `@pleach/core/streaming/dsmlSanitizer` | DSML sanitizer (chunk-time amend observer) | stable | | `@pleach/core/streaming/bracketNarratedToolExtractor` | Bracket-narrated tool-call extractor | stable | | `@pleach/core/streaming/hallucinatedToolDetector` | Hallucinated tool-call detector entry | stable | | `@pleach/core/streaming/streamDegenerationGuards` | Stream degeneration guard primitives | stable | | `@pleach/core/finalization` | Finalization barrel | stable | | `@pleach/core/finalization/finalizeContent` | `finalizeContent` direct entry | stable | ### Providers [#providers] | Import | Ships | Stability | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | | `@pleach/core/providers` | Provider-failure taxonomy + reasoning-only recovery precondition — `FailureCategory`, `FailureClassification`, `BillingDisposition`, `ModelUnavailableReason`, `classifyEmptyStream`. The seam recovery arm keys on these; the reasoning-model set + provider string→category matchers stay host-injected (`SessionRuntimeConfig.reasoningRecovery`). See [Providers § Reasoning-only completion recovery](/docs/providers#reasoning-only-completion-recovery). | stable | ### Graph + planning + execution [#graph--planning--execution] | Import | Ships | Stability | | ----------------------------- | ---------------------------------------------------------------------------------------------------------- | --------- | | `@pleach/core/graph/planning` | Planning substrate — plan-channel utilities | stable | | `@pleach/core/engine` | `RetryPolicy`, `AbortComposer`, `SuperstepRunner`, `createPromiseBarrier`, `shouldTriggerNode` | stable | | `@pleach/core/channels` | Typed state containers — `LastValue`, `BinaryOperatorAggregate`, `Topic`, `EphemeralValue`, `NamedBarrier` | stable | | `@pleach/core/detectors` | Detector primitives — `syntheticExpansionDetector` | stable | ### Tools + plugins + meta [#tools--plugins--meta] | Import | Ships | Stability | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | | `@pleach/core/tools` | `ToolRegistryWrapper`, `ToolBatchExecutor`, catalog re-exports | stable | | `@pleach/core/tools/execution` | Tool-execution pipeline stages + resolved-tools registry (`recordRegistryResolvedTool`, `getRegistryResolvedToolNames`, `hasRegistryResolvedTool`, `resetRegistryResolvedTools`) | stable | | `@pleach/core/plugins` | `PluginManager` + `HarnessPlugin` interface | stable | | `@pleach/core/async` | Long-running task orchestration — `AsyncTaskManager`, executor contract, task reducer | stable | | `@pleach/core/plans` | Plan substrate — `PlanManager`, plan/step/revision types | stable | | `@pleach/core/subagents` | Concurrent subagent execution — `SubagentManager`, `LightweightRuntime` | stable | | `@pleach/core/memory` | Auto-extracted user facts + per-agent memory scoping | stable | | `@pleach/core/query` | Read-side query helpers | stable | ### Re-exports [#re-exports] | Import | Ships | Stability | | ----------------------- | ------------------------------------------------------------------- | --------- | | `@pleach/core/react` | Re-export of `@pleach/react` — hooks + `HarnessProvider` + DevTools | stable | | `@pleach/core/adapters` | Storage + streaming adapters | stable | ### Runtime registries + auth + utils [#runtime-registries--auth--utils] | Import | Ships | Stability | | ------------------------------------- | ------------------------------------------------------------------------ | ------------------------ | | `@pleach/core/runtimeRegistry` | Process-scoped runtime registry | stable | | `@pleach/core/guestInterruptBus` | Guest interrupt bus (registry seam) | stable | | `@pleach/core/guestInterruptCallback` | Guest interrupt callback (registry seam) | stable | | `@pleach/core/auth` | Auth providers — `ApiKeyAuth`, `BearerTokenAuth`, `AuthProvider` | stable | | `@pleach/core/utils` | UUIDv7, typed `EventEmitter`, SHA-256 integrity helpers | stable | | `@pleach/core/mock` | Offline-development substitutes for external services (dev + test paths) | stable, internal-leaning | ### Wildcard [#wildcard] | Import | Ships | Stability | | ---------------- | ------------------------------- | ------------ | | `@pleach/core/*` | Deep paths not yet barrel-named | **unstable** | `stable` means: the path is named in the published `exports` map and watched by the upstream package-export audit. Removal or rename goes through a deprecation cycle. ## The wildcard surface [#the-wildcard-surface] <Callout type="warn" title="Wildcard imports are unstable"> Paths matched by `@pleach/core/*` resolve today, but renames and removals can land in a minor release. They are watched in WARN-DRIFT mode (non-blocking) — not promoted to FAIL until a path is named. Treat them as a temporary hatch, not a contract. </Callout> `@pleach/core/*` resolves to deep paths under the package's `dist/` that aren't behind a named barrel yet. It exists so the published surface doesn't lag the source tree during the named-barrel narrowing project tracked in the upstream `pa3prep/` ledger. The upstream `audit:package-export-validation` job watches the wildcard's reach: * **WARN-DRIFT (default)** — every wildcard hit not in the published snapshot at `scripts/audit/package-export-snapshot.json` is reported. Non-blocking per D-PA3P-3 so source-tree changes don't break the build. * **`:strict` (publish-day)** — promotes WARN-DRIFT to FAIL on any unresolved import, plus walks the declared exports surface. Run before any `@pleach/core@1.x` rehearsal cut. * **`:update-snapshot`** — regenerates the snapshot when wildcard reach is intentionally widened or narrowed. Three rules of thumb when reaching into the wildcard: 1. **It works today.** Deep paths under `@pleach/core/graph/...`, `@pleach/core/replay/types`, and similar resolve against the published artifact. 2. **It will move.** As named barrels land, wildcard paths get superseded. The pa3prep cluster sweeps promote groups of paths to named entries at a time — type-only deep paths landed via cluster 15 (`./types/*` cohort), and each future cluster narrows the wildcard's reach further. 3. **`graph/*` is indefinite.** The internal graph topology is deliberately not behind a stable barrel. Don't build a public API on top of it. If a wildcard path is load-bearing for you, open an issue against [pleachhq/core](https://github.com/pleachhq/core/issues) asking for it to be named. The named-barrel pipeline takes requests. ## Internal surfaces [#internal-surfaces] <Callout type="warn" title="Not a public import surface"> Paths under `@pleach/core/internal/…` are implementation details, not a supported public API. The `internal/` segment is the signal: they resolve today, but they are **not `stable`** — they can move or change shape between releases with no deprecation cycle. Don't build against them; use the public alternatives below. </Callout> | Path | What it is | Public alternative | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `@pleach/core/internal/strategies/streamSingleTurn/helpers` (+ its `safeStringifyArgs`, `parseToolArgs`, `forceSynthesisEchoDispatch`, `imperativeProviderAttemptCallback` direct entries) | The `streamSingleTurn` body's own helper barrel — typed contracts (`TurnAccumulator`, `OrchestratorMessage`, `ProviderFallbackConfig`, `ToolDefinition`) plus runtime utilities the default body uses inline. See [streamSingleTurn § The helpers barrel](/docs/stream-single-turn#the-helpers-barrel-internal). | Public types via the named type subpaths — e.g. `ToolDefinition` from `@pleach/core/types/generic`. A custom body registers through the module-loader seam, not by importing these helpers. | | `@pleach/core/internal/tools/execution/registryResolvedTools` | Resolved-tools registry surface. | `@pleach/core/tools/execution` re-exports the supported functions (`recordRegistryResolvedTool`, `getRegistryResolvedToolNames`, `hasRegistryResolvedTool`, `resetRegistryResolvedTools`). | ## Dual-format builds [#dual-format-builds] Each named subpath ships three artifacts: ```json { "import": "./dist/sessions/index.js", "require": "./dist/sessions/index.cjs", "types": "./dist/sessions/index.d.ts" } ``` ESM consumers resolve `.js`; CommonJS consumers resolve `.cjs`; TypeScript reads the `.d.ts`. Bundlers respect `sideEffects: false` on the package — tree-shaking the runtime is supported and gives roughly a 30% bundle reduction in apps that only import a handful of named subpaths. ## First-publish packaging contract [#first-publish-packaging-contract] Every `@pleach/*` package adopts the same baseline at its first 1.x publish. Dual ESM/CJS emit, types-first `exports` ordering per the Node resolution spec, dir-style tsup entries, and DTS produced by `tsc --build` against the public surface. Tarballs ship without sourcemaps. Peer-dep ranges tighten at the 1.x cut. Each package carries a `NOTICE`, a `CHANGELOG.md`, npm provenance attestation, and a `sideEffects` declaration that matches what its modules actually do. Consumers benefit from knowing the shape: install or import failures debug faster when the resolution order is predictable. Forkers and vendored installs need to hold the line — diverging from the contract breaks the audit gates upstream that enforce it. For the full write-up — field-by-field `package.json` requirements, the audit job names, and the migration recipe for pre-1.x packages — see [Publishing contract](/docs/publishing-contract). ## Where to go next [#where-to-go-next] <Cards> <Card title="Getting started" href="/docs/getting-started" description="Install @pleach/core and wire your first SessionRuntime." /> <Card title="@pleach/core" href="/docs/core" description="Substrate overview — what ships and how it composes." /> <Card title="CLI" href="/docs/cli" description="The pleach binary — schema scaffolding and project bootstrap." /> <Card title="Publishing contract" href="/docs/publishing-contract" description="First-publish packaging baseline every @pleach/* package shares." /> </Cards> --- # Swarm agent (/docs/swarm-agent) A swarm agent is the shape that pushes the runtime's recursive contract: one user turn fans out into dozens or hundreds of subagents, each of which may spawn its own. Kimi K2-style deep sequential loops, OpenAI Deep Research-style parallel fan-out, AutoGen / LangGraph supervisor topologies, Claude Code-style recursive delegation — different topologies, same buyer cohort, same runtime stress. The load-bearing pain is the runaway-loop case. A buggy fan-out or a stuck sequential loop can spend a five-figure budget in one user turn. This page walks the cost ceiling at the root-turn boundary, the session-tree audit walk that attributes every descendant call back to the originating turn, and the concurrency primitives a swarm host reaches for. **Related shapes.** [Research agent](/docs/research-agent) for the canonical single-anchor / bounded-subagent pattern — the swarm shape inherits its primitives and pushes them further. [Coding agent](/docs/coding-agent) if the subagents run code in sandboxes. [Multi-tenant SaaS agent](/docs/multi-tenant-saas-agent) if one runtime serves many customers, each driving their own swarm. ## What you're building [#what-youre-building] A turn loop whose recursion is bounded by construction, not by hope: * Every subagent — including subagents-of-subagents — carries the originating user turn's `turnId` and its own `subagentDepth`. The whole tree is one query against the ledger. * A per-root-turn cost ceiling aborts the turn when cumulative spend crosses the line. The ledger records the cutoff. * A fan-out cap limits how many subagents one node can spawn at once. A recursion-depth cap limits how deep the tree can go. * Concurrency primitives — `race`, `waitAll`, `waitN`, `bounded`, `backpressure` — handle the structural cases a swarm host actually hits. ## Cost ceiling at the root-turn boundary [#cost-ceiling-at-the-root-turn-boundary] The cost ceiling is the load-bearing protection for a swarm. Today `@pleach/core` ships the structural caps — `maxDepth`, `maxConcurrent`, `maxPerTurn`, `timeoutMs` — as hard ceilings the substrate enforces independently of consumer config. A per-root-turn USD ceiling is on the roadmap (see below); until it lands, hosts enforce the dollar cap out-of-band against the audit ledger. ```typescript import { SessionRuntime, SUBAGENT_LIMITS, AiSdkProvider, createSupabaseAdapter, } from "@pleach/core"; const runtime = new SessionRuntime({ storage: createSupabaseAdapter({ client: supabase }), userId: "user_123", tenantId: "tenant_abc", enableSubagentConcurrency: true, // SUBAGENT_LIMITS.maxConcurrent is the substrate-enforced // ceiling (3); consumer values above the ceiling clamp down. maxConcurrentSubagents: SUBAGENT_LIMITS.maxConcurrent, orchestratorConfig: { provider: anchorProvider, }, }); // Out-of-band USD ceiling, polled by the host against the // audit ledger. `@pleach/compliance/education` ships a // per-session helper for the single-process case; for swarms // that span multiple processes, back it with a tenant-scoped // store and check on each turn boundary. import { createCostCapPolicy } from "@pleach/compliance/education"; const costCap = createCostCapPolicy({ ceilingUsd: 25, onBreach: () => runtime.abort(), }); ``` `SUBAGENT_LIMITS` carries the substrate-side hard caps: | Cap | Value | Effect when breached | | --------------- | --------- | ----------------------------------------------------------------- | | `maxDepth` | `3` | Spawn rejected — fourth nested runtime never starts | | `maxConcurrent` | `3` | New spawns queue until a slot frees | | `maxPerTurn` | `5` | Sixth spawn in a turn is rejected | | `timeoutMs` | `120_000` | Subagent emits `subagent.failed` with `terminalStatus: "timeout"` | The recursion is bounded structurally, not by per-subagent prompts. A subagent that doesn't know about the cap can't escape it. ## What today ships [#what-today-ships] Today, `@pleach/core` ships: * Recursive `SessionRuntime` sessions — every subagent is itself a `SessionRuntime`, with the same primitives the root has. * `AuditableCall` typed audit-ledger rows — every spawn writes a row that ties the child session back to the parent turn. * `parent_turn_id` and `subagent_depth` columns on the audit ledger — the tree walk is one recursive CTE. * `SUBAGENT_LIMITS` for depth, parallel fanout, and total subagent count. For the canonical anchor → bounded subagent fan-out pattern, see [Research agent](/docs/research-agent). ## Roadmap [#roadmap] The deeper swarm surface lands incrementally: * **Per-root-turn cost ceiling.** `maxCostUsdPerTurn` is on the roadmap as a first-class limit. Until it lands, hosts enforce the ceiling out-of-band — a polling read against `harness_auditable_calls` filtered by `parent_turn_id`, with the host calling `runtime.abort()` when the cumulative spend crosses the budget. `@pleach/compliance/education` ships a `createCostCapPolicy({ ceilingUsd, onBreach })` helper for the per-session shape (single-process / in-memory by default; swap in a tenant-DB backing store for multi-process) — it does not yet aggregate by `parent_turn_id` but is sufficient when the swarm root is a 1:1 session. * **Concurrency primitives.** A `@pleach/core/spawn` subpath with `race`, `waitAll`, `waitN`, `bounded`, and `backpressure` primitives that absorb the topology a swarm host already wrote in user space. Until it lands, hosts compose the same shapes with `Promise.all`, `Promise.race`, and a host-side semaphore. * **Session-tree audit walker.** A `@pleach/replay` helper that walks the recursive CTE shown below into a structured object and serializes it for offline review. The SQL is the same; the helper saves the join glue. * **Deterministic spawn-order replay.** A `spawnIndex` on `SpawnTreeState` that normalizes parallel-spawn ordering for byte-identical replay across the tree. * **Per-subagent cost ceilings.** A nested cap that lets one branch of the tree have its own budget without blocking the rest. Lands after the root-turn ceiling is in soak. No dates. Track the upstream package READMEs and CHANGELOG for landing notices. ## The session-tree audit walk [#the-session-tree-audit-walk] Every subagent's audit rows carry `parent_turn_id` and `subagent_depth`. The whole tree — sequential loops, parallel fan-out, recursive delegation — reads as one recursive CTE. ```sql with recursive tree as ( select turn_id, parent_turn_id, subagent_depth, tool_name, model_id, input_tokens, output_tokens from harness_auditable_calls where turn_id = $1 union all select c.turn_id, c.parent_turn_id, c.subagent_depth, c.tool_name, c.model_id, c.input_tokens, c.output_tokens from harness_auditable_calls c join tree t on c.parent_turn_id = t.turn_id ) select subagent_depth, count(*) as calls, sum(input_tokens + output_tokens) as tokens from tree group by subagent_depth order by subagent_depth; ``` The query reads the whole investigation top-down: depth 0 is the anchor turn; depth N is the deepest spawn. A regulator asking "what did the agent do for user turn T" gets the full tree in one read. ## Topology-neutral primitives [#topology-neutral-primitives] The runtime doesn't have a topology opinion. The same primitives — recursive sessions, the ledger tree, the cost ceiling, the spawn record — absorb four different shapes: | Topology | What the host writes | What the runtime stamps | | -------------------- | ---------------------------------- | -------------------------------- | | Deep sequential loop | a long tool-loop in one subagent | `subagent_depth = 1`, many rows | | Parallel fan-out | N subagents spawned at the anchor | `subagent_depth = 1`, N siblings | | Supervisor / worker | supervisor spawns workers per task | `subagent_depth = 1` or 2, tree | | Recursive delegation | subagent spawns its own subagent | `subagent_depth = N`, deep tree | A swarm host picks the topology; the runtime makes the audit walk identical across all four. ## Where to go next [#where-to-go-next] <Cards> <Card title="Subagents" href="/docs/subagents" description="The spawn surface, the SpawnTreeState shape, and SUBAGENT_LIMITS." /> <Card title="Research agent" href="/docs/research-agent" description="The canonical anchor → bounded subagent fan-out pattern that the swarm shape extends." /> <Card title="Audit ledger" href="/docs/audit-ledger" description="The harness_auditable_calls schema and the parent_turn_id column the tree walk reads from." /> <Card title="Channels" href="/docs/channels" description="Per-subagent stream isolation so the UI can render the tree without rebuilding it." /> <Card title="Coding agent" href="/docs/coding-agent" description="The swarm shape when the subagents run code in sandboxes." /> <Card title="Multi-tenant SaaS agent" href="/docs/multi-tenant-saas-agent" description="Per-tenant runtime construction when each tenant drives its own swarm." /> </Cards> --- # Sync (/docs/sync) `@pleach/core/sync` ships the cross-client sync primitives: version vectors for conflict detection, a hybrid logical clock for total ordering, durable outboxes that survive reloads and disconnects, and a `SyncCoordinator` that ties it all together. The version vector is one of three concepts in the [state-and-persistence cluster](/docs/storage#the-state-and-persistence-cluster) — alongside the [storage adapter](/docs/storage) and the [checkpointer](/docs/checkpointing) — that together carry session state across restarts, rewinds, and concurrent writers. This page covers the concurrent-writer axis: `Record<clientId, number>` increments per write, `compareVectors` flags `concurrent` outcomes, and `SyncCoordinator.resolveConflicts` settles them by last-writer-wins rather than clobbering by arrival order. The substrate's sync property is: **concurrent writes are detected, not blindly applied in arrival order.** Two clients writing the same session bump their respective version-vector entries; when one pushes to the server, the coordinator compares vectors and, on a `concurrent` outcome, resolves with last-writer-wins on `updatedAt`. Interactive conflict resolution — surfacing each conflict for the application to settle by hand — is an enterprise-tier surface, not part of open `@pleach/core`. ```typescript import { SyncCoordinator, type SyncCoordinatorConfig, type SyncCoordinatorLayeredConfig, type SyncCoordinatorFlatConfig, type SyncTransportConfig, type SyncEngineConfig, type OutboxFlushResult, HybridLogicalClock, InMemoryOutbox, IndexedDBOutbox, SupabaseOutbox, type OutboxEntry, type OutboxStorage, type SupabaseOutboxClient, emptyVector, createVector, incrementVersion, mergeVectors, compareVectors, hasSeen, getMissing, } from "@pleach/core/sync"; ``` <SourceMeta subpath="@pleach/core/sync" source="{ label: "src/sync/", href: "https://github.com/pleachhq/core/tree/main/src/sync" }" /> ## Picking an outbox [#picking-an-outbox] The outbox buffers `SessionChange`s until the server confirms acceptance. Match it to your storage adapter. | Outbox | Pairs with | Use case | | ----------------- | ------------------ | ----------------------------------- | | `InMemoryOutbox` | `MemoryAdapter` | Tests, ephemeral demos | | `IndexedDBOutbox` | `IndexedDBAdapter` | Browser; survives reloads + offline | | `SupabaseOutbox` | `SupabaseAdapter` | Server-side queue persistence | All three implement `OutboxStorage`; build your own (Redis, SQLite) against the same interface if you need a different backing. ## Wiring a `SyncCoordinator` [#wiring-a-synccoordinator] The config carries two distinct concerns: **transport** (where the sync service lives) and **engine** (how the client buffers, retries, and paces). The layered shape nests them separately so call sites communicate the layering at a glance. ```typescript import { SyncCoordinator, IndexedDBOutbox } from "@pleach/core/sync"; const outbox = new IndexedDBOutbox("pleach-outbox"); const coordinator = new SyncCoordinator({ clientId, transport: { endpoint: "/api/pleach/sync", authToken: await getAuthToken(), }, engine: { intervalMs: 0, // manual flushes only outbox, maxAttempts: 5, }, }); ``` The `SyncCoordinator` is itself a `SyncAdapter` — it implements `sync(request)` against `transport.endpoint` via `fetch`. Hand it to the runtime as your transport, or call `flushOutbox()` directly to drain buffered changes on app start and on connectivity-restored. ### `SyncCoordinatorConfig` [#synccoordinatorconfig] `SyncCoordinatorConfig` accepts either the **layered shape** (preferred, shown above) or the **flat shape** (back-compat, original v1.0). The constructor discriminates by the presence of a `transport` field and normalizes both into the same internal form, so existing code keeps working unchanged. #### Layered shape — `SyncCoordinatorLayeredConfig` [#layered-shape--synccoordinatorlayeredconfig] | Field | Type | Purpose | | ----------- | --------------------------------------------- | ---------------------------------------------------------------------- | | `clientId` | `string` | Stable per-client identifier — keys this client's version-vector entry | | `transport` | [`SyncTransportConfig`](#synctransportconfig) | Where the sync service lives | | `engine` | [`SyncEngineConfig`](#syncengineconfig)? | How the client behaves — buffering, retries, cadence | ##### `SyncTransportConfig` [#synctransportconfig] | Field | Type | Purpose | | ----------- | --------- | --------------------------------------------------- | | `endpoint` | `string` | URL the coordinator POSTs `SyncRequest` payloads to | | `authToken` | `string?` | Sent as `Authorization: Bearer …` when set | ##### `SyncEngineConfig` [#syncengineconfig] | Field | Type | Purpose | | ------------- | ---------------- | ------------------------------------------------------------------------------------ | | `intervalMs` | `number?` | Periodic sync cadence; `0` or omitted = manual only | | `outbox` | `OutboxStorage?` | Durable change buffer — enables at-least-once delivery | | `maxAttempts` | `number?` | Per-entry retry cap before parking; default `5`. Only consulted when `outbox` is set | #### Flat shape — `SyncCoordinatorFlatConfig` (deprecated) [#flat-shape--synccoordinatorflatconfig-deprecated] The original v1.0 shape keeps every field at one nesting level: ```typescript const coordinator = new SyncCoordinator({ syncEndpoint: "/api/pleach/sync", clientId, authToken: await getAuthToken(), outbox, maxOutboxAttempts: 5, }); ``` | Flat field | Maps to | | ------------------- | --------------------- | | `syncEndpoint` | `transport.endpoint` | | `authToken` | `transport.authToken` | | `syncIntervalMs` | `engine.intervalMs` | | `outbox` | `engine.outbox` | | `maxOutboxAttempts` | `engine.maxAttempts` | The flat shape is marked `@deprecated` but stays valid indefinitely for back-compat. Migrate at your own pace — the constructor accepts both and produces byte-identical runtime behavior. ### `OutboxFlushResult` [#outboxflushresult] Each `flushOutbox()` call returns per-cycle counts — surface them in the UI for a "syncing… / synced N changes" affordance. ```typescript const result: OutboxFlushResult = await coordinator.flushOutbox(); console.log(result.inspected, result.synced, result.failed, result.parked); ``` `parked` is the count of entries that hit `engine.maxAttempts` and are kept in the outbox with `lastError` populated, but skipped by future flushes until you reset them manually. The `lastError` field carries a free-text message from the most recent failed attempt — the server's `error` string, or the caught exception's `message` for a transport failure. It is a human-readable reason, not a structured code. Reach for DevTools' `forceSync()` to re-attempt the flush once you've confirmed the underlying issue (downed endpoint, expired auth token, full disk on the outbox store) is fixed. ### Outbox constructors [#outbox-constructors] `IndexedDBOutbox` and `SupabaseOutbox` both take their store name as a positional argument, defaulting to `"harness-outbox"`. Pass a `SupabaseOutboxClient` (the minimal Supabase shape) to `SupabaseOutbox` as the first arg. ```typescript const inMemory = new InMemoryOutbox(); const indexed = new IndexedDBOutbox("pleach-outbox"); const remote = new SupabaseOutbox(supabase, "harness_outbox"); ``` ## Version vectors [#version-vectors] A version vector is `Record<clientId, number>`. Each client increments its own entry on every write. When two clients sync, comparing vectors gives one of four outcomes: | Outcome | Meaning | | ------------- | ------------------------------------------ | | `equal` | Both vectors are identical — no work to do | | `a_dominates` | `a` is strictly ahead — push `a`, drop `b` | | `b_dominates` | `b` is strictly ahead — pull `b`, drop `a` | | `concurrent` | Neither dominates — **conflict** | The free functions handle the math: ```typescript import { emptyVector, createVector, incrementVersion, mergeVectors, compareVectors, hasSeen, getMissing, } from "@pleach/core/sync"; const local = incrementVersion({ [clientId]: 2 }, clientId); const remote = createVector(otherClient, 1); // → createVector takes (clientId, version=1) — one entry at a time. compareVectors(local, remote); // → "concurrent" — neither dominates; conflict. mergeVectors(local, remote); // → { [clientId]: 3, [otherClient]: 1 } — element-wise max. hasSeen(local, { [otherClient]: 1 }); // → false — local hasn't observed otherClient's change. getMissing(local, remote); // → { [otherClient]: 1 } — entries in remote that local is missing. ``` All functions are pure. Use them inside a custom `SyncAdapter` or in tests that simulate divergent client states. ## `HybridLogicalClock` [#hybridlogicalclock] Causal ordering across clients without wall-clock dependence. ```typescript import { HybridLogicalClock } from "@pleach/core/sync"; const clock = new HybridLogicalClock(clientId); const ts1 = clock.now(); // local tick const ts2 = clock.receive(remoteTimestamp); // advance past a remote ts HybridLogicalClock.compare(ts1, ts2); // → -1 | 0 | 1 (wallTime → logical → clientId tie-break) ``` The constructor takes `clientId` as a positional string. The returned `HybridTimestamp` is `{ wallTime, logical, clientId }`; the static `compare` orders by wall time, falls back to logical counter, then breaks ties by `clientId.localeCompare`. Use the HLC timestamp for ordering changes that need a single total order across clients (e.g. message insert order). The version vector is for conflict *detection*; the HLC is for *ordering*. ## Conflict resolution [#conflict-resolution] When `compareVectors` returns `concurrent`, the coordinator's `resolveConflicts(local, remote)` decides between local and remote using last-writer-wins on `updatedAt`: ```typescript const { resolved, strategy } = coordinator.resolveConflicts(local, remote); // strategy: "local" | "remote" ``` This is the real, working self-host surface. `resolveConflicts` takes two `SessionState` values, compares their version vectors, and returns the winning state plus a coarse `strategy` label and the list of diverged paths. On a `concurrent` outcome it keeps whichever side has the later `updatedAt` (ties favor local). The published `SyncCoordinator` ships only this last-writer-wins path. Call it on your push handler where two vectors compare `concurrent`. ### What's enterprise-tier / planned, not in open `@pleach/core` [#whats-enterprise-tier--planned-not-in-open-pleachcore] Interactive conflict resolution — the surface a UI would use to let a person settle a conflict — is not shipped in the open package: * The `sync.conflict` stream event is declared in the event-map type, but no code path emits it. A self-host build never receives one. * `runtime.resolveConflict(sessionId, conflictId, "local" | "remote")` exists on the runtime, but in `@pleach/core` it is a stub: it logs and returns a receipt without merging or persisting anything, and there is no conflict store to look up by `conflictId`. CRDT merge and an interactive conflict surface are the enterprise upgrade. * `"merged"` is likewise reserved for the CRDT path; the open build never returns it. ### The 3xxx sync-error range is defined, not yet thrown [#the-3xxx-sync-error-range-is-defined-not-yet-thrown] The 3xxx code range is a **defined-but-not-yet-thrown enum**. The constants live in `errors/codes.ts`, but no code path in open `@pleach/core` throws or attaches one — they describe a planned structured-error surface. Today a failed sync records a free-text reason instead: the outbox entry's `lastError` and each `OutboxFlushResult.failedEntries[].reason` carry the server's `error` string or a caught exception's `message`, not a 3xxx code. The planned range: | Code | Constant | Planned meaning | | ------ | ---------------------------- | ----------------------------------------------------------------- | | `3001` | `SYNC_NETWORK_ERROR` | `fetch` against `transport.endpoint` rejected or returned non-2xx | | `3002` | `SYNC_CONFLICT_UNRESOLVED` | Concurrent vector that automatic resolution declined to decide | | `3003` | `SYNC_VERSION_MISMATCH` | Server's session version disagrees with the request | | `3004` | `SYNC_OFFLINE_QUEUE_FULL` | Outbox at capacity; oldest unsent change dropped | | `3005` | `SYNC_REALTIME_DISCONNECTED` | Realtime subscription dropped and didn't reconnect | A worked example of the working last-writer-wins path: two tabs write to `session-018f-7a` within the same second. Both pushes reach the coordinator; `compareVectors` reports `concurrent`, and `resolveConflicts` keeps whichever `SessionState` has the later `updatedAt`. The losing tab's concurrent edit is dropped — there is no interactive prompt and no `sync.conflict` event in the open package. ## Connectivity awareness [#connectivity-awareness] `ConnectivityMonitor` watches `navigator.onLine` and actively probes a configurable URL on an interval. Use it to gate `flushOutbox()` calls rather than retrying through a downed network. ```typescript import { ConnectivityMonitor } from "@pleach/core/sync"; const monitor = new ConnectivityMonitor({ probeUrl: "/api/health", probeIntervalMs: 30_000, probeTimeoutMs: 5_000, }); const unsubscribe = monitor.subscribe((state) => { if (state.online) void coordinator.flushOutbox(); }); monitor.start(); ``` `ConnectivityState` carries `{ online, lastOnlineAt, lastOfflineAt, rttMs }`. The active probe pauses while `document.hidden` is true, to avoid the tab-return storm where every accumulated probe resolves at once. The `IndexedDBOutbox` makes this fully offline-safe — writes land in IndexedDB, the coordinator pushes when online, and the per-entry retry ladder (capped by `engine.maxAttempts`) handles transient failures before parking. ## Force-syncing from DevTools [#force-syncing-from-devtools] ```javascript __HARNESS_DEVTOOLS__.syncStatus(); // current state __HARNESS_DEVTOOLS__.forceSync(); // drain the outbox now ``` Useful during development when you want to verify a write made it through without waiting for the next `flushIntervalMs` tick. ## Where to go next [#where-to-go-next] <Cards> <Card title="Storage" href="/docs/storage" description="Pair the outbox with the matching storage adapter." /> <Card title="Stream events" href="/docs/stream-events" description="The emitted sync event payloads (`sync.conflict` is type-only, not fired in open core)." /> <Card title="Error codes" href="/docs/error-codes" description="The 3xxx sync-error range." /> <Card title="React" href="/docs/react" description="`useSyncStatus`, `useIsSynced`, `usePendingChanges` hooks." /> </Cards> --- # runtime.tenant facet (/docs/tenant-facet) The `runtime.tenant` facet is the substrate's single read site for the active tenant scope. Construction takes `tenantId` and an optional `subTenantId`; every downstream write site — event log, cost events, OTel spans, outbound HTTP — derives its scoping from the facet rather than from a re-threaded parameter. This page is the primitive reference. For operations (RLS templates, cost rollups, deployment checklist), see [Multi-tenant deployments](/docs/multi-tenant). For the SaaS use-case shape, see [Multi-tenant SaaS agent](/docs/multi-tenant-saas-agent). <SourceMeta subpath="@pleach/core/tenant" source="{ label: "src/tenant/", href: "https://github.com/pleachhq/core/tree/main/src/tenant" }" /> ## `SessionRuntimeConfig.tenantId` + `subTenantId` [#sessionruntimeconfigtenantid--subtenantid] Pass both at construction. `tenantId` is the primary partition key; `subTenantId` is the optional second scope for nested isolation (org → team, or tenant → sub-account). Both flow to every downstream write site without further wiring. ```typescript import { SessionRuntime } from "@pleach/core"; const runtime = new SessionRuntime({ storage: pgAdapter, tenantId: "tenant_abc", subTenantId: "team_42", // ... }); ``` `tenantId` rejects the empty string at construction with `TenantIdEmptyError`. The silent-isolation case — an unset env var interpolated as `""` — becomes a load-bearing throw at init, not a months-later billing incident. `subTenantId` is optional; when omitted, the writer stamps `NULL` and the second-scope columns stay empty. ## `runtime.tenant` facet shape [#runtimetenant-facet-shape] Read the active scope through the facet rather than the constructor config. The facet is the stable accessor; constructor fields are implementation detail that can move. ```typescript interface RuntimeTenantFacet { readonly id: string; readonly subId: string | undefined; } ``` `id` and `subId` are properties, not methods. The facet is built once in the `SessionRuntime` constructor and `Object.freeze`d for identity stability — `runtime.tenant === runtime.tenant` always holds, so consumers can put it in `useMemo` deps without triggering re-renders. Plugins, tool handlers, and gateway adapters that need the tenant scope should reach through `runtime.tenant` — not through their own config and not through a parallel context object. See [Facets](/docs/facets) for the broader facet model and why a typed accessor surface is the contract a plugin should code against. ```typescript const tenantId = runtime.tenant.id; const subTenantId = runtime.tenant.subId; ``` `id` is always a non-empty string — the constructor defaults it to `"default"` when omitted and throws `TenantIdEmptyError` on explicit `""`. `subId` is `undefined` when not configured. ## Automatic row stamping at `EventLogWriter` [#automatic-row-stamping-at-eventlogwriter] Every row persisted to `harness_event_log` carries `tenant_id` and `sub_tenant_id` columns derived from the facet at write time. Consumers don't pass these through call sites; the writer fills them in from the runtime it was constructed against. Audit and operator queries can read tenant scope without joining the auditable-call table: ```sql SELECT event_type, COUNT(*) FROM harness_event_log WHERE tenant_id = 'tenant_abc' AND created_at >= now() - interval '24 hours' GROUP BY event_type ORDER BY 2 DESC; ``` The stamping is structural. There is no application-layer code path that writes an event log row without the tenant scope attached — which is what the first audit gate below verifies. ## RLS policy shape [#rls-policy-shape] The canonical Postgres policy reads a per-connection setting: ```sql CREATE POLICY tenant_isolation ON harness_event_log USING (tenant_id = current_setting('app.tenant_id', true)::text); ``` `app.tenant_id` is set on session start by the application server, typically from the JWT claim or the request context. The `true` flag on `current_setting` returns `NULL` instead of erroring when the setting is unset — the policy then refuses every row, which is the correct failure mode for an unauthenticated connection. Service-role clients bypass RLS; the substrate's structural stamping is what keeps those writes correctly scoped. Anon clients rely on the policy. ## `withTenantHeader` adapter [#withtenantheader-adapter] The adapter wraps a `fetch` client (or any outbound HTTP layer) and stamps a tenant header onto every request. Useful for hosts running behind an upstream gateway that routes by tenant header — the previous pattern was manual header-threading per call site, which the adapter replaces. ```typescript import { withTenantHeader } from "@pleach/core/tenant"; const tenantFetch = withTenantHeader(fetch, { header: "x-tenant-id", tenantId: runtime.tenant.id, }); await tenantFetch("https://gateway.internal/v1/chat", { method: "POST", body: JSON.stringify(payload), }); ``` The adapter captures the `tenantId` string at wrap time — pass `runtime.tenant.id` in when you construct it. The binding is to that captured value, not to the runtime. The `header` name is required (there is no default); set it to whatever key the upstream gateway expects. ## `CostEvent.tenantId` propagation [#costeventtenantid-propagation] Cost events emitted by the runtime carry `tenantId` automatically once the facet is configured. Per-tenant cost rollups work without consumer-side annotation — every emitter inside the substrate reads the facet and stamps the field on the way out. ```typescript runtime.on("cost.event", (event) => { // event.tenantId === runtime.tenant.id metrics.increment("llm_cost_usd", event.usdCost, { tenant: event.tenantId, }); }); ``` See [Observability](/docs/observability) for the full `CostEvent` shape and the dashboard patterns that consume it. ## OTel `pleach.tenant_id` attribute [#otel-pleachtenant_id-attribute] When OTel is wired, every emitted span carries `pleach.tenant_id` automatically. No opt-in needed once the facet is configured — the attribute is set by the substrate's span factory, not by the call sites that open spans. Spans whose runtime had a `subTenantId` configured also carry `pleach.sub_tenant_id`; the attribute is absent (not empty) when the second scope wasn't set. ``` WHERE pleach.tenant_id = "tenant_abc" VISUALIZE P95(duration_ms), COUNT GROUP BY name ``` See [OTel observability](/docs/otel-observability) for the full attribute catalog and the collector wiring. ## Migration SQL [#migration-sql] Consumers upgrading from a single-tenant or org-scoped schema run a three-step migration on every table the substrate writes (event log, cost events, audit ledger). Add the columns nullable, backfill, then tighten. ```sql -- 1. Add the columns. ALTER TABLE harness_event_log ADD COLUMN tenant_id TEXT, ADD COLUMN sub_tenant_id TEXT; -- 2. Backfill from the previous tenant column (or NULL for -- pre-multi-tenant rows that have no source value). UPDATE harness_event_log SET tenant_id = COALESCE(legacy_org_id, 'default') WHERE tenant_id IS NULL; -- 3. Tighten once the backfill verifies clean. ALTER TABLE harness_event_log ALTER COLUMN tenant_id SET NOT NULL; ``` Run the tighten step only after a verification query confirms zero unscoped rows. The audit gate below fails CI if the `NOT NULL` constraint is missing on a post-migration deploy. ```sql SELECT COUNT(*) AS unscoped_rows FROM harness_event_log WHERE tenant_id IS NULL; -- Expect: 0 ``` ## Audit gates that enforce scoping [#audit-gates-that-enforce-scoping] Four CI gates ride alongside the runtime contract and fail the build when scoping drifts. Two enforce tenant primitives directly; two ride alongside on the event-log allowlist surface every tenant row persists through. ### `audit:tenant-scoping` [#audittenant-scoping] Source-text scan of every `supabase/migrations/*.sql` file. Every table that carries a `tenant_id` column must also ship an RLS policy referencing `current_tenant()`. A failure means a table accepts tenant-scoped writes without the database enforcing the partition. ### `audit:harness-event-log-tenant-id-required` [#auditharness-event-log-tenant-id-required] Scans every `harness_event_log` write site under `packages/core/src/eventLog/**` for a `tenant_id` reference in the row literal, the canonical `eventToRow` stamping path (Phase A.3 wire-layer stamp), or an explicit `// tenant-id: <reason>` opt-out marker. Failures surface as the file + line of the bypassing call site. A row that lands without `tenant_id` is the silent-isolation case `audit:tenant-scoping` cannot catch from the schema side. ### `audit:c8-event-type-allowlist-coverage` [#auditc8-event-type-allowlist-coverage] Every `EventLogInput` discriminated-union member must have an entry in `SCRUBBABLE_FIELDS` — the per-event-type allowlist the C8 redaction substrate and `@pleach/compliance` scrubbers consume. The empty-array shape (`[]`) is a valid entry signalling "audit ran; no fields require redaction". Tenant-bearing rows are no exception — a new event type without an allowlist row would land unscrubbed. ### `audit:c8-union-member-has-producer` [#auditc8-union-member-has-producer] Reverse-invariant companion. Every `EventLogInput` member must have at least one `writer.write({ type: "<event.type>", ... })` producer site under `packages/core/src/` or `__tests__/core/`. A union member with no producer is dead substrate that the allowlist gate still demands an entry for. Together the four gates close the loop: the substrate stamps the partition at the row, the schema enforces it at read time, the scrubber list stays exhaustive on the write side, and the union surface stays free of orphan members on either side. ## What this facet is NOT [#what-this-facet-is-not] * **Not auth.** The facet records *who* the active tenant is; auth decides *whether* the caller may act as that tenant. Wire your auth layer in front of construction; the facet trusts what it's given. * **Not RLS itself.** The facet provides the value RLS checks against; RLS provides the enforcement. A correctly-stamped row with no RLS policy still leaks across tenants on a service-role query. * **Not a billing meter.** For per-tenant billing, join `harness_event_log` to your billing table via `tenant_id` — see [Observability § billing join](/docs/observability) for the pattern. The facet stamps the partition key; the meter is a consumer surface. ## Where to go next [#where-to-go-next] <Cards> <Card title="Multi-tenant deployments" href="/docs/multi-tenant" description="The operations guide — RLS templates, per-tenant cost rollups, deployment checklist." /> <Card title="Multi-tenant SaaS agent" href="/docs/multi-tenant-saas-agent" description="The use-case shape — per-tenant agents in a SaaS, the recurring product pattern this primitive serves." /> <Card title="Facets" href="/docs/facets" description="The facet model and why typed accessors are the stable surface plugins should target." /> <Card title="OTel observability" href="/docs/otel-observability" description="`pleach.tenant_id`, `pleach.sub_tenant_id`, and the rest of the substrate's OTel attribute catalog." /> <Card title="SessionRuntime" href="/docs/session-runtime" description="The constructor that takes `tenantId` and `subTenantId` and threads them into the facet." /> </Cards> --- # Testing (/docs/testing) `@pleach/core` is designed to be tested without a real database or LLM provider account. The combination of mock mode, the in-memory storage/checkpointer pair, and `MockToolExecutor` covers most agent-level tests; deterministic fingerprints and the audit ledger handle integration tests. ```typescript import { MockToolExecutor } from "@pleach/core"; import { MemoryAdapter } from "@pleach/core/sessions"; import { MemorySaver } from "@pleach/core/checkpointing"; ``` ## Mock mode (one-line setup) [#mock-mode-one-line-setup] Set the env var; the runtime wires in-memory adapters and a mock executor automatically. ```bash HARNESS_MOCK_MODE=true ``` ```typescript const runtime = new SessionRuntime({ userId: "test-user", // No storage / checkpointer / provider — mock mode fills them in. }); ``` What mock mode wires: | Slot | Wired to | | ------------- | ------------------------------------------------------- | | Storage | `MemoryAdapter` | | Checkpointer | `MemorySaver` | | Provider | A synthesized provider that returns plausible mock text | | Tool executor | `MockToolExecutor` (returns synthetic results) | Use it during local dev, in CI for tests that don't need to hit real providers, and as the substrate for example apps. ## `MemoryAdapter` + `MemorySaver` for tests [#memoryadapter--memorysaver-for-tests] When you want explicit construction (no env vars): ```typescript import { SessionRuntime } from "@pleach/core"; import { MemoryAdapter } from "@pleach/core/sessions"; import { MemorySaver } from "@pleach/core/checkpointing"; const runtime = new SessionRuntime({ storage: new MemoryAdapter(), checkpointer: new MemorySaver(), userId: "test-user", }); ``` Both are deterministic — same sequence of writes yields the same read state. Tests can `await runtime.createSession()`, drive a turn, and assert against state without race conditions. ## `MockToolExecutor` [#mocktoolexecutor] Generates plausible mock results based on the tool's output schema. Three modes: synthetic (default), recording (capture real responses for replay), and deterministic (seeded RNG). ```typescript import { MockToolExecutor } from "@pleach/core"; const executor = new MockToolExecutor({ latencyMs: { min: 50, max: 200 }, deterministicSeed: 12345, mockResponses: new Map([ ["search_corpus", { result: { results: [{ id: "doc-abc123", title: "Stub", year: 2024 }] }, }], ]), }); const result = await executor.execute({ id: "tc_1", name: "search_corpus", arguments: { query: "stub query" }, status: "pending", }); ``` ### Options [#options] | Field | Type | Purpose | | -------------------------------------- | --------------------------- | ---------------------------------------------- | | `latencyMs` | `{ min, max }` | Simulated delay range — exercises streaming UI | | `recordMode` | `boolean` | Save real responses to disk for later replay | | `deterministicSeed` | `number` | Seed for reproducible mock generation | | `mockResponses` | `Map<string, MockResponse>` | Explicit per-tool responses | | `registry` | `ToolRegistryWrapper` | Tool registry for output schema lookup | | `onExecuteStart` / `onExecuteComplete` | callbacks | Lifecycle hooks for assertions | ### Deterministic mode [#deterministic-mode] Pass a `deterministicSeed` and the mock executor produces byte-identical results across runs — what you want for snapshot tests: ```typescript const executor = new MockToolExecutor({ deterministicSeed: 42 }); const a = await executor.execute(toolCall); const b = await executor.execute(toolCall); expect(a).toEqual(b); // identical ``` The seed feeds an internal RNG that drives the synthetic data generator, so concrete strings like ids, titles, and timestamps stay stable. ### Record-and-replay [#record-and-replay] `recordMode: true` captures real responses to a JSONL file. Use once against a real provider, then commit the file and switch back to synthetic mode for CI: ```typescript const executor = new MockToolExecutor({ recordMode: true, recordPath: "./test/fixtures/tool-responses.jsonl", }); ``` Pair with `MockResponse.fromRecording()` to load on the next run: ```typescript const responses = MockResponse.fromRecording("./test/fixtures/tool-responses.jsonl"); const executor = new MockToolExecutor({ mockResponses: responses }); ``` ## Headless turn driver (`@pleach/core/testing`) [#headless-turn-driver-pleachcoretesting] `MockToolExecutor` mocks the tool layer of a runtime you assemble yourself. The `@pleach/core/testing` subpath is the other half: it drives a **real** `SessionRuntime` turn with no network, no browser, and no real provider, and hands you a structured result. Script the model's output, drive one turn, inspect final text, tool calls, the raw event log, and wall-clock timing. This is the surface for unit tests, regression locks, and benchmark harnesses. ```ts import { runScriptedTurn, scriptText } from "@pleach/core/testing"; // One call: builds a bare runtime, injects a scripted adapter, drives a turn. const result = await runScriptedTurn({ prompt: "hello", script: scriptText("hello world"), }); result.text; // "hello world" — a real synthesized turn, not a stub result.ttfbMs; // time-to-first-delta in ms (benchmark signal) result.totalMs; // total drain time in ms result.eventCount; // number of StreamEvents observed result.toolCalls; // tool calls seen this turn result.events; // the raw StreamEvent[] for exact-sequence assertions ``` A multi-element `scriptText` exercises the streaming/delta path — the concatenation is the final text: ```ts const r = await runScriptedTurn({ prompt: "hi", script: scriptText(["hello", " ", "world"]), // 3 text chunks + a done chunk }); r.text; // "hello world" ``` ### Driving a runtime you already built [#driving-a-runtime-you-already-built] `runScriptedTurn` builds the runtime for you. When you need a runtime with your own plugins, storage, or config, install the scripted adapter with `createScriptedAdapter` and collect the turn with `collectTurn`: ```ts import { createScriptedAdapter, scriptText, collectTurn } from "@pleach/core/testing"; runtime.adapter.set(createScriptedAdapter(scriptText("hello world"))); const session = await runtime.sessions.create({ provider: { type: "anthropic" }, model: { id: "scripted-model" }, }); const result = await collectTurn(runtime.executeMessage(session.id, "hello")); ``` ### Capturing probe emits [#capturing-probe-emits] Core's default `[UXParity:*]` probe emit is silent. `captureProbes` installs a formatter that records the emits a turn produces, so you can assert on them: ```ts import { captureProbes, runScriptedTurn, scriptText } from "@pleach/core/testing"; const cap = captureProbes(); await runScriptedTurn({ prompt: "hello", script: scriptText("hello world") }); const labels = cap.emits.map((e) => e.label); cap.stop(); // restore the prior formatter ``` ### Which helper when [#which-helper-when] | Reach for… | When you want to… | | ------------------------------------------- | ------------------------------------------------------------------------------------- | | `runScriptedTurn` | the one-liner — a fully synthesized turn from a fixed script, no runtime setup | | `createScriptedAdapter` + `collectTurn` | drive a runtime **you** built (custom plugins, storage, config) with a scripted model | | `runProviderTurn` / `createProviderAdapter` | the same, but against a **real** `AgentProvider` through the production seam | | `benchmarkProviders` | sweep N prompts × M providers and compare latency / output size | | `captureProbes` | assert on the `[UXParity:*]` probe emits a turn produces | | `scriptText` / `textChunk` / `doneChunk` | hand-build the chunk stream (multi-delta, custom `finishReason`, tool calls) | ### Pinning a turn's behavior as a regression lock [#pinning-a-turns-behavior-as-a-regression-lock] `result.events` is the raw `StreamEvent[]`, so you can assert on the exact runtime shape — final text, tool sequencing, and the node/channel firing granularity: ```ts import { runScriptedTurn, scriptText } from "@pleach/core/testing"; const r = await runScriptedTurn({ prompt: "hi", script: scriptText("hello world") }); // 1. user-visible output expect(r.text).toBe("hello world"); // 2. the LLM node actually fired this turn const llmFired = r.events.some( (e) => e.type === "node.fired" && e.node === "llm", ); expect(llmFired).toBe(true); // 3. the messages channel took a writer bump (the assistant reply landed) const wroteMessages = r.events.some( (e) => e.type === "channel.write" && e.channel === "messages", ); expect(wroteMessages).toBe(true); ``` <Callout type="warn"> **Run them serially.** `runScriptedTurn` / `runProviderTurn` / `benchmarkProviders` swap a process-global module-loader slot per turn (saved and restored around each call), so they must run serially within a process — don't `Promise.all` them. Back-to-back inside a single test is fine. </Callout> ### Benchmarking real providers [#benchmarking-real-providers] The scripted driver runs a fixed script. To drive a **real** `AgentProvider` through the same production seam codepath, wrap it in an adapter with `createProviderAdapter` and drive a turn with `runProviderTurn`. The content in `result.text` comes from `provider.execute(...)`, not a script: ```ts import { runProviderTurn } from "@pleach/core/testing"; import { AnthropicSdkProvider } from "@pleach/core/providers"; const provider = new AnthropicSdkProvider({ apiKey: process.env.ANTHROPIC_API_KEY! }); const result = await runProviderTurn({ prompt: "Reply with a short greeting.", provider, model: "claude-sonnet-4-5", }); result.text; // the real model's reply result.ttfbMs; // time-to-first-delta in ms result.totalMs; // total drain time in ms ``` For a runtime you already built, `createProviderAdapter` implements the same `AgentAdapter` contract as `createScriptedAdapter`: ```ts import { createProviderAdapter } from "@pleach/core/testing"; runtime.adapter.set(createProviderAdapter(provider, { model: "claude-sonnet-4-5" })); ``` `benchmarkProviders` sweeps several prompts across several providers/models. It runs every prompt against every subject serially and aggregates per-subject latency, output length, and error count: ```ts import { benchmarkProviders } from "@pleach/core/testing"; const report = await benchmarkProviders({ prompts: ["Reply with a short greeting.", "Count to three."], subjects: [ { label: "anthropic", provider: anthropicProvider, model: "claude-sonnet-4-5" }, { label: "ai-sdk", provider: aiSdkProvider, model: "gpt-4o-mini" }, ], }); for (const s of report.subjects) { console.log(s.label, s.meanTtfbMs, s.meanTotalMs, s.meanTextLen, s.errorCount); } ``` <Callout type="info"> **Honest scope.** `benchmarkProviders` measures the raw provider/model dimension — latency (ttfb + total) and output size — over a single non-tool turn. It is *not* a full domain pipeline: no tool loop, no enrichment, no scoring. For graded offline evaluation (rubrics, datasets, scoring) reach for [`@pleach/eval`](/docs/eval). One gotcha: a degenerate or trivial prompt can make the bare graph short-circuit before the LLM call, so send a real prompt. </Callout> ## Asserting against the audit ledger [#asserting-against-the-audit-ledger] For tests that verify which model fired, which fallback path ran, or which call class was selected: assert against the `AuditableCall` rows the runtime emits. ```typescript import { MemoryProviderDecisionLedger } from "@pleach/core/audit"; const ledger = new MemoryProviderDecisionLedger(); const runtime = new SessionRuntime({ storage: new MemoryAdapter(), userId: "test-user", }); // drive a turn ... const rows = await ledger.getSession(sessionId); expect(rows.filter((r) => r.call.callClass === "synthesize")).toHaveLength(1); expect(rows.filter((r) => r.familyLock !== undefined)).toHaveLength(1); ``` The exactly-one-synthesize invariant is the easiest test to write and the highest-signal failure when something has drifted. ## Asserting against the stream [#asserting-against-the-stream] `executeMessage` is an async generator — collect events and assert on shapes: ```typescript async function collect<T>(iter: AsyncIterable<T>): Promise<T[]> { const out: T[] = []; for await (const v of iter) out.push(v); return out; } const events = await collect(runtime.executeMessage(sessionId, "Hello")); const messageDeltas = events.filter((e) => e.type === "message.delta"); const toolCompleted = events.filter((e) => e.type === "tool.completed"); expect(toolCompleted).toHaveLength(2); expect(messageDeltas.length).toBeGreaterThan(0); ``` For streaming tests, prefer assertions on event *counts* and *ordering* over assertions on exact deltas — model responses shift across versions even with deterministic seeds. ## Fingerprint-based golden tests [#fingerprint-based-golden-tests] Two runs of the same turn with the same input + same package version produce the same `fingerprint` on the ledger row. That's the snapshot key for replay tests: ```typescript import { computeFingerprint } from "@pleach/core"; const fp1 = computeFingerprint(turnInput); // run turn ... const fp2 = (await ledger.getSession(sessionId))[0].cacheBreakpoint?.fingerprintComposite; expect(fp2).toEqual(fp1); ``` If the fingerprint drifts between runs, something non-deterministic slipped in — a runtime-aware prompt contribution that should have been static, an async stream observer (illegal), or a wall-clock read in a reducer. ## Testing plugins [#testing-plugins] Plugins are pure objects implementing `HarnessPlugin` — test them in isolation: ```typescript const myPlugin = makePlugin(); const contribs = myPlugin.contributePrompts?.() ?? []; expect(contribs.map((c) => c.id)).toEqual([ "my-plugin.domain-hint", "my-plugin.safety-frame", ]); ``` For end-to-end plugin tests, register the plugin against a mock-mode runtime and assert on stream events + ledger rows. ## CI patterns [#ci-patterns] Three things keep an agent's tests stable in CI: 1. **`HARNESS_MOCK_MODE=true`** — no provider creds, no real DB. 2. **`deterministicSeed`** on every `MockToolExecutor`. 3. **Snapshot the ledger, not the stream** — token-level deltas shift with provider versions; ledger row shape doesn't. ## Where to go next [#where-to-go-next] <Cards> <Card title="Stream events" href="/docs/stream-events" description="The event types your test assertions match against." /> <Card title="Auditable call row" href="/docs/auditable-call-row" description="The ledger row shape used in fingerprint assertions." /> <Card title="Eval and replay" href="/docs/eval-and-replay" description="replayTurn and the recording modes that drive regression eval." /> <Card title="Env vars" href="/docs/env-vars" description="HARNESS_MOCK_MODE and the other test-time flags." /> </Cards> --- # Time travel (/docs/time-travel) `TimeTravelAPI` is the navigable view over the checkpoint history a `Checkpointer` writes. It reconstructs `StateSnapshot` records from raw checkpoint rows, walks history newest-first, applies synthetic supersteps for replay, and forks a checkpoint into a new session. ```typescript import { TimeTravelAPI, type StateSnapshot, type StateSnapshotMetadata, type StateSnapshotConfig, type GetStateHistoryOptions, } from "@pleach/core/time-travel"; ``` This is the substrate-level fork surface. `runtime.checkpoints.rollback` is the higher-level path that rebuilds session state in place and bumps the version vector;`TimeTravelAPI` is what you reach for when you want to branch into a new session id or inspect snapshot internals without touching the live session. The canonical runtime entry point is `runtime.timeTravel.api` — the facet returns the lazily-constructed `TimeTravelAPI` instance (or `undefined` when the runtime has no checkpointer). All four methods — `getState`, `getStateHistory`, `bulkUpdateState`, `fork` — live on that instance. ```typescript const api = runtime.timeTravel.api; if (api) { const fork = await api.fork(sourceSessionId, checkpointId, newSessionId); } ``` <SourceMeta source="{ label: "src/time-travel/", href: "https://github.com/pleachhq/core/tree/main/src/time-travel" }" /> ## How it differs from `runtime.checkpoints.rollback` [#how-it-differs-from-runtimecheckpointsrollback] | | `runtime.checkpoints.rollback` | `TimeTravelAPI.fork` | | ---------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------- | | Target session | Same session id | New session id | | Live session | Mutated in place | Untouched | | Version vector | Bumps; writes a new `source: "rollback"` checkpoint | Forked checkpoint has `source: "fork"`, `parent_id: source.id` (cross-session edge) | | Lineage edge | `parentCheckpointId` on the new rollback checkpoint | `metadata.forkedFrom: { sessionId, checkpointId }` + cross-session `parent_id` | | Event-log cursor | Inherited from the rollback target | Inherited from the source checkpoint's `metadata.lastEventSequence` | | Use case | Recover from a stuck turn, retry from earlier state | Explore an alternative branch without losing the original | Both write to the same checkpointer. Forks land in the new session's namespace; the source session keeps its own history intact. ## Constructor [#constructor] ```typescript new TimeTravelAPI( checkpointer: CheckpointerLike, createChannels: () => Record<string, ChannelLike>, nodeSubscriptions: Record<string, string[]> = {}, ); ``` `createChannels` is a factory that returns a fresh channel map keyed by channel name — the API uses it to replay reducer logic when applying synthetic supersteps. `nodeSubscriptions` maps node name to the set of channels each node subscribes to; it drives the `next` array on every returned `StateSnapshot`. If you only need read methods (`getState`, `getStateHistory`, `fork`), an empty `nodeSubscriptions` is fine — `next` will simply be empty. ## Methods [#methods] | Method | Returns | Notes | | --------------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------- | | `getState(sessionId, checkpointId?)` | `Promise<StateSnapshot \| null>` | Latest when `checkpointId` is omitted. | | `getStateHistory(sessionId, options?)` | `AsyncGenerator<StateSnapshot>` | Newest-first; `limit` caps iteration, `before` cursors, `source` filters by node. | | `bulkUpdateState(sessionId, supersteps)` | `Promise<StateSnapshot>` | Replays writes through `createChannels()`, persists with `source: "bulk_update"`. | | `fork(sourceSessionId, checkpointId, newSessionId)` | `Promise<StateSnapshot>` | Copies channel state into a new session; sets `parent_id: source.id` (cross-session lineage edge). | ## `StateSnapshot` shape [#statesnapshot-shape] ```typescript interface StateSnapshot<S = Record<string, unknown>> { values: S; // reconstructed channel values pendingTasks: Array<{ id; name; input }>; // in-flight at snapshot time pendingWrites: Array<{ taskId; channel; value }>; // buffered but not committed metadata: StateSnapshotMetadata; config: StateSnapshotConfig; next: string[]; // nodes eligible to run next channelVersions: Record<string, number>; manifest?: ChatManifestSnapshot; // chat-manifest ledger AS-OF this checkpoint (inspection-only) } interface StateSnapshotMetadata { step: number; timestamp: string; // ISO-8601 source: string; // node or trigger that produced this state writtenBy: string[]; parents: string[]; // parent checkpoint ids; cross-session for forks lastEventSequence?: number; // event-log cursor at snapshot time } ``` `next` is computed from `channelVersions` + `versions_seen` against the constructor's `nodeSubscriptions` — a node appears if any channel it subscribes to has advanced past the version it last saw. That's what makes a snapshot replayable: you can pick up exactly where the graph left off without re-running already-applied work. `metadata.parents` is the parent-link chain — a single-element array for ordinary checkpoints, multi-element for merge scenarios. Forked snapshots populate `parents` with the source checkpoint id (a cross-session edge), so traversals walking `metadata.parents` no longer hide the fork's origin. `manifest` is the chat-manifest ledger (tool/job invocations, notices, provider switches) AS-OF the checkpoint — surfaced for **inspection** (`getState`/`getStateHistory`/`fork`/`bulkUpdate`). It is OPTIONAL (`undefined` for an empty ledger or a legacy checkpoint). Inspection-only: it does NOT feed the next generation — that functional rehydration runs through `rollbackToCheckpoint`/`resumeSession` into the live `ChatManifestProvider`. The snapshot rides both the checkpoint `metadata` (this field) and `state.extensions` (the functional path) for the two distinct purposes. ### Event-log cursor on the snapshot [#event-log-cursor-on-the-snapshot] `metadata.lastEventSequence` carries the event-log `sequence_number` the snapshot was taken at. A host hydrating from this snapshot can fast-forward intermediate state without re-folding the event log from genesis: pass the cursor straight to `runtime.events.iterate({ chatId, fromSequenceNumber: snapshot.metadata.lastEventSequence })` and replay events forward from there. The same value flows into `hydrateFromEvents()` as `fromSequenceNumber`. The field is `undefined` on checkpoints written before the cursor field landed — consumers must back-compat to "no skip" when it's missing. ## Fork semantics [#fork-semantics] Which channels survive a fork is decided by the underlying `channel_values` blob: every channel in the source checkpoint copies verbatim into the new session. There is no per-channel survival policy at this layer — that lives in the reducer definition for each channel, and the fork respects whatever the snapshot already has. ```typescript const api = new TimeTravelAPI(checkpointer, createDefaultChannels); const snapshot = await api.fork( sourceSessionId, "cp_018f...", crypto.randomUUID(), ); snapshot.config.sessionId; // new session id snapshot.metadata.source; // "fork" snapshot.metadata.parents; // [source.id] — cross-session edge snapshot.metadata.lastEventSequence; // inherited from the source checkpoint // the lineage edge on the raw checkpoint metadata: // metadata.forkedFrom = { sessionId: sourceSessionId, checkpointId: "cp_018f..." } ``` ### Fork lineage [#fork-lineage] A forked checkpoint records its origin in two places: * **`metadata.forkedFrom: { sessionId, checkpointId }`** — the semantic lineage edge. Audit-ledger queries join against this to walk forked sessions back to their origin. The forked session keeps its own audit rows under the new `sessionId`; `forkedFrom` on the first checkpoint is the one place the cross-session relationship is recorded. * **`parent_id: source.id`** on the raw checkpoint row (surfaced as `metadata.parents: [source.id]` on the snapshot). This is a cross-session parent edge — distinct from `forkedFrom` only by shape (a parent id vs. a `{sessionId, checkpointId}` pair). Same-session supersteps already set `parent_id` to the previous checkpoint; fork now sets it to the source checkpoint so traversals walking `metadata.parents` see the origin instead of treating forked snapshots as orphan roots. ### Event-log cursor carry [#event-log-cursor-carry] The fork inherits `metadata.lastEventSequence` from the source checkpoint. A consumer hydrating the forked session passes that cursor to `runtime.events.iterate({ chatId, fromSequenceNumber })` exactly as it would for the source — the cursor is the join point between snapshot state and the event log. ### Materializing the forked session row [#materializing-the-forked-session-row] `TimeTravelAPI` accepts an optional `createForkedSession` hook on its constructor options. After the forked checkpoint lands, the API invokes the hook so the host can materialize a `Session` record at `newSessionId` — typically by cloning the source session's `SessionConfig`. Without the hook, a subsequent `runtime.sessions.resume(newSessionId)` will throw on a missing session row; legacy callers must materialize the row themselves before resume. The hook is best-effort: a thrown error is caught and logged via `console.warn`, and the fork's checkpoint write is not rolled back. The checkpoint is the durable artifact; the session row is a derived convenience. ## Walking history [#walking-history] ```typescript for await (const snapshot of api.getStateHistory(sessionId, { limit: 50 })) { console.log(snapshot.metadata.step, snapshot.metadata.source, snapshot.config.checkpointId); } ``` Pass `source: "tool-loop"` to filter to checkpoints written by the tool-loop stage; pass `before: "cp_..."` to page backwards from a known checkpoint id. ## Replaying synthetic supersteps [#replaying-synthetic-supersteps] `bulkUpdateState` is how a test harness or migration script writes a sequence of channel updates without running the graph. Each superstep is an array of writes; the API replays them through `createChannels()` so reducers fire normally, then persists a single `source: "bulk_update"` checkpoint at the end. ```typescript const snapshot = await api.bulkUpdateState(sessionId, [ // Superstep 1 — seed the corpus channel. [{ channel: "search_results", value: { docs: [{ id: "doc_001" }] } }], // Superstep 2 — append a summary. [{ channel: "summary", value: "Initial pass complete." }], ]); snapshot.metadata.source; // "bulk_update" snapshot.channelVersions.summary; // bumped once per superstep that touched it ``` `bulkUpdateState` throws if the target session has no prior checkpoint — it needs an anchor to write `parent_id` against. Use `fork` for the first checkpoint in a new session id. ## Side effects don't roll back [#side-effects-dont-roll-back] A snapshot captures channel state, not external writes. If a tool called between the target snapshot and now wrote to a database, sent an email, or charged a card, rolling back or forking from the earlier snapshot does not unwind those writes. Tools that need true rollback have to implement compensating actions themselves — Pleach doesn't have a transactional surface across third-party systems. ## Where to go next [#where-to-go-next] <Cards> <Card title="Checkpointing" href="/docs/checkpointing" description="The savers that produce the history this API navigates; `lastEventSequence` is the join point." /> <Card title="Event-log projections" href="/docs/event-log-projections" description="Replay events forward from `snapshot.metadata.lastEventSequence` via `runtime.events.iterate({ fromSequenceNumber })`." /> <Card title="Channels" href="/docs/channels" description="The reducers `createChannels()` reconstructs during bulkUpdateState." /> <Card title="Lineage" href="/docs/lineage" description="How fork edges and audit rows join across forked sessions." /> <Card title="DevTools" href="/docs/devtools" description="`__HARNESS_DEVTOOLS__.rollback` calls through to this API in the browser." /> </Cards> --- # Tools (/docs/tools) > **Tools are a thematic island.** Not one of the [six cluster > triplets](/docs/concept-clusters#the-six-cluster-triplets) — > tooling is one surface end to end (contract + dispatch + > result-handling), not a three-concept cluster. See [What lives > outside the cluster pattern](/docs/concept-clusters#what-lives-outside-the-cluster-pattern). Hand tools for the lattice — typed, scoped, batched, returned to the shed at turn's end. A tool is a named function the LLM can call. `@pleach/core` ships the contract (`defineTool`); the runtime handles dispatch, streaming partial arguments, execution, and writing the result back into the conversation. Input and output are Zod-validated; an invalid call fails before dispatch with a structured error from the [1xxx range](/docs/error-codes). See [Stream events](/docs/stream-events) for the per-tool lifecycle on the wire. ```typescript import { defineTool } from "@pleach/core"; import type { ToolDefinition, ToolContext } from "@pleach/core"; ``` <SourceMeta source="{ label: "src/tools/", href: "https://github.com/pleachhq/core/tree/main/src/tools" }" /> ## `defineTool` [#definetool] Identity at runtime; the type parameters give callers an inferred `ToolDefinition<TInput, TOutput>` without ceremony. ```typescript // lib/tools/searchCorpus.ts import { z } from "zod"; import { defineTool } from "@pleach/core"; export const searchCorpus = defineTool({ name: "search_corpus", description: "Free-text search over the host corpus.", inputSchema: z.object({ query: z.string().min(1), limit: z.number().int().min(1).max(50).default(10), }), outputSchema: z.object({ results: z.array(z.object({ id: z.string(), title: z.string(), year: z.number().int(), })), }), async execute(input, ctx) { const res = await fetch( `${process.env.CORPUS_URL}/search?q=${encodeURIComponent(input.query)}`, { signal: ctx.signal }, ); const data = await res.json(); return { results: data.results.slice(0, input.limit) }; }, }); ``` ### Required fields [#required-fields] | Field | Type | Purpose | | ------------- | ---------------------------------- | ------------------------------------------------------------------- | | `name` | `string` | Unique identifier; what the LLM emits in `tool_calls[].name` | | `description` | `string` | What the tool does — the LLM reads this to decide when to call it | | `inputSchema` | `ZodType<TInput>` | Validated before `execute` runs; invalid args throw before dispatch | | `execute` | `(input, ctx) => Promise<TOutput>` | The implementation | ### Optional fields [#optional-fields] | Field | Type | Purpose | | -------------- | ------------------ | ------------------------------------------------------------------------ | | `outputSchema` | `ZodType<TOutput>` | Validates the return; failures surface as `tool.failed` with code `1002` | `safetyTier` is **not** a `defineTool` field — it lives on the richer registry-level tool shape. See [`safetyTier`](#safetytier) below. ## `safetyTier` [#safetytier] A policy marker the runtime reads when a host-supplied detector flags a tool argument as fabricated (an argument value with no provenance in the turn's prior tool results). It is **not** part of the basic `defineTool` contract — `ToolDefinition` carries only `name`/`description`/`inputSchema`/`outputSchema`/`execute`, so passing `safetyTier` to `defineTool({ … })` is a TypeScript excess-property error. It is a field of the richer `UnifiedToolDefinition` shape, set through `createUnifiedTool` (see [Registering tools](#registering-tools-with-a-session) below). Three values, three destinations: | Value | Destination | User can override? | | ------------ | -------------------------------------------------------------------------------------------------------------------------- | ------------------ | | `"critical"` | A hard-halt pipeline stage short-circuits dispatch with `_recoverable: false`. The operator's pre-committed safety policy. | No | | `"standard"` | The `InterruptApprovalCard` surfaces with a ground-truth panel; the user can accept, edit, or reject. | Yes | | `"advisory"` | Probe-only — the detector still fires for observability; no halt and no card. | N/A | Absent on a tool that the host's detector doesn't guard, the field is a no-op. Absent on a tool the host's detector *does* guard, the runtime treats it as `"standard"`. The field lives on the registry-level `UnifiedToolDefinition` shape (reached through `@pleach/core/tools`), not on the basic `ToolDefinition` returned by `defineTool`. Set it with `createUnifiedTool` and register the result: ```typescript // lib/tools/dangerousLookup.ts import { createUnifiedTool, toolRegistry } from "@pleach/core/tools"; export const dangerousLookup = createUnifiedTool({ id: "dangerous_lookup", name: "dangerous_lookup", displayName: "Dangerous Lookup", category: "analysis", schemaKey: "DangerousLookupInput", safetyTier: "critical", // hard-halt routing async execute(input, ctx) { /* ... */ }, }); toolRegistry.register(dangerousLookup); ``` Hosts contribute the detector that decides what "fabricated" means for their domain — see [Fabrication detection](/docs/fabrication-detection#host-supplied-tool-argument-fabrication-detectors) for the detector contract and the routing rules. The runtime enforces an invariant: every tool tagged `"critical"` must be reachable by the host's detector. CI fails when coverage is missing, so a `"critical"` tool can't silently bypass the hard-halt. ## `ToolContext` [#toolcontext] Passed as the second arg to `execute`. Intentionally minimal — the contract stays portable across hosts. | Field | Type | Use | | ------------ | -------------- | ---------------------------------------------------- | | `toolCallId` | `string` | Correlate audit + event log rows | | `signal` | `AbortSignal?` | Per-turn cancel — propagate into every fetch / spawn | Tools that ignore `signal` keep burning resources after the user hits stop. Always thread it through. ```typescript async execute(input, ctx) { const child = spawn("expensive-cli", [input.q], { signal: ctx.signal }); // ... } ``` ## Registering tools with a session [#registering-tools-with-a-session] Pass tool names to `createSession`: ```typescript const session = await runtime.createSession({ tools: { enabled: ["search_corpus", "calculator"] }, }); ``` The runtime looks the names up in the active tool registry. Wire the registry at runtime construction either through a plugin (`contributeTools` is the standard path) or via the legacy `setOrchestratorRegistry` shim for hosts mid-migration: ```typescript import { setOrchestratorRegistry } from "@pleach/core/tools"; const tools = [searchCorpus, calculator]; setOrchestratorRegistry({ getToolDefinitions: () => tools, get: (name) => tools.find((t) => t.name === name), has: (name) => tools.some((t) => t.name === name), size: tools.length, }); ``` `@pleach/tools` is the sibling SKU that ships a Zod-validated, intent-categorized reference catalog (filesystem, HTTP, shell, structured parse). Install it for the common cases; write `defineTool` calls for your domain-specific tools. ## Validating arguments before dispatch [#validating-arguments-before-dispatch] `useToolValidation(name)` validates input shape against the tool's Zod schema on the client — useful for schema-driven forms that build a tool call manually: ```tsx import { useToolValidation } from "@pleach/core/react"; function SearchForm() { const [args, setArgs] = useState({ query: "", limit: 10 }); const result = useToolValidation({ id: "call_1", name: "search_corpus", arguments: args, status: "pending", }); return ( <form onSubmit={(e) => { e.preventDefault(); if (result?.valid) submitToolCall("search_corpus", args); }}> <input value={args.query} onChange={(e) => setArgs({ ...args, query: (e.target as unknown as { value: string }).value })} /> {result && !result.valid && <ErrorList errors={result.errors} />} </form> ); } ``` ## Batching [#batching] `ToolBatchExecutor` groups tool calls the runtime decides can fire concurrently. The batching strategy per tool defaults to inferred from the schema; override it explicitly when the inference is wrong: ```typescript // lib/tools/fetchDocument.ts import { defineTool } from "@pleach/core"; export const fetchDocument = defineTool({ name: "fetch_document", description: "Fetch full text for one document id.", inputSchema: z.object({ id: z.string() }), async execute(input, ctx) { /* ... */ }, }); // `batching` is NOT a `defineTool` field. Override the inferred strategy // from a plugin via the `contributeBatchingHints` hook (routed through // `_raw` on `definePleachPlugin`), keyed by tool name: // contributeBatchingHints: () => [ // { toolName: "fetch_document", strategy: "parallel", maxConcurrency: 5 }, // ] ``` Available strategies: | Strategy | Concurrency | Use case | | ---------- | ------------------------------------------------------ | ----------------------------------------------- | | `serial` | 1 | Writes, side-effecting tools, dependency chains | | `parallel` | Up to `BatchingConfig.maxConcurrency` | Independent reads (database lookups, API calls) | | `chunked` | Grouped by key — parallel across groups, serial within | Per-resource serialization (per-user, per-file) | `inferBatchingStrategy(toolDef)` reads strategy hints from the definition; `getBatchingStrategyForTool(name)` resolves through the active registry. Use `Semaphore` from `@pleach/core` if you need a hand-rolled concurrency limit inside `execute`: ```typescript import { Semaphore } from "@pleach/core"; const limit = new Semaphore(4); async execute(input, ctx) { return limit.run(async () => fetchOneThing(input, ctx)); } ``` ## Lifecycle in the stream [#lifecycle-in-the-stream] A successful tool call produces three stream events: ```text tool.started { toolCall: { id, name, args } } tool.delta { toolCallId, delta } // streaming argument assembly tool.completed { toolCall, result } ``` A failure swaps the last for `tool.failed`: ```text tool.failed { toolCall, error } ``` See [Stream events](/docs/stream-events) for the full payload shapes. ## Where to go next [#where-to-go-next] <Cards> <Card title="Base tools" href="/docs/base-tools" description="The batteries-included bundle — math, datetime, scratchpad, unit_convert, text_search, opt-in url_fetch." /> <Card title="Providers" href="/docs/providers" description="How tools get serialized for the underlying LLM SDK." /> <Card title="Stream events" href="/docs/stream-events" description="`tool.started` / `tool.delta` / `tool.completed` / `tool.failed`." /> <Card title="Interrupts" href="/docs/interrupts" description="Pause for human approval before a tool fires." /> <Card title="Error codes" href="/docs/error-codes" description="The 1xxx tool-error range." /> </Cards> --- # @pleach/transport-azure-openai (/docs/transport-azure-openai) <Callout type="warn"> **Not yet published.** `@pleach/transport-azure-openai` is marked `private` in this monorepo and is not on npm — `npm install @pleach/transport-azure-openai` will not resolve today. This page documents the planned surface; the package publishes in a later cut. </Callout> `@pleach/transport-azure-openai` is the Azure OpenAI Service route for a Pleach session whose buyer reaches OpenAI models through their Azure tenancy rather than the direct OpenAI API. It implements the `AgentProvider` interface from `@pleach/core` against Azure OpenAI's REST surface, so the cascade decisions and audit shape are the same as any other provider — only the transport and the credential source change. ## What it provides [#what-it-provides] * An `AgentProvider` implementation against the Azure OpenAI Service per the `cloud-routed-agent.mdx` transport-vs-family split. * Managed-identity credential resolution — workload-bound, short-lived tokens from the Azure identity layer, per the IAM-federated credentials section of `cloud-routed-agent.mdx`. * Deployment-name routing, since Azure OpenAI's resource model addresses models by deployment name rather than vendor model id (`cloud-routed-agent.mdx` roadmap entry for this SKU). * Azure-specific cost-event emission so the audit row carries the cloud, region, and deployment dimensions Azure Cost Management rolls up by. ## Where it fits [#where-it-fits] `@pleach/transport-azure-openai` plugs into the same `provider` slot on `SessionRuntime` that the direct-API providers fill — it satisfies the `AgentProvider` contract documented in [Providers](/docs/providers). For the architectural shape of a cloud-routed deployment — transport vs family, IAM federation, region as a hard constraint, cost reconciliation — see [Cloud-routed agent](/docs/cloud-routed-agent). ## Install [#install] ```bash npm install @pleach/transport-azure-openai ``` ## API surface [#api-surface] The provider factory signature, the managed-identity credential option shape, the deployment-name routing fields, and the emitted cost-event payload all live on the package's npm page: [`@pleach/transport-azure-openai`](https://www.npmjs.com/package/@pleach/transport-azure-openai). This page is placement orientation — what the SKU is for and where it slots into the substrate. The npm README is the source of truth for the constructor and option shapes; if a claim here disagrees with the published README, the README wins. ## Where to go next [#where-to-go-next] <Cards> <Card title="Cloud-routed agent" href="/docs/cloud-routed-agent" description="Transport-vs-family, IAM federation, region pinning, and cost reconciliation to the cloud invoice." /> <Card title="Providers" href="/docs/providers" description="The AgentProvider contract this transport implements and the cascade rules it threads through." /> <Card title="Multi-tenant SaaS agent" href="/docs/multi-tenant-saas-agent" description="Per-tenant runtime construction when each tenant reaches its own Azure tenancy." /> </Cards> --- # @pleach/transport-bedrock (/docs/transport-bedrock) <Callout type="warn"> **Not yet published.** `@pleach/transport-bedrock` is marked `private` in this monorepo and is not on npm — `npm install @pleach/transport-bedrock` will not resolve today. This page documents the planned surface; the package publishes in a later cut. </Callout> `@pleach/transport-bedrock` is the AWS Bedrock route for a Pleach session whose contract clause forbids direct provider API keys. It implements the `AgentProvider` interface from `@pleach/core` against the Bedrock Runtime API, so the family is still `anthropic`, `openai`, or whichever model the buyer cleared — only the transport changes. The canonical use case is a regulated buyer reaching Claude (or any Bedrock-hosted family) through their own VPC endpoint, with the call recorded on the runtime ledger and reconcilable to the AWS bill. ## What it provides [#what-it-provides] * An `AgentProvider` implementation against the Bedrock Runtime API per the `cloud-routed-agent.mdx` transport-vs-family split. * Static AWS keys or an STS session token today (`accessKeyId` + `secretAccessKey`, optional `sessionToken`); container/role-assumption resolution (`fromContainerMetadata` / `credentialProvider` callback) is planned for a follow-on minor, per the IAM-federated credentials section of `cloud-routed-agent.mdx`. * Region pinning at the endpoint URL, with the cloud as the enforcement authority (`cloud-routed-agent.mdx`: region as a hard constraint). * Bedrock-specific cost-event emission so the `harness_auditable_calls` row carries the cloud, region, and model dimensions the AWS billing detail rolls up by — see the reconciliation SQL in `cloud-routed-agent.mdx`. ## Where it fits [#where-it-fits] `@pleach/transport-bedrock` plugs into the same `provider` slot on `SessionRuntime` that `AiSdkProvider` and `AnthropicSdkProvider` fill — it satisfies the `AgentProvider` contract documented in [Providers](/docs/providers). For the architectural shape of a Bedrock-routed deployment — transport-vs-family, IAM federation, region as a hard constraint, cost reconciliation — see [Cloud-routed agent](/docs/cloud-routed-agent). ## Install [#install] ```bash npm install @pleach/transport-bedrock ``` ## API surface [#api-surface] The `createBedrockProvider` factory signature, the credential and region option shape, the defaultable model id field, and the emitted cost-event payload all live on the package's npm page: [`@pleach/transport-bedrock`](https://www.npmjs.com/package/@pleach/transport-bedrock). This page is placement orientation — what the SKU is for and where it slots into the substrate. The npm README is the source of truth for the constructor and option shapes; if a claim here disagrees with the published README, the README wins. ## Where to go next [#where-to-go-next] <Cards> <Card title="Cloud-routed agent" href="/docs/cloud-routed-agent" description="Transport-vs-family, IAM federation, region pinning, and cost reconciliation to the cloud invoice." /> <Card title="Providers" href="/docs/providers" description="The AgentProvider contract this transport implements and the cascade rules it threads through." /> <Card title="Region-pinned agent" href="/docs/region-pinned-agent" description="The sibling shape when family + region pinning is procurement-driven rather than cloud-driven." /> </Cards> --- # @pleach/transport-vertex (/docs/transport-vertex) <Callout type="warn"> **Not yet published.** `@pleach/transport-vertex` is marked `private` in this monorepo and is not on npm — `npm install @pleach/transport-vertex` will not resolve today. This page documents the planned surface; the package publishes in a later cut. </Callout> `@pleach/transport-vertex` is the Google Vertex AI route for a Pleach session whose buyer reaches Anthropic, Google, or other Vertex-hosted families through their GCP project. It implements the `AgentProvider` interface from `@pleach/core` against the Vertex AI API, so cascade behavior and audit shape stay consistent with every other provider — only the transport and the credential binding change. ## What it provides [#what-it-provides] * An `AgentProvider` implementation against the Vertex AI API per the `cloud-routed-agent.mdx` transport-vs-family split. * Workload-identity credential resolution — pod-bound, short-lived tokens from the GCP identity layer, per the IAM-federated credentials section of `cloud-routed-agent.mdx`. * Region pinning at the endpoint URL, enforced by Vertex rather than the runtime (`cloud-routed-agent.mdx`: region as a hard constraint). * Vertex-specific cost-event emission so the `harness_auditable_calls` row carries the cloud, region, and model dimensions GCP billing rolls up by — same reconciliation shape as the Bedrock route, against a different invoice. ## Where it fits [#where-it-fits] `@pleach/transport-vertex` plugs into the same `provider` slot on `SessionRuntime` that the direct-API providers fill — it satisfies the `AgentProvider` contract documented in [Providers](/docs/providers). For the architectural shape of a Vertex-routed deployment — transport vs family, IAM federation, region as a hard constraint, cost reconciliation — see [Cloud-routed agent](/docs/cloud-routed-agent). ## Install [#install] ```bash npm install @pleach/transport-vertex ``` ## API surface [#api-surface] The provider factory signature, the workload-identity option shape, the region and project fields, and the emitted cost-event payload all live on the package's npm page: [`@pleach/transport-vertex`](https://www.npmjs.com/package/@pleach/transport-vertex). This page is placement orientation — what the SKU is for and where it slots into the substrate. The npm README is the source of truth for the constructor and option shapes; if a claim here disagrees with the published README, the README wins. ## Where to go next [#where-to-go-next] <Cards> <Card title="Cloud-routed agent" href="/docs/cloud-routed-agent" description="Transport-vs-family, IAM federation, region pinning, and cost reconciliation to the cloud invoice." /> <Card title="Providers" href="/docs/providers" description="The AgentProvider contract this transport implements and the cascade rules it threads through." /> <Card title="Region-pinned agent" href="/docs/region-pinned-agent" description="The sibling shape when family + region pinning is procurement-driven rather than cloud-driven." /> </Cards> --- # Troubleshooting (/docs/troubleshooting) A failure-mode reference. Each entry pairs an observed symptom with the most likely cause and the concrete fix. When a symptom has multiple causes, they're ordered by frequency. If a failure isn't here, the [Error codes](/docs/error-codes) catalog covers the structured 1xxx–9xxx ranges with recovery hints. CI-time `audit:*` gate failures are covered by [Audit gates](/docs/audit-gates). ## Runtime construction [#runtime-construction] ### `[UXParity:metaToolNames-config-missing]` at startup [#uxparitymetatoolnames-config-missing-at-startup] **Cause.** `SessionRuntimeConfig.metaToolNames` not passed; the runtime falls back to an empty set and continuation / fabrication guards depending on the set silently disable. **Fix.** Pass the set explicitly: ```typescript const runtime = new SessionRuntime({ metaToolNames: new Set(["set_step_complete", "wait_for_jobs"]), // ... }); ``` See [SessionRuntime](/docs/session-runtime). ### `HarnessModuleLoaderUnregisteredError` [#harnessmoduleloaderunregisterederror] **Cause.** `executeMessage` was called before `setHarnessModuleLoader(...)` registered a loader; happens in hosts mid-migration from legacy orchestrator integration. **Fix.** Register the loader at startup. The error message names the first key it tried to resolve — implement that arm at minimum. See [Host adapter](/docs/host-adapter). ### `UnknownSafetyPolicyError` [#unknownsafetypolicyerror] **Cause.** A policy id in `enabledSafetyPolicies` doesn't match any policy registered through `contributeSafetyPolicies()`. **Fix.** Check the spelling against the plugin that ships the policy. Programmer-error class — surfaces at construction, not on the first turn. ### Bisecting "is the bug in the runtime or my tool?" [#bisecting-is-the-bug-in-the-runtime-or-my-tool] Set `HARNESS_MOCK_MODE=true` and re-run the failing input. Mock mode replaces the provider with a deterministic stub but leaves your tools, plugins, and runtime config untouched: ```bash HARNESS_MOCK_MODE=true npm run dev # In another shell: curl -X POST http://localhost:3000/api/chat \ -H 'content-type: application/json' \ -d '{"sessionId":"session-018f-7a","message":"fetch doc-abc123"}' ``` If the bug reproduces under mock mode, it's in tool code or plugin code (the provider isn't running). If it disappears, the bug is in provider config or in the cascade. Never ship code with `HARNESS_MOCK_MODE=true` set — it's a debug-only switch. ### Runtime works in dev, throws in production on first request [#runtime-works-in-dev-throws-in-production-on-first-request] **Cause.** `HARNESS_MOCK_MODE=true` was set in dev but not in production; the runtime tried to construct a real provider / storage adapter with missing env vars. **Fix.** Verify production env. The mock-mode env var should never be set in production; the real `OPENROUTER_API_KEY` (or `ANTHROPIC_API_KEY` / `OPENAI_API_KEY`) plus `SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` should be. ### OTel exports are empty, but the runtime is firing turns [#otel-exports-are-empty-but-the-runtime-is-firing-turns] **Cause.** The OTel SDK was started after `new SessionRuntime(...)` was constructed. The runtime reads the global tracer provider at construction time. **Fix.** Call `sdk.start()` before constructing the runtime. If the runtime is built in a module-level constant, hoist the SDK start to a top-level side-effect import before any runtime import. See [OTel observability](/docs/otel-observability). ## Streaming [#streaming] ### Stream silently disconnects after \~30 s in production [#stream-silently-disconnects-after-30-s-in-production] **Cause.** SSE response is being buffered by a reverse proxy or CDN. Most common with Vercel's default response handling when `Cache-Control` isn't set. The diagnostic: open the network panel and watch the response — if `data:` lines arrive in a single burst at \~30s instead of trickling, the proxy is buffering; if no bytes arrive at all before the timeout, the connection is being killed upstream. Buffered streams break the chunked rendering; the fix below addresses both. **Fix.** Set headers on the SSE response: ```typescript return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", "Connection": "keep-alive", "X-Accel-Buffering": "no", // nginx }, }); ``` ### `tool.started` fires but `tool.completed` never does [#toolstarted-fires-but-toolcompleted-never-does] **Cause.** Tool's `execute` is awaiting an external resource that never resolves (no timeout, no abort handling). The diagnostic step: pull the `toolCallId` from the `tool.started` payload and query the audit ledger for `payload->>'toolCallId' = $1` — if no row exists, `execute` never returned at all (and the provider call was never made); if a row exists with `outcome.status === "failed"`, the tool errored but the failure event was lost (likely durable flush wasn't registered). **Fix.** Honor `ToolContext.signal` in every fetch / spawn: ```typescript async execute(input, ctx) { const res = await fetch(url, { signal: ctx.signal }); // ... } ``` The user pressing stop cancels the AbortController; tools that ignore the signal keep running and the stream looks frozen. ### `message.delta` chunks arrive, then a `content.correction` replaces them [#messagedelta-chunks-arrive-then-a-contentcorrection-replaces-them] **Cause.** Not a bug. A post-stream fabrication guard detected phantom tool references or another correctable issue and rewrote the streamed content. **Fix.** Treat `correctedContent` as the authoritative final text — replace the last assistant message wholesale. See [Stream events](/docs/stream-events). ### Stream stops mid-token with `stream.truncated` [#stream-stops-mid-token-with-streamtruncated] **Cause.** A degeneration guard (entropy collapse, phrase loop, substring repetition) detected runaway output and stopped the stream. **Fix.** Surface the truncation reason to the user (`entropy_collapse` typically means the model lost coherence; `phrase_loop` means it got stuck repeating). The `partialLength` field tells you how much was emitted before truncation. ## Tools [#tools] ### `Tool not found` (code `1001`) [#tool-not-found-code-1001] **Cause.** Tool name in `createSession({ tools: { enabled: [...] } })` doesn't match any registered tool, or registration ran after the session was created. **Fix.** Register tools before constructing the runtime (or before any `createSession` call). Verify with DevTools: ```javascript __HARNESS_DEVTOOLS__.tools().map((t) => t.name); ``` ### Tool fires the wrong arguments [#tool-fires-the-wrong-arguments] **Cause.** The model is producing arg shapes that don't match your Zod schema; the validator throws and `tool.failed` fires with the validation error. **Fix.** Two paths. 1. **Tighten the description.** The model uses `description` to pick args; ambiguous descriptions produce ambiguous args. 2. **Loosen the schema.** Use `z.string().or(z.number())` for fields where the model produces both, then normalize in `execute`. ### Tool runs serially even though it's safe to parallelize [#tool-runs-serially-even-though-its-safe-to-parallelize] **Cause.** Default batching strategy is conservative; tools inherit `serial` unless overridden. **Fix.** Mark explicitly: ```typescript defineTool({ name: "fetch_paper", // @ts-expect-error — extended field batching: { strategy: "parallel", maxConcurrency: 5 }, // ... }); ``` See [Tools](/docs/tools) for the strategies. ### Outbound HTTP from a tool doesn't carry the tenant header in a tenant-scoped runtime [#outbound-http-from-a-tool-doesnt-carry-the-tenant-header-in-a-tenant-scoped-runtime] **Cause.** The fetch client wasn't wrapped with `withTenantHeader`. The runtime stamps `tenant_id` on internal write sites; outbound HTTP is consumer-owned by default. **Fix.** Wrap the fetch client at construction (`withTenantHeader(fetch, runtime.tenant)`) and pass the wrapped client to whatever performs the outbound call — the tool's `fetch`, the SDK client, the gateway adapter. See [Tenant facet](/docs/tenant-facet). ## Storage and sync [#storage-and-sync] ### `Version conflict` (code `3001`) on every write after a network blip [#version-conflict-code-3001-on-every-write-after-a-network-blip] **Cause.** The client's version vector has drifted behind the server's; the conflict-detection layer is doing its job. **Fix.** Pull the remote vector, merge, and re-push. The `SyncCoordinator` does this automatically; the symptom usually means a custom adapter is bypassing the coordinator. ### `Session not found` (code `2001`) right after `createSession` returns [#session-not-found-code-2001-right-after-createsession-returns] **Cause.** The runtime's `userId` doesn't match the RLS policy's expected scope. Common when constructing with an anon client and no auth context. **Fix.** Either pass an authed session token (anon client path) or use a service-role client (server path). Verify with a direct DB query — the row exists; RLS is hiding it. ### Sync conflicts surfacing constantly in multi-device setup [#sync-conflicts-surfacing-constantly-in-multi-device-setup] **Cause.** Two clients writing to the same session with overlapping fields and no merger configured. **Fix.** Configure a custom merger in `SyncCoordinatorConfig` that picks `remote` for fields where the server is authoritative (timestamps, server-derived metadata). See [Sync](/docs/sync). ### Outbox grows unbounded; never drains [#outbox-grows-unbounded-never-drains] **Cause.** Network is failing silently; the durable-flush retry budget is exhausted; or `ConnectivityMonitor` is reporting offline when the network is actually up. The diagnostic: check the most recent parked entry's `lastError` — a `3001` code means the network refused every retry (look at the probe URL); a `3003` means the server is rejecting on version mismatch (look at `SyncCoordinator`'s merger configuration); a `3004` means the outbox cap is too small for the write rate. **Fix.** Check `__HARNESS_DEVTOOLS__.syncStatus()` for the `errors` array. Forced sync surfaces the underlying error: ```javascript await __HARNESS_DEVTOOLS__.forceSync(); __HARNESS_DEVTOOLS__.syncStatus().errors; ``` ### A field that used to appear in event log payloads is now blank [#a-field-that-used-to-appear-in-event-log-payloads-is-now-blank] **Cause.** A scrubber matched the field and redacted it before persistence. Scrubbers run at write time; nothing flows past them. **Fix.** List the active scrubbers via the plugin contract surface to find which one matched. If the redaction is overly aggressive, override the matcher pattern — for `KeyedRegex`, narrow the regex; for the bundled scrubbers, register a more specific scrubber that runs first and tags the field as exempt. See [Scrubbers](/docs/scrubbers). ### A custom `GraphProjection<T>` returns stale data on a fresh deployment [#a-custom-graphprojectiont-returns-stale-data-on-a-fresh-deployment] **Cause.** The projection is folding against pre-stamping rows (rows written before the projection's substrate landed) that don't carry the event types the reducer expects. **Fix.** Scope the projection's read with `since: cutoverId` where `cutoverId` is the first row written after the substrate upgrade. For shipped projections in soak, depend on `GraphProjection<T>` directly and treat the bundled implementations as references — their signatures may move between releases. See [Event log projections](/docs/event-log-projections). ## React [#react] ### `useHarness` returns stale messages [#useharness-returns-stale-messages] **Cause.** `HarnessProvider` re-mounted because the `runtime` prop was re-created on every render. **Fix.** Wrap runtime construction in `useMemo`: ```tsx const runtime = useMemo( () => new SessionRuntime({/* ... */}), [userId], // recreate only when scope changes ); ``` ### `useHarnessDevTools` doesn't expose `window.__HARNESS_DEVTOOLS__` [#useharnessdevtools-doesnt-expose-window__harness_devtools__] **Cause.** The hook ran before `HarnessProvider` mounted, or the hook is inside a conditional that didn't fire. **Fix.** Call `useHarnessDevTools()` inside a component that's a descendant of `HarnessProvider`. The hook attaches via the runtime context. ### Chat resets on tenant switch [#chat-resets-on-tenant-switch] **Cause.** `useMemo` dep array missing the tenant id; runtime gets reused across tenants. **Fix.** Add the tenant to the deps: ```tsx const runtime = useMemo( () => new SessionRuntime({/* ... */}), [tenantId, userId], ); ``` This is also the right pattern for tenant isolation — see [Multi-tenant](/docs/multi-tenant). ## Provider [#provider] ### `LLM unavailable` (code `5001`) on the first call [#llm-unavailable-code-5001-on-the-first-call] **Cause.** Provider credentials missing or invalid. The runtime's cascade walked every in-family rung and all failed. **Fix.** Check the env var set per provider — `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`. Run a smoke test against the provider SDK directly to verify credentials before involving the runtime. ### `Rate limited` (code `5003`) under normal load [#rate-limited-code-5003-under-normal-load] **Cause.** Provider-side rate limit hit on a single API key across all sessions; the cascade walks rungs but every rung shares the same upstream limit. **Fix.** Two paths. 1. **Multi-key routing.** `@pleach/gateway@0.1.0` ships the Phase A surface (`GatewayClient.route`, BYOK fingerprint, family-locked failover for `anthropic`). Other transports are deferred to Phase B per [Gateway](/docs/gateway). For broader coverage today, wire your own router behind the provider adapter or use a third-party gateway (Portkey, LiteLLM proxy, OpenRouter). 2. **Increase the budget.** Contact provider; raise the limit. ### `Token limit exceeded` (code `5005`) on long sessions [#token-limit-exceeded-code-5005-on-long-sessions] **Cause.** Context exceeded the model's window; the runtime emitted a `context.summarized` event and retried, but the retry also exceeded. **Fix.** The runtime's context compaction is automatic but bounded. For sessions that hit it repeatedly, configure a more aggressive compaction strategy or split the session. The diagnostic step: query the ledger for the affected `turnId` and read `payload.tokenUsage.in` — a value approaching the model's window cap (200k for Claude Sonnet 4.5, for example) confirms the prompt itself is the bottleneck rather than the response; a value well under cap with the error firing means a tool result inflated the context mid-turn, and the fix is to truncate the tool result upstream rather than tune compaction. ### Confirming a fallback fired (without trusting logs) [#confirming-a-fallback-fired-without-trusting-logs] The audit row is the ground truth — logs may be rate-limited, the ledger is not. Query for the suspect turn: ```sql SELECT created_at, payload->>'modelId' AS model, payload->>'family' AS family, payload->>'fallbackReason' AS fallback_reason, outcome->>'status' AS outcome FROM harness_auditable_calls WHERE payload->>'turnId' = 'turn-018f-7a-3' ORDER BY created_at; ``` A row with non-null `fallback_reason` confirms the cascade advanced; multiple rows for one `turnId` show the full walk through the family. ### `family-exhausted` event after a fallback cascade [#family-exhausted-event-after-a-fallback-cascade] **Cause.** Every rung in the locked family failed. The runtime emits this rather than silently switching families — see [Architecture](/docs/architecture) §4. **Fix.** Surface a "pick a different model family" prompt to the user. Silent cross-family widening is structurally disallowed by design. ## Audit ledger [#audit-ledger] ### `cacheHit: false` on every call, even ones that should be deduped [#cachehit-false-on-every-call-even-ones-that-should-be-deduped] **Cause.** No fingerprint cache wired. The substrate ships the fingerprint but not the cache layer — that's a contract you implement. **Fix.** Wrap your provider with a fingerprint-keyed cache decorator. See [Performance](/docs/performance). ### Cache hit rate is unexpectedly low despite identical-looking prompts [#cache-hit-rate-is-unexpectedly-low-despite-identical-looking-prompts] **Cause.** One of the four fingerprint gaps differs — `systemPrompt`, `temperature`, `runtimeMode`, or `tenantId`. The cache key is structurally distinct across any gap; identical content with different gap values misses by design. **Fix.** Print the fingerprint via `cacheBackend.metricsSnapshot()` and compare the gap values across the two calls you expected to share a cache row. If the gap difference is structural (cross-tenant pollution, mode change at turn start), that's correctness — the cache is doing its job. If it's accidental (a timestamp leaking into the system prompt), normalize the input via `prepareCacheInputs`. See [Cache](/docs/cache). ### `audit:c8-event-type-allowlist-coverage` fails CI after adding a new event type [#auditc8-event-type-allowlist-coverage-fails-ci-after-adding-a-new-event-type] **Cause.** The new event type doesn't have a matching `Scrubber` allowlist entry. Every persisted event type needs scrubber coverage, even if it's a pass-through. **Fix.** Register a `Scrubber` that allowlists the new event type (a pass-through scrubber is fine if no fields need redaction) via `contributeScrubbers` in the plugin that owns the event type. See [Scrubbers](/docs/scrubbers), [Plugin contract](/docs/plugin-contract). ### Hash-chain verification SQL returns the same row repeatedly [#hash-chain-verification-sql-returns-the-same-row-repeatedly] **Cause.** The recursive CTE's join condition is matching multiple successor rows when no `prev_hash` constraint is present — typically because the chain has a NULL gap mid-stream. **Fix.** Filter to `row_hash IS NOT NULL` on both sides of the join and walk in `record_id` order. A gap of NULL rows is back-compat-expected; the verification resumes at the next stamped row. See [Hash chain](/docs/hash-chain). ### After adding `@pleach/compliance`, CI fails on `audit:tenant-scoping` [#after-adding-pleachcompliance-ci-fails-on-audittenant-scoping] **Cause.** The package brings two CI gates with it. Adopting the package without first wiring `runtime.tenant` (so every write site has a tenant) makes the gate fail. **Fix.** Configure `tenantId` (and optionally `subTenantId`) on `SessionRuntimeConfig` before enabling the compliance plugin. Run `audit:tenant-scoping` locally first; it lists the write sites that need backfill. See [Compliance](/docs/compliance), [Tenant facet](/docs/tenant-facet). ### Ledger rows missing after a crash [#ledger-rows-missing-after-a-crash] **Cause.** Durable flush wasn't registered with `waitUntil` on a serverless function; teardown lost the queued writes. **Fix.** Register the durable-flush pipeline at handler entry: ```typescript import { setWaitUntilImpl } from "@pleach/core/eventLog"; setWaitUntilImpl(ctx.waitUntil.bind(ctx)); ``` See [Event log](/docs/event-log). ### `Two synthesize rows for one turn` invariant violation [#two-synthesize-rows-for-one-turn-invariant-violation] **Cause.** Code somewhere is bypassing `SynthesizeSeamHolder`. Typically a custom plugin reaching across the seam boundary, or a stream observer emitting a synthesized payload. **Fix.** Run `npm run lint:harness-boundary` and `npm run audit:graph-stages` — both will flag the violation. The seam invariants are CI-enforced; if both pass, the violation is in code outside the package's lint scope. ## Schema migrations [#schema-migrations] ### `Schema mismatch` (code `2006`) reading rows in production [#schema-mismatch-code-2006-reading-rows-in-production] **Cause.** The package's schema bundle has advanced past what's applied to the database. **Fix.** Re-run `npx pleach init --apply --target ./supabase/migrations` and apply the new files. The bundle is additive — old files won't change. ### `harness_set_updated_at()` function already exists [#harness_set_updated_at-function-already-exists] **Cause.** A previous migration applied the trigger function, and the bundle's `CREATE OR REPLACE` is being rejected by RLS or permission policies. **Fix.** Run the bundle as the database owner (or service role). The function is shared across tables; redefining it during a migration is intended. ## Where to go next [#where-to-go-next] <Cards> <Card title="Error codes" href="/docs/error-codes" description="The structured 1xxx–9xxx catalog with recovery hints." /> <Card title="Audit gates" href="/docs/audit-gates" description="CI-time `audit:*` gate failures and what they detect." /> <Card title="DevTools" href="/docs/devtools" description="`window.__HARNESS_DEVTOOLS__` for in-browser diagnosis." /> <Card title="Deployment" href="/docs/deployment" description="Production checklist and rollback strategy." /> </Cards> --- # Turn lifecycle (/docs/turn-lifecycle) A turn is one user message in, one assistant answer out. The runtime drives that arc through the four-stage lattice, yields typed stream events at every step, and writes one audit row per addressable decision. This page walks the dynamic path; the static lattice itself is documented at [Architecture](/docs/architecture#1-stage-lattice). Session minting, resume, and delete live a layer above the turn — see [Session lifecycle](/docs/session-lifecycle) for that arc. The [runtime-lifecycle cluster](/docs/session-lifecycle#the-runtime-lifecycle-cluster) names this concept alongside [Session lifecycle](/docs/session-lifecycle) (the arc above the turn) and [Event log](/docs/event-log) (the append-only stream both write into). Turn lifecycle is the per-message arc; the others are the container above and the persistence layer below. ## The big picture [#the-big-picture] <Mermaid chart="sequenceDiagram participant B as Browser participant R as SSE route participant SR as SessionRuntime participant P as Provider participant A as Audit ledger B->>R: POST /api/harness/sessions/[id]/execute R->>SR: runtime.executeMessage(sessionId, content) SR->>P: anchor-plan + tool-loop + synthesize calls P-->>SR: streaming deltas SR-->>R: yield StreamEvent (per chunk / lifecycle) SR->>A: recordCall (one row per addressable decision) R-->>B: data: {...}\n\n (SSE frame) SR-->>R: yield done / error R-->>B: close" /> Five participants, three write streams: stream events to the client, ledger rows to the audit table, observable events to the event log (omitted here — see [Event log](/docs/event-log)). ## Server side: where the runtime lives [#server-side-where-the-runtime-lives] One `SessionRuntime` per Node process — or per warm function instance on a Fluid Compute / Lambda deployment. The runtime owns the compiled graph, the channel state, and the seam-bound provider entry points; constructing it is the cold-start cost. The HTTP route is a thin adapter. It calls `runtime.executeMessage(sessionId, content)`, iterates the async generator, and pipes each yielded event into an SSE `data:` frame. ```typescript // app/api/harness/sessions/[id]/execute/route.ts import { createPleachRoute } from "@pleach/core/quickstart" // Web-standard POST handler. Provider auto-detected from the env; // pass { provider, plugins, tools, storage } to override. export const POST = createPleachRoute() ``` The execute route is mounted at `POST /api/harness/sessions/[id]/execute` — see [API routes](/docs/api-routes#post-apiharnesssessionsidexecute) for the request body and response framing. The substrate-level handler set is documented at [HarnessServer](/docs/server) for non-Next.js transports. ## Client side: consuming the stream [#client-side-consuming-the-stream] The client POSTs to the execute route, reads the response body as a stream of SSE frames, parses each frame into a `StreamEvent`, and switches on `type`. The connection closes when the generator yields `done` — or throws, in which case the last frame is an `error` event. ```typescript const res = await fetch(`/api/harness/sessions/${sessionId}/execute`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content }), }) const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader() let buffer = "" while (true) { const { done, value } = await reader.read() if (done) break buffer += value const frames = buffer.split("\n\n") buffer = frames.pop() ?? "" for (const frame of frames) { const line = frame.replace(/^data: /, "") const event = JSON.parse(line) as StreamEvent switch (event.type) { case "message.delta": appendDelta(event.delta); break case "tool.started": showSpinner(event.toolCall); break case "tool.completed": resolveSpinner(event.toolCall, event.result); break case "error": showError(event.error); break } } } ``` The full Next.js wiring — with auth, `HarnessProvider`, and the `useHarness` hook on the client — is [Recipe #1](/docs/recipes#1-nextjs-app-router-chat-with-streaming). Crib from that rather than re-deriving the consumer. ## The four-stage execution path [#the-four-stage-execution-path] The runtime walks the lattice in order: `anchor-plan → tool-loop ⇄ synthesize → post-turn`. Each stage emits a distinct event family and writes its own ledger rows. The structural why — why these four, why this order — lives in [Architecture § stage lattice](/docs/architecture#1-stage-lattice). ### `anchor-plan` [#anchor-plan] Bootstrap: classify the user's intent, build a plan, anchor any references the tool loop will need. One or more `utility` / `reasoning` LLM calls; no user-visible prose yet. * Stream events: `step.start` (`step: "anchor-plan"`), optionally `message.entities` for anchored references, `step.end`. * Audit: one row per call with `payload.kind: "planGeneration"`. ### `tool-loop` [#tool-loop] The iterative core. Each iteration runs one `reasoning` LLM call that may emit tool calls; the runtime dispatches them, collects results, and re-enters the loop. The loop exits when the LLM produces a response with no tool calls. Per iteration: * One LLM call streams `message.delta` / `thinking.delta` and writes a `cacheBreakpoint` row at the provider response boundary; in-family retries write `fallbackStep` rows. * Each tool dispatch emits `tool.started`, optionally `tool.delta` for streaming argument assembly, then `tool.completed` or `tool.failed`. The dispatching call writes a `toolSelection` row; a cascade across tools writes `toolFallbackStep`. * Async-job tools also emit `job.dispatched` / `job.progress` / `job.completed`. ### `synthesize` [#synthesize] Exactly one `synthesize` call per turn — structurally capped by the `SynthesizeSeamHolder` singleton + `TurnSynthesizeCounter` so the rendered string and the audited string are the same string. * Stream events: `message.delta` (the same event type the `tool-loop` stage yields — readers discriminate by the `stageId` the runtime stamps on the envelope, not by a separate event name), then `message.complete` with the final assembled message. * Audit: one row with `payload.kind: "synthesisQuality"`. ### `post-turn` [#post-turn] Terminal stage. Durable writes flush, enrichment plugins run, checkpoints land. No further LLM calls fire here. * Stream events: `checkpoint.created` at the stage boundary, optionally late `message.citations` / `message.entities`, then the generator yields `done` and returns. * Audit: no LLM rows; plugin-namespaced rows via `pluginPayloads` if the post-turn enrichment writes any. ## What the client sees, in order [#what-the-client-sees-in-order] A reader who sees an event and wants to know which stage produced it: | Event type | Stage | When | | ---------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------- | | `step.start` / `step.end` | any | Stage boundary bracket | | `message.entities` | `anchor-plan` (anchoring) or `tool-loop` (extraction) | After a tool result lands or anchor pass completes | | `tool.started` / `tool.delta` / `tool.completed` / `tool.failed` | `tool-loop` | Per tool dispatch within the loop | | `thinking.delta` / `thinking.complete` | `tool-loop` | Provider emits a reasoning trace | | `message.delta` | `tool-loop` then `synthesize` | Streaming token output | | `provider.cascade` | `tool-loop` or `synthesize` | A rung in the family-strict cascade failed | | `message.complete` | `synthesize` | Final assembled assistant message | | `checkpoint.created` | `post-turn` | Stage boundary snapshot written | | `error` | any | Recoverable or terminal error | The full payload catalog is at [Stream events](/docs/stream-events). ## What the audit ledger sees, in order [#what-the-audit-ledger-sees-in-order] The typed payload slots that land per stage: | Stage | Typed payload | When | | ------------- | ------------------ | ----------------------------------------------- | | `anchor-plan` | `planGeneration` | Plan composed | | `tool-loop` | `cacheBreakpoint` | Per LLM call, at the provider response boundary | | `tool-loop` | `fallbackStep` | Per in-family retry rung | | `tool-loop` | `toolSelection` | Per LLM call that emits tool calls | | `tool-loop` | `toolFallbackStep` | When the tool cascade pivots across tools | | `synthesize` | `synthesisQuality` | The one synthesize call | Every row also carries the shared `identity` tuple (`sessionId`, `turnId`, `stageId`, `seqWithinTurn`) and the post-routing `call` shape — see [The AuditableCall row → typed payload slots](/docs/auditable-call-row#typed-payload-slots) for per-slot field shapes. ## Aborting mid-turn [#aborting-mid-turn] Pass an `AbortSignal` into `executeMessage`. The signal propagates into the active provider stream, the tool loop unwinds, and the runtime flushes a final ledger row with `outcome.status: "user-aborted"` so the partial input-token spend stays attributable. The client sees one of two terminal states: a final `error` event with the abort code, or the SSE connection closing. UI consumers should treat them as the same end-of-stream. The abort code path is documented at [SessionRuntime → Aborting a turn](/docs/session-runtime#aborting-a-turn). ```typescript const ctrl = new AbortController() const res = await fetch(url, { method: "POST", body, signal: ctrl.signal }) // later ctrl.abort() ``` Closing the SSE response from the client side has the same effect on the server — `createPleachRoute` threads `req.signal` through to the runtime's execute call. ## Errors and recovery [#errors-and-recovery] Errors come from inside the loop: provider HTTP failures, tool throws, guard rejections. The runtime doesn't surface them immediately. For provider failures, the family-strict cascade walks the next in-family rung and writes a `providerCascade` row per step. For tool failures, the tool cascade tries the next in-class tool and writes a `toolFallbackStep` row. Only when every rung in the active class is exhausted does an `error` event reach the client. The structured `code` field on the event discriminates the failure mode — see [Error codes](/docs/error-codes) for the catalog. `family-exhausted` is the canonical terminal state when the in-family ladder runs out; the consumer surface is expected to ask the user to pick a different family rather than silently widening. ## Where to go next [#where-to-go-next] <Cards> <Card title="Session lifecycle" href="/docs/session-lifecycle" description="The arc above the turn — minting, resume, abort, time-travel, delete." /> <Card title="SessionRuntime" href="/docs/session-runtime" description="The executeMessage signature, options, and abort handling." /> <Card title="Stream events" href="/docs/stream-events" description="Every event type the generator yields, with payload shapes." /> <Card title="Architecture" href="/docs/architecture" description="The static lattice — why these four stages, what the seams enforce." /> <Card title="Recipe #1: Next.js chat" href="/docs/recipes#1-nextjs-app-router-chat-with-streaming" description="The canonical streaming-chat wiring, end to end." /> </Cards> --- # Typed auditable-call records (/docs/typed-records) Typed records are the payload shape inside the [audit-ledger cluster](/docs/audit-ledger#the-audit-ledger-cluster) — the row carries them; the `ProviderDecisionLedger` writes them; the hash chain seals them. For read-side observability (OTel spans that join to these records on `turnId`, Datadog and Honeycomb wiring), see [Observability](/docs/observability). Five `AuditableCall` records that used to persist as opaque JSON now ship as typed shapes carried in named, optional top-level slots on the row (`interruptDecision`, `tokenCost`, `toolSelection`, `planGeneration`, `synthesisQuality`). Consumers reading rows narrow by checking which slot is present — correlated with the `stageId` that fired the row; analytics queries stop reaching into untyped blobs; breaking changes get caught at compile time. The version log on the audit row tracks the promotion (v9 through v13, one additive bump per shape). <SourceMeta subpath="@pleach/core/audit" source="{ label: "src/audit/records/", href: "https://github.com/pleachhq/core/tree/main/src/audit/records" }" /> ## The five promoted records [#the-five-promoted-records] | Record | Audit version | What it captures | | ------------------------- | ------------- | ------------------------------------------------------------------------------------------------ | | `InterruptDecisionRecord` | v9 | Interrupt `verdict` (`approved` / `edited` / `rejected` / `timeout`) with the resolution payload | | `TokenCostRecord` | v10 | Per-call token usage split by cache hit, cache miss, and reasoning tokens | | `ToolSelectionTrace` | v11 | Candidate tool set, eliminated tools with reasons, final pick | | `PlanGenerationRecord` | v12 | Plan produced for a turn — anchor, step list, revision history | | `SynthesisQualityRecord` | v13 | Synthesis-time quality probes (including `imperativeFabricationDetected`) | Each slot is stage-correlated and optional. A row MAY carry more than one — a `tool-loop` row can carry `toolSelection` (and, when an interrupt fired that iteration, `interruptDecision`); an `anchor-plan` row carries `planGeneration`. Consumers narrow on `stageId` or on slot presence. See [AuditableCall row](/docs/auditable-call-row) for the full stage-to-slot map. ## The additive-promotion contract [#the-additive-promotion-contract] Each version bump is additive. New fields appear; existing fields stay; renames and removals are wire-format breaks that need their own bump with explicit deprecation overlap. A consumer pinned to v9 keeps working when the substrate writes v13 — it sees the v13 row and narrows only the fields it knows about. The `AUDIT_RECORD_VERSION_HISTORY` constant exposes the per-version delta so consumer code can render "what changed in v11" at runtime: ```typescript import { AUDIT_RECORD_VERSION_HISTORY } from "@pleach/core/audit"; const v11 = AUDIT_RECORD_VERSION_HISTORY.find((e) => e.to === 11); console.log(v11?.reason); // → "ToolSelectionTrace promoted to a typed slot; // adds the `toolSelection` slot with candidate set, // eliminations, and final pick." ``` The `audit:auditable-call-version` CI gate enforces the additive rule. A PR that removes or renames a field on an existing record fails the gate unless it also bumps the version and ships a deprecation overlap. ## Narrowing on the typed slot [#narrowing-on-the-typed-slot] Each typed record is an optional named slot on the row. Narrow by checking slot presence (correlated with the `stageId` that fired the row); TypeScript then narrows the slot to its concrete type: ```typescript import type { AuditableCall } from "@pleach/core/audit"; function summarize(call: AuditableCall): string { if (call.interruptDecision) return `interrupt:${call.interruptDecision.verdict}`; if (call.tokenCost) return `tokens:in=${call.tokenCost.inputTokens},out=${call.tokenCost.outputTokens}`; if (call.toolSelection) return `tool:${call.toolSelection.selectedTool}`; if (call.planGeneration) return `plan:${call.planGeneration.stepCount}`; if (call.synthesisQuality) return `synthesis:fabricationDetected=${call.synthesisQuality.imperativeFabricationDetected}`; return "no typed slot"; } ``` Adding a sixth record type adds a sixth optional slot; existing consumers keep compiling and simply don't read it until they add a branch — that's the additive, non-breaking guarantee the promotion buys. ## `InterruptDecisionRecord` (v9) [#interruptdecisionrecord-v9] Captures the outcome of a human-in-the-loop interrupt: the tool the interrupt gated, the operator who resolved it, the recorded `verdict` (`approved` / `edited` / `rejected` / `timeout`), and the resolution payload (`argsBefore` / `argsAfter`, plus the NL `editPrompt` when the verdict is `edited`). **What to derive from it.** Replay tools assert that an interrupt resolved the same way on re-run — deterministic interrupt resolution is what makes replay reproducible across operator sessions. Analytics roll up interrupt rates per channel to spot channels whose guardrails fire too often (a signal the underlying tool schema needs tightening). ## `TokenCostRecord` (v10) [#tokencostrecord-v10] Per-call token accounting, broken out by cache-read, cache-write, input, output, and reasoning tokens. Each field maps directly to the provider's billing line so a consumer can re-derive cost without re-querying the provider. **What to derive from it.** Billing joins on `recordId` and sums tokens per `tenantId` and `turnId`. The cache-hit / cache-miss split is the cache-effectiveness signal — a tenant whose cache-read ratio drops over time has prompts whose stable prefix is fragmenting. Reasoning tokens are tracked separately because the unit cost is different. ## `ToolSelectionTrace` (v11) [#toolselectiontrace-v11] Records the tool-selection path: the candidate set the agent considered, every tool eliminated with the elimination reason, and the final pick. Fires on every `tool-loop` row that runs a tool-selection step. **What to derive from it.** Debugging "why did the agent pick this tool" without re-running the turn — the trace is the audit trail. Build a per-tool elimination-reason histogram to find tools whose descriptions consistently lose to a sibling (usually a sign the description needs work). ## `PlanGenerationRecord` (v12) [#plangenerationrecord-v12] Carries the plan produced for a turn: the anchor that grounds the plan, the ordered step list, and the revision history if the plan was re-generated mid-turn. **What to derive from it.** Auditing plan-revision rate per agent. Plans that revise more than once per turn are a model-drift signal — the model is generating plans it can't follow. The revision-history field lets you read what the original plan looked like and what the model swapped in. ## `SynthesisQualityRecord` (v13) [#synthesisqualityrecord-v13] Synthesis-time quality probes that fire on every `synthesize` row, including the `imperativeFabricationDetected` boolean alongside the standard synthesis-quality fields and any consumer-registered probes. Each probe is a typed sub-field on the record — adding a probe is itself an additive bump. **What to derive from it.** Per-channel alerting on rising `imperativeFabricationDetected` rates — a canonical "the model got worse" signal. Long-running rollups also catch slow drift that single-turn inspection misses. ## Why version bumps are additive [#why-version-bumps-are-additive] The contract is "additive only." A field removal, type narrowing, or rename is a wire-format break and needs a v-bump with a deprecation overlap (the old field stays for one version, marked deprecated; consumers migrate; the next bump removes it). The `audit:auditable-call-version` CI gate enforces this. It diffs the wire shapes against the previous version and fails the build on any non-additive change that doesn't ship matching deprecation metadata. Consumers reading the version log can confirm whether a bump touches a field they read: ```typescript import { AUDIT_RECORD_VERSION_HISTORY } from "@pleach/core/audit"; const touched = AUDIT_RECORD_VERSION_HISTORY .filter((e) => e.to > pinnedVersion) .flatMap((e) => e.fields); if (touched.includes("synthesisQuality.imperativeFabricationDetected")) { // re-derive the alert threshold against the new field } ``` ## Analytics integration [#analytics-integration] Each record kind lends itself to a canonical SQL rollup. A synthesis-quality fabrication-detection rate by day: ```sql SELECT date_trunc('day', created_at) AS day, tenant_id, count(*) AS synthesize_rows, count(*) FILTER ( WHERE (payload->'synthesisQuality'->>'imperativeFabricationDetected')::boolean ) AS fabrication_detected FROM harness_auditable_calls WHERE payload->'synthesisQuality' IS NOT NULL AND created_at >= now() - interval '30 days' GROUP BY 1, 2 ORDER BY 1 DESC, 2; ``` The exact JSONB path is a property of your persistence adapter — the in-memory shape carries `synthesisQuality` as a named top-level slot; a Postgres adapter that nests the typed slots under a `payload` JSONB column reaches it as `payload->'synthesisQuality'`. Narrowing on slot presence (`... IS NOT NULL`) is the discriminator; once the adapter projects the slot to its own column, the predicate hits an index. See [Query patterns](/docs/query) for the full set of per-record rollups. ## What typed records do NOT replace [#what-typed-records-do-not-replace] * **The lifecycle stream (`runtime.on`).** Typed records are persisted state read after the turn finishes; lifecycle events are real-time signals the runtime emits as the turn runs. A consumer that needs to react during the turn subscribes to the stream; a consumer that needs to audit afterwards reads the records. * **OTel spans.** Spans carry timing and parent/child structure; records carry decision payload. The two surfaces join on `turnId` — a span tells you *how long* the tool-selection step took, and the matching `ToolSelectionTrace` tells you *what it decided*. * **The fingerprint.** The fingerprint identifies which substrate version wrote the row and what cache bucket the call hit. The record kind identifies what the row describes. Both appear on every row; neither replaces the other. ## Where to go next [#where-to-go-next] <Cards> <Card title="AuditableCall row" href="/docs/auditable-call-row" description="The full row shape and the stage-to-payload map." /> <Card title="Audit ledger" href="/docs/audit-ledger" description="The persistence interface that writes typed records." /> <Card title="Tamper-evident hash chain" href="/docs/hash-chain" description="The chain that hashes typed records as part of each row." /> <Card title="Observability" href="/docs/observability" description="Read-side OTel spans and lifecycle events that join to typed records on `turnId`." /> <Card title="Eval and replay" href="/docs/eval-and-replay" description="The primary consumer of typed records — replay asserts on payload narrowing." /> </Cards> --- # Versioning policy (/docs/versioning) `@pleach/core` ships under semantic versioning with a deliberate deprecation policy. Three orthogonal version axes matter to consumers: the package semver, the schema bundle version, and the `auditRecordVersion` on the audit row. Each axis has its own evolution rules. This page walks them so you know what's compatible across upgrades and what isn't. ## Package semver [#package-semver] Standard semver: `MAJOR.MINOR.PATCH`. | Bump | Triggers | | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PATCH` | Bug fixes, performance improvements, internal refactors with no API surface change | | `MINOR` | New typed config fields, new optional plugin hooks, new subpaths, new SQL files in the schema bundle, new `StreamEvent` variants, new error codes | | `MAJOR` | Removal of a typed config field, signature change to `executeMessage` / `createSession`, removal of a `StreamEvent` variant, removal of a public subpath | Practical implications: * Minor upgrades are always safe — adding a new variant to `StreamEvent` doesn't break `switch`-based consumers that have a `default` arm. * Major upgrades come with a migration note in the changelog. The substrate has shipped one major (1.0.0) so far; future majors will be telegraphed. * Patch upgrades never change runtime behavior in a way that invalidates the cache — same `pleachVersion` field on the fingerprint. ## What lives outside the semver contract [#what-lives-outside-the-semver-contract] A few surfaces are deliberately excluded from the semver guarantee, because pinning them would prevent the substrate from fixing bugs. | Surface | Why | | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | Internal channel names (`__planning__`, `__synth__`, etc.) | Internal; not part of the public contract | | Loader-seam keys under `setHarnessModuleLoader` | Surface is shrinking; key removal isn't a major-version event | | Deep wildcard imports under `@pleach/core/*` | Marked unstable; expect rename / removal without major bump | | Exact bytes of composed prompts under `seedCoreDefaults: true` | Determinism guarantee is "same version, same input"; the version moving is what invalidates | If you depend on any of these for a contract behavior, the right fix is to file an issue asking for the surface to be promoted to a stable subpath. The wildcard-narrowing track in upstream `pa3prep/` does exactly this when there's external demand. ## Deprecation policy [#deprecation-policy] A surface marked deprecated stays in for at least: * **Two minor versions** if it's a typed config field or a documented public function. * **One major version** if it's a structural commitment (a channel kind, an export-map subpath, an audit row column). Deprecation notices land in three places: 1. The package's `CHANGELOG.md` for the version that deprecates. 2. A `@deprecated` JSDoc on the type definition. 3. The relevant docs page on this site. A deprecated surface still works until removed. The deprecation notice tells you what to migrate to and which version removes it. ## Schema bundle versioning [#schema-bundle-versioning] The schema bundle is versioned by file count, not by any in-row column. Files are additive: * New schema files land as `NNN_<table_name>.sql` with the next available `NNN`. The bundle never edits existing files. * File 001 (`harness_sessions`) and file 010 (`harness_auditable_calls`) are the load-bearing files; changing either's column shape is an `auditRecordVersion` event (next section). * New files only add tables or columns; they don't drop or rename. Applying an older bundle against a newer database is safe — the existing tables stay; the new bundle's `IF NOT EXISTS` clauses skip them. Going the other direction (newer bundle, older database) installs new tables; the runtime will use them on the next call. The `schema_version` column on `harness_sessions` is the in-row marker. Bumps coordinate with the package's `SESSION_SCHEMA_VERSION` constant; old rows hydrate through a migration function inside the runtime. ## `auditRecordVersion` [#auditrecordversion] The wire-format version of `AuditableCall` rows. Bumps are gated by an upstream audit (`audit:auditable-call-version`) and coordinate with consumer adapters. ```typescript import { AUDIT_RECORD_VERSION_HISTORY } from "@pleach/core/audit"; for (const entry of AUDIT_RECORD_VERSION_HISTORY) { console.log(`v${entry.to} (${entry.landedAt}): ${entry.reason}`); } ``` The history constant lets consumers render "what changed in v7" at runtime. Each entry carries: | Field | Purpose | | --------------- | ----------------------------------------------------- | | `version` | The wire version this entry describes | | `shippedIn` | The `@pleach/core` package version that introduced it | | `summary` | Short prose description | | `addedFields` | Field names added on this version | | `removedFields` | Field names removed (rare; gated by major bump) | Adapters check this constant during migration to know which payload fields are new. Adding a field to the payload is non-breaking for readers — old rows simply lack the new field. ## Pre-1.0 and reserved placeholders [#pre-10-and-reserved-placeholders] <Callout title="What ships today"> The first-wave publish ceremony cuts every shipping `@pleach/*` package at `0.1.0 · FSL-1.1-Apache-2.0`: `@pleach/core`, `@pleach/tools`, `@pleach/react`, `@pleach/base-tools`, `@pleach/replay`, `@pleach/sandbox`, `@pleach/langchain`, `@pleach/compliance`, `@pleach/eval`, `@pleach/gateway`, `@pleach/mcp`, `@pleach/coding-agent`, `@pleach/observe`, and `@pleach/recipes`. `@pleach/trust-pack` alone remains a reserved npm name at `0.0.1 · UNLICENSED`. See [Packages](/docs/packages) for per-SKU notes (which packages are substrate-complete, which expose a typed contract with one method still throwing a sentinel, and which are pure contract). </Callout> Pin `0.1.0` exactly. The `0.0.x → 0.1.0` jump and the `0.1.x → 1.0.0` jump are both non-caretable, and `^0.1.0` will not pick up later `0.x` releases either. Use `"@pleach/<name>": "0.1.0"` in `dependencies` — not `^0.1.0`. For future `1.x` releases, caret ranges become safe. ## License [#license] `@pleach/*` ships under **FSL-1.1-Apache-2.0** (Functional Source License with Apache 2.0 as the future license). Source-available, usable in production, free of charge during the FSL window; auto- transitions to permissive Apache 2.0 two years after first stable publish. The license text is in `LICENSE` at each package root. `@pleach/trust-pack` remains at `0.0.1 · UNLICENSED` as a reserved npm name with no shipping code. See per-SKU README for the canonical license declaration. ## Migration guides [#migration-guides] Each major release ships a migration guide in the upstream `docs/MIGRATION-vN.md`. The site mirrors the most relevant ones when they affect typical consumers: * [Migrating from the AI SDK](/docs/migrating-from-ai-sdk) — not a version migration, a tool migration. * [Migrating from LangChain](/docs/migrating-from-langchain) — same. Future version migrations (`@pleach/core@2.x` → `3.x`, etc.) will land as `migrating-from-v2.mdx` etc. when they exist. ## Registry-state discipline [#registry-state-discipline] A CI audit (`audit:package-version-vs-registry`) compares the in-tree `package.json#version` for every `@pleach/*` package against the latest tag on npmjs.org. The audit runs on every PR and on `main`. For consumers vendoring `@pleach/*` SKUs, this catches "silent past 1.0.0" drift — when a vendored copy's in-tree version slides past 1.0.0 without a matching registry publish, the audit flags it. Vendored trees and the registry stay aligned, or the build fails. For day-to-day consumers reading from npm, the gate is informational. It asserts the public registry matches what the source tree claims to ship. ## Peer-dependency promotion timing [#peer-dependency-promotion-timing] Peer-dep ranges on `@pleach/core` and `@pleach/react` tightened at the first 1.x cut. The ranges are now a contract, not a suggestion. Policy: peer-dep ranges move on **minor** bumps only — never on patch. Consumer code should pin peer deps explicitly and re-verify ranges at each minor bump. A patch upgrade never forces a peer-dep churn. At a major bump, recompute the full peer-dep tree (peer ranges, peer-meta-dependencies). Major bumps are when wider compatibility changes land. See [publishing contract](/docs/publishing-contract) for the broader packaging conventions. ## Path-alias leaks in published `dist/` [#path-alias-leaks-in-published-dist] For forkers and vendored installs, one packaging failure mode warrants its own audit: workspace path aliases leaking into the published tarball. The class is structural and invisible to most in-monorepo gates. The setup that creates the leak is innocuous. A package's source tree imports a sibling module via a workspace alias — `@/lib/foo`, `@/components/bar`, or a deep relative path like `../../../shared/helpers`. The monorepo's `tsconfig.json` resolves the alias cleanly through its `paths` map. The build emits to `dist/` carrying the alias verbatim. Every in-monorepo gate passes — type-check resolves, source-mode audits resolve, package-build emits artifacts. A standalone consumer running `npm install @pleach/<pkg>` cannot reach those aliases. The published tarball has no `tsconfig.json paths` to resolve them against. The first `import` from the package fails with `ERR_MODULE_NOT_FOUND`. Two audits gate this class. ### `audit:package-runtime-deps` [#auditpackage-runtime-deps] Scans `dist/**/*.{js,cjs,mjs}` for third-party `import`/`require` specifiers and verifies every package name is declared in `dependencies`, `peerDependencies`, or `optionalDependencies`. Catches the case where a runtime import resolves to a sibling package that's only declared as a `devDependency` — fine in the monorepo, broken in a consumer install. ### `audit:package-dist-runtime-aliases` [#auditpackage-dist-runtime-aliases] Scans `dist/**/*.{js,cjs,mjs}` for workspace-internal aliases (`@/lib/...`, `@/components/...`, `@/hooks/...`, and every other top-level workspace alias) and bare relative paths that escape the package root (`../../../...` chains walking above `dist/`). Either class resolves only inside the monorepo; both surface as runtime failures in a standalone consumer. The audit has two modes. Single-package mode (`--package <path>`) scans one package, used during routine PR-time gating. Multi-package mode (`--all-packages`) auto-discovers every `@pleach/*` package under `packages/` and scans each one — used before any coordinated multi-package release or first-publish rehearsal. `:include-dts` extends the scan to `*.d.ts` files. Type-only leaks break consumer `tsc` even when the runtime build resolves, since a consumer's TypeScript can't see the workspace alias either. The runtime sweep and the type sweep are independent — both must pass for a clean publish. The remediation pattern, when an alias leak is found, is one of: * Verbatim relocate the imported module into the package's own source tree, eliminating the cross-tree import. * Dep-invert via a host-supplied factory — the package defines an interface; the host implements it at runtime. * Add the import to the package's per-package allowlist with a documented rationale (used for genuine consumer-extension hooks where the alias points to an opt-in host integration). See [Publishing contract](/docs/publishing-contract) for the broader pre-publish gate set this audit pair sits inside. ## Where to go next [#where-to-go-next] <Cards> <Card title="Subpath exports" href="/docs/subpath-exports" description="Which subpaths are stable, which are unstable wildcards." /> <Card title="Schema" href="/docs/schema" description="The schema-bundle file list and the additive rule." /> <Card title="Audit ledger" href="/docs/audit-ledger" description="`AUDIT_RECORD_VERSION_HISTORY` and the version-bump contract." /> <Card title="FAQ" href="/docs/faq" description="The FSL-1.1-Apache-2.0 license question, distilled." /> </Cards> --- # What's new — June 2026 (/docs/whats-new-2026-06) June 2026 was a heavy substrate month. Work landed across `@pleach/core`, `@pleach/gateway`, `@pleach/compliance`, `@pleach/replay`, `@pleach/eval`, `@pleach/coding-agent`, and `@pleach/recipes` — multi-tenant gateway primitives, replay-fork operations, real recipe bodies for the education, government, and cloud-mediated patterns, and the swarm spawn-event substrate. This page is the consumer-facing summary: what shipped per package and where to read more. ## `@pleach/gateway` — auth, BYOK, cost, region, failover, identity, swarm [#pleachgateway--auth-byok-cost-region-failover-identity-swarm] Multi-tenant routing got its substrate. * **Auth adapters.** Tenant identity resolves from JWT claims, the `X-Tenant-Key` header, Okta sessions, or Clerk sessions — without you writing the boilerplate. * **BYOK substrate.** `TenantCredentialStore` plus Postgres / Redis / external-secret-manager adapters. Per-tenant provider keys resolve at request time, and the gateway routes the call with the right credential. * **Cost adapters.** `CostEmitter` plus OTel, Postgres, Datadog, and Honeycomb sinks. Pick whichever observability stack you already run. * **Region routing.** `RegionRouter`, `RegionAwareCascade`, and `RegionDrPolicy` — region-aware routing, cascade-time region constraints, and DR policy in active-passive, active-active, or warm-standby modes. Deterministic ordering for replay safety. * **Failover.** `FailoverPolicy` (chain progression + linear backoff) and `FailoverMiddleware` (transient-error retry + `onExhausted` callback), with a `FailoverExhausted` event. * **Federated identity.** `IdentityProvider` (`kind: "federated"`, distinct from static BYOK) + `TokenCache` (LRU + TTL) + AWS STS, Azure managed identity, and GCP workload identity providers — all via structural peer interfaces, with no `@aws-sdk/*` / `@azure/identity` / `google-auth-library` runtime requirement. * **Swarm governance.** `RootTurnCostAggregator` + `CostCeilingMiddleware` (throws `CostCeilingExceededError` on breach) + `FailoverCoordinator` + `SwarmTopologyClassifier`. Read more → [`@pleach/gateway`](/docs/gateway). ## `@pleach/coding-agent` — file tools + sandbox facade + policy [#pleachcoding-agent--file-tools--sandbox-facade--policy] The vendor-neutral file/diff/exec tool surface. * **Read tools.** `read_file`, `write_file`, `apply_diff`, `search_files`, `list_files` — vendor-neutral, working against any provider that supports tool calls. * **Exec tools.** `run_tests`, `git_clone`, `git_diff`, bound to the runtime-adjacency contract that controls when a tool fires. * **Sandbox facade.** `SandboxProvider` facade + `SandboxComposite` for stitching multiple sandbox providers together. * **Policy + facade.** `createCodingAgentPolicy({ maxSynthesizePerTurn: 5 })` ships the opinionated coding-agent default — five synthesizes per turn, matching the `plan → diff → verify → retry → verify` loop. `createPerCallClassRouting({ routes })` layers per-CallClass model overrides on top. Read more → [`@pleach/coding-agent`](/docs/coding-agent) and [Multi-synthesize](/docs/coding-agent/multi-synthesize). ## `@pleach/eval` — divergence, benchmarks, parity [#pleacheval--divergence-benchmarks-parity] * **Divergence reporting.** `DivergenceReporter` + distance utilities * heatmap visualization. Compare model-A vs model-B outputs at the row level. * **Benchmarks + cost estimation.** MMLU, SWE-bench, HumanEval, and custom-domain loaders, plus `CostEstimator` for projecting a benchmark run's spend before you fire it. * **Configuration parity.** `runParitySuite` + `computeDivergence` — a model-swap matrix where each run is a different configuration over the same scenarios. Sibling of the within-swarm `runSwarmParity`. Read more → [`@pleach/eval`](/docs/eval) and [Parity](/docs/eval/parity). ## `@pleach/replay` — cache adapters, fork, strict [#pleachreplay--cache-adapters-fork-strict] * **Cache adapters.** Disk / Redis / S3 cache adapters plus `CacheMiddleware`. Replay runs share inference results across re-runs, so an eval loop costs what one run costs. * **Fork + strict.** `forkAtEvent` branches replay at a specific event; `StrictReplay` is production-grade replay-with-tolerance; `captureSlots` captures replay slots; spawn-order serialization keeps swarm replay deterministic. Read more → [`@pleach/replay`](/docs/replay). ## `@pleach/compliance` — redaction, audit query, hash chain, erasure [#pleachcompliance--redaction-audit-query-hash-chain-erasure] * **Redaction pipeline.** `RedactionGate` + `RedactionPipeline` + Presidio / LLM / PHI scrubbers. * **Audit query + export.** `queryAudit` + `exportAuditPdf` + `DeterminismCertifier` + RLS templates — the surface a regulator queries against and the PDF export they read in a deposition. * **Hash-chain middleware.** `createHashChainMiddleware({ complianceRuntime, tenantId, chatId })` decorates each event with `rowHash` + `prevHash` before the host writer canonicalizes and persists; `verifyAfterBatch(rows)` returns `{ chainValid, failedIndex? }`. * **GDPR erasure.** `executeErasureRequest({ request, eventStore, rebuildChainFromGenesis })` is the structural primitive for GDPR Article 17 erasure. See [GDPR right-to-erasure](/docs/compliance/gdpr-erasure) for the honest scope-limits on crypto-shredding vs row-deletion + chain rebuild. Read more → [`@pleach/compliance`](/docs/compliance). ## `@pleach/core` — spawn substrate, multi-synthesize, fixes [#pleachcore--spawn-substrate-multi-synthesize-fixes] * **Canonical spawn.** The `spawn()` factory wraps a nested `SessionRuntime` via a `SpawnableParent` interface. Three fan-out caps ship with sensible defaults (`depth=4`, `perTurn=10`, `total=40`); a breach throws `SubagentFanOutExceeded` with a structural `.cap` shape. `SpawnEvent` canonical schema + `RootTurnRollup` + `recordSpawn`. * **Multi-synthesize contract.** `SessionRuntimeConfig.maxSynthesizePerTurn?: number` plus an optional `TurnSynthesizeCounter.setMaxPerTurn?(max)`. The singleton-seam invariant stays; only the per-turn invocation cap relaxes — the widening that unblocks the coding-agent reflection loop. * **Air-gapped substrate.** `AirGappedRuntimeOption` + `AirGappedHostRejectedError`, with fail-closed-on-empty-allowlist semantics and subdomain matching with leading-dot normalization. * **`generateClientId` jsdom/SSR fix.** Now guards `window.localStorage` consistently across jsdom and SSR, eliminating a hydration flake. * **Quickstart regression locks + plugin examples.** Regression tests covering `createPleachRoute()`, `useChat()`, and `providerDetection`, plus runnable plugin/observer examples — `empty-plugin`, `domain-entity-recognition`, `custom-event-channel`, a `dialect-normalization` stream-observer, and a `halt-on-pattern` observer. Read more → [`@pleach/core`](/docs/core) and [Subagents](/docs/subagents). ## `@pleach/recipes` — real bodies for three patterns [#pleachrecipes--real-bodies-for-three-patterns] The previously-stubbed recipe bodies now compose real pieces. * **`educationAgent`.** Composes the student-safety scrubber with a per-tenant cost cap. * **`governmentAgent`.** Composes the air-gapped runtime + IAM federated identity + audit export. * **`cloudMediatedAgent`.** Composes AWS / Azure / GCP identity providers with region routing + failover. Read more → [Recipes](/docs/recipes-pleach-recipes). ## Publishing prep [#publishing-prep] CI scaffolds for SBOM emission and npm publish with provenance attestations landed, plus a license matrix covering every SKU's FSL-1.1-Apache-2.0 transition surface — the groundwork for government procurement conversations and ISV SBOM-conformance requirements. ## Where to read more [#where-to-read-more] <Cards> <Card title="@pleach/gateway" href="/docs/gateway" description="Auth, BYOK, cost, region, failover, identity, swarm governance." /> <Card title="@pleach/compliance" href="/docs/compliance" description="Redaction pipeline, scrubbers, audit query, PDF export, RLS templates." /> <Card title="@pleach/replay" href="/docs/replay" description="Cache adapters, fork, strict replay, spawn-order serialization." /> <Card title="@pleach/eval" href="/docs/eval" description="Divergence reporting, benchmark loaders, cost estimation, parity." /> <Card title="Subagents" href="/docs/subagents" description="Canonical spawn substrate, fan-out caps, swarm topology." /> <Card title="Recipes" href="/docs/recipes-pleach-recipes" description="Real composition bodies for the patterns above." /> </Cards> --- # Which SKU do I need? (/docs/which-sku) The `@pleach/*` surface is 14 published packages (plus `@pleach/observe` in alpha and the reserved `@pleach/trust-pack`). Most projects need **three or four**. The packages are branches grafted onto one lattice — pick the branches that carry weight for what you're building, leave the rest for later. This page maps what you're building to the shortest install list, plus a one-line reason for each. If you're not sure, start with the first row — `@pleach/core` alone covers more cases than you'd guess. ## The shortest answer [#the-shortest-answer] | You want | Install | Read | | --------------------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | **A streaming chat handler in 5 lines** | `@pleach/core` | [Getting started](/docs/getting-started) | | **A chatbot with a working UI** | `@pleach/core`, `@pleach/react` | [`recipes/simple-chatbot`](/docs/recipes/simple-chatbot) | | **RAG over your knowledge base** | `@pleach/core`, `@pleach/react` (+ your vector store) | [`recipes/rag-chatbot`](/docs/recipes/rag-chatbot) | | **An agent with tools** | `@pleach/core`, `@pleach/base-tools`, `@pleach/tools` | [Tools](/docs/tools), [`@pleach/base-tools`](/docs/base-tools) | | **A coding agent (SWE-bench-shaped)** | `@pleach/core`, `@pleach/coding-agent`, `@pleach/sandbox` | [`@pleach/coding-agent`](/docs/coding-agent) | | **A multi-tenant SaaS with per-tenant cost rollup** | `@pleach/core`, `@pleach/gateway` | [Multi-tenant](/docs/multi-tenant), [Gateway](/docs/gateway) | | **OTel-instrumented observability** | `@pleach/core` (OTel is built in); `@pleach/observe` (alpha) adds destination plugs | [`@pleach/observe`](/docs/observe) | | **PII / PHI scrubbing for HIPAA / GDPR / PCI-DSS** | `@pleach/core`, `@pleach/compliance`, `@pleach/compliance-contract` | [Compliance](/docs/compliance), [Scrubbers](/docs/scrubbers) | | **Regression-grade evals against the audit log** | `@pleach/core`, `@pleach/eval`, `@pleach/replay` | [`@pleach/eval`](/docs/eval), [`@pleach/replay`](/docs/replay) | | **An MCP server backed by Pleach sessions** | `@pleach/core`, `@pleach/mcp` | [`@pleach/mcp`](/docs/mcp) | | **A LangChain / LangGraph adapter layer** | `@pleach/core`, `@pleach/langchain` | [`@pleach/langchain`](/docs/langchain), [Migrating from LangChain](/docs/migrating-from-langchain) | The recipes column on the right is the canonical worked example for each row. Most of them are under 100 lines of glue over `@pleach/core`. ## Shortcuts by shape [#shortcuts-by-shape] If "what are you building?" is too narrow a question, here are seven common shapes the runtime is built around. Each maps to a recipe in `@pleach/recipes` and a SKU bundle. ### SaaS adding a chat tab [#saas-adding-a-chat-tab] A typical multi-tenant web app that wants a chatbot tab. You own your auth, your DB, your billing. Add Pleach without touching any of them. **Install:** `@pleach/core`, `@pleach/react` **Recipe:** [`simpleChatbot`](/docs/recipes/simple-chatbot) **Reach for next:** `@pleach/observe` (alpha) once you want per-tenant cost rollup. ### Vertical agent (specific tools, specific knowledge) [#vertical-agent-specific-tools-specific-knowledge] You're shipping an agent for a specific domain — legal review, medical coding, ops triage — with a curated tool set and a knowledge base. Not generic ChatGPT-shaped. **Install:** `@pleach/core`, `@pleach/base-tools`, `@pleach/tools` **Recipe:** [`verticalAgent`](/docs/recipes/vertical-agent) ### Compliance-bound (regulated industry) [#compliance-bound-regulated-industry] HIPAA, GDPR, PCI-DSS, SOC 2. You need scrubbers on inbound content, audit attestation on every call, and a hash chain you can prove to a regulator. **Install:** `@pleach/core`, `@pleach/compliance`, `@pleach/compliance-contract` **Recipe:** [`compliantChatbot`](/docs/recipes/compliant-chatbot) **Reach for next:** `@pleach/replay` once you need chain-verifier attestation. ### On an existing Anthropic / OpenAI Enterprise contract [#on-an-existing-anthropic--openai-enterprise-contract] You already have an Anthropic Enterprise or OpenAI Enterprise contract — SSO, ZDR, Workspaces / Projects. You want per-axis cost rollup *inside* one Workspace without a new vendor. **Install:** `@pleach/core`, `@pleach/gateway` (+ `@pleach/observe`, alpha) **Recipe:** [`enterpriseAgent`](/docs/recipes/enterprise-agent) **Read first:** [Migrating from Anthropic Enterprise](/docs/migrating-from-anthropic-enterprise) or [Migrating from OpenAI Enterprise](/docs/migrating-from-openai-enterprise). ### Multi-tenant platform (per-customer BYOK + billing) [#multi-tenant-platform-per-customer-byok--billing] You're running multiple customer agents on the same substrate, with per-customer BYOK, per-customer policies, and per-customer billing. **Install:** `@pleach/core`, `@pleach/gateway`, `@pleach/compliance` **Recipe:** [`enterpriseAgent`](/docs/recipes/enterprise-agent) * [Multi-tenant SaaS agent](/docs/multi-tenant-saas-agent). ### Subagent swarm (bounded fan-out under a root turn) [#subagent-swarm-bounded-fan-out-under-a-root-turn] A root agent that spawns N specialist sub-agents and reconciles their answers, with a per-root-turn cost ceiling. Common shape for research, planning, and refactor agents. **Install:** `@pleach/core`, `@pleach/recipes` **Recipe:** [`subagentSwarm`](/docs/recipes/subagent-swarm) ### Regression-grade eval lab [#regression-grade-eval-lab] You want fixture-driven eval against the audit log with replay determinism and Welch t-test scoring. CI-runnable, not notebook-shaped. **Install:** `@pleach/core`, `@pleach/eval`, `@pleach/replay` **Recipe:** [`evalLab`](/docs/recipes/eval-lab) ## When you don't need a sibling SKU [#when-you-dont-need-a-sibling-sku] `@pleach/core` already ships these in the substrate; you don't need a sibling for them: * **Session lifecycle, channels, checkpointing.** Built-in. * **Storage adapters** (Memory / IndexedDB / Supabase). Built-in. * **Family-lock, model resolution matrix, transport selection.** Built-in. * **`AuditableCall` row + event log + hash chain (schema-side).** Built-in. The verifier ships in `@pleach/replay`. * **OpenTelemetry spans (session.turn, llm.invocation, etc.).** Built-in. `@pleach/observe` (alpha, not yet on npm) adds destination plugs (Postgres, Supabase, Pleach Hosted); core ships the OTel emission. * **Plugin contract.** Built-in. Authoring conventions in [Plugin authoring standards](/docs/plugin-authoring-standards). * **The four-stage lattice + singleton synthesize seam.** Built-in invariants enforced by lint on the core repo. ## Quick "do I install this?" gut-checks [#quick-do-i-install-this-gut-checks] A few SKUs get asked about more than others. Short answers: * **`@pleach/base-tools`** — install if you want a working agent today and you don't have a domain tool set yet. Ships math, datetime, scratchpad, unit\_convert, text\_search, opt-in url\_fetch. * **`@pleach/tools`** — install if you're authoring tool definitions. Provides the contract + Zod helpers + queryable registry. Skip if you're using `@pleach/base-tools` directly. * **`@pleach/sandbox`** — install if you're running untrusted code (coding agent, user-supplied scripts). Contract today; vendor adapters land under `@pleach/sandbox-<vendor>`. * **`@pleach/recipes`** — install if you want high-level factories (`simpleChatbot`, `ragChatbot`, `compliantChatbot`, `verticalAgent`, `subagentSwarm`, `enterpriseAgent`) instead of wiring `SessionRuntime` from scratch. * **`@pleach/trust-pack`** — reserved name. Not shipping code today. Don't install. ## Where to go next [#where-to-go-next] <Cards> <Card title="Packages" href="/docs/packages" description="The canonical per-SKU status table — versions, links, contract status." /> <Card title="Recipes" href="/docs/recipes" description="End-to-end runnable patterns — Next.js chat, OTel, multi-tenant, compliance." /> <Card title="Comparison" href="/docs/comparison" description="How @pleach/core compares to the AI SDK, LangChain, LlamaIndex, Mastra, and to agent harnesses." /> <Card title="Adoption paths" href="/docs/adoption-paths" description="Three phased on-ramps — from one-file prototype to multi-tenant production." /> </Cards> --- # Pleach + Anthropic SDK (/docs/with-anthropic-sdk) This page is the *coexist* pattern: `@pleach/core` underneath `@anthropic-ai/sdk`, neither package replacing the other. If you're choosing between keeping the Anthropic SDK and moving an existing Anthropic Enterprise contract behind Pleach, see [Migrating from Anthropic Enterprise](/docs/migrating-from-anthropic-enterprise) for the contract-side framing. The other coexist patterns — [OpenAI SDK](/docs/with-openai-sdk), [Mastra](/docs/with-mastra), [Inngest](/docs/with-inngest) — follow the same posture. The Anthropic SDK and Pleach aren't competing for the same slot. `@anthropic-ai/sdk` owns the **transport** — the typed `client.messages.create` surface, prompt-caching breakpoints, the latest tools API, the batches API, the files API, extended thinking. Pleach owns the **substrate around the call** — the typed `AuditableCall` row in your Postgres, family-lock at session start, replay-deterministic streaming, subagent cost rollup to the parent `turnId`. If you wired up the Anthropic SDK directly, don't tear it out. Drop Pleach underneath via `AnthropicSdkProvider` — the SDK keeps running, you gain a per-tenant audit row, and the option of adding OpenAI or Google later without rewriting the call site. If your team graduated from a self-serve API key to an Anthropic Enterprise contract, the coexist shape doesn't change — Pleach runs underneath the SDK regardless of the billing model. The contract closes vendor-side properties (SSO, ZDR, Workspaces, the Admin API). The substrate closes the three downstream walls: per- axis rollup inside one Workspace (external customers or internal employees, teams, cost centers), a hash-chained `AuditableCall` row in your own Postgres, and replay-deterministic regression across model snapshots. See [Migrating from Anthropic Enterprise](/docs/migrating-from-anthropic-enterprise) for the contract-side walk-through. ## The architecture [#the-architecture] ``` ┌─────────────────────────────────────────────────────────────┐ │ Pleach SessionRuntime │ │ - AuditableCall row → your Postgres │ │ - Family-lock at session start │ │ - Replay-deterministic StreamEvent │ │ - Subagent rollup to parent turnId │ │ │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ AnthropicSdkProvider │ │ │ │ (wraps @anthropic-ai/sdk directly) │ │ │ │ │ │ │ │ Anthropic SDK │ │ │ │ - client.messages.create │ │ │ │ - prompt-caching breakpoints │ │ │ │ - latest tools API │ │ │ │ - batches API, files API │ │ │ │ - extended thinking │ │ │ │ │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────┘ ``` The Anthropic SDK's request log is the **transport** trail — what hit the API, what HTTP status came back, which cache prefix hit. Pleach's `AuditableCall` ledger is the **business** trail — which turn the user typed, which tools the agent invoked, which subagents spawned, what each cost, attributed to the turn that caused them. Finance reads Pleach; SDK telemetry stays at the transport layer. ## The code shape [#the-code-shape] ```typescript import { createPleachRuntime, AnthropicSdkProvider, type StreamEvent, } from "@pleach/core"; import { SupabaseAdapter } from "@pleach/core/sessions"; const runtime = createPleachRuntime({ tenantId: "acme-corp", userId: "alice@example.com", storage: new SupabaseAdapter({ client: supabase }), // AnthropicSdkProvider wraps @anthropic-ai/sdk directly. Prompt // caching breakpoints, the latest tools API, batches, and files // all keep working — Pleach owns the audit row, not the // transport. provider: new AnthropicSdkProvider({ apiKey: process.env.ANTHROPIC_API_KEY!, model: "claude-sonnet-4-5", maxTokens: 4096, }), }); // Lock the family + transport at session creation. The cascade // walks in-family rungs only — no silent cross-family widening // mid-conversation if you add OpenAI fallback later. const session = await runtime.sessions.create({ provider: { type: "anthropic" }, model: { id: "claude-sonnet-4-5" }, }); // Every StreamEvent this iterator yields was paired with an // AuditableCall row in your Postgres — keyed // (sessionId, turnId, stageId, seqWithinTurn), carrying tenantId, // toolName, subagentDepth, modelId, and tokenUsage. for await (const event of runtime.executeMessage( session.id, "Summarize the latest filings.", )) { // event: StreamEvent — message.delta, tool.started, tool.completed, ... } ``` Three things this pattern gives you that calling `@anthropic-ai/sdk` directly doesn't: 1. **The `AuditableCall` row lands in your Postgres** during the call, not as an afterthought. Finance can `SELECT SUM(token_usage) FROM harness_auditable_calls WHERE tenant_id = ? AND created_at > ?` without parsing SDK request logs or scraping the Anthropic console. The row exists whether the HTTP call succeeds, retries, or fails permanently. 2. **Family-lock prevents silent cross-family widening.** The session locks tokenizer, prompt-cache key, tool-call dialect, and refusal pattern at `runtime.sessions.create()`. When you add OpenAI as a fallback later, the cascade walks in-family rungs only — a user mid-conversation on Anthropic never gets silently routed to GPT because of a transient 5xx. 3. **Subagents spawned by the agent roll their cost back to the parent `turnId`** via `SpawnTreeState`. The Anthropic SDK doesn't model subagents — per-token cost attribution between a parent turn and the children it spawned is your code's problem without Pleach, and a one-line `GROUP BY turn_id` with it. A note on prompt caching: Anthropic's prompt-cache breakpoints (the `cache_control: { type: "ephemeral" }` markers on system prompts and tool definitions) **keep working unchanged** — the SDK speaks to the API, Pleach doesn't intercept the request body. Pleach's own [fingerprint](/docs/fingerprint) is the cache key for *replay*, a different concern from provider-side prefix reuse. See [Prompt caching](/docs/prompt-caching) for the distinction. ## Where each is load-bearing [#where-each-is-load-bearing] | Concern | Anthropic SDK's slot | Pleach's slot | | ------------------------------------------------------- | ----------------------------- | -------------------------------------------------------- | | `client.messages.create` typed surface | yes (their core primitive) | None — wraps it | | Prompt-caching breakpoints (`cache_control: ephemeral`) | yes (passes through to API) | None — provider-side feature | | Latest tools API + tool-use beta flags | yes | None — provider-side feature | | Batches API, files API | yes | None — provider-side feature | | Extended thinking | yes | Surfaced as `thinking.delta` `StreamEvent`s | | HTTP retry / 429 backoff | yes (SDK-internal) | Re-runs at the seam if you want it there | | Per-tenant LLM cost rollup in YOUR schema | None — request log is opaque | `AuditableCall` row keyed `(tenantId, turnId)` | | Tamper-evident audit trail | None | `prev_hash` + `row_hash` in your DB | | Family-locked provider routing | Single-vendor by construction | locked at `runtime.sessions.create({ provider, model })` | | Deterministic replay of the LLM stream | None | Fingerprint replays byte-identical `StreamEvent`s | | Subagent cost rollup to parent `turnId` | None | `SpawnTreeState` | | Time-travel checkpoints inside a session | None | `runtime.checkpoints.rollback()` / `.list()` | | Multi-provider portability (add OpenAI later) | None (Anthropic-only) | Same call site, new family + lock | | OTel span set with auto parent-threading | None | `runtime.spans` facet | The two coverage maps barely overlap. The Anthropic SDK is the right tool for talking to Anthropic; Pleach is the right tool for everything *around* talking to Anthropic. Use the Anthropic SDK for its native primitives. Use Pleach for the audit row, family-lock, and replay. Neither does the other's job. ## When you don't need the Anthropic SDK directly [#when-you-dont-need-the-anthropic-sdk-directly] * You don't need prompt-caching breakpoints, batches, files, or any other Anthropic-specific feature. The `AiSdkProvider` route through `@ai-sdk/anthropic` is simpler, and the audit row looks the same. See [Providers](/docs/providers). * You're already on multi-provider routing from day one and the unified AI SDK shape pays back across families. Reach for `AiSdkProvider` and stop one layer earlier. ## When you don't need Pleach [#when-you-dont-need-pleach] * A single-shot chat with the Anthropic SDK, tools, no persistence, no per-tenant audit obligation. Twenty lines of `client.messages.create` and a `for await` loop is the right floor — Pleach's overhead doesn't pay back. The [comparison page](/docs/comparison) covers when to stay on a direct SDK call. * Single-tenant internal tooling where the SDK's request log is a good-enough audit trail and you're not going to add a second provider. The family-lock and the `AuditableCall` row are optimizing for problems you don't have. ## Migrating from a direct Anthropic SDK call [#migrating-from-a-direct-anthropic-sdk-call] The migration shape is small: keep your `@anthropic-ai/sdk` dependency, install `@pleach/core`, wrap your client construction in `AnthropicSdkProvider`, and replace the `client.messages.create` call with `runtime.executeMessage`. Your prompt-caching breakpoints, tool definitions, and beta headers carry through unchanged. The first session you run produces an `AuditableCall` row in your Postgres; everything downstream of that — per-tenant cost queries, replay determinism, subagent rollup — is then available, whether or not you use it on day one. ## Where to go next [#where-to-go-next] <Cards> <Card title="Migrating from Anthropic Enterprise" href="/docs/migrating-from-anthropic-enterprise" description="The matching contract-side page — keep the Enterprise SLA, add the AuditableCall row, per-end-customer rollup, and replay-deterministic eval." /> <Card title="Pleach + OpenAI SDK" href="/docs/with-openai-sdk" description="The same coexist posture for OpenAI's transport — Chat Completions, the Responses API, plus the Assistants API caveat." /> <Card title="Pleach + Mastra" href="/docs/with-mastra" description="Coexist with Mastra's workflow + hosted dashboard, with the audited LLM turn inside it." /> <Card title="The AuditableCall row" href="/docs/auditable-call-row" description="What lands in your Postgres on every LLM call, and what you can join it against." /> </Cards> --- # Pleach + Inngest (/docs/with-inngest) This page is the *coexist* pattern: `@pleach/core` as the LLM-turn substrate inside an Inngest `step.run`, neither package replacing the other. The other coexist patterns — [Anthropic SDK](/docs/with-anthropic-sdk), [OpenAI SDK](/docs/with-openai-sdk), [Mastra](/docs/with-mastra) — follow the same posture; if you're moving off a different stack entirely, see the [Migrate & coexist](/docs/migrating-from-ai-sdk) docs. Inngest and Pleach aren't competing for the same slot. Inngest wraps the **request** — durable steps, retries, scheduling, cron, fan-out, a hosted run dashboard. Pleach owns the **LLM turn inside the request** — the typed `AuditableCall` row, family-locked provider routing, replay-deterministic streaming, subagent cost rollup to the parent `turnId`. If you're already on Inngest, don't rip it out. Wrap your `executeMessage` call in `step.run` and add Pleach as the substrate inside the step. The same pattern applies to Trigger.dev, DBOS, Temporal, and any other durable-execution platform. ## The architecture [#the-architecture] ``` ┌─────────────────────────────────────────────────────────────┐ │ Inngest function │ │ - Durable retry across crashes │ │ - Step memoization │ │ - Run dashboard │ │ - Cron / event-triggered │ │ │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ step.run("agent-turn", async () => { │ │ │ │ │ │ │ │ Pleach SessionRuntime │ │ │ │ - AuditableCall row → your Postgres │ │ │ │ - Family-lock at session start │ │ │ │ - Replay-deterministic StreamEvent │ │ │ │ - Subagent rollup to parent turnId │ │ │ │ │ │ │ │ }) │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────┘ ``` Inngest's run history is the **operational** trail — what fired, what retried, what crashed. Pleach's `AuditableCall` ledger is the **business** trail — what tools the agent invoked, which subagents spawned, what each cost, attributed to the turn the user typed. Finance reads Pleach; on-call reads Inngest. ## The code shape [#the-code-shape] ```typescript // inngest/handleChatTurn.ts import { inngest } from "./inngest-client"; import { createPleachRuntime, type StreamEvent, } from "@pleach/core"; import { SupabaseAdapter } from "@pleach/core/sessions"; import { AiSdkProvider } from "@pleach/core"; import { anthropic } from "@ai-sdk/anthropic"; export const handleChatTurn = inngest.createFunction( { id: "chat-turn", retries: 3 }, { event: "chat/turn.requested" }, async ({ event, step }) => { // Build the runtime once per request. createPleachRuntime is // the zero-config factory — pass the per-request fields. const runtime = createPleachRuntime({ tenantId: event.data.tenantId, userId: event.data.userId, storage: new SupabaseAdapter({ client: supabase }), provider: new AiSdkProvider({ model: anthropic("claude-sonnet-4-5"), }), }); // step.run gives durable retry + memoization across crashes. // Inside it, runtime.executeMessage gives you the AuditableCall // row in your Postgres, family-lock, replay determinism, and // the subagent rollup — none of which Inngest tracks. const events = await step.run("agent-turn", async () => { const collected: StreamEvent[] = []; for await (const evt of runtime.executeMessage( event.data.sessionId, event.data.prompt, )) { collected.push(evt); } return collected; }); return { events }; }, ); ``` Three things this pattern gives you that AgentKit alone doesn't: 1. **The `AuditableCall` row lands in your Postgres** while the step is running. Finance can `SELECT SUM(token_usage) FROM harness_auditable_calls WHERE tenant_id = ? AND created_at > ?` without parsing Inngest's run history. The row exists whether the Inngest step succeeds or retries or fails permanently — it was written during the LLM call, not at function return. 2. **Family-lock survives `step.run` retry.** If the step retries because of a network blip, Pleach replays against the same family + transport the session locked at start — never silently widens to a different provider mid-conversation. 3. **Subagents spawned by the agent roll their cost back to the parent `turnId`** via `SpawnTreeState`. Inngest's `step.invoke` can spawn child functions but per-token cost attribution between parent and child is your code's problem. ## Where each is load-bearing [#where-each-is-load-bearing] | Concern | Inngest's slot | Pleach's slot | | ----------------------------------------- | ----------------------------------------- | -------------------------------------------------------- | | Crash recovery across function boundaries | Step memoization | None — Pleach is per-turn | | Retry on transient errors | `retries:` config + backoff | Re-runs inside the step | | Cron / event-triggered execution | `inngest.createFunction` triggers | None | | Fan-out / map across N items | `step.invoke` parallel | None | | Per-tenant LLM cost rollup | Manual via `event.data` + dashboard query | `AuditableCall` row keyed `(tenantId, turnId)` | | Tamper-evident audit trail | Inngest run history (vendor-shaped) | `prev_hash` + `row_hash` in your DB | | Family-locked provider routing | None | locked at `runtime.sessions.create({ provider, model })` | | Deterministic replay of the LLM stream | Step memoization re-runs your code | Fingerprint replays byte-identical `StreamEvent`s | | Subagent cost rollup to parent | Manual | `SpawnTreeState` | | Time-travel checkpoints inside a session | None | `runtime.checkpoints.rollback()` / `.list()` | | Operational dashboard (what fired when) | Built-in | None — bring Datadog / Honeycomb | The two coverage maps barely overlap. That's why the partnership is honest — neither package is doing the other's job badly. ## When you don't need Inngest [#when-you-dont-need-inngest] * A request-scoped agent turn that completes inside one HTTP request, with no external waits, no cron, no fan-out. You can call `runtime.executeMessage` directly from an API route. * The Pleach checkpointer covers your in-session time-travel. Cross-request durability — i.e. "if the server crashes mid-turn, resume the turn on restart" — is Inngest's slot, not Pleach's. ## When you don't need Pleach [#when-you-dont-need-pleach] * No multi-tenant concern, no audit obligation, no replay requirement. The AI SDK + Inngest combo is enough. * Single-shot chatbot with tools, no persistence, no compliance surface. Pleach's overhead doesn't pay back at this size — the [comparison page](/docs/comparison) covers when to stay on the AI SDK alone. ## Trigger.dev, DBOS, Temporal, durable-execution generally [#triggerdev-dbos-temporal-durable-execution-generally] The same pattern works for any durable-execution platform. The shape is "wrap `runtime.executeMessage` inside the platform's durable-task primitive." Pleach doesn't compete with any of them for the durability slot — it occupies the LLM-turn slot inside the durable task. ## Where to go next [#where-to-go-next] <Cards> <Card title="Pleach + Mastra" href="/docs/with-mastra" description="The same coexist posture for workflows + the hosted observability dashboard." /> <Card title="Pleach + Anthropic SDK" href="/docs/with-anthropic-sdk" description="Coexist with the Anthropic SDK as transport — prompt caching, the latest tools API, batches, files." /> <Card title="Pleach + OpenAI SDK" href="/docs/with-openai-sdk" description="Coexist with the OpenAI SDK — Chat Completions, the Responses API, structured outputs." /> <Card title="The AuditableCall row" href="/docs/auditable-call-row" description="What lands in your Postgres on every LLM call, and what you can join it against." /> </Cards> --- # Pleach + Mastra (/docs/with-mastra) This page is the *coexist* pattern: `@pleach/core` as the LLM-turn substrate inside a Mastra workflow step, neither package replacing the other. The other coexist patterns — [Anthropic SDK](/docs/with-anthropic-sdk), [OpenAI SDK](/docs/with-openai-sdk), [Inngest](/docs/with-inngest) — follow the same posture; if you're moving off a different orchestration stack, see the [Migrate & coexist](/docs/migrating-from-langchain) docs. Mastra and Pleach aren't competing for the same slot. Mastra wraps the **workflow** — `Workflow` and `Agent` primitives, step-based durable execution, vector DB integrations, an evals module, a hosted observability dashboard. Pleach owns the **LLM turn inside the workflow step** — the typed `AuditableCall` row in *your* Postgres, family-locked provider routing, replay-deterministic streaming, subagent cost rollup to the parent `turnId`. If you're already on Mastra, don't rip it out. Wrap your `runtime.executeMessage` call inside a Mastra workflow step — or use Pleach's `SessionRuntime` as the engine behind what would otherwise be a Mastra `Agent`. The hosted dashboard, the workflow graph, and the evals stay; the agent turn underneath gains the audit row, the family lock, and the replay guarantee. ## The architecture [#the-architecture] ``` ┌─────────────────────────────────────────────────────────────┐ │ Mastra Workflow │ │ - Step-based durable execution │ │ - Hosted observability dashboard │ │ - Vector DB integration │ │ - Evals module │ │ - Agent network / suspend + resume │ │ │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ step.execute(async ({ inputData }) => { │ │ │ │ │ │ │ │ Pleach SessionRuntime │ │ │ │ - AuditableCall row → your Postgres │ │ │ │ - Family-lock at session start │ │ │ │ - Replay-deterministic StreamEvent │ │ │ │ - Subagent rollup to parent turnId │ │ │ │ │ │ │ │ }) │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────┘ ``` Mastra's workflow events and hosted dashboard are the **operational** trail — what step ran, what suspended, what the graph looked like, what the eval scored. Pleach's `AuditableCall` ledger is the **business** trail — what tools the agent invoked, which subagents spawned, what each cost, attributed to the turn the user typed, written to a row in *your* Postgres. Finance reads Pleach; product and on-call read Mastra. ## Vocabulary mapping [#vocabulary-mapping] If you're coming from Mastra docs, the rough one-to-one: | Mastra | Pleach | Notes | | ---------------------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Agent` | `SessionRuntime` + a system prompt | Pleach has no `Agent` class — the system prompt is plugin-contributed; the loop is `runtime.executeMessage`. | | `agent.generate({ messages })` | `for await (const e of runtime.executeMessage(sessionId, msg))` | Pleach is stream-first. Collect into a final string with `collectStream(...)` if you want generate-shaped sync. | | `agent.stream(...).fullStream` | `runtime.executeMessage(...)` (the same async iterable) | The Pleach `StreamEvent` discriminated union is richer than `fullStream` — tool / subagent / interrupt / checkpoint events. | | `model: "openai/gpt-4o"` (model-router string) | `new AiSdkProvider({ model: openrouter("openai/gpt-4o") })` | Pleach goes through the AI SDK provider; OpenRouter gives the same `<family>/<model>` swap pattern. See [Providers → Same shape, different provider](/docs/providers#same-shape-different-provider). | | `Workflow` + `createStep` | Not a Pleach primitive | Workflows are out of scope for `@pleach/core`. Compose with Mastra (this page), Inngest ([with-inngest](/docs/with-inngest)), or LangGraph. | | Scorers attached to an agent | `@pleach/eval` `new EvalSuite({ ... }).run()` | Pleach evals are CI-shaped, not in-line. Production sampling is `@pleach/observe`'s job. | | Memory primitives | `@pleach/core` Memory + `MemoryAdapter` | Similar conceptually; see [Memory](/docs/memory). | | Hosted Mastra Studio (`localhost:4111`) | No Pleach equivalent | The explicit trade — see [Playground & devtools](/docs/playground-and-devtools). | If you've never read Mastra docs, skip this table — it's only useful for translating between two vocabularies. ## The code shape [#the-code-shape] ```typescript // mastra/index.ts import { Mastra } from "@mastra/core"; import { createWorkflow, createStep } from "@mastra/core/workflows"; import { createPleachRuntime, type StreamEvent, } from "@pleach/core"; import { SupabaseAdapter } from "@pleach/core/sessions"; import { AiSdkProvider } from "@pleach/core"; import { anthropic } from "@ai-sdk/anthropic"; import { z } from "zod"; // Mastra's step primitive — durable, observable, replayable // at the workflow layer. The body wraps a Pleach turn. const agentTurnStep = createStep({ id: "agent-turn", inputSchema: z.object({ sessionId: z.string(), tenantId: z.string(), userId: z.string(), prompt: z.string(), }), outputSchema: z.object({ events: z.array(z.any()), }), execute: async ({ inputData }) => { // Build the runtime per request. createPleachRuntime is the // zero-config factory — pass the per-request fields. const runtime = createPleachRuntime({ tenantId: inputData.tenantId, userId: inputData.userId, storage: new SupabaseAdapter({ client: supabase }), provider: new AiSdkProvider({ model: anthropic("claude-sonnet-4-5"), }), }); // Inside the Mastra step, runtime.executeMessage gives you // the AuditableCall row in your Postgres, family-lock, replay // determinism, and the subagent rollup — none of which // Mastra's workflow events track at the row level. const collected: StreamEvent[] = []; for await (const evt of runtime.executeMessage( inputData.sessionId, inputData.prompt, )) { collected.push(evt); } return { events: collected }; }, }); export const chatWorkflow = createWorkflow({ id: "chat-turn", inputSchema: agentTurnStep.inputSchema, outputSchema: agentTurnStep.outputSchema, }) .then(agentTurnStep) .commit(); export const mastra = new Mastra({ workflows: { chatWorkflow }, }); ``` Three things this pattern gives you that a plain Mastra `Agent` doesn't: 1. **The `AuditableCall` row lands in your Postgres** while the step is running. Finance can `SELECT SUM(token_usage) FROM harness_auditable_calls WHERE tenant_id = ? AND created_at > ?` without parsing Mastra's workflow event store, which is shaped for the dashboard. The row is keyed `(sessionId, turnId, stageId, seqWithinTurn)`, joinable to billing and to a compliance review, and it exists whether the Mastra step succeeds, suspends, or fails — it was written during the LLM call, not at workflow completion. 2. **Family-lock survives step retry and suspend/resume.** If the Mastra step resumes after an interrupt, Pleach replays against the same family + transport the session locked at start — never silently widens to a different provider mid-conversation. Mastra's workflow primitive is provider-agnostic by design; the lock lives at the Pleach layer. 3. **Subagents spawned by the agent roll their cost back to the parent `turnId`** via `SpawnTreeState`. Mastra's agent network lets you compose multiple agents and its workflows can fan out, but per-token cost attribution between a parent turn and its child spawns is your code's problem. Pleach's spawn record keys it for you. ## Using Pleach instead of `Agent` inside a Mastra workflow [#using-pleach-instead-of-agent-inside-a-mastra-workflow] The pattern above wraps a Pleach turn inside a Mastra step. An adjacent pattern is to keep Mastra's workflow + dashboard + evals and use Pleach's `SessionRuntime` as the underlying engine for what would otherwise be a Mastra `Agent`. The workflow node calls `runtime.executeMessage` rather than `agent.generate` / `agent.stream`. You keep Mastra's orchestration surface and you get the Pleach audit row + family-lock + replay determinism in exchange for hand-wiring the agent-shape Mastra's `Agent` class gives you for free. Both shapes are honest. Pick the wrap when the team is already shipping Mastra `Agent`s and you want the audit row added. Pick the replace when you're starting fresh and want the runtime substrate to be the agent. ## Where each is load-bearing [#where-each-is-load-bearing] | Concern | Mastra's slot | Pleach's slot | | ------------------------------------------------------ | ----------------------------------------- | -------------------------------------------------------------- | | Workflow primitive (`Workflow`, steps, suspend/resume) | Built-in | None — Pleach is per-turn | | Hosted observability dashboard | Built-in (their key DX advantage) | None — bring Datadog / Honeycomb | | Vector DB integration | First-class (multiple stores) | None — DIY behind a tool | | Evals module | Built-in (`@mastra/evals`) | Strict-mode replay against the ledger (planned `@pleach/eval`) | | Agent abstraction (`Agent` class) | Built-in (instructions + tools + model) | `SessionRuntime` + plugin contract | | Per-call audit row in YOUR schema | Workflow events are dashboard-shaped | `AuditableCall` row keyed `(tenantId, turnId)` | | Tamper-evident audit trail | None at the row level | `prev_hash` + `row_hash` in your DB | | Family-locked provider routing | None — provider-agnostic by design | locked at `runtime.sessions.create({ provider, model })` | | Deterministic replay of the LLM stream | Workflow-level replay (rerun steps) | Fingerprint replays byte-identical `StreamEvent`s | | Subagent cost rollup to parent `turnId` | Agent network composes, doesn't roll cost | `SpawnTreeState` | | Time-travel checkpoints inside a session | Workflow suspend/resume | `runtime.checkpoints.rollback()` / `.list()` | | Multi-tenant `tenant_id` stamping on every row + span | Consumer responsibility | `runtime.tenant` facet + CI gates | | Hosted control plane (cloud) | Yes (Mastra Cloud) | None — Pleach is a runtime library | The coverage maps overlap on "agent abstraction" and "evals" — both ship those, with different shapes. They diverge sharply everywhere else: Mastra owns the workflow graph and the hosted dashboard; Pleach owns the row in your DB and the replay guarantee. That's why the partnership reads cleanly — neither package is doing the other's job badly. ## When you don't need Mastra [#when-you-dont-need-mastra] * A per-request agent turn that completes inside one HTTP request, with no scheduled jobs, no fan-out, no vector DB needs, and no workflow graph to model. You can call `runtime.executeMessage` directly from an API route and skip the workflow layer entirely. * The Pleach checkpointer covers your in-session time-travel. Cross-step workflow durability — suspend a step, resume it later, fan out across N branches, schedule a cron — is Mastra's slot, not Pleach's. ## When you don't need Pleach [#when-you-dont-need-pleach] * Single-tenant prototype with no audit obligation, no per-tenant cost rollup, and no replay requirement. Mastra alone is enough — the hosted dashboard gives you a perfectly good operational view for that shape. * The [comparison page](/docs/comparison) covers when the AI SDK alone is enough and when Mastra alone is enough. Reach for Pleach when the audit row, the family lock, or the replay guarantee shows up on the requirements list. ## Mastra Cloud, the dashboard, and the bits Pleach intentionally doesn't ship [#mastra-cloud-the-dashboard-and-the-bits-pleach-intentionally-doesnt-ship] Mastra's hosted dashboard is the part of the product Pleach has no equivalent for, and intentionally so. Pleach is a library that writes rows into a database you own. The dashboard you build on top of those rows is yours to ship — wire Datadog, Honeycomb, Grafana, or a custom Postgres view; the [OTel spans](/docs/otel-observability) and the [`AuditableCall` row](/docs/auditable-call-row) are the materials. If you want a hosted dashboard out of the box, Mastra ships one. If you want the rows in your own schema with no vendor lock-in on the audit surface, Pleach ships that. The pairing pattern gives you both: Mastra's dashboard for the workflow trail, your own Postgres for the audit trail. ## Where to go next [#where-to-go-next] <Cards> <Card title="Pleach + Inngest" href="/docs/with-inngest" description="The same coexist posture for durable-execution platforms — Inngest, Trigger.dev, DBOS, Temporal." /> <Card title="Pleach + Anthropic SDK" href="/docs/with-anthropic-sdk" description="Coexist with the Anthropic SDK as transport — prompt caching, the latest tools API, batches, files." /> <Card title="Pleach + OpenAI SDK" href="/docs/with-openai-sdk" description="Coexist with the OpenAI SDK — Chat Completions, the Responses API, structured outputs." /> <Card title="The AuditableCall row" href="/docs/auditable-call-row" description="What lands in your Postgres on every LLM call, and what you can join it against." /> </Cards> --- # Pleach + OpenAI SDK (/docs/with-openai-sdk) This page is the *coexist* pattern: `@pleach/core` underneath the OpenAI SDK, neither replacing the other. If you're choosing between keeping OpenAI Enterprise and moving the contract substrate, see [Migrating from OpenAI Enterprise](/docs/migrating-from-openai-enterprise) for the contract-side framing. The other coexist patterns — [Anthropic SDK](/docs/with-anthropic-sdk), [Mastra](/docs/with-mastra), [Inngest](/docs/with-inngest) — follow the same posture. OpenAI's SDK and Pleach aren't competing for the same slot. The OpenAI SDK is the provider transport — chat completions, the Responses API, structured outputs, tools, prompt caching, the Assistants threads. Pleach owns what sits **underneath** the transport — the typed `AuditableCall` row that lands in your Postgres, family-lock at session start, replay-deterministic streaming, subagent cost rollup to the parent `turnId`. If you're already calling `openai.chat.completions.create` or `openai.responses.create` directly, don't rip it out. Wire OpenAI as the provider behind Pleach's `AgentProvider` interface and keep the call shape you already have. If your team is on an OpenAI Enterprise contract (Projects, SSO, the Compliance API, ZDR), the coexist shape stays the same — Pleach sits under the SDK regardless of the billing model. The contract closes the vendor surface. The substrate closes the three downstream walls: per-axis rollup inside one Project (external customers or internal employees, teams, cost centers), a hash-chained `AuditableCall` row in your own Postgres, and replay- deterministic regression across model snapshots. See [Migrating from OpenAI Enterprise](/docs/migrating-from-openai-enterprise) for the contract-side walk-through. The objection this pattern closes is vendor lock-in: you keep OpenAI's transport-side properties (prompt caching, structured outputs, the latest API surface) while gaining an audit row you own, family-locked routing, and the option to swap providers without rewriting the agent loop. ## The architecture [#the-architecture] ``` ┌─────────────────────────────────────────────────────────────┐ │ Your API route / serverless function │ │ │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ Pleach SessionRuntime │ │ │ │ - AuditableCall row → your Postgres │ │ │ │ - Family-lock at session start (tokenizer, │ │ │ │ prompt-cache key, tool-call dialect locked) │ │ │ │ - Replay-deterministic StreamEvent │ │ │ │ - Subagent rollup to parent turnId │ │ │ │ │ │ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ │ │ AiSdkProvider({ model: openai("gpt-4o") }) │ │ │ │ │ │ - OpenAI Chat Completions / Responses API │ │ │ │ │ │ - Prompt caching, structured outputs, tools │ │ │ │ │ │ - Native OpenAI tool-call dialect (JSON) │ │ │ │ │ └─────────────────────────────────────────────────┘ │ │ │ └───────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` The OpenAI SDK gives you the **transport** trail — what the model returned, what tools it called, what tokens it spent, all vendor-shaped. Pleach gives you the **substrate** trail — the audit row keyed `(sessionId, turnId, stageId, seqWithinTurn)` in your own schema, joinable to a billing or compliance review six months from now. Finance reads Pleach; the OpenAI dashboard is where you read raw usage. ## The code shape — Chat Completions / Responses API [#the-code-shape--chat-completions--responses-api] This is the cleanest pairing. OpenAI's Chat Completions and the newer Responses API are stateless from the model's point of view — the conversation lives in your DB, and each call sends the full message array. Pleach owns the session, the audit row, and the streaming primitive; OpenAI owns the transport. ```typescript import { createPleachRuntime, AiSdkProvider, type StreamEvent, } from "@pleach/core"; import { SupabaseAdapter } from "@pleach/core/sessions"; import { openai } from "@ai-sdk/openai"; // One runtime per request. The AiSdkProvider wraps @ai-sdk/openai // so the call lands at openai.chat.completions.create under the // hood — you keep prompt caching, structured outputs, tools, and // the native OpenAI tool-call dialect (JSON Schema, not the // XML-shaped variant Anthropic uses). const runtime = createPleachRuntime({ tenantId: "acme-corp", userId: "user_123", storage: new SupabaseAdapter({ client: supabase }), provider: new AiSdkProvider({ model: openai("gpt-4o"), }), }); // Family-lock happens here. The session pins: // - tokenizer (OpenAI's cl100k variant) // - prompt-cache key (so prefix reuse stays stable per session) // - tool-call dialect (OpenAI JSON Schema) // - refusal pattern (OpenAI's shape, not Anthropic's) // None of those silently mutate mid-conversation. const session = await runtime.sessions.create({ provider: { type: "openai" }, model: { id: "gpt-4o" }, }); // executeMessage writes the AuditableCall row at call time — // before the stream completes — so finance can: // SELECT SUM(token_usage->>'totalTokens')::bigint // FROM harness_auditable_calls // WHERE tenant_id = $1 AND created_at > $2 // without parsing OpenAI dashboard exports. const events: StreamEvent[] = []; for await (const evt of runtime.executeMessage(session.id, "Hello")) { events.push(evt); } ``` Three things this pattern gives you that calling `openai.chat.completions.create` directly doesn't: 1. **The `AuditableCall` row lands in your Postgres** keyed by `(sessionId, turnId, stageId, seqWithinTurn)`, with `toolName`, `subagentDepth`, and `tokenUsage`. The OpenAI SDK returns a `ChatCompletion` object; what it doesn't do is write a typed audit row into your DB during the call. Pleach does, before the stream finishes, so the row exists even if your function crashes between the OpenAI response and your final commit. 2. **Family-lock survives provider swaps.** The day you decide to route some sessions to Anthropic for cost or capability reasons, the audit row shape doesn't change, the session contract doesn't change, and the existing OpenAI sessions keep replaying byte-identical because their family-lock froze `openai` at session-create time. The OpenAI SDK alone offers no such property — switching providers means rewriting the call site. 3. **Subagents spawned during the turn roll their cost back to the parent `turnId`** via `SpawnTreeState`. The OpenAI SDK has no concept of subagents — if your agent loop spawns helpers, you write the parent-child attribution code yourself. Pleach ships the row shape. ## The code shape — Assistants API [#the-code-shape--assistants-api] The Assistants API is a different shape: OpenAI persists thread state on their side, runs are stateful, and tool calls happen inside `runs` you poll or stream. The Pleach pairing works but is **narrower** — see the caveat below before committing to this path. ```typescript import OpenAI from "openai"; import { createPleachRuntime, type StreamEvent, } from "@pleach/core"; import { SupabaseAdapter } from "@pleach/core/sessions"; const openaiClient = new OpenAI(); const runtime = createPleachRuntime({ tenantId: "acme-corp", userId: "user_123", storage: new SupabaseAdapter({ client: supabase }), // No provider here — the Assistants API is driven by you // calling openaiClient.beta.threads.* directly. Pleach captures // per-call audit rows around those calls, not as a transport. }); const session = await runtime.sessions.create({ provider: { type: "openai" }, model: { id: "gpt-4o" }, }); // You drive the Assistants API yourself. Pleach's role is the // audit wrapper around each run, not the transport. const thread = await openaiClient.beta.threads.create(); await openaiClient.beta.threads.messages.create(thread.id, { role: "user", content: "Hello", }); // Emit an observability row for the assistants-run call to YOUR // destination. The thread state itself still lives at OpenAI — see caveat. const startedAt = Date.now(); const run = await openaiClient.beta.threads.runs.createAndPoll(thread.id, { assistant_id: "asst_abc123", }); runtime.observe.record({ turnId: session.id, providerId: "openai", family: "openai", callClass: "converse", model: "openai.assistants.run", inputTokens: run.usage?.prompt_tokens ?? 0, outputTokens: run.usage?.completion_tokens ?? 0, costUSD: 0, startedAt, completedAt: Date.now(), }); ``` The pattern works — you get a per-call observability row for every Assistants run, keyed to your session and turn (the `AuditableCall` ledger covers the calls the runtime drives; this out-of-band Assistants call is captured via `runtime.observe`). What you don't get is ownership of the conversation primitive itself, because that lives in OpenAI's `thread`. ## The Assistants API caveat [#the-assistants-api-caveat] If your thread state lives at OpenAI, that's a strategic commitment Pleach can't undo. The Assistants API persists thread messages, tool outputs, and run history on OpenAI's infrastructure. Pleach can still capture per-call audit rows around each `runs.create` or `runs.createAndPoll`, but the **conversation primitive itself is vendor-shaped** — the durable record of "what did this conversation contain" lives at OpenAI, not in your DB. What this means in practice: * **Audit-at-the-call-level still works.** You get an `AuditableCall` row per run, with `tokenUsage`, `toolName`, `subagentDepth`. The hash chain still holds. Finance can still roll up cost. * **Audit-at-the-conversation-level is partial.** Asking "show me the full transcript of session X" requires fetching thread state from OpenAI, not querying your Postgres. If OpenAI deprecates the Assistants API (as has happened with previous beta surfaces), your historical conversations are vendor-dependent on the migration path. * **Replay determinism is weaker.** Pleach's replay primitive reproduces the `StreamEvent` sequence for a turn given the same fingerprint inputs. When the conversation state lives at OpenAI, the inputs to the next call depend on what OpenAI stored — outside the fingerprint's reach. * **Provider portability is gone.** Swapping to Anthropic or a different OpenAI surface means migrating thread state out of OpenAI first, which is a one-way data move. **The recommendation: if audit matters, use Chat Completions or the Responses API with Pleach owning the session.** The Assistants API is the right shape when its hosted-thread model is what you actually want — typically when the convenience of OpenAI-managed state outweighs the durability trade-off. If you reached for Assistants because it was the easiest "thread" API to wire up, the Pleach + Chat Completions pairing gives you that shape with your own DB underneath. ## Where each is load-bearing [#where-each-is-load-bearing] | Concern | OpenAI SDK's slot | Pleach's slot | | ------------------------------------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------- | | Sending tokens to a model | yes (that's the SDK's job) | none — Pleach delegates to a provider | | Prompt caching (server-side prefix reuse) | yes (OpenAI's caching layer) | preserved; Pleach doesn't interfere | | Structured outputs (JSON Schema response format) | yes | preserved through `AiSdkProvider` | | Native tool-call dialect | yes (OpenAI JSON Schema) | family-locked at session start so it can't silently change | | Per-call audit row in YOUR Postgres | none — SDK returns a response object, not a row | `AuditableCall` row keyed `(sessionId, turnId, stageId, seqWithinTurn)` | | Tamper-evident hash chain | none | `prev_hash` + `row_hash` columns at the schema level | | Family + transport lock | none | locked at `runtime.sessions.create({ provider, model })` | | Replay-deterministic LLM stream | none — same prompt can return different stream | fingerprint replays byte-identical `StreamEvent`s | | Subagent cost rollup to parent `turnId` | none — no subagent concept | `parentTurnId` + `rootTurnCostCap` on the spawn tree | | Time-travel checkpoints inside a session | none | `runtime.checkpoints.rollback()` / `.list()` | | Multi-tenant `tenant_id` stamping on every row | manual | enforced by `audit:tenant-scoping` CI gate | | Provider portability (swap to Anthropic without rewriting the agent loop) | none — call sites are SDK-specific | yes — swap `AiSdkProvider` model | | OpenAI dashboard (usage, errors, rate limits) | built-in | none — Pleach doesn't replace it | | Conversation state durability | Assistants API: OpenAI; Chat/Responses: yours | yours (in both cases, via `SupabaseAdapter`) | The coverage maps barely overlap. That's why the pairing is honest — the OpenAI SDK keeps doing what it's good at (transport, prompt caching, the latest API surface), and Pleach adds the substrate properties that aren't the SDK's job. ## When you don't need Pleach [#when-you-dont-need-pleach] * A single-shot script that calls `openai.chat.completions.create` once and prints the result. No persistence, no multi-tenancy, no audit obligation. The SDK alone is the right shape; the [comparison page](/docs/comparison) covers when to stay on the raw SDK. * A chatbot where the OpenAI request id in your logs is enough audit trail. Pleach's overhead doesn't pay back until finance asks for a per-axis monthly rollup (per end customer in a SaaS, or per employee / team / cost center under one Enterprise Project) or compliance asks for the tool-invocation trail. ## When you don't need the OpenAI SDK directly [#when-you-dont-need-the-openai-sdk-directly] * You want provider portability from day one. Wire `AiSdkProvider` against `@ai-sdk/openai` and your call sites reach the same OpenAI surface, with the option to swap to `@ai-sdk/anthropic` later without touching the agent loop. * You're on the four-stage lattice already. The seam structure (`anchor-plan`, `tool-loop`, `synthesize`, `post-turn`) routes every call through Pleach; the OpenAI SDK shape becomes the provider's internal concern, not your code's. ## Native OpenAI SDK transport (without the AI SDK) [#native-openai-sdk-transport-without-the-ai-sdk] If you want to skip `@ai-sdk/openai` and call the OpenAI SDK directly inside a custom provider, the `AgentProvider` interface is the seam. Implement `execute` against `openai.chat.completions.create({ stream: true })` and the substrate properties above still hold — the audit row, the family-lock, the replay determinism. The [Providers page](/docs/providers) walks through writing a custom provider end-to-end. ## A note on the Realtime API [#a-note-on-the-realtime-api] The OpenAI Realtime API (audio in, audio out, WebRTC transport) is out of scope for this pairing today. The Pleach session model assumes turn-based execution; bidirectional audio streams need a different substrate shape. Track the [`@pleach/core` repo](https://github.com/pleachhq/core) for when the Realtime story lands. ## Where to go next [#where-to-go-next] <Cards> <Card title="Migrating from OpenAI Enterprise" href="/docs/migrating-from-openai-enterprise" description="The matching contract-side page — keep the Enterprise contract, add per-end-customer rollup inside a Project and replay-deterministic eval." /> <Card title="Pleach + Anthropic SDK" href="/docs/with-anthropic-sdk" description="The same coexist posture for Anthropic's transport — prompt-caching breakpoints, the latest tools API, batches, files." /> <Card title="Pleach + Inngest" href="/docs/with-inngest" description="Coexist with Inngest (or Trigger.dev / DBOS / Temporal) for durability, with the audited LLM turn inside the step." /> <Card title="The AuditableCall row" href="/docs/auditable-call-row" description="What lands in your Postgres on every LLM call, and what you can join it against." /> </Cards> --- # @pleach/base-tools changelog (/docs/changelog/base-tools) This page collects the **site-content changelog entries** that materially touched the [`@pleach/base-tools`](/docs/base-tools) docs surface or named a `@pleach/base-tools` symbol. It is not the runtime changelog — the canonical record of `@pleach/base-tools` runtime behavior lives in the upstream package [`CHANGELOG.md`](https://github.com/pleachhq/getpleach) and on [npmjs.org/package/@pleach/base-tools](https://www.npmjs.com/package/@pleach/base-tools). See the combined site changelog at [`/docs/changelog/combined`](/docs/changelog/combined) for the chronological unified view across every SKU. ## Unreleased [#unreleased] ### Fixed [#fixed] * **Schema `codeRepository` + `sameAs` for [`@pleach/base-tools`](/docs/base-tools) corrected** in the site's Organization + SoftwareApplication JSON-LD `@graph` (inlined in `<head>` on every page). Both fields now point at `https://github.com/pleachhq/base-tools` (was `https://github.com/pleachhq`, a leftover from when the SKU schemas were drafted against an upstream monorepo). Load-bearing because `sameAs` is the field AI search engines use for entity reconciliation across the npm ↔ GitHub ↔ docs boundary. See the [combined view](/docs/changelog/combined) for the full cross-SKU pack. ## 2026-06-08 — page-level restructuring [#2026-06-08--page-level-restructuring] ### Changed [#changed] * **[Examples](/docs/reference-apps) reframed and renamed to [Reference apps](/docs/reference-apps).** Inbound links on [Getting started](/docs/getting-started), [`@pleach/base-tools`](/docs/base-tools), and the prior changelog entry are repointed. ## 2026-06-08 [#2026-06-08] ### Changed [#changed-1] * [Packages](/docs/packages) table corrected to real published version — `@pleach/base-tools 0.1.0` (was largely stale "Reserved · placeholder" framing). --- # @pleach/coding-agent changelog (/docs/changelog/coding-agent) This page collects the **site-content changelog entries** that materially touched the [`@pleach/coding-agent`](/docs/coding-agent) docs surface or named a `@pleach/coding-agent` symbol. It is not the runtime changelog — the canonical record of `@pleach/coding-agent` runtime behavior lives in the upstream package [`CHANGELOG.md`](https://github.com/pleachhq/getpleach) and on [npmjs.org/package/@pleach/coding-agent](https://www.npmjs.com/package/@pleach/coding-agent). See the combined site changelog at [`/docs/changelog/combined`](/docs/changelog/combined) for the chronological unified view across every SKU. ## Unreleased [#unreleased] ### Added [#added] * **`@pleach/coding-agent/context` documented as shipped (`createLruContextStrategy`, `createImportanceContextStrategy`).** [Long session](/docs/coding-agent/long-session) flipped from "(roadmap)" to shipped for the two pure `ContextStrategy` eviction factories over the `(state, budget) → string[]` contract. The fictional `importance` options (`recencyWeight`/`frequencyWeight`) were corrected to `{ maxEntries }`, the additive `ContextEntry.accessCount` field documented, and the runtime-side `CodingContextManager` kept honestly labeled as roadmap. * **Per-recipe deep-dive page** — [`instrumentedCodingAgent`](/docs/recipes/instrumented-coding-agent) composes `@pleach/coding-agent` + `@pleach/sandbox` + `@pleach/observe`. Rank-2-publish-relevant once `coding-agent` ships per the upstream rank-2 runbook. ### Fixed [#fixed] * **[`@pleach/sandbox`](/docs/sandbox) peer-dep pin corrected to `^0.1.0` (devharness FINDING 94).** The [Coding agent](/docs/coding-agent) sandbox-handle comment claimed a `^1.0.0` peer-dep; the shipped `packages/coding-agent/package.json` `peerDependencies` pins `"@pleach/sandbox": "^0.1.0"` (matching the [Sandbox](/docs/sandbox) page). Now `^0.1.0`. ## 2026-06-08 — sibling-SKU references [#2026-06-08--sibling-sku-references] ### Added [#added-1] * **Homepage Enterprise section** — new paragraph names [`@pleach/coding-agent`](/docs/coding-agent) (typed `CodingAgentRuntime` contract at `0.2.0-alpha.0`) as the sandboxed coding-agent surface for the internal dev-tools team that often sits inside the same Enterprise contract footprint — same row, same `tenantId` axis, same chargeback. * **[Migrating from Anthropic Enterprise](/docs/migrating-from-anthropic-enterprise#sibling-skus-that-ride-alongside-the-contract) and [Migrating from OpenAI Enterprise](/docs/migrating-from-openai-enterprise#sibling-skus-that-ride-alongside-the-contract)** — new "Sibling SKUs that ride alongside the contract" section, with one bullet for `@pleach/coding-agent` (Workspace or Project framing per doc). * **[Comparison Enterprise section](/docs/comparison#if-you-already-buy-direct-from-anthropic-or-openai)** — paragraph appended after the cloud-mediated transports note, mirroring the homepage's coding-agent + language-agnostic-contract framing for the doc-side reader. ## 2026-06-08 [#2026-06-08] ### Added [#added-2] * `@pleach/coding-agent/runtime` contract section in [Coding agent](/docs/coding-agent#pleachcoding-agent-runtime-contract) with the typed `CodingAgentRuntime` interface and the per-method first-slice behavior. ### Changed [#changed] * [Packages](/docs/packages) table corrected to real published version — `@pleach/coding-agent 0.2.0-alpha.0` (was largely stale "Reserved · placeholder" framing). * [Plugin contract → Sibling SKUs as plugins](/docs/plugin-contract#sibling-skus-as-plugins) section retitled and rewritten with per-row real Phase A status — includes `@pleach/coding-agent`. --- # Changelog — combined view (legacy) (/docs/changelog/combined) This is the **combined view** — the original chronological, multi-SKU site-content changelog for getpleach.com. It records changes to the site itself: new docs pages, corrections, and the upstream packs each change reflects. It is not the runtime changelog; the per-package `CHANGELOG.md` files in the upstream `@pleach/*` repository hold the canonical record of runtime behavior. For a SKU-scoped view, see the per-SKU pages under [`/docs/changelog`](/docs/changelog) — each one excerpts the entries below that materially touched its docs surface. The mirror copy at [`/CHANGELOG.md`](https://github.com/pleachhq/getpleach/blob/main/CHANGELOG.md) is the source of truth in the repo. Both files update together, not separately. When a site change tracks an upstream pack, the entry cites the upstream commit so the linkage is auditable. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## Unreleased [#unreleased] ### Added [#added] * **Mermaid flowcharts now render as diagrams.** Twelve `mermaid` fenced blocks across seven pages — including the [Overview](/docs/overview) "mental model", [Architecture](/docs/architecture), [Turn lifecycle](/docs/turn-lifecycle), [Determinism](/docs/determinism), [Family lock](/docs/family-lock), [Config manifest](/docs/config-manifest), and [Replay](/docs/replay) — were authored as Mermaid but had no renderer, so each showed as bare text. The site now renders them as theme-aware diagrams via the fumadocs Mermaid integration. Mermaid is loaded only on pages that carry a diagram. ### Changed [#changed] * **Two hand-drawn ASCII diagrams converted to Mermaid.** The build pipeline on [Supply-chain risk & SBOM](/docs/use-cases/government/scrm-and-sbom) and the reference architecture on [Air-gapped architecture](/docs/use-cases/government/air-gapped-architecture) were box-drawing text; both now render as Mermaid diagrams. Node text is preserved verbatim — no claim change. ### Fixed [#fixed] * **Two family-cascade diagrams used an invalid Mermaid arrow.** The "no crossover to the other family" edge on [Architecture](/docs/architecture) and [Family lock](/docs/family-lock) was written with an unparseable `-.x` link; corrected to `-.-x` (dotted line, cross head). The diagrams rendered as bare text before the Mermaid renderer landed, so the break was never visible. All Mermaid blocks on the site now parse. ### Added [#added-1] * **[Comparison](/docs/comparison) gains a category for agent-memory & knowledge-graph substrates (a read-side category).** Generalizes the event-sourced-memory-graph shape — memory layers and knowledge-graph engines (Letta, Mem0, Zep) whose reader is the agent itself, not a human dashboard. The section pins the read-vs-write delineation: a memory substrate is a *read substrate* (the graph exists so the agent can query it to decide), [`@pleach/core`](/docs/core) is a *write substrate* (the ledger exists so every provider call is recorded, then read to account). Shared skeleton (append-only, content-addressed, causal chain, fork/replay); divergent object of record (the agent's knowledge/decisions vs. one provider call with model/tokens/cost). Framed as compose-not-compete — a memory graph reads the same session the [`AuditableCall` row](/docs/auditable-call-row) writes. Distinguishes this read-side from the observability read-side (reads for a human dashboard). * **[Homepage](/) rebuilt as six diagram-paired blocks in a side-by-side layout.** Every block — hero, "What you can do", "Should you stay", "Already paying the lab", "How it works", and "Quick answers" — now pairs left-aligned copy with one SQL-grounded diagram: a full `AuditableCall` record (`SELECT *`), the per-tenant `GROUP BY` rollup, a shape → tool decision flow, the contract-composes-underneath stack, the four-stage lattice, and a new append-only `session_manifest` event log with a fork-point checkpoint. Diagrams animate via CSS only, gated on `prefers-reduced-motion`, with gentle continuous pulsing on the load-bearing fields. Content width moved to \~`75vw`; the footer aligns to the same width. * **[Homepage](/) gains a seventh block — "Keep your stack", the brownfield [`@pleach/observe`](/docs/observe) path.** A new top-level section between "Should you stay where you are?" and "Already paying the lab": a buyer already shipping on the Vercel AI SDK, LangChain, or a hand-rolled loop adds the audit row in \~15 lines, no rewrite. Its diagram (`components/observe-brownfield-diagram.tsx`) shows the existing loop on top, the `init` + per-turn recorder + destination-plug hook in the middle, and a fan-out to the four destinations you pick — `postgres()`, `supabase()`, `otel()`, `memory()` — over the BYOK code shape. Cross-links [`@pleach/observe`](/docs/observe), [adoption paths](/docs/adoption-paths), and [BYOK observability](/docs/recipes/byok-observability). The `ObserveRow`-is-a-strict-subset-of-`AuditableCall` runway claim is the figure caption. * **[`/investors`](/investors) — public-safe positioning page, footer-linked.** A new top-level page states the fair-source thesis without numbers: the runtime is free and trust-protected, the hosted operational layer is the business, and the FSL conversion clock makes the openness contractual rather than revocable. Investment specifics route to `getpleach@protonmail.com` rather than living on the page. Linked from the footer only, between Pricing and Security — not in the top nav. * **`engine/` superstep scheduler named as an architecture piece + `facets.mdx` synced to the shipped facet inventory (pkgclean 06 D7/D9).** [Architecture](/docs/architecture#the-execution-graph-cluster) and [Concept clusters](/docs/concept-clusters#execution-graph-cluster) now name the `engine/` scheduler (`SuperstepRunner` + its primitives) as the deterministic superstep executor that *runs* the compiled graph — previously determinism/scheduling was attributed only to "the graph". [Facets](/docs/facets) gained the two shipped `SessionRuntime` accessors it omitted — `runtime.observe` (`record(row)`) and `runtime.probes` (`emit` / typed `typed<K>`) — plus the four per-stage graph introspection facets `graph.anchor` / `graph.toolLoop` / `graph.synthesize` / `graph.postTurn`. * **`@pleach/gateway/next` documented as shipped (`createGatewayRoute`).** [Gateway → migration from core](/docs/gateway/migration-from-core) flipped from "roadmap / NOT shipped today" to shipped. The factory is a Web-standard `(req) => Response` POST handler wrapping `GatewayClient`; docs now carry the real opts shape (`client` | `clientOptions` + `tenantId`, optional `transports` / `resolveRoute`) and the `{ family, callClass, model, prompt, byokKey? }` request body. The never-shipped `{ runtime, credentialStore, costEmitter }` params were removed. * **`@pleach/coding-agent/context` documented as shipped (`createLruContextStrategy`, `createImportanceContextStrategy`).** [Coding-agent → long session](/docs/coding-agent/long-session) flipped from "(roadmap)" to shipped for the two pure `ContextStrategy` eviction factories. The fictional `importance` options (`recencyWeight`/`frequencyWeight`) were corrected to `{ maxEntries }`, the additive `ContextEntry.accessCount` field documented, and the runtime-side `CodingContextManager` kept honestly labeled as roadmap. * **`@pleach/core/skills` + `@pleach/core/agents/types` are real emitted subpaths.** [Skills](/docs/skills) updated to import `SkillLoader`, `parseSkillFrontmatter`, `Skill`, and `SkillFrontmatter` from the `@pleach/core/skills` barrel (no `/loader`, `/parser`, `/types` sub-subpaths); [Agents](/docs/agents) prose corrected from "wildcard path" to a real emitted `@pleach/core/agents/types` subpath. ### Changed [#changed-1] * **`@pleach/core/internal/…` subpaths retired from the public docs surface** (pkgclean 06 D8). [streamSingleTurn](/docs/stream-single-turn) and [Subpath exports](/docs/subpath-exports) no longer present `@pleach/core/internal/strategies/streamSingleTurn/helpers` (+ its four direct helper entries) or `@pleach/core/internal/tools/execution/registryResolvedTools` as `stable`, installable public imports — the `internal/` segment marks implementation details, not a supported API. The removed copy-paste example that imported the streamSingleTurn helper barrel is replaced; the internal paths now live in a "Not a public import surface" section with their public alternatives: `ToolDefinition` from `@pleach/core/types/generic`, and `@pleach/core/tools/execution` for the resolved-tools registry functions. No `package.json:exports` change. * **[Homepage](/) section order resequenced to a how-then-whether-then-where arc.** "How it works" moves up ahead of "Should you stay where you are?" so the reader learns the structure before the qualification, and the new "Keep your stack" block lands between "Should you stay" and "Already paying the lab". Final order: hero → What you can do → How it works → Should you stay → Keep your stack → Already paying the lab → Quick answers. No copy on the moved blocks changed. * **[`/pricing`](/pricing) FSL-conversion explainer deduplicated to one canonical statement.** The "fair source = source-available with delayed Apache-2.0 conversion" definition appeared four times (hero, License table row, "What FSL-1.1-Apache-2.0 lets you do" opener, "For procurement & legal"). It now reads once, canonically, in the hero; the table's License row keeps its tabular one-liner. No load-bearing claim changed — the `pleach.fslConversionDate` `package.json` pointer, the LICENSE link, and the permission rows are intact. ### Fixed [#fixed-1] * **Doc-fence drift campaign — 129 findings surfaced by the fixed `audit:doc-snippet-typecheck` gate; all doc TypeScript fences now compile against the shipped `@pleach/*` APIs.** The gate now resolves `@pleach/*` against source (not a build-state-dependent `dist/`) and excludes syntax-broken fences that had been suppressing program-wide semantic analysis — the honest run exposed 129 usage-drift snippets. Corrections span: **`SessionRuntimeConfig` capabilities → plugins** — the invented flat `safety:`, `systemPrompt:`, `routing:`, `context:`, `callClassFamilies:`, `transport:`, and `region:` fields on `new SessionRuntime({...})` removed; safety policies + system prompts now flow through `definePleachPlugin(name, { safetyPolicies, prompts })`, `permittedFamilies` / `permittedRegions` hoisted to their real top-level `ReadonlySet` config fields, `userId` / `organizationId` / `tenantId` hoisted out of the fictional `context` wrapper, and per-call-class family pinning reframed as the model-family matrix (family × callClass) rather than a session field ([customer-support-agent](/docs/customer-support-agent), [internal-knowledge-agent](/docs/internal-knowledge-agent), [regulated-domain-agent](/docs/regulated-domain-agent), [region-pinned-agent](/docs/region-pinned-agent), [cloud-routed-agent](/docs/cloud-routed-agent)); **`OrchestratorRegistry` shape** — [langchain](/docs/langchain)'s `setOrchestratorRegistry({ tools })` rewritten to the ergonomic `definePleachPlugin` `contributeTools` path; **`WithTenantHeaderOptions`** — `withTenantHeader(fetch, { runtime, header })` corrected to `{ header, tenantId }` ([multi-tenant](/docs/multi-tenant)); plus earlier waves: `AuditableCall` nested payload paths, `VersionHistoryEntry` fields, `ReplayTurnOutput` / `ReplayTurnInput` / replay `RuntimeConfig` shapes, and `VerifyResult` narrowing. * **Contract / plugin / audit-page accuracy sweep (F260–F331) — 31 doc/impl divergences corrected against `packages/core` + `packages/compliance`.** *event-log-projections*: real folded event types restored per projection (`configProjection` → `session.created`; `messageProjection` → `message.user` / `message.assistant` / `content.delta`; `toolCallProjection` → `tool.started` / `tool.completed` / `tool.failed`; `jobProjection` states → `pending | completed | failed | timeout`); the fabricated `sessionStateProjection` export replaced with the real `reconstructSessionState` function; built-in count "six" → nine + composite; `events.iterate` / `fold` options corrected to `{ chatId?, fromSequenceNumber?, tenantId? }`; `HarnessEventReader` → `HarnessEventLogReader`; the fabricated pre-filter `selector` replaced with the real post-fold `finalize`. *audit-ledger*: `TamperEvidence` → `link` / `verifySequence` / `schemeId` (sync); `PIIRedactor` → `redact(record): AuditableCall` + `policyId`; `GDPRSoftDelete` → single `softDelete(...)`; `AuditEmitter` 5 → 10 methods. *scrubbers*: scrubbers do not declare event-type allowlists (global `SCRUBBABLE_FIELDS`); `ScrubResult` → `{ redacted, matchCount, matchedScrubberIds }`; the three bundled scrubbers independently `implements Scrubber`; opt-out via `[]` / `SCRUB_NONE_AUDITED`. *plugin-contract / authoring / stream-observers / plugin-authoring-standards*: retired `onSessionCreated` / `onToolCompleted` / `onMessageAdded` removed (→ `runtime.events`); hook tally 65+5 → 67 `contributeX` + 7 top-level; not-contributed collectors 5 → 11; stream-observer verdict `halt` → `stop`; `definePromptPlugin` → `definePromptsPlugin`. *hash-chain*: `prev_hash` is the prior `row_hash` copied verbatim; `created_at` → `sequence_number` in the canonical field order. *attestation*: deleted the fabricated `pleach.unsigned.v1` short-circuit paragraph. *audit-gates*: `NODE_STAGE_MAP` 43 → 44. *tenant-facet*: `withTenantHeader({ header, tenantId })` (raw string at wrap time, `header` required with no default). * **Reference-page accuracy sweep (F226–F295) — 34 doc/impl divergences corrected against `packages/core`.** Error codes re-derived from `src/errors/codes.ts` (1002 `TOOL_VALIDATION_FAILED`, 1003 `TOOL_EXECUTION_FAILED`, 1004 `TOOL_TIMEOUT`, 2002 `SESSION_CONFLICT`, 2003 `SESSION_EXPIRED`, 2004 `SESSION_LOCKED`, 3001 `SYNC_NETWORK_ERROR`, 4003 `STORAGE_QUOTA_EXCEEDED`, 5001–5005 provider codes, 6002/6003 checkpoint codes); the fabricated "safety-refusal on code 5004" example rewritten to model-not-found (no safety-refusal code exists in the enum). Graph node catalog 43 → 44 nodes (added the `answerSufficiency` post-turn judge; post-turn 6 → 7) with corrected source-org file counts. Edge catalog: "three conditional routers" → two (`shouldContinue` + `afterHallucination`); recovery dispatch is a stream-filter collection, not a router. Model matrix + env-vars: `mistral` marked unrouted (excluded from `EXPOSED_FAMILIES`), `GOOGLE_GENERATIVE_AI_API_KEY` added as the primary Google var, and `FEATURE_HARNESS_V2_RUNTIME` / `FEATURE_TOOL_CALL_RUNAWAY_HARD_BLOCK` moved to a host-read section (core reads no `FEATURE_*`). Auditable-call row + typed records: version 18 → 19 (`outcome.why`); the fabricated `payload.kind` discriminated-union framing replaced with the real named optional slots narrowed on `stageId` (no `payload.identity` / `payload.output.auditNote`; principal is top-level; `pluginPayloads[]` is the extension slot); interrupt outcomes corrected to `verdict: approved | edited | rejected | timeout`. Glossary tenant facet fixed to the `runtime.tenant.id` / `.subId` properties (no `getTenantId()` methods). Subpath exports: `SectionContributor` → `PromptContribution`; capability list 38 → 35 (31 modern + 4 deprecated). Schema: 10 → 12 SQL files (added `000_supabase_compat.sql` + `011_spawn_event_fields.sql`) and the config manifest renumbered to `012_harness_config_manifest.sql`. Removed the fabricated `config_manifest_retention_days` identifier. * **Fictional `@pleach/*` identifiers in `.mdx` snippets corrected — surfaced by the `audit:doc-import-resolution` TS2724 pass.** The React interrupt hook was documented as `useInterrupt` with a `{ pending, resolve, ignore }` return and a `resolve({ type, args })` `HumanResponse`-shaped payload — none of which exist. The real hook is **`useInterruptUI(config)`** (handler-driven: `{ activeInterrupts, renderActive, resolveInterrupt, cancelInterrupt }`); it surfaces a `PendingInterrupt` (`interrupt.toolCall.name` / `.arguments`, **not** `action_request` / `config.allow_*` / `description` — those live on the separate `HumanInterrupt` envelope), and decisions are an **`ApprovalDecision`** (`{ approved, note?, modifiedArguments? }`). Rewrote both usages: [interrupts](/docs/interrupts) now shows the `useInterruptUI` handler pattern, and [platform recipes](/docs/platform-recipes) uses the imperative `runtime.interrupts.resolve(id, decision)` loop. Grouped with two other identifier corrections from the same TS2724 pass: the plugin-bundles page's `definePromptsPlugin` rename (was a fictional factory name), and the [query](/docs/query) page's tool-inspection + cross-session analytics signature fixes. * **Behavioral / semantic prose drift corrected across the conceptual and recipe pages (findings 150–225), each verified against shipped `@pleach/*` source.** *Recipes:* `observableChatbot({ serviceName })` takes no `subagent` field — the row label is `observeSubagent(serviceName)`, and the disable switch is `serviceName: false` ([Pleach recipes](/docs/recipes-pleach-recipes)); a second `init(...)` on `@pleach/observe` **throws** (singleton-per-process), it does not warn-and-return; and destination errors — sync throws and async rejections alike — are **swallowed by default**, surfacing them is opt-in via `init({ throwOnDestinationError: true })` ([BYOK observability](/docs/recipes/byok-observability), [observable chatbot](/docs/recipes/observable-chatbot)). *Facets:* `graph.synthesize.getSeamIdentity()` always returns `undefined` today; the `sync.resolveConflict` receipt has no `conflictedPaths`; `runtime.interrupts` exposes a `manager` property (not `getInterruptManager`) and `runtime.timeTravel` an `api` property (not `getTimeTravelApi()`) ([facets](/docs/facets)). *Graph / nodes:* the compiled graph ships **44** node names / **7** post-turn nodes (adding `answerSufficiency`, D-NC-6), and a metadata-less node **subscribes to every channel** rather than inferring reads/writes from its body ([graph](/docs/graph), [nodes](/docs/nodes)). The `reasoning` seam is **reserved/planned** — the default runtime has no live consumer routing through it ([seams](/docs/seams)). *Interrupts:* removed fabricated surfaces (plugin `ctx.raiseInterrupt` / `ctx.tokenUsage`, `ApprovalDecisionKind` with `reason`/`riskLevel`, and `runtime.getInterruptScratchpad`); `InterruptConfig` enables via `interruptBefore` / `interruptAfter` / `interruptOn` (not `enabled`/`perToolApproval`/`globalApprovalThreshold`); and resolving an interrupt consumes an `ApprovalDecision` — an approval-with-edit is `{ approved: true, modifiedArguments }`, so an `{ type: "edit", args }` payload leaves `approved === undefined` and reads as a rejection ([interrupts](/docs/interrupts)). *Query:* usage rollups read `ai_chat_messages` (not `harness_auditable_calls`); `getSessionReview` throws `QueryError("NOT_FOUND")` so the never-throws guarantee is scoped, and the three codes are `NOT_FOUND` / `DB_ERROR` / `INVALID_INPUT`; `queryHarnessEvents` orders by `created_at`, cursors on a base64 `created_at|id` compound, and returns `{ events, nextCursor, count }`; `getEnrichedSessionReview` gates on `includeCitations` / `includeEntities` / `includeUsage`; and the chat-keyed readers take a `chatId`, not a `sessionId` (a session id silently returns `[]`) ([query](/docs/query)). Subagent provenance is `delegated_from`, not `used_result_of` ([lineage](/docs/lineage)). *Host adapter / runtime:* the module-loader probe sample uses an early-resolved key rather than the post-stream `streamDegenerationGuards`; `metaToolNames` is probed lazily on first read and `setOrchestratorAdapter` is a deprecated delegating stub; module loaders re-invoke every call (dedup is the process-wide `import()` cache, not per-runtime) ([host adapter](/docs/host-adapter)); `HARNESS_MOCK_MODE` is read only by the host-side `MockToolExecutor` (it synthesizes tool results; it does not classify seams or write AuditableCall/ledger rows — the default ledger is the no-op) ([runtime construction](/docs/runtime-construction)); an absent `providerFallback` loader **throws `MODULE_NOT_FOUND`** rather than falling through a built-in chain ([runtime strategies](/docs/runtime-strategies)); and a custom `streamSingleTurn` body registers through the global module-loader seam, not a `SessionRuntimeConfig` strategy slot ([stream single turn](/docs/stream-single-turn)). * **Method / field / type prose drift corrected across the migration and comparison pages (findings F107–F140).** Usage-shape corrections (not import paths), each verified against the shipped `@pleach/*` source: [`ProviderFamily`](/docs/family-lock) now lists all **seven** families including `xai`; provider-detection prose reframed to "six of the seven families (`xai` has no standalone env-var detection) plus `openrouter`"; the [`@pleach/transport-bedrock`](/docs/transport-bedrock) credentials note corrected to "static AWS keys or an STS session token today; container/role-assumption resolution is planned" (the `BedrockCredentials` shape requires `accessKeyId` + `secretAccessKey`). Method/field fixes: `useHarness()` returns `isLoading`, not `isStreaming`; the `AgentProvider` seam method is `execute`, not `invokeStream`; `tool.started` is a `StreamEvent` on the `executeMessage()` iterable, not a `runtime.on(...)` emitter event; session read is `runtime.sessions.find(id)` (returns `Session | null`) then `session.state.messages`, not `runtime.getSession(id).messages`; `AuditableCall` nests `call.call.model` + `call.outcome.latencyMs`; the ad-hoc `runtime.audit.recordCall(meta, fn)` wrapper rewritten to the shipped `runtime.observe.record(row)`; the family + transport lock is applied at `runtime.sessions.create({ provider, model })` (`runtime.providerLock` does not exist); `@pleach/eval` usage is `new EvalSuite({ ... }).run()`, not `runEval({ suite, target })`; the checkpoint rollback surface is `runtime.checkpoints.rollback(sessionId, checkpointId)` / `.list()` (not `checkpointer.snapshot()` / `restore()` or `restoreCheckpoint(...)`); the fabricated `SubagentSpawnRecord` type replaced with the real primitives (`SpawnTreeState` / `parentTurnId` / `rootTurnCostCap`, and the `AuditableCall` row's parent `turnId` where the claim is about a stored, `GROUP BY`-queryable rollup); and hash-chain verification (`verifyChainForChat` / `generateProof`) re-attributed to [`@pleach/core/eventLog`](/docs/event-log), which `@pleach/replay` composes. * **`@pleach/*` doc import paths corrected across 26 concept pages, surfaced by the now-fixed `audit:doc-import-resolution` gate.** The gate had been resolving `@pleach/*` doc imports against `dist/` — build-state-dependent, so an absent `.d.ts` silently typed the module as `any` and false-greened real drift. It now resolves against package **source** via `tsc`, which exposed 59 imports naming a symbol on a path where it does not ship. Most were root-vs-subpath drift, now pointed at the barrel that actually re-exports each symbol: `StateGraphNodeMetadata` / `START` / `DefaultAgentState` / `buildDefaultAgentGraph` / `DEFAULT_TURN_FLAGS` / `AnnotationSchema` / `InferAnnotationState` → [`@pleach/core/graph`](/docs/graph); `CallClass` / `ProviderFamily` → `@pleach/core/modelfamily`; `ProviderMessage` / `ProviderToolDef` (and the sibling provider types) → [`@pleach/core/providers`](/docs/providers); `FabricationDetector` / `FabricationDetectorContext` / `FabricationFinding` / `SanitizerPass` / `FinalizationPassContext` / `PluginGraphNodeRegistration` / `PluginGraphNodeContext` / `composePlugin` → [`@pleach/core/plugins`](/docs/plugin-contract); `StreamObserverRegistration` → `@pleach/core/plugins/stream`; `Scrubber` → [`@pleach/core/scrubbers`](/docs/scrubbers); `EventLogRow` → [`@pleach/core/eventLog`](/docs/event-log); `MemoryProviderDecisionLedger` → [`@pleach/core/audit`](/docs/audit-ledger); the guest-interrupt bus helpers (`setGuestInterruptStore` / `publishInterruptRequest` / `popInterruptRequest` / `recordInterruptDecision` / `waitForInterruptDecision`) → `@pleach/core/guestInterruptBus`; `ToolDefinition` → `@pleach/core/types/generic`; `ConnectivityMonitor` → [`@pleach/core/sync`](/docs/sync); and the BYOK gateway helpers (`createCredentialRoutingMiddleware` → [`@pleach/gateway`](/docs/gateway) root, `createPostgresCredentialStore` → `@pleach/gateway/byok/adapters/postgres`). Four snippets imported symbols that ship **nowhere** and were rewritten to the real API: `@pleach/observe`'s `startTurn()` / `turn.recordCall()` → the shipped `init` + top-level `recordCall(row)` surface; the fabricated `appRegistries.setProviderDecisionLedgerFactory(...)` object → the free `setProviderDecisionLedgerFactory({ fromSupabase })` accessor from `@pleach/core/runtime`; `durableFlush.register(...)` → `setWaitUntilImpl(...)` from [`@pleach/core/eventLog`](/docs/event-log); and `pickNextInFamily` / `deriveFamilyFromModelId` (host-supplied — the harness ships no model registry) reframed as host functions handed in through the `contributeFamilyPivot` plugin hook. The `registerIntentLabel()` roadmap page now imports only the shipped `definePleachPlugin` and `declare`s the proposed helper. * **mcp / sandbox / coding-agent / replay doc pages reconciled with the shipped surface (devharness FINDING 94–103).** [MCP](/docs/mcp): `registerSession(sessionId, runtime, { tenantId? })` is a **live** in-memory session registry (plus `getSession` / `listSessions` / `unregisterSession`) — no longer a `NotImplementedError("D-PA-184")` throw; `resources/*` and `prompts/*` are dispatched live on the `MCPServer` wire (both the SDK `setRequestHandler` path and the pluggable `dispatchPluggableMethod`); the pluggable dispatcher implements the `initialize` handshake (not `-32601 Method not found`); `McpRuntime.registerCapability` lands resource / prompt kinds in the last-wins ledger without throwing; the `createSSETransport().start()` **factory** throws a generic `Error` (the typed `NotImplementedError("D-PA-181")` is the *options-bag* `server.start({ transport: "sse" })` path). [Coding agent](/docs/coding-agent): the `@pleach/sandbox` peer-dep is `^0.1.0` (was `^1.0.0`). [Sandbox](/docs/sandbox): there is no `ctx.sandbox` — the low-level `SandboxProvider` contract is `execute` / `readFile` / `writeFile` / `listFiles`, and `@pleach/coding-agent` exposes a thin `SandboxClient` facade (`exec` / `readFile` / `writeFile`, no `list`) the tool handlers close over. [Region-pinned agent](/docs/region-pinned-agent): `createReplayRuntime` takes `sessionRuntime` (not `liveRuntime`); `replayTurn` input is `{ chatId, tenantId, messageId }` (not `{ turnId, mode }`) and returns `{ chatId, tenantId, messageId, state, sequenceNumberRange }` — there is no `.diff` field (the hash-chain `verifyIntegrity` verdict covers tamper-evidence, not parity). * **observe / eval / replay / core concept-page snippets reconciled with the shipped signatures (devharness FINDING 83–93).** [Observe](/docs/observe): `getRecorder()` returns `ObserveRecorder` and throws pre-`init` (never `undefined`); a second `init` throws (singleton-per-process); a throwing `redactor` is fail-closed (swallow + warn `[Observe:redactor-threw]` + drop the row, never write raw); the OTel envelope is span `` `<family>.<callClass>` `` + `gen_ai.*` / `pleach.observe.*` attrs (not `pleach.audit.call`); `BUILT_IN_MATCHERS` = email + phone + SSN (no credit card). [Eval](/docs/eval): cross-mode buckets are `interactive`/`headless-eval`/`headless-replay` (not `production`/`replay`/`eval-noncached`). [Replay](/docs/replay): eval-coupling is `new EvalSuite({ suiteId, runtime })` + `suite.setReplayClient(replay)` (no `replay` ctor field; `suiteId` required). [Determinism](/docs/determinism): ledger read is `await ledger.getSession(sessionId)` (not `ledger.list({ sessionId })`), and `runtimeMode` distinguishes **five** contracts (not three). [OTel observability](/docs/otel-observability): `executeMessage(sessionId, "hello")` is positional. [Stream events](/docs/stream-events): re-read via `runtime.sessions.resume(sessionId)` (there is no `sessions.get()`). * **More SKU doc-page snippets reconciled with the shipped signatures (devharness FINDING 80–82).** [Fabrication detection](/docs/fabrication-detection) custom-detector example (no `ctx.toolArgs`; real `FabricationFinding` = `{detectorId, severity, reason, evidence?}`, no `suspect`/`tier`); [Multi-tenant](/docs/multi-tenant) `getAggregateUsage` is 3-positional `(client, store, filters)`, no `groupBy:"tenantId"`, dates under `dateRange`, requires `userId`; [Determinism](/docs/determinism) fingerprint round-trip reads `fingerprintComposite` (string), not `.fingerprint`. The ctor-shape/call-arity/ field-name classes the `audit:doc-import-resolution` gate doesn't cover. * **Event-log read side documented as store-agnostic — `eventReader` / `HarnessEventReader`.** [Event-log projections](/docs/event-log-projections) claimed `runtime.events.iterate` / `.fold` read `harness_event_log` rows and went live "on the Supabase backend in PA-1 Phase A.2" — store-specific language corrected to the shipped surface: both accessors delegate to a `HarnessEventReader` you inject as `eventReader` (read-side counterpart to `eventLogWriter`), backing the same `iterate`/`fold`/resume surface with Postgres, Supabase, SQLite/pglite, or an HTTP API. `iterate` gains `tenantId`; the reader is required (no implicit store fallback). New **Backing the reader** section shows the one-method contract. [Session runtime](/docs/session-runtime) config table gains `eventLogWriter` + `eventReader`; [Storage](/docs/storage) grows the `ci:devharness-sql` battery from four durable properties to nine. * **SKU doc pages reconciled with the shipped import/construction surface (devharness FINDING 76–79).** [Building a chat UI](/docs/building-chat-ui) fabricated a `@pleach/react` chat-hook API (`useChat`/`HarnessProvider` from root, `useInterruptDecision`, `useChatVercelCompat`, `sendMessage`) → corrected to the real surface. [LangChain](/docs/langchain): `LangChainProvider` positional ctor + top-level `provider:` slot. [Base tools](/docs/base-tools): scratchpad is per-session, param `operation`, string value. [Coding agent](/docs/coding-agent): sandbox tools register via a `contributeTools` plugin (no `tools`/`context` config field). Import-path half regression-locked by `docImportSurface.smoke.test.mjs`. * **Observability + gateway-cost docs corrected to the shipped surface (devharness FINDING 73–75).** `@pleach/core` ships no `@opentelemetry/*` dep + never reads the global tracer provider — the NodeSDK/OTLP recipe captured zero pleach spans; [OTel observability](/docs/otel-observability) + [Observability](/docs/observability) now show the real `new SessionRuntime({ otelExporter })` bridge path (default `NoopOtelExporter` drops spans), and `runtime.spans.snapshot()` returns `{inFlightCount,isShutdown}` (use `CapturingOtelExporter.captured` for emitted spans). [Gateway cost events](/docs/gateway/cost-events): `computeCost` read fields absent from `RouteChatCompletionOutput` (`asTenantId(undefined)` threw → zero CostEvents) — corrected to real output fields + host context, with a slice-2-stub note. * **Subagents + async-tasks docs reconciled with the host-completion-required reality (devharness FINDING 66–72).** Bare `@pleach/core`'s subagent surface is host-completion-required; the docs over-promised a substrate-delivered one. [Subagents](/docs/subagents): dropped the fabricated `spawnsSubagent` flag; `LightweightRuntime` → `LightweightSessionRuntime` from `@pleach/core/subagents` with its real ctor; `fetchWorkspaceContext`/`formatWorkspaceContextPrompt` moved to the subpath; concurrency corrected (hard `SUBAGENT_LIMITS` cap, fail-not-queue on the runtime path; `SubagentManager` queue/DAG is host-driven); `context.variables` inert; host-completion caveat added. [Async tasks](/docs/async-tasks): the tool-loop async bridge is not wired in bare core (`ASYNC_TASK_BRIDGE_NOT_WIRED`), and the task executors are host/plugin-supplied, not "in-tree defaults". * **Abort + resume docs synced to the shipped runtime behavior (devharness FINDING 56 + 62).** Aborting a turn now records `outcome.status: "user-aborted"` (not `"aborted"` / not `provider-error`) at both the decision call and the synthesis terminal — corrected across [session lifecycle](/docs/session-lifecycle), [SessionRuntime](/docs/session-runtime), [turn lifecycle](/docs/turn-lifecycle), [API routes](/docs/api-routes). And [resuming a session](/docs/session-lifecycle#resuming-a-session) now describes the real three-layer rebuild (storage row → version-guarded checkpoint overlay → server-only event-log hydration of ephemeral card state onto a transient `Session.hydratedHarnessState`), correcting the prior "`hydrateFromEvents` rebuilds messages" claim. * **Model-resolution-matrix path corrected — it is host-supplied, not a `@pleach/core` dir (pkgclean 06 D7).** The docs cited the family-lock matrix at the fictional `src/graph/modelfamily/` ([family-lock](/docs/family-lock)) and `@pleach/core/ai/modelFamily/matrix.ts` ([quickstart](/docs/quickstart), [provider detection](/docs/quickstart/provider-detection)) — neither path exists in the package (there is no `ai/` or `graph/modelfamily/` dir). The `(family × callClass)` matrix table is host-supplied and reached through the `AgentAdapter.resolveModel<C>()` seam; the `ProviderFamily` set is now pointed at the real `@pleach/core/modelfamily` export. The routing-decision seam's Ivy-internal host path was also removed from [routing decisions](/docs/plugins/routing-decisions). * **Docs reconciled with the shipped `@pleach/core` runtime (value-prop-vs-impl audit, devharness FINDING 56–64).** Corrected doc claims that diverged from the implementation across [Time travel](/docs/time-travel) (`fork` sets `parent_id: source.id`), [Checkpointing](/docs/checkpointing) (message/event-boundary checkpoints discriminated by `CheckpointMetadata.source`, no per-stage `stageId`; `SupabaseSaver` `"replicated"`; rollback `source: "manual"`; string `fork` arg), [Session lifecycle](/docs/session-lifecycle) (checkpoint snapshot authoritative; event-log canonical reader is a flag-gated shadow), [Sync](/docs/sync) (interactive conflict resolution / `sync.conflict` / 3xxx codes are enterprise-tier/planned; open-core is `SyncCoordinator` last-writer-wins-on-push), [Query](/docs/query) (dropped non-existent `queryAuditableCalls`; fixed `getChatUsage` / `lookupModelCost` / `getSessionReview` / `getInterruptChain` sigs+shapes), [Memory](/docs/memory) (graph-node extraction path is production, not the `memoryExtractionHook` → queue pipeline; `LearnedFact.source` string+object), and [Lineage](/docs/lineage) (opt-in via `config.lineageTracker`; runtime records the node + `branched_from` fork edge, other relations host-recorded; `completeSession` host-called). ### Added [#added-2] * **[Landscape](/docs/landscape) — new page.** Twenty agent-runtime capabilities ranked by how differentiated `@pleach/core` actually is, in three tiers: structural to the audit row, assembled here from parts the market sells separately, and table stakes. Each capability names its mechanism and where the rest of the field lands, graded against the observability / cost / eval / durable-execution cohort (Helicone, Langfuse, LangSmith, Portkey, LiteLLM, OpenMeter, MLflow, Logfire, Braintrust, LangGraph, Temporal, Inngest, the OpenTelemetry GenAI conventions). States plainly where the substrate is structural (hash-chained [`AuditableCall` row](/docs/auditable-call-row), [deterministic replay](/docs/determinism) + [family-lock](/docs/family-lock), subagent spend rolled to the turn) and where it is commodity (per-tenant cost, checkpointing, PII redaction). Linked into the sidebar after [Comparison](/docs/comparison). * **[Family-locked routing](/docs/family-lock) gains a "Why the lock matters: provenance" section.** Frames the value around audit provenance, not operational breakage: when the model that answers changes (transient failover, or a migration over a release cycle), the ledger has to record which provider and model ran, and when it changed. In-family-only fallback (`fallbackStep` with `inFamily: true`) plus an explicit `sessions.updateProviderModel` re-lock (`session-lock-resynced` event) keep the `provider` / `transport` on every `AuditableCall` row trustworthy — provenance for an auditor, not a UI label or a local dev-tool file. * **Homepage value-prop tiles reordered to lead with the differentiated capabilities.** Leads with deterministic replay, family-lock, subagent-cost rollup, and the hash-chained row; demotes the commodity per-tenant-cost framing and drops the niche sync-conflict tile. The family-lock tile reframes around model provenance — proving which provider and model produced each answer after a failover or migration. Links the new [Landscape](/docs/landscape) page. * **[Comparison](/docs/comparison) gains a read-side cohort table.** Adds an "Observability, cost, and eval tools" section covering Helicone, Langfuse, LangSmith, LiteLLM, MLflow, Logfire, Braintrust, and OpenMeter — the tools a team shops against for "audit, cost, replay" and which the prior matrix omitted. Draws the write-side-substrate vs read-side-store boundary, marks where each lands data (their store vs your DB), and states the compose pattern (Pleach writes the row; the read tools stay good at reading). * **[Agent instrumentation recipe](/docs/recipes/agent-instrumentation)** — new recipe page for the do-it-yourself instrumentation path. Subscribe to the runtime lifecycle with `runtime.events.on(kind, handler)` over `model.called` plus the `stage.*` / `turn.*` / `recovery.fired` / `retry.attempted` / `stream.*` kinds, then bridge `model.called` to a destination with `observeSink({ destinations })` in one line. `model.called` now fires for both the main agentic turn and each `synthesize` / `reasoning` / `utility` seam call — seam-call rows carry real `costUSD` from per-1M-token rates, main-turn rows carry a `0` sentinel (cost derived downstream from `llm.turn` token counts). Distinct from [observable-chatbot](/docs/recipes/observable-chatbot) (recipe wrapper) and [BYOK observability](/docs/recipes/byok-observability) (no-`@pleach/core` brownfield path). Registered in [`meta.json`](/docs/recipes) and the llms.txt Recipe-walkthroughs group. * **["Live lifecycle events" on observable-chatbot](/docs/recipes/observable-chatbot)** — links the chatbot recipe to the bare-subscription instrumentation path and shows `bot.runtime.events.on("model.called", ...)` off the recipe's `runtime` escape hatch. * **[CI enforcement of the audit contract](/docs/ci-enforcement)** — new Reference page indexing the [audit-gate catalog](/docs/audit-gates) by the auditability and replayability clause each gate enforces. Pairs every clause with its named gate and the condition that turns it red — `audit:auditable-call` and `audit:auditable-call-soak` for the row shape and DB wire-format, `audit:event-log-manifest-hash-completeness` plus the three `audit:config-manifest-*` gates for substrate reconstruction, `audit:c8-event-type-allowlist-coverage` for scrubber coverage, `audit:c9-hash-chain-integrity` and `audit:eval-phase-a-coverage` for replay. Names the `graphnoderef`, `local-clone`, and per-SKU `consumer-rehearsal` CI jobs. Cross-linked from [Audit gates](/docs/audit-gates). * **[Contribution namespaces](/docs/plugins/namespaces)** — new page documenting the nine namespaces every `HarnessPlugin` contribution hook resolves to (`prompts`, `stream`, `safety`, `audit`, `intent`, `tools`, `middleware`, `policy`, `lifecycle`) as the canonical, audit-gated contract layout, with representative hooks per namespace and the rolling-out typed namespaced authoring form (`plugin.stream.contributeX`, additive alongside the flat form). Cross-linked from [Plugin contract](/docs/plugin-contract). * **[Config manifest](/docs/config-manifest)** — new page documenting the content-addressable substrate snapshot the runtime writes to `harness_config_manifest`: one row per distinct plugin/prompt/node/channel/filter set, keyed by a SHA-256 Merkle roll-up (`pleach.manifest.v1` prefix, `0x1F` separator, mirroring the [hash chain](/docs/hash-chain)'s `pleach.c9.v1` canonicalization). Covers the five per-surface child hashes, the nullable `manifest_hash` foreign reference on `harness_event_log`, reference-counted retention (default forever, tenant-overridable floor), full-snapshot storage, and the replay-by-config, cross-tenant-by-plugin-set, and substrate-reconstruction query shapes. Marked `in-flight` — table and event-log column land as additive schema files during rollout. * **[What Pleach writes to your database](/docs/emitted-data)** — new page mapping every table the runtime and its plugins write, what's on by default versus opt-in, and what each row carries. Leads with the destination-is-yours posture: no phone-home; writes land in your Postgres / Supabase / OTel collector / Memory buffer. * **`manifest_hash` cross-references** added to [Event log](/docs/event-log) (stamping section + `EventLogRow` field) and [Schema](/docs/schema) (rolling-out `harness_config_manifest` section). * **MCP Server Card (SEP-1649) at [`/.well-known/mcp/server-card.json`](/.well-known/mcp/server-card.json) and a backing JSON-RPC endpoint at `/api/mcp`.** The card declares `streamable-http` transport (MCP protocol `2025-06-18`) pointing at `/api/mcp` and enumerates two read-only docs tools — `search_docs` (full-text over the docs corpus) and `get_doc_markdown` (markdown twin of any docs page by slug). The endpoint handles `initialize`, `tools/list`, `tools/call`, `resources/list`, `ping`, and the `notifications/*` no-ops; `tools/call` for `search_docs` runs the same `createFromSource(source)` engine that backs [`/api/search`](/api/search), and `get_doc_markdown` resolves through `source.getPage()` against the same processed body the agent-skills index digests. Advertised via `rel="mcp-server-card"` in the `Link` headers on `/` and `/docs/:path*`, in the `<link rel>` mirror in the root layout, and under `service-meta` in the [RFC 9727 `/api-catalog`](/api-catalog) linkset. * **WebMCP browser provider mounted from the root layout.** Calls `navigator.modelContext.provideContext({ tools })` (with a fallback to per-tool `registerTool` for older Chrome origin-trial builds) to expose the same two tools — `search_docs`, `get_doc_markdown` — to AI agents embedded in the browser. Silent no-op on browsers without the API. Tool shapes are identical to the HTTP MCP surface, so an agent reaches the same operations whether it dials in over `/api/mcp` or rides inside a browser tab. * **Agent Skills Discovery index at `/.well-known/agent-skills/index.json`.** Follows Cloudflare's RFC v0.2.0 shape. Every docs page is exposed as a `documentation`-type skill whose `url` points at the per-page markdown twin (`/llms.mdx/docs/<slug>/content.md`) and whose `sha256` digests the same processed body that URL serves. A changed digest is the signal a page has been re-authored; agents can verify ingestion against it byte-for-byte. The index iterates `source.getPages()` so any new docs page joins automatically. Advertised via `rel="agent-skills"` in the `Link` headers on `/` and `/docs/:path*`, in the [`<link rel>` mirror](/) in the root layout, and under `service-meta` in the [RFC 9727 `/api-catalog`](/api-catalog) linkset. An isitagentready.com audit also flagged OAuth metadata, `/auth.md`, MCP Server Card (SEP-1649), WebMCP, and DNS-AID. The MCP Server Card and WebMCP additions above resolve two of those flags; the remainder still don't apply — this site has no protected APIs and no agent-registration surface, so OAuth metadata and `/auth.md` would advertise endpoints that don't exist. The existing crawl surface — [sitemap](/sitemap.xml), [`llms.txt`](/llms.txt), [`llms-full.txt`](/llms-full.txt), per-page `.md` twins via `Accept: text/markdown`, [`robots.txt`](/robots.txt), [`ai.txt`](/ai.txt), [`/api-catalog`](/api-catalog) (RFC 9727), and RFC 8288 `Link` headers — is what an agent actually needs to crawl the docs effectively. * **[Node catalog](/docs/graph-node-catalog) and [Edge catalog](/docs/graph-edge-catalog) reference pages, slotted into the Architecture sidebar after [Channels](/docs/channels).** Node catalog enumerates the 43-name registry the `audit:graph-stages` script accepts, grouped by lattice stage with the `acceptsSeam` literal and a per-node `gated` column (`always` vs. `config.<field>` — the executor or hook the host must supply for the node to wire). Edge catalog enumerates the nine allowed cross-stage edge patterns plus the seven forbidden ones verbatim, then documents the four static chains (pre-LLM, enrich-route, post-tool, terminal), the two conditional routers (post-LLM four-arm, post-hallucination two-arm), and the four post-turn recovery stream filters (`refusalHintFilter` 100, `retryNarrationFilter` 200, `garbleRecoveryFilter` 300, `standardRecoveryFilter` 400) with per-arm target tables. * **DX sections in [Node catalog](/docs/graph-node-catalog) and [Edge catalog](/docs/graph-edge-catalog) documenting the three extension paths external developers can use.** Node catalog adds an "Adding or removing nodes" matrix covering config omission (drop a gated executor, the chain composes around the absent node via `.filter(hasNode)`), the plugin path (`HarnessPlugin.extraGraphNodes()` with the substrate's registration constraints), and the custom-builder path (a raw `StateGraph` for full topology control under the lattice gate). Edge catalog adds a parallel matrix plus the asymmetric removal story: a `synthesize → tool-loop` edge cannot be re-added through any extension path, and removing an allowed pattern requires upstream contribution to `topology.ts`. Both pages keep the upstream-contributor checklist as a separate section so the two audiences don't collide. * **[Ownership boundaries](/docs/ownership) page, slotted into the Get Started sidebar after [Which SKU do I need?](/docs/which-sku).** Single-page map of where each SKU's locked surface ends and host code begins. Three columns per SKU: *Owned by the SKU* (locked, upstream-contribution-only), *You configure* (typed config field, plugin hook, or registry), *You must supply* (required at construction). Covers every shipping `@pleach/*` SKU. Names the two structural pins that hold across every SKU — the [four-stage lattice](/docs/graph#the-lattice-gate) and the [`AuditableCall`](/docs/auditable-call-row) row shape — and the cross-SKU concerns where ownership spans packages (audit ledger, cost rollup, storage, determinism). Closes with a "Quick lookup — I want to change X" table mapping 20 common host-side change requests to their surface and path. Fills the gap between [Which SKU do I need?](/docs/which-sku) (task → install list) and [Plugin contract](/docs/plugin-contract) (full extension API). * **DX additions to six existing pages after a corpus-wide audit of where extension guidance was missing or weak.** The audit scanned 177 MDX files; the pass rate was high (the site was already DX-aware on most surfaces). Six rows landed: (1) [The AuditableCall row](/docs/auditable-call-row) gains a locked-contract callout naming the three extension slots (`tenantId`, `payload.identity`, `payload.output.auditNote`) and pointing the rest at upstream contribution; (2) [Fabrication detection](/docs/fabrication-detection) gains an "Adding a custom detector" section with a worked `contributeFabricationDetectors` example plus the three detector rules (sync `detect`, side-effect-free, tier-determines-routing); (3) [Event log](/docs/event-log) gains an "Authoring a custom projection" section against the verified `GraphProjection<T>` interface plus the three contract rules (pure, order-preserving, total); (4) [Observability](/docs/observability) gains an "Extending observability — plugin hooks" section surfacing `contributeStreamObservers` and `contributeFinalizationPasses`; (5) [`@pleach/mcp`](/docs/mcp) gains a "Registering custom capabilities" section honest about what works today (`McpToolRegistration`) versus what throws (`McpResourceRegistration` + `McpPromptRegistration` are shape-stable but body-deferred until Phase B second slice); (6) [Audit gates](/docs/audit-gates) gains a "Mirroring gates in your own repo" section citing the `audit:plugin-contract-completeness` script shape as a small reference for host-side invariant gates. Every hook name in every example was verified against the upstream source before landing — no fabricated APIs. ### Fixed [#fixed-2] * **[AuditableCall row](/docs/auditable-call-row) `AuditRecordVersion` corrected 13 → 14** — both the literal description and the version-log range (`v8 through v14`) now match the upstream `AUDIT_RECORD_VERSION_HISTORY`, whose latest entry is `to: 14` (Cluster C: additive `rewriteStatus?` + `tokenCost.callClass`). * **Dead link `/docs/plugins-and-extensions` corrected to [Plugin contract](/docs/plugin-contract)** on [Install](/docs/install). * **Docs home "Three paths" header corrected** — it preceded four cards; the count was dropped. * **Schema `codeRepository` + `sameAs` corrected across seven SKUs in the site's Organization + SoftwareApplication JSON-LD `@graph`.** [`@pleach/base-tools`](/docs/base-tools), [`@pleach/observe`](/docs/observe), [`@pleach/compliance`](/docs/compliance), [`@pleach/compliance-contract`](/docs/compliance), [`@pleach/gateway`](/docs/gateway), [`@pleach/mcp`](/docs/mcp), and [`@pleach/recipes`](/docs/recipes) each declared `https://github.com/pleachhq` as their canonical repository — a leftover from when the SKU schemas were drafted against an upstream monorepo. Both `codeRepository` and `sameAs` now point at `https://github.com/pleachhq/<sku>`, matching the canonical pattern set by [`@pleach/core`](/docs/core), [`@pleach/tools`](/docs/tools), [`@pleach/eval`](/docs/eval), [`@pleach/replay`](/docs/replay), [`@pleach/sandbox`](/docs/sandbox), [`@pleach/coding-agent`](/docs/coding-agent), and [`@pleach/react`](/docs/react). Load-bearing because `sameAs` is the field AI search engines (ChatGPT, Perplexity, Gemini, AI Overviews) use for entity reconciliation across the npm ↔ GitHub ↔ docs boundary; the wrong URL was being served to every crawler on every page request via the JSON-LD `@graph` inlined in the docs root layout. * **Schema `codeRepository` + GitHub `sameAs` entry added to [`@pleach/langchain`](/docs/langchain).** The langchain SKU's `SoftwareApplication` block was the only entry in the JSON-LD graph without a `codeRepository` field, and its `sameAs` array referenced only the npm page and the docs page (not the GitHub repo). Now declares `https://github.com/pleachhq/langchain` consistent with the other SKUs. * **Pleaching metaphor branches list corrected across four surfaces.** The prior framing named the four stages (intent / tool pick / refusal / answer) as the branches woven into the lattice, but stages are sequential phases of one turn — not parallel branches the metaphor needs. The [glossary](/docs/glossary#p) already had the right framing, matching the upstream `@pleach/core` README: branches are the addressable events (each LLM call, tool dispatch, subagent spawn) — each one writes a row to the ledger. The bad list had been introduced during the lift-and-rewrite from the upstream metaphor passage and replicated to the homepage hero, the docs [index](/docs), the [overview](/docs/overview), and the [audit ledger](/docs/audit-ledger) page. All four now align with the glossary and the upstream README, and a closing line on the homepage and docs index/overview names the four stages as the *trellis* and the [audit row](/docs/auditable-call-row) as the *weave* so the metaphor's load-bearing parts each have a job. No change to the glossary, the upstream README, or [replay](/docs/replay) (which uses *branch* generically and was already metaphor-safe). ### Removed [#removed] * **Company attribution and private references stripped from shipped copy.** A company name, private repository links, a private filesystem path, and internal tracker links were removed from the homepage and docs; a few domain-specific example terms in prose were genericized. * **Internal planning citations removed across the docs.** Decision, pack, slice, and open-question IDs that meant nothing to an outside reader were stripped, each annotated claim left intact. Live runtime symbols a consumer sees — the `PACK_*` constants and the literal `NotImplementedError("D-PA-181")` / `("D-PA-184")` payloads — were retained. * **Homepage `Four Properties` section deleted, plus the duplicate hero "From those three" paragraph.** The `Four Properties` grid (cost / audit / compliance / legibility) restated the same primitive — `tenantId` / `turnId` / `tokenUsage` rolled up via `GROUP BY` — that the `What you can do with it` section two blocks above already covered, with different labels. The hero paragraph "From those three: per-tenant cost in a `GROUP BY`, compliance review in SQL…" restated the load-bearing fact the previous paragraph (session / turn / row) had just landed. Both cuts follow a verbosity audit of every public non-docs page; the deletions shorten the homepage by \~120 lines without losing a load-bearing claim — scrubber names and the four-stage shape are still documented at [Compliance](/docs/compliance) and [Architecture](/docs/architecture), which the deleted pillars linked to anyway. The [Agent shapes](/docs/agent-shapes) CTA the deleted section carried is rewired into the `Should you stay where you are?` section, folded into the existing shape-enumeration paragraph so it lands where a reader is already asking the what-shape-am-I question. ### Changed [#changed-2] * **`@pleach/observe` repositioned from shipping to alpha across the site.** [Packages](/docs/packages), the docs home, and [Which SKU](/docs/which-sku) had listed it at `0.1.0 · npm`; [`@pleach/observe`](/docs/observe) is `0.1.0-alpha.0` — scoped, not yet on npm. Install recommendations now tag it `(alpha)`. * **Package-count framing corrected to 14 published.** `@pleach/compliance-contract` joins the first-wave list; `@pleach/observe` (alpha) and `@pleach/trust-pack` (reserved) move out of the shipping set. * **Homepage `@pleach/coding-agent` version aligned to `0.1.0`** — it had shown `0.2.0-alpha.0`, contradicting the first-wave cut on [Packages](/docs/packages). * **[Which SKU](/docs/which-sku) persona section rewritten to named archetypes.** Internal persona codes became plain audience shapes. * **[What's new — June 2026](/docs/whats-new-2026-06) reframed as reader-facing release notes** — reorganized by package; slice numbers, commit SHAs, and persona codes removed. * **Graph lattice docs synced to the current `@pleach/core` topology across eight pages.** Corrected three load-bearing claims that shipped against an older substrate. The `NODE_STAGE_MAP` registry is **43** names, not 48. `ALLOWED_EDGE_PATTERNS` carries **nine** rows and `FORBIDDEN_EDGE_PATTERNS` **seven**, not eight/eight — the `tool-loop → post-turn` recovery-dispatch edge moved from the forbidden table into the allowed table. Recovery shaping is no longer four `synthesize`-stage graph nodes (`recovery`, `refusalHint`, `retryNarration`, `garbleRecovery`) plus a `recoveryDispatchPredicate` derivation node and a `shouldContinueWithGarbleRoute` wrapper; it is four post-turn stream filters (`refusalHintFilter` 100, `retryNarrationFilter` 200, `garbleRecoveryFilter` 300, `standardRecoveryFilter` 400) dispatched via `StreamObserverRegistry` and locked by `audit:recovery-dispatch-single-surface`. `synthesize` is now a true singleton — only `synthesizer`. Touched [Graph](/docs/graph), [Node catalog](/docs/graph-node-catalog), [Edge catalog](/docs/graph-edge-catalog), [Nodes](/docs/nodes), [Architecture](/docs/architecture), [Ownership boundaries](/docs/ownership), and [Plugin contract](/docs/plugin-contract); [Audit gates](/docs/audit-gates) gains graph + config-manifest gate rows. [Node catalog](/docs/graph-node-catalog) also gains a "How the substrate source is organized" section — the `src/graph/` layout (`nodes/` one-file-per-node, `wiring/` registrars split by stage, the `topology.ts` lattice layer, concern-split `seams/` / `predicates/` / `strategies/`) — and its upstream-contributor steps now point at the `wiring/register<Stage>Nodes.ts` registrar rather than the thin `defaultAgentGraph.ts` orchestrator. * **Config manifest foreign key corrected in [Config manifest](/docs/config-manifest).** The page claimed the `harness_event_log.manifest_hash` reference was gate-enforced "rather than a database foreign key." It is a real foreign key with `ON DELETE SET NULL` and a load-bearing `DEFERRABLE INITIALLY DEFERRED` clause — deferring to commit time lets the fire-and-forget manifest write race the first event-log write within one transaction without a transient FK violation. Added the missing `reference_count` column to the table DDL and cited the `audit:config-manifest-fk-constraint-applied` and `audit:config-manifest-referential-integrity` gates. * **[Authoring a HarnessPlugin](/docs/plugins/authoring) informal "12 buckets" reframed as the scan view of the canonical nine namespaces.** The at-a-glance hook grouping now points at [Contribution namespaces](/docs/plugins/namespaces) as the canonical, audit-gated contract layout. Hooks and counts unchanged; the framing reconciles the two pages so the site carries one canonical taxonomy. * **[Graph](/docs/graph) structural pin corrected from a fixed "32 nodes / 70 edges" count to a config-honest framing.** The old prose claimed the canonical graph "compiles to 32 nodes and 70 edges" and that the audit asserts that count is byte-identical PR-to-PR. The numbers were correct for a specific compile config but misleading as a general claim: the `NODE_STAGE_MAP` registry in `topology.ts` carries 43 names, and the actual compiled count depends on which executors and hooks the host supplies to `buildDefaultAgentGraph(config)`. The corrected prose names the 43-name registry as the upper bound, names the `audit:graph-stages` byte-identity guarantee as *per-config* not absolute, and links to the two new catalogs ([Node catalog](/docs/graph-node-catalog), [Edge catalog](/docs/graph-edge-catalog)) as the enumerated references. * **Schema [`@pleach/core`](/docs/core) description amended to name the language-agnostic contract.** The `description` field in the Organization + SoftwareApplication JSON-LD graph previously described `@pleach/core` only in TypeScript terms, contradicting both the marketing surface (which states the wire contract is language-agnostic with an independent Go implementation) and the editorial position at [`/ai.txt`](https://getpleach.com/ai.txt). The description now names the wire shapes — HTTP+SSE, `StreamEvent`, [`AuditableCall`](/docs/auditable-call-row), [`Checkpoint`](/docs/checkpointing), version vector — as language-agnostic and clarifies that the npm package is the TypeScript reference distribution. `programmingLanguage: ["TypeScript"]` stays accurate — the npm package IS TypeScript; the language-agnosticism belongs to the contract, not the publishable artifact. See [Language-agnostic contract](/docs/language-agnostic-contract) for the full wire-shape inventory. * **[`/llms.txt`](https://getpleach.com/llms.txt) per-page markdown advice tightened from `.mdx` to `.md`.** The closing `## Machine-readable surface` block in the curated AI index previously instructed agents to construct URLs like `https://getpleach.com/llms.mdx/docs/<slug>.mdx`, hitting the underlying route handler directly. The site's markdown-negotiation proxy supports the more idiomatic `https://getpleach.com/docs/<slug>.md` form, which is the pattern most AI crawlers actually look for. The instruction now recommends the proxied form and notes it's equivalent to requesting the same URL with `Accept: text/markdown`. * **Pricing page export-bridge prose tightened, "What we sell on top" restructured.** The export-bridge paragraph collapsed from four sentences with dual "If your team / If you don't" framing to two sentences carrying the same destination list (Postgres, Supabase, OTel collector, custom) and the same bridge-vs-destination framing. The "What we sell on top" paragraph — a 70-word run-on with three nested parentheticals (Hosted Gateway, Hosted Observe at enterprise scope, marketplace bundles) — became a three-item bulleted list, one SKU per line. Same audit pass as the homepage cut above. ### Added [#added-3] * **Agent-discovery surface extended to deep-link landings and body-only fetchers.** Three reach extensions to the existing RFC 8288 `Link:` header + `robots.txt` + [`/llms.txt`](https://getpleach.com/llms.txt) + [`/ai.txt`](https://getpleach.com/ai.txt) + [`/.well-known/api-catalog`](https://getpleach.com/.well-known/api-catalog) stack — none of them corrections, each one closes a gap where a non-default crawler shape misses the discovery surface. * The docs root layout mirrors the homepage `Link:` headers as `<link rel>` elements in `<head>` on every page, so agents that read rendered HTML body but never see response headers (cached archive fetchers, lightweight AI ingestors) discover `api-catalog`, `service-doc`, `alternate` → `/llms.txt`, `describedby` → `/ai.txt`, `sitemap`, and `author` the same way they would from the header. * The site's Next.js header config extends the same `Link:` headers (with `Vary: Accept`) to every `/docs/*` URL. Agents arriving at a deep-link citation in an AI answer now get the same discovery hints — api-catalog, service-doc, ai.txt, sitemap, author — without traversing back to `/`. The home-only `/llms.txt` `alternate` is omitted from this path because the per-page markdown twin is the same URL with `.md` appended, served via the markdown-negotiation proxy; advertising `/llms.txt` would falsely imply that's the markdown twin of the current page. * The sitemap generator emits a `.md` twin entry for every docs page at priority `0.4` (below the `0.7` HTML form, so search engines prefer the human-facing URL for ranking). Sitemap-only crawlers — some AI ingestors don't follow `Accept: text/markdown` content negotiation — now discover the machine-readable surface from the sitemap alone. * **Pleaching metaphor surfaces on the homepage hero and docs index.** The horticultural framing the runtime takes its name from previously lived only in the brand motif component and the [glossary](/docs/glossary#p)'s `P` section. The homepage hero and the [docs index](/docs) now carry a single voice-tightened paragraph between the lede and the payoff — pot-and-stakes for ai-sdk/LangChain, lattice for Pleach, branches for intent / tool pick / refusal / answer. Lifted and rewritten from the metaphor passage in the upstream `@pleach/core` README; the docs-index version cross-links the canonical glossary entry. One flourish per page per voice rules; mechanism still leads, metaphor follows. Three concept pages pick up a single sub-metaphor each: [Comparison](/docs/comparison) carries the pot-and-stakes-vs-lattice contrast right under the lede; [Audit ledger](/docs/audit-ledger) adds "the lattice carrying weight — every branch lands in a row at the same grain" after the stage walkthrough; [`@pleach/replay`](/docs/replay) adds "walking the rows in order is walking the lattice" under the determinism claim. Skipped `determinism.mdx`, `concept-clusters.mdx`, `checkpointing.mdx`, and API references — too instrumental for the flourish to earn its weight. * **Fumadocs UI surface broadened** — the docs MDX registry now carries `Steps`/`Step`, `Accordion`/`Accordions`, `TypeTable`, `File`/`Files`/`Folder`, `Banner`, `ImageZoom`, `InlineTOC`, `DynamicCodeBlock`, and `GithubInfo` alongside the existing defaults. `Card`/`Cards` and `Callout` were already in the fumadocs defaults; the new registrations unlock the rest across every page without further wiring. * **Three first-party enterprise-shape components** — `StatusBadge` (8 semantic statuses: `stable`, `beta`, `preview`, `alpha`, `deprecated`, `reserved`, `pre-1.0`, `in-flight`), `VersionPill` (monospace `pkg@version` pill with optional `href`), and `Kbd` (themed keyboard-shortcut display). Pure-SSR React, theme-aware via `text-fd-*` tokens, no client JS. [`/docs/replay`](/docs/replay) adopts `<StatusBadge status="in-flight">Phase A + Phase B</StatusBadge>` under its Status heading as the reference adoption. * **`<TypeTable>` on reference pages** — [`/docs/auditable-call-row`](/docs/auditable-call-row) converts all five sub-shape tables (Identity, Principal, Call, Decision, Outcome) into `<TypeTable>` blocks. [`/docs/stream-events`](/docs/stream-events) and [`/docs/env-vars`](/docs/env-vars) each convert one representative table as the pattern reference for follow-up work. * **`<Accordions>` on the FAQ Capabilities cluster** — the 9-question "Capabilities" section of [`/docs/faq`](/docs/faq) now renders as a single `<Accordions>` with each Q an `<Accordion>` carrying an explicit `id` for deep-link stability. The main FAQ above stays as ##-headed prose; the `FAQPage` JSON-LD graph is unaffected. * **`<Steps>` on the migration pages** — [`/docs/migrating-from-ai-sdk`](/docs/migrating-from-ai-sdk) and [`/docs/migrating-from-langchain`](/docs/migrating-from-langchain) wrap their six step sections in `<Steps>`/`<Step>`. The auto-numbered left-rail track replaces the `## Step N —` prefix in each heading, so H2 anchor IDs are now content-only (`#convert-your-tools` instead of `#step-2-convert-your-tools`). No inbound links pointed at the old anchors. * **`<Callout type="info">` on migration scoping blocks** — the "You don't need this migration if" pre-flight bullets and the "Costs that don't go away" sections in both migration pages now render as info callouts. ### Changed [#changed-3] * **[`/docs/replay`](/docs/replay) error hierarchy diagram** — the ASCII tree rendering `ReplayError` / `ReplayDivergenceError` / `ReplayCacheMissError` / `ReplayUnknownEventError` / `NotImplementedError` is now a mermaid `classDiagram` with inheritance arrows and per-class fields. Matches the established mermaid idiom used on [`/docs/overview`](/docs/overview), [`/docs/architecture`](/docs/architecture), [`/docs/determinism`](/docs/determinism), [`/docs/family-lock`](/docs/family-lock), and [`/docs/turn-lifecycle`](/docs/turn-lifecycle). * **Four SKU placement pages** — [`/docs/sandbox`](/docs/sandbox), [`/docs/transport-bedrock`](/docs/transport-bedrock), [`/docs/transport-azure-openai`](/docs/transport-azure-openai), and [`/docs/transport-vertex`](/docs/transport-vertex). Each page is a thin orientation: what the SKU is for, where it slots in the substrate, which contract page it implements, and a pointer to the package's npm README as the canonical source for constructor signature and option shape. Authored to retire four audit failures for SKUs already on `SKU_PUBLISHED`. The repo's `audit:docs-vs-contract` script now reports `0 missing pages`. * **[`/docs/recipes`](/docs/recipes) jump-link table** — 10-row index inserted after the lead so a reader scanning for one recipe doesn't sift through \~1,000 lines. Each row names the recipe's central primitive (`@pleach/react` + `createPleachRoute`, `ProviderDecisionLedger`, etc.) in 3–8 words. * **HowTo schema on [`/docs/adoption-paths`](/docs/adoption-paths)** — 5-step JSON-LD mirroring the brownfield→greenfield progression. Improves AI-search citability on a load-bearing discovery page. * **FAQPage schema on [`/docs/comparison`](/docs/comparison)** — 11 Q/A entries across the four comparison clusters (TS LLM libraries, durable-execution platforms, agent harnesses, Enterprise contracts). Each Q matches a question shape a reader would type into ChatGPT or Perplexity; each A is the load-bearing fact from the page. * **`---Transport providers---` sidebar group** below `Bundled packages`, listing [`transport-azure-openai`](/docs/transport-azure-openai), [`transport-bedrock`](/docs/transport-bedrock), and [`transport-vertex`](/docs/transport-vertex). [`sandbox`](/docs/sandbox) slots into `Bundled packages` after `base-tools`. ### Changed [#changed-4] * **[`/docs/session-runtime`](/docs/session-runtime) — facets-first restructure.** The facets inventory (`runtime.sessions`, `runtime.events`, `runtime.sync`, `runtime.dev`) now appears directly after Strategy injection. The flat method surface relocated under a new `Flat method surface (legacy)` H2 with a one-line banner forward-pointing to facets. Readers previously learned the deprecated path first; the new order matches the deprecation contract the page already states. * **[`/docs/adoption-paths`](/docs/adoption-paths) — brownfield→greenfield framing promoted.** What used to be a page-bottom section is now a callout directly after the two-paths comparison table, so decision-makers see the upgrade path before they pick a lane. Hook sentence: "Every choice you make on the SDK destination travels with you when you adopt the full runtime." * **Sidebar group rename: `Use cases` → `Agent patterns`.** Same six pages ([`customer-support-agent`](/docs/customer-support-agent) … [`regulated-domain-agent`](/docs/regulated-domain-agent)) — the prior name read as features, the new name reads as patterns. Operations stays as a single group; cosmetic clustering wouldn't help scanning. * **[`/docs/recipes`](/docs/recipes) lead** — count flipped `Nine` → `Ten` and the inline name list extended to include the regulated-host end-to-end recipe. ### Fixed [#fixed-3] * **[`/docs/plugin-authoring-standards`](/docs/plugin-authoring-standards) — ghost hook reference.** A code example referenced a non-existent contribute-prefixed spelling of the pre-plan primer hook; the real hook is `prePlanPrimer` (no `contribute` prefix — lifecycle hooks like `prePlanPrimer`, `postSynthesisGuard`, `onJobDispatch` carry no prefix; the `contribute` prefix is reserved for contribution-slot hooks). Caught by the in-repo `audit:docs-vs-contract` script — first F-11 drift hit since the SKU coverage check landed. Audit now reports `0 novel ghosts`. ### Added [#added-4] * **Rank-2 per-recipe deep-dive pages** — four additional pages under [`/docs/recipes/`](/docs/recipes/simple-chatbot) covering the recipes excluded from the prior six because their peer SKUs were outside the first-publish wave: [`observableChatbot`](/docs/recipes/observable-chatbot) (composes `@pleach/observe`), [`instrumentedCodingAgent`](/docs/recipes/instrumented-coding-agent) (composes `@pleach/coding-agent` + `@pleach/sandbox` + `@pleach/observe`), [`enterpriseAgent`](/docs/recipes/enterprise-agent) (composes `@pleach/compliance` + `@pleach/observe`, surfaces the procurement-visible `ENTERPRISE_PERMITTED_FAMILIES_TAG` envelope a gateway-aware host reads), and [`evalLab`](/docs/recipes/eval-lab) (composes `@pleach/eval` * `@pleach/replay` via DI-callback factories so the recipe doesn't take hard build-time peer deps). The four are rank-2-publish-relevant once `eval`, `mcp`, `sandbox`, and `coding-agent` ship per the upstream rank-2 runbook. Each page mirrors the prior six-page convention — best fit, quickstart, runtime composition, TypeScript config interface, common gotchas, cross-links. Sidebar registration in \[the docs `meta.json`] adds the four pages immediately after the prior six under Bundled packages. * **Per-recipe deep-dive pages under [`/docs/recipes/`](/docs/recipes/simple-chatbot)** — six new pages covering the first-publish wave (`@pleach/core`, `@pleach/tools`, `@pleach/compliance`, `@pleach/gateway`) and the brownfield observability SDK: [`simpleChatbot`](/docs/recipes/simple-chatbot), [`ragChatbot`](/docs/recipes/rag-chatbot), [`compliantChatbot`](/docs/recipes/compliant-chatbot), [`verticalAgent`](/docs/recipes/vertical-agent), [`subagentSwarm`](/docs/recipes/subagent-swarm), and [`BYOK observability`](/docs/recipes/byok-observability). Each carries a quickstart, the runtime composition the recipe wires, a TypeScript config interface, common gotchas, and cross-links. Recipes composing SKUs outside the first-publish wave (`observableChatbot`, `instrumentedCodingAgent`, `enterpriseAgent`, `evalLab`) are not yet covered and will land when those SKUs ship. * **[`/robots.txt`](/robots.txt)** — emits `Content-Signal: search=yes, ai-train=yes, ai-input=yes` per the contentsignals.org draft. The signal mirrors the editorial position already on [`/ai.txt`](/ai.txt) — crawling, indexing, retrieval, and training are all permitted. * **Homepage `Link:` response header** — the `/` response now ships RFC 8288 `Link:` advertising [`/.well-known/api-catalog`](/.well-known/api-catalog) (`rel="api-catalog"`), [`/docs`](/docs) (`rel="service-doc"`), [`/llms.txt`](/llms.txt) (`rel="alternate"; type="text/markdown"` — the markdown twin of `/` under content negotiation), [`/ai.txt`](/ai.txt) (`rel="describedby"`), [`/sitemap.xml`](/sitemap.xml) (`rel="sitemap"`), and the maintainer inbox (`rel="author"`). `/` also gets `Vary: Accept` so a CDN can't cross-contaminate the HTML and markdown responses. Agents can find the discovery surface without parsing HTML. * **[`/.well-known/api-catalog`](/.well-known/api-catalog)** — RFC 9727 catalog returned as `application/linkset+json` (RFC 9264). Two anchors: the site root and `/api/search` (the docs search endpoint). ### Changed [#changed-5] * **Markdown-for-Agents on the homepage** — `/` with `Accept: text/markdown` now rewrites to [`/llms.txt`](/llms.txt) (which uses fumadocs's q-value-aware `isMarkdownPreferred` to choose `text/markdown` over `text/plain` and ships `Vary: Accept`). Per-page markdown under `/docs/*` is unchanged. * **[`/pricing`](/pricing)** — *Two surfaces. One runtime.* closing paragraphs refine to drop claims cohort research contradicted, without committing to hypothesis-grade tier prices still gated by buyer-outreach. Specifically: *compliance attestation* drops as a standalone enterprise surface and folds inside *Hosted Observe at enterprise scope* (nobody sells "cryptographic audit chain" as a standalone procurement category — Vanta / Drata / Credo AI all sell per-framework + per-headcount); *Hosted Gateway* description rewrites to per-tenant subscription pricing with optional consolidated billing, drops any percentage-on-routed-spend framing (100% of disclosed-pricing LLM router cohort runs zero markup on inference — Vercel + OpenRouter have done buyer education); the bridge paragraph expands to acknowledge the self-serve segment where Pleach becomes the destination (no existing observability stack to bridge to). New closing paragraph names self-serve tiers as in development without committing to specific prices. * **[`/faq`](/faq) and [`/docs/faq`](/docs/faq)** — *Is there a hosted version?* answer rewrites the hosted product list: compliance attestation folds inside Hosted Observe Enterprise (no separate SKU); Hosted Gateway names per-tenant subscription pricing and "no markup on inference"; self-serve tiers named as in development. * **[`/pricing`](/pricing)** — *What stays free, and what doesn't* rewritten as *Two surfaces. One runtime.* with a side-by-side comparison table (substrate vs hosted) across 11 dimensions: license, phone-home, license check, account-to-run, retro-paywall, self-host, SSO/SAML/SCIM, SLA, retention, SOC 2, pricing. The table makes the trust-vs-revenue boundary structurally instead of by meta-commentary. Two short closing paragraphs add: (a) audit rows ship where you point them — Postgres, Supabase, OTel collector into Datadog / Honeycomb / Grafana / your existing OTLP backend, or a custom destination; the enterprise integration is the *bridge* from `@pleach/observe` into the review surface your team already runs, not "adopt another dashboard"; (b) what we sell on top: Hosted Gateway, compliance attestation services, Hosted Observe at enterprise scope, AWS/Azure/GCP marketplace bundles. The prior absolutist lines — *self-hosting the equivalent stays on the table at every release* and *no procurement-style enterprise tier today* — drop because they foreclosed the only viable revenue stream. * **[`/`](/)** — homepage hero closer rewrites the *no procurement-style enterprise tier* line in favor of *hosted products on the runtime are first-class enterprise products*. Trust commitment intact; revenue posture honest. * **[`/faq`](/faq) and [`/docs/faq`](/docs/faq)** — *Is there a hosted version?* answer rewrites under the two-surface frame. Hosted Gateway, Hosted Observe at enterprise scope, and compliance attestation services name themselves as enterprise products with per-seat pricing, SSO/SAML/SCIM, retention SLAs, and SOC 2 wrappers. Runtime trust commitments intact. ### Added [#added-5] * **[`@pleach/observe`](/docs/observe)** — new top-level page for the brownfield audit-row SDK. Documents the five-entry facade surface (`init`, `startTurn`, `flush`, `recordCall`, `subagent`, `getRecorder`), the `ObserveRow` shape as a strict subset of the [`AuditableCall` v7](/docs/auditable-call-row) schema, the four destinations (Postgres, Supabase, OTel, Memory) on the `./destinations` subpath, the custom-destination interface (`write` + optional `flush`), the hookable-vs-not-hookable split against the runtime, and composition with [`@pleach/core`](/docs/core) when both surfaces run in the same process. Phase 0 status callout makes the design-target stance explicit — `@pleach/observe@0.1.0-alpha.0` is scoped, not yet on npm; Phase 1 publish is the next cut. Slotted into the Bundled packages cluster after [`@pleach/gateway`](/docs/gateway). * **[Adoption paths](/docs/adoption-paths)** — new top-level orientation page making the brownfield-vs-greenfield decision first-class. Leads with a side-by-side capability table (audit row, family-locked routing, replay determinism, channels, checkpoint/restore, migration cost), then "pick brownfield when" and "pick greenfield when" sections with concrete code shapes for each, then the monotonic brownfield-to-greenfield composition story (runtime detects SDK `init`, `startTurn` becomes a no-op inside a runtime-managed turn, shared fingerprint compute, no double-recording), then "pick neither when" — single-shot RAG, provider-dashboard-is-enough, prototyping-and-changing- weekly. Slots in at the top of the Compose & migrate cluster as the orientation page the per-stack `migrating-from-*` walkthroughs ([AI SDK](/docs/migrating-from-ai-sdk), [LangChain](/docs/migrating-from-langchain), [Anthropic Enterprise](/docs/migrating-from-anthropic-enterprise), [OpenAI Enterprise](/docs/migrating-from-openai-enterprise)) sit downstream of. * **[Packages](/docs/packages)** — added the `@pleach/observe` matrix row in the Reserved tier with the row note `0.0.1 · Reserved · 0.1.0-alpha.0 scoped`, and reworked the Reserved bullet in the intro narrative to distinguish [`@pleach/trust-pack`](/docs/packages) (placeholder, no contract published) from [`@pleach/observe`](/docs/observe) (Phase 0 contract locked, scoped for Phase 1 under FSL-1.1-Apache-2.0). * **Sidebar** — added `observe` under Bundled packages after [`@pleach/gateway`](/docs/gateway), and [Adoption paths](/docs/adoption-paths) at the top of Compose & migrate ahead of the migrating-from-\* pages. * **[Attestation](/docs/attestation)** — new top-level page on the Ed25519-signed envelope substrate that sits on top of the [hash chain](/docs/hash-chain) and turns a row-slice into third-party-verifiable evidence. Documents the `@pleach/core/attestation` subpath (signer, verifier, key-store interface), the two stub production adapters (AWS KMS, Vault Transit), and the file-backed `attestation/testing` adapter. Frames the boundary against policy and rotation, which stay in [`@pleach/compliance`](/docs/compliance) or host-side. Slotted into the Tamper evidence cluster directly after [Hash chain](/docs/hash-chain). * **[Audit gates](/docs/audit-gates)** — new top-level page cataloging the CI- and PR-time invariant checks in `scripts/audit/` — package-shape, plugin-contract, tool- coverage, event-log integrity, runtime soak ledgers, and cross-repo reservations. Documents the `:strict` / `:json` / `:list` / `:update-baseline` suffix convention and the soak-ledger generalization. Slotted into the Reference cluster after [Subpath exports](/docs/subpath-exports). * **[Project layout](/docs/project-layout)** — new top-level page giving the orientation that previously had to be reconstructed from the use-case and recipe pages. Leads with the three things the runtime actually requires (a [`SessionRuntime`](/docs/session-runtime) per process, a chosen [`ProviderDecisionLedger`](/docs/audit-ledger), a [host adapter](/docs/host-adapter) if HTTP is in play); shows one working Next.js-shaped layout with each piece cross-linked to its canonical docs page; descends into the six use-case shapes ([customer support](/docs/customer-support-agent), [research](/docs/research-agent), [coding](/docs/coding-agent), [internal knowledge](/docs/internal-knowledge-agent), [multi-tenant SaaS](/docs/multi-tenant-saas-agent), [regulated domain](/docs/regulated-domain-agent)) via a `<Cards>` block; points at the three filesystem-touching [recipes](/docs/recipes) (Next.js streaming, SQLite storage adapter, custom event-log projection); and closes with the deliberate non-choices (no `pleach.config.ts`, no reserved `.pleach/` dir, no `agents/` folder convention — the `agents/<specName>/` prefix in [Agents](/docs/agents) is a channel-path string, not a directory). Slots into a new Project layout sidebar cluster between [Getting started](/docs/getting-started) and [Agent shapes](/docs/agent-shapes). Voice-aligned to the library-not-framework stance: describe, don't prescribe. * **Sidebar** — added the Project layout cluster. The cluster has room for siblings (deployment-shape layouts, multi-tenant layouts) without a future re-org. * **Per-use-case Project layout sections** appended to all six use-case pages — [Customer support](/docs/customer-support-agent#project-layout), [Research](/docs/research-agent#project-layout), [Coding agent](/docs/coding-agent#project-layout), [Internal knowledge](/docs/internal-knowledge-agent#project-layout), [Multi-tenant SaaS](/docs/multi-tenant-saas-agent#project-layout), [Regulated domain](/docs/regulated-domain-agent#project-layout). Each shows the same baseline tree from [Project layout](/docs/project-layout) with the shape-specific delta inlined (split tools by integration; subagents directory; sandbox boundary + durable checkpointer; index + ingestion split; per-request runtime factory; `compliance/` wiring the three audit plug-points). Section is inserted before the existing **Where to go next** block so the descent from [Project layout](/docs/project-layout) lands, deepens, and exits through each page's existing cards. Voice-aligned: each entry names the failure mode the structural choice fixes, not a generic benefit. ### Changed [#changed-6] * **Cross-docs status-flip pass** — rolling correction across \~55 docs pages tracking the alpha-ship of [`@pleach/compliance`](/docs/compliance), [`@pleach/gateway`](/docs/gateway), and [`@pleach/replay`](/docs/replay). Claims that previously read "planned" or "no shipping code yet" now name the actual alpha (`@pleach/compliance@0.8.0-alpha.0` four-scrubber bundle; `@pleach/gateway@0.8.0-alpha.0` Phase A with Anthropic transport real and other transports deferred to Phase B; `@pleach/replay@0.7.0` hash-chain verifier shipping `verifyChainForChat` / `generateProof`, with writer-side stamping in `@pleach/core/eventLog` behind `c9PhaseBEnabled`). Touched pages include [Audit ledger](/docs/audit-ledger), [Compliance](/docs/compliance), [Gateway](/docs/gateway), [Replay](/docs/replay), [Hash chain](/docs/hash-chain), [Eval and replay](/docs/eval-and-replay), [Event log](/docs/event-log), [Event-log projections](/docs/event-log-projections), [Subpath exports](/docs/subpath-exports), [Seams](/docs/seams), [Prompts](/docs/prompts), [Prompt builder](/docs/prompt-builder), [Session runtime](/docs/session-runtime), [Recipes](/docs/recipes), and the rest of the Trust + integrity, Providers, and Reference clusters. Each edit is a load-bearing claim correction, not voice tightening — readers who saw the prior pages learned that pieces "weren't shipping" that now are, and the changelog entry exists so they can re-orient. * **[Project layout](/docs/project-layout)** — corrected the multi-tenant card description. Originally read "One runtime per process serves many tenants"; the [use-case page](/docs/multi-tenant-saas-agent#per-tenant-runtime-construction) actually builds the runtime per request via a `buildTenantRuntime(req)` factory. Updated to match — the cards descent must not misrepresent the page it lands on. * **[CLI](/docs/cli)** — substantial rewrite. The page previously described `pleach init` as a SQL-bundle scaffolder with `--target` / `--dry-run` / `--apply` / `--timestamped` flags. That surface still exists but is now [`pleach schema`](/docs/cli#pleach-schema--copy-the-sql-bundle); [`pleach init`](/docs/cli#pleach-init--the-wizard) is an interactive wizard that scaffolds a starter project (route, page, optional plugin stub, optional schema copy) for the detected framework. The old form `pleach init --apply` is routed to `schema` for back-compat (existing scripts keep working). Cited against the upstream `scripts/harness-init.mjs` already pinned in the page's `<SourceMeta>`. The rewrite is a correction to a load-bearing claim — readers who saw the old page learned that the binary "does one thing", which is no longer true. ### Added [#added-6] * **[CLI — `pleach init` wizard](/docs/cli#pleach-init--the-wizard)** — the four-question flow (template, provider, plugin stub, schema scaffold), the flag table (`--template`, `--yes`, `--cwd`), the framework-detection matrix (8 framework values), the package-manager detection (bun / pnpm / yarn / npm), the provider env-var matrix (anthropic / openai / openrouter / google with the four env-var names), the template-to-files matrix per framework, idempotence behavior (skip-not- overwrite), CI mode behavior (`--yes` and non-TTY auto-yes), exit codes (0 / 1), and a verbatim worked-example transcript of the wizard running in a Next.js App Router project. * **[CLI — `pleach schema`](/docs/cli#pleach-schema--copy-the-sql-bundle)** — the former `init` flag table, now under its true subcommand name. Adds honest exit codes (0 / 2 / 3) that the previous page omitted: schema bundle missing exits 2, overwrite refusal exits 3. * **[CLI — Project layout after the wizard](/docs/cli#project-layout--after-the-wizard)** — matches the per-use-case layout sections added in the prior pass. Shows the [baseline](/docs/project-layout#a-layout-that-works) Next.js layout with the four wizard-written paths inlined (`app/api/chat/route.ts`, `app/page.tsx`, `pleach.plugin.ts`, `supabase/migrations/*pleach*.sql`). Closes the loop between the CLI page and the project-layout cluster. * **[Subpath exports → `@pleach/core/quickstart`](/docs/subpath-exports)** — added under the **Wire + tooling** cluster. Labeled `experimental` (matches upstream module status). Lists the five LANDED deliverables — `createPleachRoute` (fetch-handler factory), `useChat` (Pleach-native React hook), `<ChatBox />` (unstyled, accessible), `defaultPlugin` (empty baseline), `providerDetection` helpers + constants — plus the benchmark- adjacent `createBenchmarkPlugin`. Closes the gap flagged in the prior cli.mdx pass. * **[Getting started → The quickstart subpath](/docs/getting-started#the-quickstart-subpath)** — new section after the existing [Shortest-path quickstart — `createPleachRuntime`](/docs/getting-started#shortest-path-quickstart--createpleachruntime). Five-export table with shapes and jobs; zero-config + with- options `createPleachRoute` examples; two failure-mode behaviors (HTTP 503 on missing provider env var, Pleach-native vs Vercel-AI-SDK event taxonomy); `useChat` / `<ChatBox />` composition example; `createBenchmarkPlugin` paragraph; a three-row "which factory do I reach for?" matrix differentiating [`createPleachRuntime`](/docs/getting-started#shortest-path-quickstart--createpleachruntime) (substrate), `createPleachRoute` (HTTP), and the bare [`SessionRuntime`](/docs/session-runtime) constructor (custom transport / multi-tenant). Voice-aligned: failure modes named at mechanism, not vibes. ### Changed [#changed-7] * **[CLI](/docs/cli)** — wizard-section cross-links retargeted from [Shortest-path quickstart](/docs/getting-started#shortest-path-quickstart--createpleachruntime) (a related but different surface) to [The quickstart subpath](/docs/getting-started#the-quickstart-subpath) (the actual subpath the wizard scaffolds against). Also added a paragraph noting the wizard's 4-provider list is narrower than the runtime's 7-provider detection — a missed provider in the prompt does NOT mean the deployed handler will miss it, only that the wizard's default-selection logic doesn't know to highlight it. * **[Getting started → Scaffold a new project](/docs/getting-started#scaffold-a-new-project)** — the second instance of the stale `pleach init` claim that [CLI](/docs/cli)'s rewrite corrected last pass. The section previously said `pleach init` "scaffolds a minimal example agent plus the schema-bundle SQL migrations"; it now describes the four-question interactive wizard and cross-links to [CLI → `pleach init`](/docs/cli#pleach-init--the-wizard) for the framework matrix and walkthrough. ### Fixed [#fixed-4] * Resolved the **Known gap** entry from the previous Unreleased pass: `@pleach/core/quickstart` is now listed in [Subpath exports](/docs/subpath-exports) and documented in [Getting started](/docs/getting-started#the-quickstart-subpath), with cross-links from [CLI](/docs/cli). The wizard's emitted code (`createPleachRoute`, `ChatBox`, `defaultPlugin`) is no longer symbol-without-a-home. ### Changed [#changed-8] * **[Pricing](/pricing)** — the *What will and won't ever cost money* section rewrites to *What stays free, and what doesn't*. Drops the absolutist *One paid product, ever* and *No enterprise tier. No hosted-only features.* framings; replaces with the net-additive + FSL-irrevocability commitment the license posture already provides for free. New phrasing: any paid surface is net-additive and can't gate a feature already in a published release; the FSL conversion clock guarantees that for FSL releases and Apache releases are irrevocably Apache on their published tag. Keeps the load-bearing *Self-host means self-host* row. Adds an explicit *No procurement-style enterprise tier today* qualifier that scopes what the brand commitment actually means (no contact-sales, no SSO behind paywall over substrate features, no per-seat upsells layered onto runtime functionality). The rewrite is a correction to a load-bearing claim — readers who saw the old page learned that *no enterprise tier · one paid product, ever* was a permanent commitment, which it is not. * **Homepage hero** — closing line *No enterprise tier, no hosted-only features.* → *No procurement-style enterprise tier; paid surfaces won't gate runtime features.* Aligns the headline-scale claim with the [Pricing](/pricing) revision. * **[FAQ → Is there a hosted version?](/docs/faq#is-there-a-hosted-version)** — answer rewrites to lead with *No today*, name the gateway as the planned multi-tenant routing SKU, and close with the FSL-conversion-clock mechanism behind the net-additive guarantee. * **`/ai.txt`** — single-word fix: *open-source agent-runtime* → *fair-source agent-runtime*. Restores the labeling-discipline qualifier the rest of the site already carries. ### Added [#added-7] * **[Security → Deployment shape](/security)** — new section after *Scope*. Scopes the *self-host means self-host* promise to what the runtime actually does today; names air-gapped operation, on-prem provider endpoints, and sub-tenant routing at scale as roadmap items with typed options today + runtime guards later. Points procurement teams at the canonical inbox for current state. Closes the gap between the pricing-page commitment and what the runtime literally does today for regulated buyers. ## 2026-06-08 — canonical contact collapsed to a single inbox [#2026-06-08--canonical-contact-collapsed-to-a-single-inbox] ### Changed [#changed-9] * **Canonical contact is now `getpleach@protonmail.com`.** The previous split — `admin@getpleach.com` for general inquiries and `security@getpleach.com` for disclosures — is retired. Both roles now route to the single ProtonMail inbox; the [Security](/security) page and the security-disclosures channel on [Community](/community) include a `?subject=Security%20disclosure` mailto hint so the inbox can still triage the two flows. Reflected on the homepage Organization JSON-LD, [Security](/security), [Privacy](/legal/privacy), [Terms](/legal/terms), [License](/license), [Pricing](/pricing), [Community](/community), [FAQ](/faq), the [docs FAQ](/docs/faq), the [contributing guide](/docs/contributing), the blog RSS feed, and `/ai.txt`. ## 2026-06-08 — chat-pipeline lift into `@pleach/core` + `audit:domain-string-purity` Pre-Merge Gate [#2026-06-08--chat-pipeline-lift-into-pleachcore--auditdomain-string-purity-pre-merge-gate] ### Added [#added-8] * **[OrchestratorClient](/docs/orchestrator-client)** — new top-level page documenting `OrchestratorClient`, the per-turn handle the runtime threads through the stream body. Lifted from app-side into the substrate; canonical import path is `@pleach/core/runtime/orchestratorClient`. Page covers the six user-facing facets (`config`, `history`, `context`, `tools`, `model`, `prompts`), the `_internal.graph` snapshot consumer code must not touch, where a host receives the client (inside a custom strategy, inside a plugin hook, inside a custom turn body), and the imperative-vs-graph coexistence model. Slots into the "Build an agent" sidebar group between [`SessionRuntime`](/docs/session-runtime) and [`Providers`](/docs/providers). Pairs with the `audit:orchestrator-facet-coverage` CI gate already documented at [Facets](/docs/facets#ci-gates-enforcing-facet-coverage). * **[streamSingleTurn](/docs/stream-single-turn)** — new top-level page documenting `streamSingleTurn`, the canonical per-turn body. Lifted into `@pleach/core/strategies/streamSingleTurn/` with a stable helpers barrel that re-exports `TurnAccumulator`, `ToolDefinition`, `OrchestratorMessage`, and `ProviderFallbackConfig`. Page documents what the body consumes (the typed strategy slots — [`hallucinationDetectorFactory`](/docs/runtime-strategies#newer-injection-hooks), `continuationMetaToolNames`, `repetitionGuard`, `orchestrator.providers.providerFallback`, `EMPTY_SINGLE_TURN_FLAGS`), what it never sees (the five forbidden literal categories the [domain-string purity gate](/docs/architecture#10-boundary-rules) enforces), and how a host wires a custom body when the linear-turn default doesn't fit. Slots into the "Plugins & strategies" sidebar group after [`Runtime strategies`](/docs/runtime-strategies). * **[Subpath exports](/docs/subpath-exports)** — adds two rows to the Runtime + composition table: `@pleach/core/runtime/orchestratorClient` and `@pleach/core/strategies/streamSingleTurn`. Both are now named subpath exports rather than wildcard-only reachable. * **[Architecture § Boundary rules](/docs/architecture#10-boundary-rules)** — adds a sixth row to the gate table: `audit:domain-string-purity` forbids domain-specific literals in `packages/core/src/**` across five pattern families (host vocabulary, vendor backend names, sandbox tool prefixes, identity discriminators, domain phrasing; \~50 patterns; baseline-gated `:strict` mode). Closes the rationale section with a fourth load-bearing-invariant sentence: the gate makes plugins the only legitimate channel for consumer-specific content. Pairs with the existing `lint:harness-boundary` row, which forbids domain *imports* — the new gate covers domain *strings*. * **[Language-agnostic contract § How the contract stays honest](/docs/language-agnostic-contract#how-the-contract-stays-honest)** — promotes the three honest-keeping mechanisms (shared fixtures, cross-runtime replay, schema gate) to four by adding the domain-string purity gate. Frames the gate as a falsification mechanism: a TS-side string leak the Go runtime would otherwise have to mirror fails CI instead of becoming a wire-shape obligation. ### Why now [#why-now] The Pre-Merge Gate substrate landed upstream — the audit script, the five-family blocklist, the baseline, and the npm script variants are all live. The docs side previously only enumerated [`lint:harness-boundary`](/docs/architecture#10-boundary-rules) (the import-side companion); the string-side gate is a structural guarantee the docs now name alongside it. ## 2026-06-08 — page-level restructuring (splits, renames, related-shapes nav) [#2026-06-08--page-level-restructuring-splits-renames-related-shapes-nav] ### Added [#added-9] * **[Platform & operations recipes](/docs/platform-recipes)** — extracts four platform-team recipes from [Recipes](/docs/recipes) (long-running async jobs, multi-step [interrupts](/docs/interrupts), per-call cost reporter, OpenTelemetry wiring) into a dedicated page under the Operations sidebar group. Lets a consumer reader on [`/docs/recipes`](/docs/recipes) skip patterns a platform team owns; lets a platform reader land directly on the four ops recipes without scrolling past chat/storage/BYOK. * **[Runtime strategies](/docs/runtime-strategies)** — extracts the \~140-line *Strategy injection* section from [SessionRuntime](/docs/session-runtime) (representative slots table, `fabricationGuardStrategy` bundle, newer injection hooks including `hallucinationDetectorFactory`, `continuationMetaToolNames`, the `repetitionGuard` extensions, the `orchestrator.providers.providerFallback` registry-seam, and `EMPTY_SINGLE_TURN_FLAGS`). Lands under a new *Plugins & strategies* sidebar group alongside [Plugin contract](/docs/plugin-contract). Frees the runtime page to read as a clean construction + lifecycle + `executeMessage` reference. * **Related shapes blocks on every use-case page.** [Customer support](/docs/customer-support-agent), [Research](/docs/research-agent), [Coding](/docs/coding-agent), [Internal knowledge](/docs/internal-knowledge-agent), [Multi-tenant SaaS](/docs/multi-tenant-saas-agent), and [Regulated-domain](/docs/regulated-domain-agent) agents each open with a three-bullet pointer at sibling use-case pages a reader on this one is likely to also need (e.g., *"Regulated-domain agent if the SaaS serves a regulated vertical"*). Fixes the brittle cross-page navigation — a reader on Customer support whose actual case is closer to Research now sees the pointer in the lead. ### Changed [#changed-10] * **[Examples](/docs/reference-apps) reframed and renamed to [Reference apps](/docs/reference-apps).** The duplicated *Agent shapes* and *Recipes* hub sections (word-for-word repeats of [Agent shapes](/docs/agent-shapes) and [Recipes](/docs/recipes) cards) are dropped; the page is now focused on the two runnable examples that ship inside the `@pleach/core` npm package (`minimal-agent`, `host-adapter`). Slug change updates the URL from `/docs/examples` to [`/docs/reference-apps`](/docs/reference-apps); inbound links on [Getting started](/docs/getting-started), [`@pleach/base-tools`](/docs/base-tools), and the prior changelog entry are repointed. * **Sidebar reorder in the Start group:** [Getting started](/docs/getting-started) → [Agent shapes](/docs/agent-shapes) → [Recipes](/docs/recipes) → [Reference apps](/docs/reference-apps) → [Comparison](/docs/comparison). Brings the *"which shape is mine"* pattern lookup ahead of the catalog of wiring recipes — a reader matches their problem first, then opens the recipe that fits, then reads reference-apps only when they want a runnable starting skeleton. * **[Facets](/docs/facets) reordered** so the inventory tables on `SessionRuntime` and `OrchestratorClient` lead the page, with the (now tightened) *Why facets, not flat methods* rationale moved to the section after. Drops the 28-line philosophical lead that buried what's actually on the runtime; rationale prose shrank from \~32 lines to \~18 while keeping the human + agent discoverability angle. * **[Prompt builder](/docs/prompt-builder) reordered** so *The composer signature* leads directly after the lead paragraph and `SourceMeta`. The prior *When you call it* preamble (16 lines of "you usually don't") moved after the signature and tightened to a 9-line *When you call it directly* section. A reader who arrives wanting the API shape now sees the API shape first. * **[Recipes](/docs/recipes) lead rewritten** to reflect the consumer-facing split (chat, tools, storage, BYOK, moderation, multi-tenant, compliance, projections, hash chain) and to point at [Platform & operations recipes](/docs/platform-recipes) for the platform-team patterns. Recipes 10–13 (multi-tenant, compliance, projection, hash-chain) renumbered to 6–9; anchor URLs for those four sections shift accordingly. No inbound docs links referenced the old `#10`/`#11`/`#12`/`#13` anchors. * **[SessionRuntime](/docs/session-runtime) shrunk** from 472 lines to \~330. The *Strategy injection* section is now a one-paragraph framing + pointer at [Runtime strategies](/docs/runtime-strategies); everything else (construction, `SessionRuntimeConfig` field table, sessions, `executeMessage`, abort, deletion, lifecycle methods, stream subscription, accessors, lifecycle events, facets, `cacheBackend`) stays. ## 2026-06-08 — docs de-dup + DX-ordered sidebar [#2026-06-08--docs-de-dup--dx-ordered-sidebar] ### Changed [#changed-11] * **Sidebar reorder** — reshuffled groups to a build → ship → internals walk. [Audit ledger](/docs/audit-ledger) lifts from group 10 to group 4 (right after [Composing agents](/docs/agents)), [Observability](/docs/observability) and [Operations](/docs/deployment) follow, [Frontend integration](/docs/react) becomes its own top-level group at 7, and the substrate internals ([Architecture](/docs/architecture), [Execution graph](/docs/graph), [Routing](/docs/seams), [Runtime lifecycle](/docs/session-lifecycle), [State](/docs/storage), [Safety](/docs/safety), [Plugins](/docs/plugin-contract)) collapse into a contiguous internals block at groups 9–15. [Compose & migrate](/docs/migrating-from-anthropic-enterprise) drops to group 16 — it's an existing-stack on-ramp, not a first walk. No URLs change; only group ordering and labels (the `Core API` group becomes `Build an agent`, the lone [`plugin-contract`](/docs/plugin-contract) page gets its own `Plugins` group instead of riding under Composing agents). Fixes the encyclopedic-not-DX order the cluster groupings were producing — readers now reach the load-bearing audit-ledger story before the substrate deep-dives. * **Cluster-host triplet sections collapsed to one-paragraph framings.** [Family lock → the routing cluster](/docs/family-lock#the-routing-cluster), [Audit ledger → the audit-ledger cluster](/docs/audit-ledger#the-audit-ledger-cluster), [Storage → the state-and-persistence cluster](/docs/storage#the-state-and-persistence-cluster), [Agents → the composing-agents cluster](/docs/agents#the-composing-agents-cluster), and [Session lifecycle → the runtime-lifecycle cluster](/docs/session-lifecycle#the-runtime-lifecycle-cluster) no longer restate the three-concept bullets that already live on [Concept clusters](/docs/concept-clusters). Each cluster anchor stays — sibling pages still link to it — but the section now carries one paragraph framing the triplet and pointing at the cluster map. Matches the lighter-weight pattern [Seams](/docs/seams) and [Call classes](/docs/call-classes) were already using. * **[Architecture](/docs/architecture) trimmed from 486 lines to 233.** Each of the per-piece sections (1. Stage lattice through 9\. Determinism) reduces to a 2–3 sentence framing plus a pointer to the deep-dive page that owns the topic, instead of restating the full per-class table / cascade walk / event-log three-layer prose / facets inventory / channel-snapshot walk. The TL;DR mermaid, the six-piece numbered list, the stage-lattice mermaid, the family-strict cascade mermaid, the audit-ledger row-fan mermaid, and the unique-to-architecture [10. Boundary rules](/docs/architecture#10-boundary-rules) table all stay. Drops verbatim duplication of [Graph](/docs/graph), [Nodes](/docs/nodes), [Channels](/docs/channels), [Seams](/docs/seams), [Call classes](/docs/call-classes), [Family-locked routing](/docs/family-lock), [Audit ledger](/docs/audit-ledger), [The AuditableCall row](/docs/auditable-call-row), [Event log](/docs/event-log), [Cache](/docs/cache), [Facets](/docs/facets), [Storage](/docs/storage), [Sync](/docs/sync), [Plugin contract](/docs/plugin-contract), and [Determinism](/docs/determinism). * **[Session lifecycle → Time-travel and rollback](/docs/session-lifecycle#time-travel-and-rollback)** reduced to the session-level invariants (audit rows never rolled back; subagent provenance preserved). The rollback mechanic and the rollback-vs-fork choice already live on [Checkpointing → Rolling back to a checkpoint](/docs/checkpointing#rolling-back-to-a-checkpoint); the prior 97% identical paragraph here was a copy-paste. * **[Audit ledger → Tamper-evident hash chain](/docs/audit-ledger#tamper-evident-hash-chain)** reduced to one paragraph pointing at [Hash chain](/docs/hash-chain). The prior section restated the two-column contract, the back-compat policy, and the verification walk that the dedicated page already covers. * **`audit:c8-event-type-allowlist-coverage` mentions** thinned to single-canonical home on [Scrubbers](/docs/scrubbers). [The AuditableCall row](/docs/auditable-call-row) lost a trailing *Event-type allowlist for redaction* section that purely restated the gate (the page already carries the Scrubbers card in its *Where to go next*). [Plugin contract → `contributeScrubbers`](/docs/plugin-contract#contributescrubbers) keeps one sentence naming the gate and pointing at scrubbers, instead of re-explaining its purpose. ## 2026-06-08 — sidebar + homepage label consistency [#2026-06-08--sidebar--homepage-label-consistency] ### Changed [#changed-12] * **Homepage** — renamed *Four pillars* section heading to *Four properties*. The intro paragraph already self-describes as "four properties"; the H2 now matches the body. Drops the architectural-pillars connotation that misframed the section: Cost / Audit / Compliance / Legibility are properties that fall out of the row primitive, not foundational supports. * **Sidebar** — split the *Substrate* group into *Execution graph* (graph, nodes, channels) and *Routing* (seams, call-classes, family-lock, model-resolution-matrix). Brings the sidebar group names into agreement with the cluster anchors the child pages point at — [the execution-graph cluster](/docs/architecture#the-execution-graph-cluster) on architecture, [the routing cluster](/docs/family-lock#the-routing-cluster) on family-lock. No URLs change; only the separator labels. ## 2026-06-08 — concept-map closeout (three more triplets + map page) [#2026-06-08--concept-map-closeout-three-more-triplets--map-page] Completes the cluster-framing pattern across all sidebar groups where a clean triplet exists, and adds a dedicated concept-map landing page. Brings the total to **seven cluster triplets at four depths**: substrate-wide (session/turn/row) plus six cluster-level (execution-graph, routing, audit-ledger, composing-agents, runtime-lifecycle, state-and-persistence). ### Added [#added-10] * **Composing-agents cluster** (Skill + Subagent + Agent profile). New section on [Agents](/docs/agents#the-composing-agents-cluster). Sibling openers added to [Subagents](/docs/subagents) and [Skills](/docs/skills). * **Runtime-lifecycle cluster** (Session lifecycle + Turn lifecycle + Event log). New section on [Session lifecycle](/docs/session-lifecycle#the-runtime-lifecycle-cluster). Sibling openers added to [Turn lifecycle](/docs/turn-lifecycle) and [Event log](/docs/event-log). * **State-and-persistence cluster** (Storage adapter + Checkpoint * Sync version vector). New section on [Storage](/docs/storage#the-state-and-persistence-cluster). Sibling openers added to [Checkpointing](/docs/checkpointing) and [Sync](/docs/sync). * **New [Concept clusters](/docs/concept-clusters) landing page** walking all seven cluster triplets as a navigable concept map. Opens with the substrate-wide triplet (session/turn/row), then the six cluster-level triplets in execution order. Closes with an explicit note on what lives outside the cluster pattern. * Sidebar entry for the new page in `meta.json` under "Architecture & comparison". * Footer extended with an `Architecture` link between `Docs` and `FAQ`, so readers landing on a marketing subpage have a second- level docs hook. ### Changed [#changed-13] * **Docs index** — dropped *How the three concepts fit together* section (now redundant with the cluster pattern propagating in the deep-dive pages) and added a one-line pointer to [Concept clusters](/docs/concept-clusters) immediately after the substrate-wide triplet. * **Docs index "Read next" cards** trimmed from six to five: dropped Session lifecycle and Recipes; added Concept clusters as the new map entry. ## 2026-06-08 — cluster-framing pass (three triplets) [#2026-06-08--cluster-framing-pass-three-triplets] Extends the slow-ramp pattern from session/turn/row down through three more concept clusters, one layer in from the homepage. Each cluster lives on a host page that walks the three concepts in parallel-bulleted voice; the sibling concept pages each open with a one-paragraph cluster pointer that names the triplet and deep-links the host. ### Added [#added-11] * **Execution-graph cluster (graph + node + channel).** New *The execution-graph cluster* section on [Architecture](/docs/architecture#the-execution-graph-cluster) between the TL;DR six-piece map and *1. Stage lattice*, walking Graph, Node, Channel as the per-turn execution substrate that runs *inside* the lattice. Sibling-page openers added to [Graph](/docs/graph), [Nodes](/docs/nodes), and [Channels](/docs/channels), each pointing at the new architecture anchor. * **Routing cluster (CallClass + Seam + family-lock).** New *The routing cluster* section on [Family-locked routing](/docs/family-lock#the-routing-cluster), walking CallClass, Seam, and family-lock as the cluster that decides which model fires for which kind of call. Sibling-page openers added to [Call classes](/docs/call-classes) and [Seams](/docs/seams). Reference-page pointers added to [Providers](/docs/providers) (transport layer) and [Model resolution matrix](/docs/model-resolution-matrix) (the reference table), each naming the triplet and deep-linking the cluster framing. * **Audit-ledger cluster (row + ProviderDecisionLedger + hash chain).** New *The audit-ledger cluster* section on [Audit ledger](/docs/audit-ledger#the-audit-ledger-cluster) before the existing *ProviderDecisionLedger* deep-dive, walking the three concepts as a triplet so the row, the write interface, and the tamper-evidence layer each get one parallel bullet before the deep dive lands. Sibling-page openers added to [The AuditableCall row](/docs/auditable-call-row) and [Tamper-evident hash chain](/docs/hash-chain). ### Why [#why] The slow-ramp pattern landed on the homepage + docs index for the foundational triplet (session/turn/row). Three more triplets sit one click in: execution-graph (inside a turn), routing (inside a stage), audit-ledger (after the call). Each gets the same parallel-bulleted treatment so a reader walking from the homepage into the docs hits the same shape at every depth. ## 2026-06-08 — slow-ramp entry-point pass [#2026-06-08--slow-ramp-entry-point-pass] ### Added [#added-12] * **Homepage hero**: new substrate-framing paragraph between the H1 and the existing benefit paragraph. Defines Pleach as "a runtime substrate for LLM agents — the backbone an agent harness builds on, usable standalone or as the runtime layer underneath a product's agent loop" and names the three concepts inline (session, turn, row keyed by `turnId`). The existing benefit claims now read as the consequence of the three concepts, not the lead. * **Homepage new section** "Three concepts the substrate hangs on" between the hero and "What you can do with it," with three Pillar cards — Session, Turn, AuditableCall row — each carrying a definition and a deep-link to [SessionRuntime](/docs/session-runtime), [Turn lifecycle](/docs/turn-lifecycle), and [The AuditableCall row](/docs/auditable-call-row). * **[Docs index](/docs)**: opening rewritten to lead with *"Pleach is a runtime substrate for LLM agents…"*, followed by a new "The three concepts" section walking session → turn → row, and a "How the three concepts fit together" section that retains the runtime + contract + ledger triangle framing. The existing "The shape, in one paragraph" remains as the next layer down. ### Why [#why-1] The initial entry point now opens high-level — *what is a runtime substrate, what does it sit underneath, what are the three foundational primitives* — before the load-bearing benefit claims land. The dense framing didn't disappear; it now reads as the consequence of the three concepts rather than the cold-open. ## 2026-06-08 — discovery + entry-point depth pass [#2026-06-08--discovery--entry-point-depth-pass] ### Added [#added-13] * **[`/llms.txt`](/llms.txt)** — lead paragraph appended with one sentence on Enterprise composition; "Migrating from other frameworks" group renamed to "Migrating & composing under enterprise contracts" with the two enterprise migration slugs added to the head so AI search retrievers see them first. * **Homepage JSON-LD `SoftwareApplication.description`** extended with the Enterprise composition clause for knowledge-graph entity extractors (Google AI Overviews, Gemini). * **Root `metadata.description`** in `app/layout.tsx` extended so every non-home page inherits the signal for OG / Twitter. * **[`/ai.txt`](/ai.txt)** — new "High-signal entry points for enterprise readers" section naming both migration docs and [Language-agnostic contract](/docs/language-agnostic-contract). * **[Overview](/docs/overview)** — paragraph after the per-turn cost rollup SQL example naming `tenantId` as opaque (customer vs cost-center) and pointing to both migration docs. * **[Architecture](/docs/architecture) §5 Audit ledger** — paragraph after the identity-tuple claim connecting it to per-axis attribution inside one Workspace or Project. * **[Getting started](/docs/getting-started)** — Enterprise callout after the server quick-start naming `AnthropicSdkProvider` and the AI SDK's OpenAI provider as the composition entry points. * **[FAQ](/docs/faq)** — new entry "Does Pleach replace my Anthropic or OpenAI Enterprise contract?" before "Who's behind this?" * **[Core](/docs/core)** "What you get" — new bullet "Composes under an existing Enterprise contract" after the AuditableCall ledger bullet. ### Changed [#changed-14] * **Sidebar separator** `Migrate & coexist` renamed to `Compose & migrate` so the Enterprise composition story isn't mis-sold as a migration *away from* the lab. * **[Agent shapes](/docs/agent-shapes)** Multi-tenant SaaS card blurb widened from "per-customer invoice" to "per-axis rollup (end-customer invoice, or employee / team / cost-center chargeback inside one Anthropic Workspace or OpenAI Project)." *(Originally on `/docs/examples`; that page was reframed as [Reference apps](/docs/reference-apps) on 2026-06-08, with the agent-shapes cards moved to their canonical home on `/docs/agent-shapes`.)* * **[Pleach + OpenAI SDK](/docs/with-openai-sdk)** and **[Comparison](/docs/comparison)** — "per-customer monthly rollup" graduation claim widened to per-axis (per end customer in a SaaS, or per employee / team / cost-center under one Workspace or Project). * **Homepage Enterprise section** — two SKU paragraphs collapsed into one tighter paragraph naming both `@pleach/coding-agent` and the language-agnostic contract; load-bearing sentence *"answers 'is this TypeScript-only?' before Enterprise IT asks"* preserved verbatim. ## 2026-06-08 — sibling-SKU references [#2026-06-08--sibling-sku-references] ### Added [#added-14] * **Homepage Enterprise section** — two new paragraphs after the cloud-mediated transports note. First paragraph names [`@pleach/coding-agent`](/docs/coding-agent) (typed `CodingAgentRuntime` contract at `0.2.0-alpha.0`) as the sandboxed coding-agent surface for the internal dev-tools team that often sits inside the same Enterprise contract footprint — same row, same `tenantId` axis, same chargeback. Second paragraph names the [language-agnostic contract](/docs/language-agnostic-contract) (wire shapes: HTTP+SSE, `StreamEvent`, `AuditableCall`, checkpoint envelope, version vector) and the Go round-trip as the procurement-visible answer to "is this TypeScript-only?" * **[Migrating from Anthropic Enterprise](/docs/migrating-from-anthropic-enterprise#sibling-skus-that-ride-alongside-the-contract)** and **[Migrating from OpenAI Enterprise](/docs/migrating-from-openai-enterprise#sibling-skus-that-ride-alongside-the-contract)** — new "Sibling SKUs that ride alongside the contract" section before "Where to go next" on each migration doc, with one bullet for `@pleach/coding-agent` (Workspace or Project framing per doc) and one for the language-agnostic contract + Go round-trip. * **[Comparison](/docs/comparison#if-you-already-buy-direct-from-anthropic-or-openai) Enterprise section** — paragraph appended after the cloud-mediated transports note, mirroring the homepage's coding-agent + language-agnostic-contract framing for the doc-side reader. ## 2026-06-08 — re-settling pass [#2026-06-08--re-settling-pass] ### Added [#added-15] * **Marketing surface.** New CostRow body on [Pricing](/pricing) for "LLM provider tokens" naming Anthropic Enterprise and OpenAI Enterprise composition, and the per-axis `tenantId` framing. * **Marketing FAQ.** New entry "Does this replace my Anthropic or OpenAI Enterprise contract?" on the [FAQ](/faq) page. * **Docs adjacency.** Enterprise-contract bridge paragraphs added to [Pleach + Anthropic SDK](/docs/with-anthropic-sdk) and [Pleach + OpenAI SDK](/docs/with-openai-sdk), each pointing readers who graduated from a self-serve key to the right migration doc. * **`tenantId` opacity** paragraphs added to [Multi-tenant deployments](/docs/multi-tenant) and [The AuditableCall row](/docs/auditable-call-row), plus a `GatewayClient`-per-cost-center note on [Gateway](/docs/gateway#tenant-scoping). ### Changed [#changed-15] * **Sidebar reorder.** The "Migrate & coexist" group now opens with the two enterprise migration docs ([Migrating from Anthropic Enterprise](/docs/migrating-from-anthropic-enterprise), [Migrating from OpenAI Enterprise](/docs/migrating-from-openai-enterprise)) above [Migrating from the AI SDK](/docs/migrating-from-ai-sdk) and [Migrating from LangChain](/docs/migrating-from-langchain). * **[Agent shapes](/docs/agent-shapes) shape 04** renamed from *"Per-customer cost attribution"* to *"Per-axis cost attribution"* with body, team-shape, and sharpest-vertical lines rewritten to cover the internal-Enterprise case alongside SaaS. Anchor URL is now `#04--per-axis-cost-attribution`; the prior anchor was unreferenced elsewhere on the site. * **[Multi-tenant SaaS agent](/docs/multi-tenant-saas-agent)** three-places list widened to four, naming the internal- Enterprise composition case. * **Marketing FAQ "Who is this for?"** answer on [FAQ](/faq) widened to name internal platform teams attributing employee, team, or cost-center spend under one Workspace or Project. * **Homepage tightening.** The FAQ entry "Already on Anthropic Enterprise or OpenAI Enterprise — what does Pleach add?" compressed since the new homepage section above it now earns the depth. The bridge sentence at the end of the comparison- table section rewritten to point forward at the Enterprise- contract section. ### Fixed [#fixed-5] * **A11y on Enterprise-card pseudo-CTAs.** The "Migrating from Anthropic Enterprise →" and "Migrating from OpenAI Enterprise →" paragraphs inside the two homepage migration cards now carry `aria-hidden="true"` so screen readers announce each card once through its `<h3>`, not twice through both heading and pseudo- button text. ## 2026-06-08 — later [#2026-06-08--later] ### Added [#added-16] * New homepage section "Already paying the lab" between the comparison table and the four pillars, pitching Pleach as the substrate that composes underneath an Anthropic Enterprise or OpenAI Enterprise contract. Three pillars (per-axis rollup inside one Workspace / Project, hash-chained [`AuditableCall`](/docs/auditable-call-row) row in your own Postgres, replay-determinism across snapshots) and two cards linking to the migration paths below. * New homepage FAQ entry "Already on Anthropic Enterprise or OpenAI Enterprise — what does Pleach add?" mirrored into the JSON-LD `FAQPage` graph. * New section "If you already buy direct from Anthropic or OpenAI" on [Comparison](/docs/comparison#if-you-already-buy-direct-from-anthropic-or-openai), before "Where to go next" — three load-bearing claims plus a note on cloud-mediated transports (Bedrock, Azure OpenAI, Vertex). * New "Already on Anthropic Enterprise or OpenAI Enterprise?" section on the [docs index](/docs), immediately after "Start here," with two cards linking to [Migrating from Anthropic Enterprise](/docs/migrating-from-anthropic-enterprise) and [Migrating from OpenAI Enterprise](/docs/migrating-from-openai-enterprise). * New paragraph on [Audit ledger](/docs/audit-ledger) framing `tenantId` as an opaque rollup axis — end customer in a SaaS, or employee / team / cost-center in an internal-use enterprise deployment — with cross-links to both migration docs. ### Changed [#changed-16] * [Migrating from Anthropic Enterprise](/docs/migrating-from-anthropic-enterprise) opener widened from "end customers inside a Workspace" to "a meaningful axis inside a Workspace" (covering employees, teams, and cost centers in internal-use deployments), plus a shape-note paragraph that the same row, hash chain, and fingerprint cover both shapes; `tenantId` is opaque. Frontmatter `description` updated to match. * [Migrating from OpenAI Enterprise](/docs/migrating-from-openai-enterprise) opener and frontmatter `description` widened analogously for the OpenAI Projects case. * Homepage `metadata.keywords` extended with `Anthropic Enterprise`, `OpenAI Enterprise`, `per-end-user cost rollup`, `per-employee LLM chargeback`, `internal AI cost attribution`, `Workspace tenant attribution`, `Project tenant attribution`. ## 2026-06-08 [#2026-06-08] ### Added [#added-17] * New page [Runtime inspector](/docs/runtime-inspector) documenting `inspectRuntime()` from `@pleach/core/inspector` — the typed read-only introspection surface, the `InspectionReport` / `CapabilityReport` / `PluginReport` / `CapabilityDescriptor` shapes, and the 38 known capabilities (29 modern + 9 deprecated). * `definePleachPlugin()` factory section in [Plugin contract](/docs/plugin-contract#definepleachplugin--the-typed-factory) covering the 8-key `PluginCapabilities` cluster and the `_raw` escape hatch. * `contributeFabricationGuard` section documenting the typed `FabricationGuardImpl` hook (7 methods) that retires the `orchestratorHotpath.fabricationGuard` untyped-bag entry. * `[Pleach:capability-not-contributed]` breadcrumb section with the 5 emitting capabilities and the payload shape. * `CreatePleachRuntimeConfig.host.{strategies, modules, raw}` section in [Host adapter](/docs/host-adapter#createpleachruntimeconfighoststrategies-modules-raw) with the 15-key `HostExtensionBundle` list, the 3-key `host.modules` list (`eventLogWriter`, `store`, `interrupt`), the merge precedence, and the `pleach-plugin-modernize` codemod invocation. * `bag-entry-retirement-readiness` audit status table — `streamHelpers` **RETIRED**, `toolCoupling` RETIRE-READY, `fabricationGuard` NEAR-READY. * C9 hash-chain probe section in [Tamper-evident hash chain](/docs/hash-chain#c9-probes--proof-the-writer-is-reaching-prod) documenting `[UXParity:c9-hash-chain-row-stamp]` (PE-1) and `[UXParity:c9-hash-chain-verify]` (PE-2), payload shapes, and the 3-batch clean condition of `audit:c9-hash-chain-integrity`. * `@pleach/coding-agent/runtime` contract section in [Coding agent](/docs/coding-agent#pleachcoding-agent-runtime-contract) with the typed `CodingAgentRuntime` interface and the per-method first-slice behavior. * `@pleach/core/inspector` row in [Subpath exports](/docs/subpath-exports). * `@pleach/sandbox` row in [Packages](/docs/packages) (Phase A contract + in-memory fixture, version `0.1.0`). * This changelog page. ### Changed [#changed-17] * [Packages](/docs/packages) table corrected to real published versions (was largely stale "Reserved · placeholder" for shipping packages). New bucket framing: Published 1.x (`@pleach/core` 1.1.0, `@pleach/tools` 1.2.0, `@pleach/react` 1.0.0) / Pre-1.0 release (`@pleach/replay` 0.7.0, `@pleach/base-tools` 0.1.0, `@pleach/sandbox` 0.1.0, `@pleach/langchain` 0.1.0) / Pre-1.0 alpha (`@pleach/compliance`, `@pleach/eval`, `@pleach/gateway` all `0.8.0-alpha.0`; `@pleach/mcp` `0.3.0-alpha.0`; `@pleach/coding-agent` `0.2.0-alpha.0`) / Reserved (`@pleach/trust-pack` only). * [Plugin contract](/docs/plugin-contract#sibling-skus-as-plugins) "Sibling SKUs as plugins" section retitled (was "(planned shape; not yet shipping)") and rewritten with per-row real Phase A status. * [@pleach/replay](/docs/replay) Phase B note now distinguishes the landed `replayTurn` body from the still-throwing `fromSnapshot` / `fork` / `multiTenantAggregate`. * [Fabrication detection](/docs/fabrication-detection) plugin- supplied strategies subsection now references the typed `FabricationGuardImpl` hook. ### Fixed [#fixed-6] * [Runtime inspector](/docs/runtime-inspector) — replaced fictional `RuntimeInspection` / `CapabilityState` types with the real `InspectionReport` / `CapabilityReport` / `PluginReport` / `CapabilityDescriptor`. Removed the invented top-level `breadcrumbs` field (the inspector is read-only; breadcrumbs are emitted from `PluginManager`, not pre-computed by the inspector). Corrected the `status` discriminator to `"wired" | "deprecated-wired" | "unwired"`. * [Plugin contract](/docs/plugin-contract) — `definePleachPlugin` capability fields take values, not thunks. Corrected the 8 capability keys to the real cluster (`prompts`, `runtimeAwarePrompts`, `safetyPolicies`, `fabricationDetectors`, `tools`, `streamObservers`, `intentToolMap`, `toolCouplingHints`). Corrected `contributeFabricationGuard` to return one `FabricationGuardImpl` (not the fictional `FabricationGuardContribution`). Corrected the 5 breadcrumb capabilities to `contributeStreamObservers`, `contributeSafetyPolicies`, `contributeFabricationDetectors`, `contributeToolCouplingHints`, `contributeIntentToolMap`. * [Host adapter](/docs/host-adapter) — `host.modules` corrected to `{ eventLogWriter, store, interrupt }`. `host.strategies` populated with the real 15-key `HostExtensionBundle` list. Codemod invocation corrected to `node scripts/codemods/pleach-plugin-modernize.mjs <glob>` (it's an internal repo script, not a published npm binary). * [Tamper-evident hash chain](/docs/hash-chain) — subpath corrected from `@pleach/core/event-log` to `@pleach/core/eventLog` (camelCase is the real export). C9 probe names corrected from the fictional `[Pleach:c9-hash-chain]` to the real `[UXParity:c9-hash-chain-row-stamp]` and `[UXParity:c9-hash-chain-verify]`. * [Event-log projections](/docs/event-log-projections) — subpath corrected from `@pleach/core/event-log/projections` to `@pleach/core/eventLog` (projections export from the `./eventLog` barrel; there is no `/projections` subpath). * [Subpath exports](/docs/subpath-exports) — inspector row's type names corrected from `RuntimeInspection` to the real `InspectionReport`, `CapabilityReport`, `PluginReport`, `CapabilityDescriptor`. --- # @pleach/compliance-contract changelog (/docs/changelog/compliance-contract) This page collects the **site-content changelog entries** that materially touched the `@pleach/compliance-contract` SKU. It is not the runtime changelog — the canonical record of `@pleach/compliance-contract` runtime behavior lives in the upstream package [`CHANGELOG.md`](https://github.com/pleachhq/getpleach) and on [npmjs.org/package/@pleach/compliance-contract](https://www.npmjs.com/package/@pleach/compliance-contract). See the combined site changelog at [`/docs/changelog/combined`](/docs/changelog/combined) for the chronological unified view across every SKU. ## Unreleased [#unreleased] ### Fixed [#fixed] * **Schema `codeRepository` + `sameAs` for [`@pleach/compliance-contract`](/docs/compliance) corrected** in the site's Organization + SoftwareApplication JSON-LD `@graph` (inlined in `<head>` on every page). Both fields now point at `https://github.com/pleachhq/compliance-contract` (was `https://github.com/pleachhq`, a leftover from when the SKU schemas were drafted against an upstream monorepo). First dedicated entry for this SKU. See the [combined view](/docs/changelog/combined) for the full cross-SKU pack. ## Status [#status] `@pleach/compliance-contract` is a zero-dep cycle-break contract sub-SKU under the [`@pleach/compliance`](/docs/compliance) umbrella. It exports the `ComplianceRuntime` interface, `ComplianceProfile` literal union, `Scrubber` structural mirror, and tenant-scope sentinel that BOTH [`@pleach/core`](/docs/core) and [`@pleach/compliance`](/docs/compliance) depend on one-way, breaking the type-graph cycle that would otherwise surface as TS5055 / TS6307 on dist drift. There are no dedicated site-docs entries for `@pleach/compliance-contract` yet — the surface is reachable through the [`@pleach/compliance`](/docs/compliance) docs page and the [Subpath exports](/docs/subpath-exports) reference. Changelog entries that touch the broader compliance surface land on [`/docs/changelog/compliance`](/docs/changelog/compliance). --- # @pleach/compliance changelog (/docs/changelog/compliance) This page collects the **site-content changelog entries** that materially touched the [`@pleach/compliance`](/docs/compliance) docs surface or named a `@pleach/compliance` symbol. It is not the runtime changelog — the canonical record of `@pleach/compliance` runtime behavior lives in the upstream package [`CHANGELOG.md`](https://github.com/pleachhq/getpleach) and on [npmjs.org/package/@pleach/compliance](https://www.npmjs.com/package/@pleach/compliance). See the combined site changelog at [`/docs/changelog/combined`](/docs/changelog/combined) for the chronological unified view across every SKU. ## Unreleased [#unreleased] ### Fixed [#fixed] * **Schema `codeRepository` + `sameAs` for [`@pleach/compliance`](/docs/compliance) corrected** in the site's Organization + SoftwareApplication JSON-LD `@graph` (inlined in `<head>` on every page). Both fields now point at `https://github.com/pleachhq/compliance` (was `https://github.com/pleachhq`, a leftover from when the SKU schemas were drafted against an upstream monorepo). Load-bearing because `sameAs` is the field AI search engines use for entity reconciliation across the npm ↔ GitHub ↔ docs boundary. See the [combined view](/docs/changelog/combined) for the full cross-SKU pack. ### Added [#added] * **Per-recipe deep-dive pages under [`/docs/recipes/`](/docs/recipes/simple-chatbot)** — including [`compliantChatbot`](/docs/recipes/compliant-chatbot) and [`enterpriseAgent`](/docs/recipes/enterprise-agent), both composing `@pleach/compliance`. Each carries a quickstart, the runtime composition the recipe wires, a TypeScript config interface, common gotchas, and cross-links. ### Changed [#changed] * **Cross-docs status-flip pass** — `@pleach/compliance@0.8.0-alpha.0` four-scrubber bundle landed; "planned" / "no shipping code yet" claims across \~55 docs pages rewritten to name the actual alpha. Touched [Audit ledger](/docs/audit-ledger), [Compliance](/docs/compliance), [Event log](/docs/event-log), [Subpath exports](/docs/subpath-exports), [Scrubbers](/docs/scrubbers), and others. * **Attestation page boundary** — [Attestation](/docs/attestation) framed against policy and rotation, which stay in [`@pleach/compliance`](/docs/compliance) or host-side. * **Pricing / FAQ rewrites** — *compliance attestation* drops as a standalone enterprise surface and folds inside *Hosted Observe at enterprise scope*. ## 2026-06-08 [#2026-06-08] ### Changed [#changed-1] * [Packages](/docs/packages) table corrected to real published version — `@pleach/compliance 0.8.0-alpha.0` (was largely stale "Reserved · placeholder" framing). * [Plugin contract → Sibling SKUs as plugins](/docs/plugin-contract#sibling-skus-as-plugins) section retitled and rewritten with per-row real Phase A status — includes `@pleach/compliance`. --- # @pleach/core changelog (/docs/changelog/core) This page collects the **site-content changelog entries** that materially touched the [`@pleach/core`](/docs/core) docs surface or named a `@pleach/core` symbol, subpath, or audit gate. It is not the runtime changelog — the canonical record of `@pleach/core` runtime behavior lives in the upstream package [`CHANGELOG.md`](https://github.com/pleachhq/getpleach) and on [npmjs.org/package/@pleach/core](https://www.npmjs.com/package/@pleach/core). See the combined site changelog at [`/docs/changelog/combined`](/docs/changelog/combined) for the chronological unified view across every SKU. ## Unreleased [#unreleased] ### Added [#added] * **[Comparison](/docs/comparison) gains a category for agent-memory & knowledge-graph substrates (a read-side category).** Pins the read-vs-write delineation for `@pleach/core`: memory layers and knowledge-graph engines (Letta, Mem0, Zep) are a *read substrate* — the graph exists so the agent can query it to decide; `@pleach/core` is a *write substrate* — the ledger exists so every provider call is recorded, then read to account. Shared skeleton (append-only, content-addressed, causal chain, fork/replay); divergent object of record (the agent's knowledge/decisions vs. one provider call with model/tokens/cost). Compose-not-compete — a memory graph reads the same session the [`AuditableCall` row](/docs/auditable-call-row) writes. ### Fixed [#fixed] * **Unknown safety-policy ids now throw at construction — even with no plugins.** [Safety](/docs/safety) promises "Construction throws `UnknownSafetyPolicyError` if the config references an id no plugin registered — operator typos surface at boot, not on the first turn." Previously the enable/validate loop was gated behind the plugin block, so a plugin-less `createPleachRuntime({ enabledSafetyPolicies: ["typo"] })` silently no-op'd the enable and the typo surfaced (or silently didn't) only at first turn. The enable loop now runs unconditionally (`core` `SessionRuntime.ts`); the `register()` loop stays plugin-gated, so a legitimately plugin-contributed id is registered before the enable finds it and valid constructions are unaffected. The impl now matches the documented guarantee. * **The chronological error trail ([`errorCodesChronological`](/docs/audit-ledger)) now captures every error class.** A **completed** tool call carrying a structured `_error_code` (its status looks successful, so it tripped neither the failed-status nor the schema-validation branch) and a **caught async-planner throw** (`AsyncPlannerHandler`, previously console-only) now each persist an `error.code` row. A live turn with these errors previously persisted zero rows; the durable trail is now complete. (Direct-API tool errors were already covered by the host emit path.) * **Core-concept doc snippets reconciled with shipped signatures (devharness FINDING 88, 91–93).** [Determinism](/docs/determinism) — the ledger read used a non-existent `ledger.list({ sessionId })`; the real `AuditableCallQuery` read is async positional `await ledger.getSession(sessionId)` (`core` `audit/ProviderDecisionLedger.ts`), and the "distinguishes **three** operating contracts" line corrected to **five** (the `RuntimeMode` union — and the page's own table — lists five). [OTel observability](/docs/otel-observability) — `executeMessage` is positional `executeMessage(sessionId, "hello")`, not the object form `executeMessage({ sessionId, content })` (`core` `SessionRuntime.ts`). [Stream events](/docs/stream-events) — the LangGraph `"values"` mapping used `runtime.sessions.get()`, which `RuntimeSessionsFacet` (`core` `facets/sessions.ts`) does not expose; corrected to `runtime.sessions.resume(sessionId)`. ### Added [#added-1] * **[Gate failures → fixes](/docs/gate-failures) — the failure-string lookup.** Maps each gate's verbatim, greppable failure string to its meaning and the fix doc — `[graph-stages] Node "<name>" missing stageId in NODE_STAGE_MAP`, `[audit:c8-event-type-allowlist-coverage] FAIL`, `[auditable-call-version] FAIL rule 3`, `[audit:plugin-contract-completeness] FAIL`, and others, all verified against `scripts/audit/`. The agent-facing close of the [self-extension](/docs/extending#agent-driven-self-extension) loop. * **`AuditRecordVersion` corrected `14` → `18`** on [CI enforcement](/docs/ci-enforcement) to match the source (`export type AuditRecordVersion = 18;`) and [the AuditableCall row](/docs/auditable-call-row). * **[Extending Pleach](/docs/extending) — the gate-paired authoring map.** Pairs each extension point with the interface, the registration call, and the [audit gate](/docs/audit-gates) that fails if the addition is wrong — [nodes](/docs/nodes) (`audit:graph-stages`), event types via `contributeEventTypes` (`audit:c8-union-member-has-producer` * `audit:c8-event-type-allowlist-coverage`), [scrubbers](/docs/scrubbers), [plugin hooks](/docs/plugin-contract) (`audit:plugin-contract-completeness`), prompt blocks, and a custom audit field via `pluginPayloads` (`audit:auditable-call` — the structural column set is locked). Draws the host-extension vs upstream-contribution line, and adds an "Agent-driven self-extension" section: the gates as an autonomous write → `audit:*` → read-failure → fix loop, plus injecting the extension contract into the operating agent's prompt via `contributeRuntimeAwarePrompts`. [Nodes](/docs/nodes), [channels](/docs/channels), and [scrubbers](/docs/scrubbers) gained a "What CI checks when you add one" footer linking into it. * **`createPleachRoute({ demo })` keyless first-run + demo-first `pleach init` scaffold documented.** [Getting started](/docs/getting-started), [Quickstart](/docs/quickstart), and [Install](/docs/install) now lead the wizard path with a keyless demo: with no provider key, `createPleachRoute({ demo: process.env.NODE_ENV !== "production" })` drives a real graph turn over canned model text and writes real `AuditableCall` rows, flagged `x-pleach-mode: demo`. A resolved `<PROVIDER>_API_KEY` always wins; demo never engages in a production build. The Next.js App Router scaffold adds an `/audit` view (`app/audit/page.tsx` + `app/api/audit/route.ts`) rendering the per-model-family rollup (family · rows · tokens · est. cost). The bare `createPleachRoute()` no-key contract is unchanged (HTTP 503). * **`engine/` superstep scheduler named as an architecture piece + `facets.mdx` synced to the shipped facet inventory (pkgclean 06 D7/D9).** [Architecture](/docs/architecture#the-execution-graph-cluster) and [Concept clusters](/docs/concept-clusters#execution-graph-cluster) now name the `engine/` scheduler (`SuperstepRunner` + its primitives) as the deterministic superstep executor that *runs* the compiled graph. [Facets](/docs/facets) gained the two shipped `SessionRuntime` accessors it omitted — `runtime.observe` and `runtime.probes` — plus the four per-stage graph introspection facets `graph.anchor` / `graph.toolLoop` / `graph.synthesize` / `graph.postTurn`. * **`@pleach/core/skills` + `@pleach/core/agents/types` are real emitted subpaths.** [Skills](/docs/skills) updated to import `SkillLoader`, `parseSkillFrontmatter`, `Skill`, and `SkillFrontmatter` from the `@pleach/core/skills` barrel (the `/loader`, `/parser`, `/types` sub-subpaths do not exist); [Agents](/docs/agents) prose corrected from "wildcard path" to a real emitted `@pleach/core/agents/types` subpath. ### Changed [#changed] * **`@pleach/core/internal/…` subpaths retired from the public docs surface** (pkgclean 06 D8). [streamSingleTurn](/docs/stream-single-turn) and [Subpath exports](/docs/subpath-exports) no longer document `@pleach/core/internal/strategies/streamSingleTurn/helpers` (+ its four direct helper entries) or `@pleach/core/internal/tools/execution/registryResolvedTools` as `stable` public imports. The `internal/` segment marks implementation details, not a supported API; the copy-paste helper-barrel import is removed and both paths moved to a "Not a public import surface" section with their public alternatives — `ToolDefinition` from `@pleach/core/types/generic`, and `@pleach/core/tools/execution` (which re-exports the resolved-tools registry functions). No `package.json:exports` change. * **`core.mdx` stale numbers corrected.** `auditRecordVersion` updated `13 → 18` (real value at `AuditableCall.ts`), and the `HarnessPlugin` plugin-hook count updated `~41 → 67` `contributeX` hooks (derived by grepping `packages/core/src/plugins/types.ts`). ### Fixed [#fixed-1] * **More SKU doc-page snippets reconciled with the shipped signatures (devharness FINDING 80–82).** [Fabrication detection](/docs/fabrication-detection) — the custom-detector example used a non-existent `ctx.toolArgs` and returned `{suspect, tier}`; corrected to the real `FabricationDetectorContext` fields + the `FabricationFinding` shape (`{detectorId, severity, reason, evidence?}`), and the `tier`-routing bullet corrected (routing keys on the tool's `safetyTier`, not a finding's `severity`). [Multi-tenant](/docs/multi-tenant) — `getAggregateUsage` is 3-positional (`(client, store, filters)`), has no `groupBy: "tenantId"` value (`session`/`agent`/`model`/`tool`/`day`/`week`), nests dates under `dateRange`, and requires `userId` (it's single-user scoped; tenant rollups use the raw SQL). [Determinism](/docs/determinism) — the fingerprint round-trip read a non-existent `.fingerprint` audit field (real: `fingerprintComposite`, a string) and compared a string to a `Fingerprint` object; corrected to a like-for-like comparison over a real `FingerprintInput`. These are the ctor-shape / call-arity / field-name classes the new `audit:doc-import-resolution` gate doesn't cover (import-resolution only). * **Event-log read side documented as store-agnostic — `eventReader` / `HarnessEventReader`.** [Event-log projections](/docs/event-log-projections) said `runtime.events.iterate` / `.fold` read `harness_event_log` rows ordered by `(chat_id, sequence_number)` and became live "on the Supabase backend in PA-1 Phase A.2" — store-specific claims that no longer match the shipped surface. Corrected: both accessors delegate to a `HarnessEventReader` injected as the runtime's `eventReader` (the read-side counterpart to `eventLogWriter`), so the host casts its own store into the reader **at construction** and `@pleach/core` itself stays free of backing-store knowledge — Postgres, Supabase, SQLite/pglite, or an HTTP API back the same surface. `iterate`'s options gain `tenantId`; the reader is **required** to read the durable log (no built-in reader, no implicit store fallback — a bare runtime yields nothing, matching the `MemoryAdapter` path) and throws a typed error at the call site when absent. New **Backing the reader** section shows the one-method contract yielding `EventLogRow` (the shape `reconstructSessionState` and the shipped projections fold). [Session runtime](/docs/session-runtime) config table gains `eventLogWriter` + `eventReader` rows; [Storage](/docs/storage) grows the `ci:devharness-sql` value-prop battery from four durable properties to nine — adding the `@pleach/observe` `postgres()` destination, time-travel fork, SQL-queryable audit ledger (`harness_auditable_calls`), tamper-evident hash chain, and `@pleach/replay` deterministic replay over the injected reader. * **SKU doc pages reconciled with the shipped import/construction surface (devharness FINDING 76–79).** Four doc-site pages drifted from the (correct) package READMEs: [Building a chat UI](/docs/building-chat-ui) advertised a fabricated `@pleach/react` chat-hook API (`useChat`/`HarnessProvider` from the root, `useInterruptDecision`, `useChatVercelCompat`, `sendMessage`) — corrected to the real surface (`useSessionRuntime`/`useSessionMessageStream`/`useInterruptUI` from `@pleach/react`; `useChat`→`submit` from `@pleach/core/quickstart`; `HarnessProvider` from `@pleach/react/legacy`/`@pleach/core/react`; `useVercelAISDKCompat` from `@pleach/react/adapters/vercel-ai-sdk`). [LangChain](/docs/langchain) — `LangChainProvider` takes a POSITIONAL model arg and wires via the top-level `provider:` slot (not `{ model }` / `host.strategies.provider`). [Base tools](/docs/base-tools) — the scratchpad is per-SESSION (chatId-scoped, not per-turn), its param is `operation` (not `op`), and its value is a string. [Coding agent](/docs/coding-agent) — the four sandbox tools register via a `contributeTools` `HarnessPlugin` in `plugins:` (`SessionRuntimeConfig` has no `tools`/`context` field). A new `packages/core/test/docImportSurface.smoke.test.mjs` regression-locks the import-path half. * **Observability + gateway-cost docs corrected to the shipped exporter/cost surface (devharness FINDING 73–75).** `@pleach/core` ships no `@opentelemetry/*` dependency and never reads the global tracer provider — the documented NodeSDK + OTLP recipe captured zero pleach spans. [OTel observability](/docs/otel-observability) and [Observability](/docs/observability) now show the real path: a host-supplied `OtelExporter` bridge injected via `new SessionRuntime({ otelExporter })` (default is `NoopOtelExporter`, which drops spans). `runtime.spans.snapshot()` returns `{ inFlightCount, isShutdown }` (exporter state) — to inspect emitted spans you wire a `CapturingOtelExporter` and read `.captured` (real fields `startTimeNs`/`endTimeNs`/ `parentId`). And [Gateway cost events](/docs/gateway/cost-events) + the gateway OTel wiring: `computeCost` was reading `tenantId`/`family`/`callClass`/`byokActive`/ `routingDecision` off `RouteChatCompletionOutput`, which carries none of them (`asTenantId(undefined)` threw → zero CostEvents) — the example now reads only real output fields + host-supplied observation context, and flags that `routeChatCompletion` is currently a slice-2 stub (`costUsd: 0`). * **Subagents + async-tasks docs reconciled with the host-completion-required reality (devharness FINDING 66–72).** Bare `@pleach/core`'s subagent surface is host-completion-required (the host supplies `subagentExecutor` / drives `SubagentManager`); the docs presented it as substrate-delivered. Corrected in [Subagents](/docs/subagents): removed the fabricated `spawnsSubagent: true` auto-escalation flag; `LightweightRuntime` → the real `LightweightSessionRuntime` imported from `@pleach/core/subagents` (not root) with its real constructor; `fetchWorkspaceContext`/`formatWorkspaceContextPrompt` moved to the `@pleach/core/subagents` subpath; the concurrency model corrected (the runtime spawn path caps on the hard `SUBAGENT_LIMITS` constant and FAILS over-cap rather than queueing — the `SubagentManager` queue/DAG is host-driven); `context.variables` noted as currently inert; plus a host-completion caveat. And [Async tasks](/docs/async-tasks): the async-task tool-loop bridge is not wired in bare core (`ASYNC_TASK_BRIDGE_NOT_WIRED`), and the `"subagent"`/`"sandbox_exec"` task executors are host/plugin-supplied (the in-tree `SubagentTaskExecutor` is server-only), not "in-tree defaults". * **Abort + resume docs synced to the shipped runtime behavior (devharness FINDING 56 + 62).** Aborting an in-flight turn now records `outcome.status: "user-aborted"` (not `"aborted"` / not lumped into `provider-error`) at BOTH the tool-loop decision call and the synthesis terminal, so cost rollups + compliance separate caller cancellation from provider failure — corrected across [session lifecycle](/docs/session-lifecycle#aborting-an-in-flight-turn), [SessionRuntime](/docs/session-runtime), [turn lifecycle](/docs/turn-lifecycle), and [API routes](/docs/api-routes). And [resuming a session](/docs/session-lifecycle#resuming-a-session) now describes the real three-layer rebuild: storage row → version-guarded checkpoint overlay (the crash-recovery path) → server-only event-log hydration of the *ephemeral card-lifecycle* state (interrupts/subagents/exports/user cards) onto a transient `Session.hydratedHarnessState` field — correcting the prior claim that `hydrateFromEvents` rebuilt messages/channel state. * **Model-resolution-matrix path corrected — it is host-supplied, not a `@pleach/core` dir (pkgclean 06 D7).** The docs cited the family-lock matrix at the fictional `src/graph/modelfamily/` ([family-lock](/docs/family-lock)) and `@pleach/core/ai/modelFamily/matrix.ts` ([quickstart](/docs/quickstart), [provider detection](/docs/quickstart/provider-detection)) — neither path exists in the package. The `(family × callClass)` matrix table is host-supplied and reached through the `AgentAdapter.resolveModel<C>()` seam; the `ProviderFamily` set is now pointed at the real `@pleach/core/modelfamily` export. The routing-decision seam's Ivy-internal host path was removed from [routing decisions](/docs/plugins/routing-decisions). * **Docs reconciled with the shipped `@pleach/core` runtime (value-prop-vs-impl audit, devharness FINDING 56–64).** Corrected doc claims that diverged from the implementation: * [Time travel](/docs/time-travel) — `fork` sets `parent_id: source.id` (not `null`). * [Checkpointing](/docs/checkpointing) — checkpoints write at message/event boundaries (discriminated by `CheckpointMetadata.source`), not stamped with a per-stage `stageId`; `SupabaseSaver` durability is `"replicated"`; rollback writes `source: "manual"` + `writes: ["rollback"]`; the `fork` snippet takes a string `newSessionId`. * [Session lifecycle](/docs/session-lifecycle) — the checkpoint snapshot is authoritative on the live restore path; the event-log canonical reader is a flag-gated shadow today. * [Sync](/docs/sync) — interactive conflict resolution (`runtime.resolveConflict`, the `sync.conflict` event, the 3xxx code range) is enterprise-tier/planned; open-core uses `SyncCoordinator` last-writer-wins-on-push with a free-text `lastError`. * [Query](/docs/query) — dropped the non-existent `queryAuditableCalls`; corrected `getChatUsage` / `lookupModelCost` / `getSessionReview` / `getInterruptChain` signatures + return shapes to the real `@pleach/core/query` surface. * [Memory](/docs/memory) — the production extraction path is the graph node (`createMemoryExtractionNode`), not the `memoryExtractionHook` → queue pipeline; `LearnedFact.source` is a provenance string (+`createdAt`/`lastConfirmedAt`) on extractor facts, with the object form documented alongside. * [Lineage](/docs/lineage) — opt-in via `config.lineageTracker`; the runtime records the session node + a `branched_from` fork edge, the other relations are host-recorded; `completeSession` is host-called. ### Added [#added-2] * **Opt-in tool-event preview enrichment for the manifest projection.** New `runtimeQuirks.enrichToolEventPreviewsForReplay` flag (default **OFF**) lets the host write compact `args_preview` (`tool.started`) + `output_preview` (`tool.completed`) onto tool events; `chatManifestProjection` folds them into richer manifest `params`/`output`, closing the projection's intentional lossiness vs. the live provider. The previews are scrub-allowlisted (the C8 redaction chain processes them). OFF by default preserves the `arguments_hash`-only privacy posture. This makes the projection canonical-*capable*; the projection-canonical flip itself stays gated. See [Replay](/docs/replay). * **TimeTravel surfaces the chat manifest (`StateSnapshot.manifest`).** `getState` / `getStateHistory` / `fork` / `bulkUpdate` now carry the chat-manifest ledger AS-OF a checkpoint, for **inspection** (it does not feed the next generation — that runs through `rollbackToCheckpoint` / `resumeSession` into the live provider). The snapshot rides the checkpoint `metadata` (round-tripped via `SupabaseSaver.metadata_extra`; Memory/IndexedDB ride free), mirroring the `pendingTasks` pattern. See [Time travel](/docs/time-travel). * **`ProviderFamilyExhaustedError` promoted to the top-level `@pleach/core` surface.** The provider-family cascade terminal (raised when every in-family rung is exhausted) + its `isProviderFamilyExhaustedError` guard are now public on `@pleach/core` — previously reachable only via the deep `@pleach/core/graph/seams/providerExhaustion` path. Catch + narrow it from the top-level import. * **`@pleach/core/cache` — content-hash deriver.** `deriveContentHashKey`, `canonicalizeKeyInput`, and `ContentHashKeyInput` now have their canonical home in `@pleach/core/cache` (relocated from `@pleach/replay/cache`, which re-exports them for back-compat). The move lets `@pleach/core` derive + stamp a `cacheKey` into `tool.completed` / `turn.completed` events without the forbidden `core → replay` import. See [Subpath exports](/docs/subpath-exports). * **`@pleach/core/manifest` — reload-durability + the manifest as a projection.** The chat-manifest ledger (the `[Session Manifest]` prompt block) now survives a process reload: `SessionRuntime.saveSession` stamps the live snapshot into the persisted session state and `resumeSession` rehydrates it (complementing the existing checkpoint rollback restore). New `chatManifestProjection` / `foldChatManifest` fold the manifest deterministically from the event log (`tool.*` / `job.*` / `model.upgraded`), replayable via `runtime.events.fold` and the `@pleach/replay` stepper — intentionally lossy vs. the live provider (the log carries hashes/sizes, not full params/output), measured by a flag-gated `[UXParity:manifest-projection-divergence]` shadow probe. See [Subpath exports](/docs/subpath-exports). * **`tool.completed` / `turn.completed` — optional `cacheKey` payload slot.** Lets a deterministic replay consult the provider cache on the row (consumed by `@pleach/replay`'s `ReplayHandle`). Host- / `createCacheMiddleware`-populated; core does not derive it (the `core → replay` boundary forbids importing `deriveContentHashKey`). See [Replay](/docs/replay). ### Added [#added-3] * **[CI enforcement of the audit contract](/docs/ci-enforcement)** — new Reference page indexing the [audit-gate catalog](/docs/audit-gates) by the auditability and replayability clause each gate enforces. Covers the `@pleach/core` event-log gates (`audit:auditable-call`, `audit:auditable-call-soak`, `audit:event-log-manifest-hash-completeness`, the three `audit:config-manifest-*` gates, `audit:c8-event-type-allowlist-coverage`, `audit:c9-hash-chain-integrity`) and the CI jobs that carry them (`graphnoderef`, `local-clone`, per-SKU `consumer-rehearsal`). Cross-linked from [Audit gates](/docs/audit-gates). * **[Contribution namespaces](/docs/plugins/namespaces)** — new page documenting the nine namespaces every `HarnessPlugin` contribution hook resolves to (`prompts`, `stream`, `safety`, `audit`, `intent`, `tools`, `middleware`, `policy`, `lifecycle`) — the canonical, `audit:plugin-hook-category-assigned`-gated contract layout, with representative hooks per namespace and the rolling-out typed namespaced authoring form. [Plugin contract](/docs/plugin-contract) gains a cross-link; [Authoring a HarnessPlugin](/docs/plugins/authoring) reframes its informal 12-bucket grouping as the scan view of these nine canonical namespaces. * **[Config manifest](/docs/config-manifest)** — new page documenting the content-addressable substrate snapshot the runtime writes to `harness_config_manifest`: one row per distinct plugin/prompt/node/channel/filter set, keyed by a SHA-256 Merkle roll-up (`pleach.manifest.v1` prefix, `0x1F` separator, mirroring the [hash chain](/docs/hash-chain)'s `pleach.c9.v1` canonicalization). Per-surface child hashes, the nullable `manifest_hash` foreign reference on `harness_event_log`, reference-counted retention (default forever), full-snapshot storage, and the canonical replay/audit query shapes. Marked `in-flight` — table and event-log column land as additive schema files during rollout. * **[What Pleach writes to your database](/docs/emitted-data)** — new page mapping every `@pleach/core` table written to a buyer's database, what's default versus opt-in, and the destination-is-yours posture (no phone-home). * **`manifest_hash` cross-references** added to [Event log](/docs/event-log) (stamping section + `EventLogRow` field) and [Schema](/docs/schema) (rolling-out `harness_config_manifest` section). * **[OrchestratorClient](/docs/orchestrator-client)** — new top-level page documenting the per-turn handle the runtime threads through the stream body. Lifted from app-side into the substrate; canonical import path is `@pleach/core/runtime/orchestratorClient`. Covers the six user-facing facets (`config`, `history`, `context`, `tools`, `model`, `prompts`) and the `_internal.graph` snapshot consumer code must not touch. * **[streamSingleTurn](/docs/stream-single-turn)** — new top-level page documenting the canonical per-turn body. Lifted into `@pleach/core/strategies/streamSingleTurn/` with a stable helpers barrel re-exporting `TurnAccumulator`, `ToolDefinition`, `OrchestratorMessage`, and `ProviderFallbackConfig`. * **[Subpath exports](/docs/subpath-exports)** — adds two rows to the Runtime + composition table: `@pleach/core/runtime/orchestratorClient` and `@pleach/core/strategies/streamSingleTurn`. Both are now named subpath exports rather than wildcard-only reachable. * **[Subpath exports → `@pleach/core/quickstart`](/docs/subpath-exports)** — added under the Wire + tooling cluster. Lists the five LANDED deliverables — `createPleachRoute` (fetch-handler factory), `useChat` (Pleach-native React hook), `<ChatBox />` (unstyled, accessible), `defaultPlugin` (empty baseline), `providerDetection` helpers + constants — plus the benchmark-adjacent `createBenchmarkPlugin`. * **[Attestation](/docs/attestation)** — new top-level page on the Ed25519-signed envelope substrate that sits on top of the [hash chain](/docs/hash-chain). Documents the `@pleach/core/attestation` subpath (signer, verifier, key-store interface), the two stub production adapters (AWS KMS, Vault Transit), and the file-backed `attestation/testing` adapter. * **[Architecture § Boundary rules](/docs/architecture#10-boundary-rules)** — adds a sixth row to the gate table: `audit:domain-string-purity` forbids domain-specific literals in `packages/core/src/**` across five pattern families. Pairs with the existing `lint:harness-boundary` row, which forbids domain *imports* — the new gate covers domain *strings*. * **[Core](/docs/core) "What you get"** — new bullet "Composes under an existing Enterprise contract" after the AuditableCall ledger bullet. ### Changed [#changed-1] * **Family-lock page documents the host-internal planner carve-out.** [`family-lock.mdx`](/docs/family-lock) gains a "Host-internal planner calls" section. The lock governs seam calls — `synthesize` / `converse` / `reasoning` / `utility` on the provider seam — but a host's [`anchor-plan`](/docs/plans) planner cold-start can run on a pinned host-configured model that never consults the `(family x callClass)` matrix, so it sits outside the lock by construction, not as a leak. The row is identifiable by `stage: "anchor-plan"`, `callClass: "utility"`, `payload.kind: "planGeneration"` and carries no `fallbackStep` / `family-exhausted` / `providerCascade`. Guidance: alert on any *other* non-locked-family row; allow this planner row. * **The tool-loop decision call is `utility`-class on its own seam, not the synthesize seam (singleton-per-call-class, D-72).** Five pages carried the prior claim that the synthesizer node and the tool-loop `llmDecision` node share one `ProviderSeam<"synthesize">` identity (the old "D-35" statement). The tool-loop continuation decision — "call another tool or finish?" — now runs on a dedicated `utility` seam, so the synthesize cap is structural: the synthesizer (and the recovery path) are the only consumers of the synthesize seam, and the decision node can't reach the `TurnSynthesizeCounter`. Corrected in [Seams](/docs/seams#the-singleton-synthesize-seam) (the singleton section + the LangGraph callout's `ProviderSeam<"utility">` type), [Graph](/docs/graph#singleton-synthesize-seam), [Graph node catalog](/docs/graph-node-catalog) (the `llmDecision` row's `acceptsSeam` `converse`→`utility`, the Stage 2 intro, and the singleton-invariant wire-check), [Call classes](/docs/call-classes) (the `utility` row + section now name the tool-loop decision as the canonical utility call), and [Multi-synthesize](/docs/coding-agent/multi-synthesize). A reader on the old "shared seam" model would have mis-predicted which node bumps the synthesize counter. * **Graph lattice docs synced to the current `@pleach/core` topology across eight pages.** The `NODE_STAGE_MAP` registry is **43** names, not 48. `ALLOWED_EDGE_PATTERNS` carries **nine** rows and `FORBIDDEN_EDGE_PATTERNS` **seven**, not eight/eight — the `tool-loop → post-turn` recovery-dispatch edge moved from the forbidden table into the allowed table. Recovery shaping is no longer four `synthesize`-stage graph nodes (`recovery`, `refusalHint`, `retryNarration`, `garbleRecovery`) plus a `recoveryDispatchPredicate` derivation node and a `shouldContinueWithGarbleRoute` wrapper; it is four post-turn stream filters (`refusalHintFilter` 100, `retryNarrationFilter` 200, `garbleRecoveryFilter` 300, `standardRecoveryFilter` 400) dispatched via `StreamObserverRegistry` and locked by `audit:recovery-dispatch-single-surface`. `synthesize` is now a true singleton — only `synthesizer`. Touched [Graph](/docs/graph), [Node catalog](/docs/graph-node-catalog), [Edge catalog](/docs/graph-edge-catalog), [Nodes](/docs/nodes), [Architecture](/docs/architecture), [Ownership boundaries](/docs/ownership), [Plugin contract](/docs/plugin-contract), and [Audit gates](/docs/audit-gates). [Node catalog](/docs/graph-node-catalog) also gains a "How the substrate source is organized" section documenting the `src/graph/` layout (`nodes/` one-file-per-node, `wiring/` registrars split by stage, the `topology.ts` lattice layer, concern-split `seams/` / `predicates/` / `strategies/`), with upstream-contributor steps pointing at the `wiring/register<Stage>Nodes.ts` registrar. * **Config manifest foreign key corrected in [Config manifest](/docs/config-manifest).** The `harness_event_log.manifest_hash` reference is a real foreign key with `ON DELETE SET NULL` and a load-bearing `DEFERRABLE INITIALLY DEFERRED` clause, not gate-only as the page previously claimed. Added the `reference_count` column to the table DDL and cited the `audit:config-manifest-fk-constraint-applied` and `audit:config-manifest-referential-integrity` gates. * **Schema description for [`@pleach/core`](/docs/core) amended to name the language-agnostic contract.** The `description` field in the site's Organization + SoftwareApplication JSON-LD graph (inlined in `<head>` on every page) previously described `@pleach/core` only in TypeScript terms, contradicting both the marketing surface and the editorial position at [`/ai.txt`](https://getpleach.com/ai.txt). The description now names the wire shapes — HTTP+SSE, `StreamEvent`, [`AuditableCall`](/docs/auditable-call-row), [`Checkpoint`](/docs/checkpointing), version vector — as language-agnostic and clarifies that the npm package is the TypeScript reference distribution. `programmingLanguage: ["TypeScript"]` stays accurate — the npm package IS TypeScript; the language-agnosticism belongs to the contract, not the publishable artifact. See [Language-agnostic contract](/docs/language-agnostic-contract) for the full wire-shape inventory. * **Cross-docs status-flip pass** — rolling correction across \~55 docs pages tracking the alpha-ship of [`@pleach/compliance`](/docs/compliance), [`@pleach/gateway`](/docs/gateway), and [`@pleach/replay`](/docs/replay). Touched pages include [Audit ledger](/docs/audit-ledger), [Event log](/docs/event-log), [Subpath exports](/docs/subpath-exports), [Seams](/docs/seams), [Prompts](/docs/prompts), [Prompt builder](/docs/prompt-builder), and [Session runtime](/docs/session-runtime). ### Fixed [#fixed-2] * **[Family-lock](/docs/family-lock) lifetime claim corrected** — the page stated "switching family means a new session." The runtime ships a sanctioned in-session re-lock path (`sessions.updateProviderModel`, emitting a `session-lock-resynced` event), so switching family is an explicit operation, not a new-session requirement. The lock's real invariant is no *silent* mid-turn widen, not lifetime immutability. * **[AuditableCall row](/docs/auditable-call-row) `AuditRecordVersion` corrected 13 → 14** — both the literal description and the version-log range (`v8 through v14`) now match the upstream `AUDIT_RECORD_VERSION_HISTORY`, whose latest entry is `to: 14` (Cluster C: additive `rewriteStatus?` + `tokenCost.callClass`). ## 2026-06-08 — chat-pipeline lift into `@pleach/core` [#2026-06-08--chat-pipeline-lift-into-pleachcore] Foundational lift bringing `OrchestratorClient` and `streamSingleTurn` into the substrate. See the Unreleased section above for the Added/Changed entries. The Pre-Merge Gate `audit:domain-string-purity` landed alongside. ## 2026-06-08 [#2026-06-08] ### Added [#added-4] * New page [Runtime inspector](/docs/runtime-inspector) documenting `inspectRuntime()` from `@pleach/core/inspector` — the typed read-only introspection surface, the `InspectionReport` / `CapabilityReport` / `PluginReport` / `CapabilityDescriptor` shapes, and the 38 known capabilities (29 modern + 9 deprecated). * `definePleachPlugin()` factory section in [Plugin contract](/docs/plugin-contract#definepleachplugin--the-typed-factory) covering the 8-key `PluginCapabilities` cluster and the `_raw` escape hatch. * `contributeFabricationGuard` section documenting the typed `FabricationGuardImpl` hook (7 methods) that retires the `orchestratorHotpath.fabricationGuard` untyped-bag entry. * `CreatePleachRuntimeConfig.host.{strategies, modules, raw}` section in [Host adapter](/docs/host-adapter) with the 15-key `HostExtensionBundle` list and the `host.modules` list (`eventLogWriter`, `store`, `interrupt`). * C9 hash-chain probe section in [Tamper-evident hash chain](/docs/hash-chain) documenting `[UXParity:c9-hash-chain-row-stamp]` (PE-1) and `[UXParity:c9-hash-chain-verify]` (PE-2). * `@pleach/core/inspector` row in [Subpath exports](/docs/subpath-exports). ### Changed [#changed-2] * [Packages](/docs/packages) table corrected to real published version — `@pleach/core 1.1.0` (was largely stale "Reserved · placeholder" framing). ### Fixed [#fixed-3] * [Runtime inspector](/docs/runtime-inspector) — replaced fictional `RuntimeInspection` / `CapabilityState` types with the real `InspectionReport` / `CapabilityReport` / `PluginReport` / `CapabilityDescriptor`. Corrected the `status` discriminator to `"wired" | "deprecated-wired" | "unwired"`. * [Plugin contract](/docs/plugin-contract) — `definePleachPlugin` capability fields take values, not thunks. Corrected to the real 8 capability keys (`prompts`, `runtimeAwarePrompts`, `safetyPolicies`, `fabricationDetectors`, `tools`, `streamObservers`, `intentToolMap`, `toolCouplingHints`). * [Host adapter](/docs/host-adapter) — `host.modules` corrected to `{ eventLogWriter, store, interrupt }`. `host.strategies` populated with the real 15-key `HostExtensionBundle` list. * [Tamper-evident hash chain](/docs/hash-chain) — subpath corrected from `@pleach/core/event-log` to `@pleach/core/eventLog` (camelCase is the real export). * [Event-log projections](/docs/event-log-projections) — subpath corrected from `@pleach/core/event-log/projections` to `@pleach/core/eventLog` (projections export from the `./eventLog` barrel; there is no `/projections` subpath). --- # @pleach/eval changelog (/docs/changelog/eval) This page collects the **site-content changelog entries** that materially touched the [`@pleach/eval`](/docs/eval) docs surface or named a `@pleach/eval` symbol. It is not the runtime changelog — the canonical record of `@pleach/eval` runtime behavior lives in the upstream package [`CHANGELOG.md`](https://github.com/pleachhq/getpleach) and on [npmjs.org/package/@pleach/eval](https://www.npmjs.com/package/@pleach/eval). See the combined site changelog at [`/docs/changelog/combined`](/docs/changelog/combined) for the chronological unified view across every SKU. ## Unreleased [#unreleased] ### Fixed [#fixed] * **[`@pleach/eval`](/docs/eval) cross-mode cache buckets corrected (devharness FINDING 90).** The `cacheReadPolicy: "cross-mode-readable"` note named fictional `production` / `replay` / `eval-noncached` buckets; the real `RuntimeMode` union (`core` `fingerprint/types.ts`) is `interactive` / `headless-eval` / `headless-replay` / `headless-job` / `coding-agent`. Corrected to the real one-way read chain `interactive` → `headless-eval` → `headless-replay`. ### Added [#added] * **Per-recipe deep-dive page** — [`evalLab`](/docs/recipes/eval-lab) composes `@pleach/eval` + `@pleach/replay` via DI-callback factories so the recipe doesn't take hard build-time peer deps. Rank-2-publish-relevant once `eval` ships per the upstream rank-2 runbook. ## 2026-06-08 [#2026-06-08] ### Changed [#changed] * [Packages](/docs/packages) table corrected to real published version — `@pleach/eval 0.8.0-alpha.0` (was largely stale "Reserved · placeholder" framing). * [Plugin contract → Sibling SKUs as plugins](/docs/plugin-contract#sibling-skus-as-plugins) section retitled and rewritten with per-row real Phase A status — includes `@pleach/eval`. --- # @pleach/gateway changelog (/docs/changelog/gateway) This page collects the **site-content changelog entries** that materially touched the [`@pleach/gateway`](/docs/gateway) docs surface or named a `@pleach/gateway` symbol. It is not the runtime changelog — the canonical record of `@pleach/gateway` runtime behavior lives in the upstream package [`CHANGELOG.md`](https://github.com/pleachhq/getpleach) and on [npmjs.org/package/@pleach/gateway](https://www.npmjs.com/package/@pleach/gateway). See the combined site changelog at [`/docs/changelog/combined`](/docs/changelog/combined) for the chronological unified view across every SKU. ## Unreleased [#unreleased] ### Added [#added] * **`@pleach/gateway/next` documented as shipped (`createGatewayRoute`).** [Migration from core](/docs/gateway/migration-from-core) flipped from "roadmap / NOT shipped today" to shipped. The factory is a Web-standard `(req) => Response` POST handler wrapping `GatewayClient`; docs carry the real opts (`client` | `clientOptions` + `tenantId`, optional `transports` / `resolveRoute`) and the `{ family, callClass, model, prompt, byokKey? }` request body. The never-shipped `{ runtime, credentialStore, costEmitter }` params were removed. ### Fixed [#fixed] * **Schema `codeRepository` + `sameAs` for [`@pleach/gateway`](/docs/gateway) corrected** in the site's Organization + SoftwareApplication JSON-LD `@graph` (inlined in `<head>` on every page). Both fields now point at `https://github.com/pleachhq/gateway` (was `https://github.com/pleachhq`, a leftover from when the SKU schemas were drafted against an upstream monorepo). Load-bearing because `sameAs` is the field AI search engines use for entity reconciliation across the npm ↔ GitHub ↔ docs boundary. See the [combined view](/docs/changelog/combined) for the full cross-SKU pack. ### Added [#added-1] * **[`@pleach/observe`](/docs/observe)** slotted into the Bundled packages cluster after [`@pleach/gateway`](/docs/gateway). * **Sidebar** — added `observe` under Bundled packages after [`@pleach/gateway`](/docs/gateway). ### Changed [#changed] * **Cross-docs status-flip pass** — `@pleach/gateway@0.8.0-alpha.0` Phase A with Anthropic transport real and other transports deferred to Phase B. "Planned" / "no shipping code yet" claims across \~55 docs pages rewritten. Touched [Gateway](/docs/gateway), [Audit ledger](/docs/audit-ledger), [Event log](/docs/event-log), and others. * **Pricing / FAQ rewrites** — *Hosted Gateway* description rewrites to per-tenant subscription pricing with optional consolidated billing, drops any percentage-on-routed-spend framing (100% of disclosed-pricing LLM router cohort runs zero markup on inference). * **[FAQ → Is there a hosted version?](/docs/faq#is-there-a-hosted-version)** — answer rewrites to lead with *No today*, name the gateway as the planned multi-tenant routing SKU, and close with the FSL-conversion-clock mechanism behind the net-additive guarantee. * **`GatewayClient`-per-cost-center note** added on [Gateway](/docs/gateway#tenant-scoping). ## 2026-06-08 [#2026-06-08] ### Changed [#changed-1] * [Packages](/docs/packages) table corrected to real published version — `@pleach/gateway 0.8.0-alpha.0` (was largely stale "Reserved · placeholder" framing). * [Plugin contract → Sibling SKUs as plugins](/docs/plugin-contract#sibling-skus-as-plugins) section retitled and rewritten with per-row real Phase A status — includes `@pleach/gateway`. --- # @pleach/langchain changelog (/docs/changelog/langchain) This page collects the **site-content changelog entries** that materially touched the [`@pleach/langchain`](/docs/langchain) docs surface or named a `@pleach/langchain` symbol. It is not the runtime changelog — the canonical record of `@pleach/langchain` runtime behavior lives in the upstream package [`CHANGELOG.md`](https://github.com/pleachhq/getpleach) and on [npmjs.org/package/@pleach/langchain](https://www.npmjs.com/package/@pleach/langchain). See the combined site changelog at [`/docs/changelog/combined`](/docs/changelog/combined) for the chronological unified view across every SKU. ## Unreleased [#unreleased] ### Fixed [#fixed] * **Schema `codeRepository` field + GitHub `sameAs` entry added to [`@pleach/langchain`](/docs/langchain)** in the site's Organization + SoftwareApplication JSON-LD `@graph` (inlined in `<head>` on every page). Was the only SKU entry without a `codeRepository` field; its `sameAs` array referenced only the npm page and the docs page. Now declares `https://github.com/pleachhq/langchain`, consistent with the other SKUs. Load-bearing because `sameAs` is the field AI search engines use for entity reconciliation across the npm ↔ GitHub ↔ docs boundary; without the GitHub URL, an AI search asking "where does `@pleach/langchain` live?" had to guess. See the [combined view](/docs/changelog/combined) for the full cross-SKU pack. ## 2026-06-08 [#2026-06-08] ### Changed [#changed] * [Packages](/docs/packages) table corrected to real published version — `@pleach/langchain 0.1.0` (was largely stale "Reserved · placeholder" framing). --- # @pleach/mcp changelog (/docs/changelog/mcp) This page collects the **site-content changelog entries** that materially touched the [`@pleach/mcp`](/docs/mcp) docs surface or named a `@pleach/mcp` symbol. It is not the runtime changelog — the canonical record of `@pleach/mcp` runtime behavior lives in the upstream package [`CHANGELOG.md`](https://github.com/pleachhq/getpleach) and on [npmjs.org/package/@pleach/mcp](https://www.npmjs.com/package/@pleach/mcp). See the combined site changelog at [`/docs/changelog/combined`](/docs/changelog/combined) for the chronological unified view across every SKU. ## Unreleased [#unreleased] ### Fixed [#fixed] * **[`@pleach/mcp`](/docs/mcp) docs flipped from the pre-Phase-B "throws today" framing to the shipped surface (devharness FINDING 95–99).** `registerSession(sessionId, runtime, { tenantId? })` is a **live** in-memory session registry (plus `getSession` / `listSessions` / `unregisterSession`, last-wins on duplicate `sessionId`) — the `NotImplementedError("D-PA-184")` prose and status-table row are gone (`server.ts:364`). `resources/*` and `prompts/*` are dispatched live on the `MCPServer` wire via both the SDK `setRequestHandler` path (`server.ts:574,595`) and the pluggable `dispatchPluggableMethod` (`server.ts:757,767`); the two "Not yet exposed; Phase B" table rows moved to Live. The pluggable dispatcher implements `initialize` (`case "initialize": handleInitialize`, `server.ts:740`) — the docs no longer claim it returns `-32601 Method not found`. `McpRuntime.registerCapability` lands resource / prompt kinds in the last-wins ledger without throwing (`McpRuntime.ts:207,363`) — the "shape stable, body deferred / throws today" section was rewritten. The `createSSETransport().start()` **factory** throws a generic `Error` (`transport.ts:412`); the typed `NotImplementedError("D-PA-181")` is the *options-bag* `server.start({ transport: "sse" })` path (`server.ts:524`) — the factory table row and the `NotImplementedError` instance list were corrected accordingly. * **Schema `codeRepository` + `sameAs` for [`@pleach/mcp`](/docs/mcp) corrected** in the site's Organization + SoftwareApplication JSON-LD `@graph` (inlined in `<head>` on every page). Both fields now point at `https://github.com/pleachhq/mcp` (was `https://github.com/pleachhq`, a leftover from when the SKU schemas were drafted against an upstream monorepo). Load-bearing because `sameAs` is the field AI search engines use for entity reconciliation across the npm ↔ GitHub ↔ docs boundary. See the [combined view](/docs/changelog/combined) for the full cross-SKU pack. ## 2026-06-08 [#2026-06-08] ### Changed [#changed] * [Packages](/docs/packages) table corrected to real published version — `@pleach/mcp 0.3.0-alpha.0` (was largely stale "Reserved · placeholder" framing). --- # @pleach/observe changelog (/docs/changelog/observe) This page collects the **site-content changelog entries** that materially touched the [`@pleach/observe`](/docs/observe) docs surface or named a `@pleach/observe` symbol. It is not the runtime changelog — the canonical record of `@pleach/observe` runtime behavior lives in the upstream package [`CHANGELOG.md`](https://github.com/pleachhq/getpleach) and on [npmjs.org/package/@pleach/observe](https://www.npmjs.com/package/@pleach/observe). See the combined site changelog at [`/docs/changelog/combined`](/docs/changelog/combined) for the chronological unified view across every SKU. ## Unreleased [#unreleased] ### Fixed [#fixed] * **[`@pleach/observe`](/docs/observe) surface reconciled with the shipped recorder (devharness FINDING 83–87).** `getRecorder()` returns `ObserveRecorder` and **throws** if called before `init` (it never returns `undefined`); a second `init` **throws** (singleton-per-process, not a warn-and-return no-op); a `redactor` that throws is now documented as **fail-closed** — the SDK swallows, warns via `[Observe:redactor-threw]`, and **drops** the row (the raw pre-redaction row is never written). The OTel destination `Callout` corrected to the real envelope: span name `` `<family>.<callClass>` `` (e.g. `anthropic.synthesize`) with `gen_ai.*` attributes plus `pleach.observe.{turn_id,provider_id, cost_usd}` / `pleach.observe.tag.*` (was the fictional `pleach.audit.call` / `pleach.turn_id` set). `BUILT_IN_MATCHERS` ships email + phone + SSN only (dropped the "credit card" claim). Checked against `observe/src/recorder.ts` + `destinations/otel.ts` * `redact.ts`. * **Schema `codeRepository` + `sameAs` for [`@pleach/observe`](/docs/observe) corrected** in the site's Organization + SoftwareApplication JSON-LD `@graph` (inlined in `<head>` on every page). Both fields now point at `https://github.com/pleachhq/observe` (was `https://github.com/pleachhq`, a leftover from when the SKU schemas were drafted against an upstream monorepo). Load-bearing because `sameAs` is the field AI search engines use for entity reconciliation across the npm ↔ GitHub ↔ docs boundary. See the [combined view](/docs/changelog/combined) for the full cross-SKU pack. ### Added [#added] * **[`@pleach/observe`](/docs/observe)** — new top-level page for the brownfield audit-row SDK. Documents the five-entry facade surface (`init`, `startTurn`, `flush`, `recordCall`, `subagent`, `getRecorder`), the `ObserveRow` shape as a strict subset of the [`AuditableCall` v7](/docs/auditable-call-row) schema, the four destinations (Postgres, Supabase, OTel, Memory) on the `./destinations` subpath, the custom-destination interface (`write` + optional `flush`), the hookable-vs-not-hookable split against the runtime, and composition with [`@pleach/core`](/docs/core) when both surfaces run in the same process. Phase 0 status callout makes the design-target stance explicit — `@pleach/observe@0.1.0-alpha.0` is scoped, not yet on npm; Phase 1 publish is the next cut. * **Per-recipe deep-dive pages** — [`BYOK observability`](/docs/recipes/byok-observability), [`observableChatbot`](/docs/recipes/observable-chatbot), [`instrumentedCodingAgent`](/docs/recipes/instrumented-coding-agent), and [`enterpriseAgent`](/docs/recipes/enterprise-agent) all compose `@pleach/observe`. * **[Packages](/docs/packages)** — added the `@pleach/observe` matrix row in the Reserved tier with the row note `0.0.1 · Reserved · 0.1.0-alpha.0 scoped`, and reworked the Reserved bullet to distinguish [`@pleach/trust-pack`](/docs/packages) (placeholder, no contract published) from [`@pleach/observe`](/docs/observe) (Phase 0 contract locked, scoped for Phase 1 under FSL-1.1-Apache-2.0). * **[Homepage](/) — new "Keep your stack" block makes [`@pleach/observe`](/docs/observe) a top-level surface on the marketing home.** The brownfield path now sits between "Should you stay where you are?" and "Already paying the lab": wrap an existing Vercel AI SDK / LangChain / hand-rolled loop in \~15 lines. Its diagram (`components/observe-brownfield-diagram.tsx`) shows the `init` + per-turn recorder + destination-plug hook fanning out to the four destinations — `postgres()`, `supabase()`, `otel()`, `memory()` — over the BYOK code shape (`init`, `startTurn`, `recordCall`, `end`). The `ObserveRow`-is-a-strict-subset-of-`AuditableCall` runway claim is the figure caption. Cross-links [adoption paths](/docs/adoption-paths) and [BYOK observability](/docs/recipes/byok-observability). Block states `@pleach/observe` is in alpha; the SDK layer stays free. ### Changed [#changed] * **Pricing rewrites** — *audit rows ship where you point them* — Postgres, Supabase, OTel collector into Datadog / Honeycomb / Grafana / your existing OTLP backend, or a custom destination; the enterprise integration is the *bridge* from `@pleach/observe` into the review surface your team already runs. * **Pricing — "Hosted Observe at enterprise scope"** named as a paid enterprise product alongside Hosted Gateway and compliance attestation services. --- # @pleach/react changelog (/docs/changelog/react) This page collects the **site-content changelog entries** that materially touched the [`@pleach/react`](/docs/react) docs surface or named a `@pleach/react` symbol. It is not the runtime changelog — the canonical record of `@pleach/react` runtime behavior lives in the upstream package [`CHANGELOG.md`](https://github.com/pleachhq/getpleach) and on [npmjs.org/package/@pleach/react](https://www.npmjs.com/package/@pleach/react). See the combined site changelog at [`/docs/changelog/combined`](/docs/changelog/combined) for the chronological unified view across every SKU. ## Unreleased [#unreleased] ### Changed [#changed] * **Sidebar reorder** — [Frontend integration](/docs/react) becomes its own top-level group in the build → ship → internals walk. * **Per-recipe deep-dive page additions** — recipes naming `@pleach/react` + `createPleachRoute` now have dedicated walk-throughs at [`/docs/recipes/simple-chatbot`](/docs/recipes/simple-chatbot) and others. Each carries a quickstart, the runtime composition the recipe wires, a TypeScript config interface, common gotchas, and cross-links. ## 2026-06-08 [#2026-06-08] ### Changed [#changed-1] * [Packages](/docs/packages) table corrected to real published version — `@pleach/react 1.0.0` (was largely stale "Reserved · placeholder" framing). --- # @pleach/recipes changelog (/docs/changelog/recipes) This page collects the **site-content changelog entries** that materially touched the [`@pleach/recipes`](/docs/recipes-pleach-recipes) docs surface, the [Recipes](/docs/recipes) cookbook page, or any of the per-recipe deep-dive pages under [`/docs/recipes/`](/docs/recipes/simple-chatbot). It is not the runtime changelog — the canonical record of `@pleach/recipes` runtime behavior lives in the upstream package [`CHANGELOG.md`](https://github.com/pleachhq/getpleach) and on [npmjs.org/package/@pleach/recipes](https://www.npmjs.com/package/@pleach/recipes). See the combined site changelog at [`/docs/changelog/combined`](/docs/changelog/combined) for the chronological unified view across every SKU. ## Unreleased [#unreleased] ### Changed [#changed] * **Internal persona codes removed from the recipe and use-case pages.** The private planning codes (`P0`–`P18`, `P-RAG`, `P-Eval`, `P-Code`) and references to a private "persona doc" leaked into the shipped docs. Each recipe page's `Persona fit:` line is now `Best fit:` with the plain audience descriptor kept; the `persona:` frontmatter field is gone. Audience meaning is unchanged — only the internal codes were removed. ### Fixed [#fixed] * **Air-gapped empty-allowlist behavior corrected on [Government](/docs/use-cases/government) and [Air-gapped architecture](/docs/use-cases/government/air-gapped-architecture).** Both pages claimed the `governmentAgent` recipe validates at construction and throws `PersonaFactoryNotYetImplementedError` (with an internal `personaId` field). That class is real but is the unimplemented-stub error, not an allowlist guard. The real enforcement is in `@pleach/core`: an empty allowlist fails closed at the first provider-URL resolution via `AirGappedHostRejectedError`. Reframed to that behavior; dropped the construction-time claim and the `personaId` field. ### Added [#added] * **[Agent instrumentation recipe](/docs/recipes/agent-instrumentation) added** for the do-it-yourself instrumentation path — bare `runtime.events.on(kind, handler)` lifecycle subscription plus the `observeSink({ destinations })` bridge from `@pleach/observe`. It sits alongside [observable-chatbot](/docs/recipes/observable-chatbot) (recipe-wrapper path) and [BYOK observability](/docs/recipes/byok-observability) (no-`@pleach/core` brownfield path). `observable-chatbot` gained a "Live lifecycle events" section linking to it. See the [combined view](/docs/changelog/combined) for the full entry. ### Fixed [#fixed-1] * **Schema `codeRepository` + `sameAs` for [`@pleach/recipes`](/docs/recipes) corrected** in the site's Organization + SoftwareApplication JSON-LD `@graph` (inlined in `<head>` on every page). Both fields now point at `https://github.com/pleachhq/recipes` (was `https://github.com/pleachhq`, a leftover from when the SKU schemas were drafted against an upstream monorepo). Load-bearing because `sameAs` is the field AI search engines use for entity reconciliation across the npm ↔ GitHub ↔ docs boundary. See the [combined view](/docs/changelog/combined) for the full cross-SKU pack. ### Added [#added-1] * **Rank-2 per-recipe deep-dive pages** — four additional pages under [`/docs/recipes/`](/docs/recipes/simple-chatbot) covering the recipes excluded from the prior six because their peer SKUs were outside the first-publish wave: [`observableChatbot`](/docs/recipes/observable-chatbot) (composes `@pleach/observe`), [`instrumentedCodingAgent`](/docs/recipes/instrumented-coding-agent) (composes `@pleach/coding-agent` + `@pleach/sandbox` + `@pleach/observe`), [`enterpriseAgent`](/docs/recipes/enterprise-agent) (composes `@pleach/compliance` + `@pleach/observe`, surfaces the procurement-visible `ENTERPRISE_PERMITTED_FAMILIES_TAG` envelope), and [`evalLab`](/docs/recipes/eval-lab) (composes `@pleach/eval` + `@pleach/replay` via DI-callback factories). Each page mirrors the prior six-page convention — best fit, quickstart, runtime composition, TypeScript config interface, common gotchas, cross-links. * **Per-recipe deep-dive pages under [`/docs/recipes/`](/docs/recipes/simple-chatbot)** — six new pages covering the first-publish wave (`@pleach/core`, `@pleach/tools`, `@pleach/compliance`, `@pleach/gateway`) and the brownfield observability SDK: [`simpleChatbot`](/docs/recipes/simple-chatbot), [`ragChatbot`](/docs/recipes/rag-chatbot), [`compliantChatbot`](/docs/recipes/compliant-chatbot), [`verticalAgent`](/docs/recipes/vertical-agent), [`subagentSwarm`](/docs/recipes/subagent-swarm), and [`BYOK observability`](/docs/recipes/byok-observability). Each carries a quickstart, the runtime composition the recipe wires, a TypeScript config interface, common gotchas, and cross-links. ### Changed [#changed-1] * **[`/docs/recipes`](/docs/recipes) jump-link table** — 10-row index inserted after the lead so a reader scanning for one recipe doesn't sift through \~1,000 lines. Each row names the recipe's central primitive (`@pleach/react` + `createPleachRoute`, `ProviderDecisionLedger`, etc.) in 3-8 words. * **[`/docs/recipes`](/docs/recipes) lead** — count flipped `Nine` → `Ten` and the inline name list extended to include the regulated-host end-to-end recipe. ## 2026-06-08 — page-level restructuring [#2026-06-08--page-level-restructuring] ### Added [#added-2] * **[Platform & operations recipes](/docs/platform-recipes)** — extracts four platform-team recipes from [Recipes](/docs/recipes) (long-running async jobs, multi-step [interrupts](/docs/interrupts), per-call cost reporter, OpenTelemetry wiring) into a dedicated page under the Operations sidebar group. Lets a consumer reader on [`/docs/recipes`](/docs/recipes) skip patterns a platform team owns; lets a platform reader land directly on the four ops recipes without scrolling past chat/storage/BYOK. ### Changed [#changed-2] * **Sidebar reorder in the Start group:** [Getting started](/docs/getting-started) → [Agent shapes](/docs/agent-shapes) → [Recipes](/docs/recipes) → [Reference apps](/docs/reference-apps) → [Comparison](/docs/comparison). * **[Recipes](/docs/recipes) lead rewritten** to reflect the consumer-facing split (chat, tools, storage, BYOK, moderation, multi-tenant, compliance, projections, hash chain) and to point at [Platform & operations recipes](/docs/platform-recipes) for the platform-team patterns. Recipes 10–13 (multi-tenant, compliance, projection, hash-chain) renumbered to 6–9; anchor URLs for those four sections shift accordingly. --- # @pleach/replay changelog (/docs/changelog/replay) This page collects the **site-content changelog entries** that materially touched the [`@pleach/replay`](/docs/replay) docs surface or named a `@pleach/replay` symbol. It is not the runtime changelog — the canonical record of `@pleach/replay` runtime behavior lives in the upstream package [`CHANGELOG.md`](https://github.com/pleachhq/getpleach) and on [npmjs.org/package/@pleach/replay](https://www.npmjs.com/package/@pleach/replay). See the combined site changelog at [`/docs/changelog/combined`](/docs/changelog/combined) for the chronological unified view across every SKU. ## Unreleased [#unreleased] ### Fixed [#fixed] * **[Region-pinned agent](/docs/region-pinned-agent) replay snippet corrected to the shipped `ReplayRuntime` shapes (devharness FINDING 100–102).** `createReplayRuntime` takes `sessionRuntime`, not `liveRuntime` (`ReplayRuntimeConfig` = `{ tenantId (required), sessionRuntime?, cacheReadMode?, clock?, eventSource? }`, `replay/src/runtime/types.ts:499`). `replayTurn` input is `{ chatId, tenantId, messageId }` — `turnId` → `messageId`, `mode` is not a field, and `tenantId` is required (`ReplayRuntime.ts:521`; `ReplayTurnInput`, `types.ts:95`). Its output is `{ chatId, tenantId, messageId, state, sequenceNumberRange }` (`ReplayRuntime.ts:573`) — there is **no** `.diff` field, so the cross-family parity read was rewritten to compare the returned `state` yourself, with a pointer to the hash-chain `verifyIntegrity` verdict (`{ valid, brokenAt? }`) for tamper-evidence. * **[`@pleach/replay`](/docs/replay) eval-coupling snippet corrected (devharness FINDING 89).** The DI example constructed `new EvalSuite({ replay })`, but `EvalSuiteOptions` (`eval/src/index.ts`) has no `replay` field and **requires** `suiteId`; the real cross-wire is `suite.setReplayClient(client)`. Corrected to `new EvalSuite({ suiteId, runtime })` + `suite.setReplayClient(replay)` (the source doc comment at `replay/src/index.ts` was likewise corrected). ### Added [#added] * **[`/docs/replay`](/docs/replay) — `createStrictHandleReplay` determinism gate.** New `@pleach/replay/strict` export (re-exported at the root) that walks N independent handles over the same `(chatId, tenantId, window)` and byte-compares the folded per-step state, returning `{ deterministic, steps, firstDivergenceAt? }`. Documented in the Status section, a new "Determinism gate" subsection, and the Roadmap. Sibling of `createStrictReplay` (which gates the `ReplayRuntime.replayTurn` surface). * **[`/docs/replay`](/docs/replay) — `cacheKey` replay slot.** `@pleach/core`'s `tool.completed` / `turn.completed` events gained an optional `cacheKey` payload field; the `ReplayHandle` consults a wired `cacheBackend` for rows that carry it (per `cacheReadMode`). The "What's NOT" list notes core does not populate it (the `core → replay` boundary forbids importing `deriveContentHashKey`) — host- / `createCacheMiddleware`- populated. ### Changed [#changed] * **[`/docs/replay`](/docs/replay) — `ReplayHandle` stepper documented as wired.** `step()` / `seek()` / `replayTurn()` no longer throw `NotImplementedError`; they walk the event log via `runtime.events.iterate` and fold through `hydrateFromEvents`. Updated the Status section, the `ReplayHandle` member table (all six members Live), the `step()`/`seek()`/`replayTurn()` behavior section, the determinism contract (one shared reducer → `seek(N)` == N steps == a full fold), the error-hierarchy note on `NotImplementedError`, and the "What's NOT in this package" list (now: no `StrictReplay` diff over the handle, no automatic cache-key derivation from rows). Mirrored the one-line status flip on [`/docs/eval-and-replay`](/docs/eval-and-replay). ### Added [#added-1] * **`<StatusBadge>` reference adoption** — [`/docs/replay`](/docs/replay) adopts `<StatusBadge status="in-flight">Phase A + Phase B</StatusBadge>` under its Status heading as the reference adoption of the new first-party enterprise-shape component. ### Changed [#changed-1] * **[`/docs/replay`](/docs/replay) error hierarchy diagram** — the ASCII tree rendering `ReplayError` / `ReplayDivergenceError` / `ReplayCacheMissError` / `ReplayUnknownEventError` / `NotImplementedError` is now a mermaid `classDiagram` with inheritance arrows and per-class fields. Matches the established mermaid idiom used on [`/docs/overview`](/docs/overview) and elsewhere. * **Cross-docs status-flip pass** — `@pleach/replay`'s `ReplayRuntime` invokes the `@pleach/core/eventLog` hash-chain verifier (`verifyChainForChat` / `generateProof` live in `@pleach/core/eventLog`), with writer-side stamping in `@pleach/core/eventLog` behind `c9PhaseBEnabled`. Touched the [Replay](/docs/replay) and [Hash chain](/docs/hash-chain) pages. ## 2026-06-08 [#2026-06-08] ### Changed [#changed-2] * [Packages](/docs/packages) table corrected to real published version — `@pleach/replay 0.7.0` (was largely stale "Reserved · placeholder" framing). * [@pleach/replay](/docs/replay) Phase B note now distinguishes the landed `replayTurn` body from the still-throwing `fromSnapshot` / `fork` / `multiTenantAggregate`. --- # @pleach/sandbox changelog (/docs/changelog/sandbox) This page collects the **site-content changelog entries** that materially touched the [`@pleach/sandbox`](/docs/sandbox) docs surface or named a `@pleach/sandbox` symbol. It is not the runtime changelog — the canonical record of `@pleach/sandbox` runtime behavior lives in the upstream package [`CHANGELOG.md`](https://github.com/pleachhq/getpleach) and on [npmjs.org/package/@pleach/sandbox](https://www.npmjs.com/package/@pleach/sandbox). See the combined site changelog at [`/docs/changelog/combined`](/docs/changelog/combined) for the chronological unified view across every SKU. ## Unreleased [#unreleased] ### Changed [#changed] * **Four SKU placement pages** — [`/docs/sandbox`](/docs/sandbox) is one of four thin orientation pages added/normalized in this pass: what the SKU is for, where it slots in the substrate, which contract page it implements, and a pointer to the package's npm README as the canonical source for constructor signature and option shape. Authored to retire audit failures for SKUs already on `SKU_PUBLISHED`. The repo's `audit:docs-vs-contract` script now reports `0 missing pages`. * **Sidebar** — [`sandbox`](/docs/sandbox) slots into `Bundled packages` after `base-tools`. * **Per-recipe deep-dive page additions** — [`instrumentedCodingAgent`](/docs/recipes/instrumented-coding-agent) composes `@pleach/coding-agent` + `@pleach/sandbox` + `@pleach/observe`. ### Fixed [#fixed] * **["Where it fits"](/docs/sandbox) orientation corrected to the real contract (devharness FINDING 103).** The page claimed coding-agent tools call a sandbox through `ctx.sandbox.readFile` / `.writeFile` / `.exec` / `.list`. There is no `ctx.sandbox` — core's `ToolContext` is sandbox-agnostic. The low-level `SandboxProvider` contract (`packages/sandbox/src/types.ts`) is `execute` / `readFile` / `writeFile` / `listFiles`, and `@pleach/coding-agent` ships a thin `SandboxClient` facade (`exec` / `readFile` / `writeFile`, **no** `list`; `packages/coding-agent/src/sandbox/SandboxProvider.ts`) that the tool handlers close over. Prose rewritten to that seam. ## 2026-06-08 [#2026-06-08] ### Added [#added] * `@pleach/sandbox` row in [Packages](/docs/packages) (Phase A contract + in-memory fixture, version `0.1.0`). ### Changed [#changed-1] * [Plugin contract → Sibling SKUs as plugins](/docs/plugin-contract#sibling-skus-as-plugins) section retitled and rewritten with per-row real Phase A status — includes `@pleach/sandbox`. --- # @pleach/tools changelog (/docs/changelog/tools) This page collects the **site-content changelog entries** that materially touched the [`@pleach/tools`](/docs/tools) docs surface or named a `@pleach/tools` symbol. It is not the runtime changelog — the canonical record of `@pleach/tools` runtime behavior lives in the upstream package [`CHANGELOG.md`](https://github.com/pleachhq/getpleach) and on [npmjs.org/package/@pleach/tools](https://www.npmjs.com/package/@pleach/tools). See the combined site changelog at [`/docs/changelog/combined`](/docs/changelog/combined) for the chronological unified view across every SKU. ## 2026-06-08 [#2026-06-08] ### Changed [#changed] * [Packages](/docs/packages) table corrected to real published version — `@pleach/tools 1.2.0` (was largely stale "Reserved · placeholder" framing). * [Plugin contract → Sibling SKUs as plugins](/docs/plugin-contract#sibling-skus-as-plugins) section retitled (was "(planned shape; not yet shipping)") and rewritten with per-row real Phase A status — includes `@pleach/tools`. --- # File tools (/docs/coding-agent/file-tools) `@pleach/coding-agent/tools` ships eight tool factories the coding-agent loop drives. Each takes a `SandboxClient` (the 3-method contract from `@pleach/coding-agent/sandbox`) and returns a `PluginToolDefinition` from `@pleach/core` — the canonical `defineTool` shape. The factories do NOT import `@pleach/sandbox` directly. They are vendor-neutral by construction: anything they need flows through the 3-method `SandboxClient`, which any vendor adapter satisfies. ## What ships [#what-ships] ```ts import { createReadFileTool, createWriteFileTool, createApplyDiffTool, createSearchFilesTool, createListFilesTool, createRunTestsTool, createGitCloneTool, createGitDiffTool, } from "@pleach/coding-agent/tools"; ``` Five read-side primitives + three exec-side primitives. Eight total. | Tool | Class | What it does | | -------------- | ----- | --------------------------------------------------------------------------------------- | | `read_file` | read | Read a workspace file. Returns `{ content, bytes }`. | | `write_file` | read | Write a workspace file. Returns `{ bytesWritten }`. | | `apply_diff` | read | Apply a unified diff via POSIX `patch -p0`. Returns `{ hunksApplied, conflicts }`. | | `search_files` | read | Pattern-match across the workspace. Uses `rg` when available, falls back to `grep -rn`. | | `list_files` | read | Enumerate via `find` with `-printf '%y\t%s\t%p\n'`. | | `run_tests` | exec | Run a test command; parse jest / vitest / pytest / cargo / go test output. | | `git_clone` | exec | `git clone --depth 1 <url>` + resolve commit SHA + branch. | | `git_diff` | exec | `git diff` + `--numstat`; returns raw diff text + per-file counts. | The "read-side" labels mean the tool reads from or writes to the sandbox workspace — they're still load-bearing for the agent's behavior. The "exec-side" labels mean the tool drives non-trivial process execution (long-running tests, git operations) and surfaces exit codes / timing back to the LLM. ## Wiring against a sandbox client [#wiring-against-a-sandbox-client] ```ts import { createCodingAgentRuntime } from "@pleach/coding-agent/runtime"; import { createPooledSandboxProvider, createSandboxComposite, } from "@pleach/coding-agent/sandbox"; import { createReadFileTool, createWriteFileTool, createRunTestsTool, createGitCloneTool, } from "@pleach/coding-agent/tools"; import { createHttpStreamSandboxProvider } from "@pleach/sandbox"; const adapter = createHttpStreamSandboxProvider({ baseURL: process.env.MODAL_SANDBOX_BASE_URL!, auth: async () => ({ headers: { Authorization: `Bearer ${process.env.MODAL_TOKEN!}` }, }), }); const provider = createPooledSandboxProvider({ adapter }); const session = await provider.acquire(); const tools = [ createReadFileTool(session.client), createWriteFileTool(session.client), createRunTestsTool(session.client), createGitCloneTool(session.client), // ...four more ]; const runtime = createCodingAgentRuntime({ sandboxProvider: provider, // ...rest of config; runtime registers the tools }); ``` The tools close over `session.client` — when the session changes (a fresh acquire after a release), reconstruct the tools. ## `read_file` [#read_file] ```ts const readFile = createReadFileTool(session.client); ``` | Input | Output | | ------------------------------------------------- | ------------------------------------ | | `{ path: string, encoding?: "utf8" \| "base64" }` | `{ content: string, bytes: number }` | `bytes` is the byte length of the decoded payload — utf-8 byte length for text; raw byte length of the binary payload for base64. The tool computes byte counts locally (`Buffer.byteLength`) so the `SandboxClient` contract doesn't need to surface byte counts (many vendor adapters don't). ## `write_file` [#write_file] ```ts const writeFile = createWriteFileTool(session.client); ``` | Input | Output | | ------------------------------------------------------------------ | -------------------------- | | `{ path: string, content: string, encoding?: "utf8" \| "base64" }` | `{ bytesWritten: number }` | `bytesWritten` is computed locally via `Buffer.byteLength` *before* the sandbox call — the public output shape stays stable even when the vendor adapter returns `void`. ## `apply_diff` [#apply_diff] ```ts const applyDiff = createApplyDiffTool(session.client); ``` | Input | Output | | -------------------------------- | ----------------------------------------------- | | `{ path: string, diff: string }` | `{ hunksApplied: number, conflicts: string[] }` | Wire protocol: writes the diff to `.pleach-apply-diff.patch` in the workspace, then runs `patch -p0 <path> < .pleach-apply-diff.patch`. `hunksApplied` is parsed from `patch` stdout (`Hunk #N succeeded` lines); falls back to counting `@@ ... @@` headers in the input diff when `patch` applied silently. `conflicts` is the array of `Hunk #N FAILED` lines from stdout + stderr. Non-empty `conflicts` indicates a partial-success — the LLM gets to decide whether to retry with a fresh diff or surface the rejects. ## `search_files` [#search_files] ```ts const searchFiles = createSearchFilesTool(session.client); ``` | Input | Output | | --------------------------------------------------------- | -------------------------------- | | `{ pattern: string, glob?: string, maxMatches?: number }` | `Array<{ path, line, snippet }>` | Probes for `rg` (`command -v rg`) and uses it when available; falls back to `grep -rn`. Both produce the canonical `path:line:content` format the parser consumes. `maxMatches` defaults to `100` to keep the LLM-visible payload tractable. Raise it when the model needs a wider sweep, but expect context pressure to climb proportionally. The shell pipeline uses `| head -n N` to cap upstream — the parser doesn't have to. Pattern is treated as a regex; escape metacharacters for literal matching. ## `list_files` [#list_files] ```ts const listFiles = createListFilesTool(session.client); ``` | Input | Output | | ----------------------------------------------------- | ----------------------------------------------- | | `{ dir: string, recursive?: boolean, glob?: string }` | `Array<{ path, kind: "file" \| "dir", size? }>` | Invokes `find <dir> [-maxdepth 1] [-name <glob>] -printf '%y\t%s\t%p\n'`. `recursive: false` (default) limits to immediate children via `-maxdepth 1`. `glob` matches the basename only — consumers wanting full-path glob semantics should post-filter the result. Symlinks and devices are dropped from the result so the LLM sees a clean two-kind axis (`file` / `dir`). ## `run_tests` [#run_tests] ```ts const runTests = createRunTestsTool(session.client); ``` | Input | Output | | ------------------------------------------------------ | ---------------------------------------------------------------------- | | `{ command?: string, cwd?: string, timeout?: number }` | `{ exitCode, stdout, stderr, durationMs, passed?, failed?, skipped? }` | Default command is `npm test`. The output parser is a best-effort heuristic across five runners: * **jest / vitest** — `Tests: N failed, M skipped, K passed, T total` * **pytest** — `===== 3 passed, 1 failed, 2 skipped in 0.05s =====` * **cargo test** — `test result: ok. 5 passed; 0 failed; 1 ignored;` * **go test verbose** — counts `--- PASS:` / `--- FAIL:` / `--- SKIP:` lines The parser deliberately refuses to invent counts when no signature matched. `passed` / `failed` / `skipped` are OMITTED rather than reported as zero — by design. Downstream code should treat `undefined` as "no signal" and not "no tests". ```ts import { parseTestRunnerOutput } from "@pleach/coding-agent/tools"; // Exposed for callers who want to parse output without driving the tool. const counts = parseTestRunnerOutput(stdout + "\n" + stderr); ``` ## `git_clone` [#git_clone] ```ts const gitClone = createGitCloneTool(session.client); ``` | Input | Output | | ----------------------------------------------------------------- | ----------------------------- | | `{ url: string, dest?: string, depth?: number, branch?: string }` | `{ commitSha, branch, path }` | Defaults: destination = repo basename (strips `.git` suffix and trailing slash); no `--depth` cap unless supplied. Clones, then runs `git rev-parse HEAD` and `git rev-parse --abbrev-ref HEAD` in the cloned directory to surface the resolved SHA and branch. The tool throws on any non-zero exit code — `git_clone` is a setup step; the LLM sees an exception, not a `{ exitCode: 1, ... }` row. ## `git_diff` [#git_diff] ```ts const gitDiff = createGitDiffTool(session.client); ``` | Input | Output | | ----------------------------------------------------- | ---------------------------------------------------------------- | | `{ cwd: string, paths?: string[], staged?: boolean }` | `{ diff: string, files: Array<{ path, additions, deletions }> }` | Runs `git diff [--staged] [--numstat] [-- <paths>]` twice in parallel — once for `--numstat` to get the per-file counts, once for the raw diff text. Binary files surface as `additions: -1, deletions: -1` (git emits `-` instead of numeric counts for binaries; the tool preserves the signal as a negative sentinel rather than dropping the file). ```ts import { parseNumstat } from "@pleach/coding-agent/tools"; // Exposed for callers who want to parse `git diff --numstat` output // without driving the tool. const files = parseNumstat(numstatStdout); ``` ## Output-shape stability [#output-shape-stability] All eight tools return validated outputs through Zod schemas ([defineTool](/docs/tools)). Schema drift between an LLM-emitted output and the declared `outputSchema` is caught at the tool boundary — the runtime throws before downstream code sees the malformed shape. When you wire your own variant of one of these tools (a custom `read_file` with rate limiting, for instance), use the published schema as the floor — match the input/output shapes and the LLM's existing tool-use grammar continues to apply. ## Where to go next [#where-to-go-next] <Cards> <Card title="Sandbox providers" href="/docs/coding-agent/sandbox-providers" description="The provider + composite layer above SandboxClient." /> <Card title="defineTool" href="/docs/tools" description="The canonical @pleach/core tool shape these factories return." /> <Card title="Long session context" href="/docs/coding-agent/long-session" description="Roadmap — file-context budget management across long sessions." /> <Card title="SWE-Bench recipe" href="/docs/coding-agent/swe-bench-recipe" description="Composing these tools into a SWE-Bench Lite evaluation." /> </Cards> --- # Long session context (/docs/coding-agent/long-session) <Callout type="info"> **Status: strategy factories shipped; runtime manager roadmap.** Both strategy factories — `createLruContextStrategy` and `createImportanceContextStrategy` from `@pleach/coding-agent/context` — ship today, alongside the locked `ContextStrategy` contract on `CodingAgentRuntimeConfig` (`packages/coding-agent/src/types.ts`). What is NOT shipped yet is the runtime-side `CodingContextManager` that tracks entries and calls `strategy.evict()` after each tool turn — that remains conditional on an open scoping decision (whether the budget consumer lives in `@pleach/coding-agent` or in `@pleach/core/runtime`). Consumers who bring their own context manager can inject a shipped strategy today. </Callout> Long coding sessions exhaust the model's context window long before they exhaust their workspace. A 200K-token context window holds \~50 typical source files; a non-trivial coding task touches more than that. The model needs a policy for *which files to evict* from its working set when the budget is tight — not at the runtime layer (which would defeat replay determinism) but at a layer the runtime delegates to. `ContextStrategy` is that contract. `CodingContextManager` is the runtime-side consumer that calls the strategy with the current state and a budget. ## The contract [#the-contract] ```ts import type { ContextStrategy, ContextEntry, ContextBudget, } from "@pleach/coding-agent"; export interface ContextStrategy { evict( state: readonly ContextEntry[], budget: ContextBudget, ): readonly string[]; } export interface ContextEntry { readonly id: string; // stable id, typically `${path}:${revisionHash}` readonly path: string; // file path or other resource identifier readonly lastAccessedAt: number; // epoch ms — LRU-load-bearing readonly sizeBytes: number; readonly accessCount?: number; // optional frequency — importance scoring; absent → recency×size proxy } export interface ContextBudget { readonly maxBytes: number; readonly maxEntries?: number; // strategies MAY ignore } ``` The strategy returns the array of entry ids to evict, in eviction order. Strategies MUST be pure: same `state` + `budget` returns the same eviction list. Side-effects belong outside the strategy. This is the same shape pattern as the `EvictionPolicy` contract in [`@pleach/core/cache`](/docs/cache) — `(state, budget) → string[]` — which keeps the strategy substitutable across the runtime and the cache layers. ## Strategies [#strategies] Two pure `ContextStrategy` factories ship today from `@pleach/coding-agent/context`. ### `lru` — recency-only [#lru--recency-only] ```ts import { createLruContextStrategy } from "@pleach/coding-agent/context"; const strategy = createLruContextStrategy({ maxEntries: 100 }); ``` Sorts `state` by `lastAccessedAt` ascending; evicts the oldest entries until the remaining set's total `sizeBytes` fits under `budget.maxBytes`. The default strategy. Coding agents that work file-by-file fit this shape well — a file the agent hasn't touched in N tool calls is probably not needed for the next N tool calls either. ### `importance` — weighted by access frequency × recency [#importance--weighted-by-access-frequency--recency] ```ts import { createImportanceContextStrategy } from "@pleach/coding-agent/context"; const strategy = createImportanceContextStrategy({ maxEntries: 100 }); ``` Scores each entry as `frequency × recency ÷ size`, where `frequency` is the optional `ContextEntry.accessCount`, `recency` is `lastAccessedAt` normalized across the current `state`, and `size` is `sizeBytes`. Lowest-importance entries evict first. When `accessCount` is absent the score degrades to a recency × size proxy, so the strategy stays usable against the un-augmented `ContextEntry` shape. Pairs with project-shaped workflows where the agent re-visits a few "hub" files (a router, a config, a schema) repeatedly across the session. Pure recency would evict them between visits; importance keeps them warm. ## Wiring (planned) [#wiring-planned] ```ts import { createCodingAgentRuntime } from "@pleach/coding-agent/runtime"; import { createLruContextStrategy } from "@pleach/coding-agent/context"; const runtime = createCodingAgentRuntime({ sandboxProvider, contextStrategy: createLruContextStrategy({ maxEntries: 100 }), // ...rest of config }); ``` Omit `contextStrategy` and the runtime ships `createLruContextStrategy({ maxEntries: 100 })` by default (the literal "v1.0 default" in the [`CodingAgentRuntimeConfig` contract](/docs/coding-agent/multi-synthesize)). ## Honest scope-limit [#honest-scope-limit] What IS shipped today: the `ContextStrategy`, `ContextEntry`, and `ContextBudget` types AND both strategy factories (`createLruContextStrategy`, `createImportanceContextStrategy` from `@pleach/coding-agent/context`) — the contract is locked, the field is on `CodingAgentRuntimeConfig`, and the factories are pure functions over the `(state, budget) → string[]` contract. What is NOT shipped today: the runtime-side `CodingContextManager` that tracks entries and calls `strategy.evict(state, budget)` after each tool turn. Until it lands, the factories are injectable into a context manager you bring yourself; the runtime only smoke-invokes `evict` once at boot. Consumers can author against the full contract today and expect it to work without breaking changes when the runtime ships the manager. ## Why this is conditional [#why-this-is-conditional] The open question is layer placement. Two candidate homes: 1. **`@pleach/coding-agent/context`** — keeps the strategy with the surface that needs it. Adds a `ContextStrategy` field to the coding-agent runtime config and nowhere else. 2. **`@pleach/core/runtime` budget primitive** — promotes the strategy contract one layer down, so observability / agents that are NOT coding-shaped (chat with a large file-attach surface, research-agent with a fanout corpus) can share the same eviction primitive. The (2) shape is more general but adds a contract surface to `@pleach/core` that hosts who don't want budget management still pay type-check cost for. The (1) shape ships the value where the demand is. Resolution is tracked in an open scoping decision. ## What strategies CAN'T do [#what-strategies-cant-do] The strategy is a pure function of `(state, budget) → string[]`. It cannot: * **Re-fetch evicted content.** When the agent asks for a file it previously evicted, the runtime re-reads from the sandbox via `read_file`. The strategy doesn't cache; the workspace IS the cache. * **Mutate the entry shape.** The strategy returns ids to evict — not modifications to the remaining entries. * **Block on async work.** Strategies are synchronous. They run on the post-tool-turn boundary; making them async would mean blocking the next LLM turn behind their resolution. These constraints are load-bearing for replay determinism — a strategy that re-fetched on the side would make the recorded event log not re-derivable from the recorded inputs. ## Where to go next [#where-to-go-next] <Cards> <Card title="Multi-synthesize per turn" href="/docs/coding-agent/multi-synthesize" description="The per-turn synthesize cap that pairs with the context budget." /> <Card title="Cache" href="/docs/cache" description="The EvictionPolicy contract this strategy shape mirrors." /> <Card title="File tools" href="/docs/coding-agent/file-tools" description="The tools whose outputs land in the context budget." /> <Card title="Determinism" href="/docs/determinism" description="Why strategies must be pure." /> </Cards> --- # Multi-synthesize per turn (/docs/coding-agent/multi-synthesize) <Callout type="warn"> **Status: shipped — partial.** The contract widening landed in `@pleach/core`, and the consumer-facing policy + facade landed in `@pleach/coding-agent`. Whether a given host honors `maxSynthesizePerTurn` depends on its `TurnSynthesizeCounter`: a counter that implements `setMaxPerTurn` gets the multi-synthesize behavior end-to-end; a counter that doesn't stays at the D-50 cap of one synthesize per turn and ignores the field. </Callout> Canonical chat enforces one synthesize-class seam invocation per turn ([D-50](/docs/decisions)). Coding agents don't fit that mold: a `plan → diff → verify` flow typically issues three synthesize calls inside a single turn, and a `plan → diff → verify → retry → verify` flow issues five. `maxSynthesizePerTurn` is the per-runtime knob that opts a runtime into multi-synthesize. It preserves the D-50 invariant by construction — the same singleton-seam contract is still in effect; the counter just allows more invocations against it. ## The contract [#the-contract] ```ts import type { SessionRuntimeConfig } from "@pleach/core"; interface SessionRuntimeConfig { // ...existing fields readonly maxSynthesizePerTurn?: number; // default 1 (D-50 baseline) } interface TurnSynthesizeCounterContract { shouldAllowSynthesize(messageId: string): boolean; // ...existing surface setMaxPerTurn?(max: number): void; // optional widening } ``` The field lives at the runtime-config layer ONLY. There is no per-call override — multi-synth opt-in is per-runtime by design. Per-call overrides would re-introduce the failure mode D-50 was carved to prevent: silent counter resets mid-turn from a callsite that didn't know it was inside a budget-bounded loop. `setMaxPerTurn` on the counter is intentionally OPTIONAL. Host runtimes opt in by implementing the method; runtimes that don't implement it keep the D-50 baseline of one-per-turn regardless of what the config field says. The optional shape is the back-compat carve-out — adding the field doesn't force every existing counter implementation to be re-built. | Value | Behavior | Use case | | ---------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | | `1` | One synthesize per turn — D-50 default. | Canonical chat. | | `5` | Up to five synthesizes per turn. Soft cap matching the `plan → diff → verify → retry → verify` pattern. | Coding agent default. | | `Infinity` | No per-turn cap. The counter still emits events; only the rejection branch is disabled. | Research / SWE-Bench-style runs where convergence is the only stopping condition. | ## The coding-agent policy [#the-coding-agent-policy] `@pleach/coding-agent` ships an opinionated default that sets `maxSynthesizePerTurn` to 5 — the canonical reflection-loop cap. ```ts import { createCodingAgentPolicy } from "@pleach/coding-agent/policy"; const config = createCodingAgentPolicy({ maxSynthesizePerTurn: 5, }).apply({ // ...rest of SessionRuntimeConfig }); // `config.maxSynthesizePerTurn === 5` — picked up by a setMaxPerTurn- // capable TurnSynthesizeCounter at runtime boot. ``` The factory returns a `SessionRuntimeConfigPolicy` — a `.apply(base)` shape that composes cleanly with sibling policies. `createCodingAgentPolicy({})` without arguments still applies the coding-agent default of 5. ### Layered per-CallClass routing [#layered-per-callclass-routing] `@pleach/coding-agent` also ships a sibling policy for per-CallClass model overrides that layers on top of `createCodingAgentPolicy`: ```ts import { createCodingAgentPolicy, createPerCallClassRouting, } from "@pleach/coding-agent/policy"; const base = createCodingAgentPolicy({ maxSynthesizePerTurn: 5 }).apply({}); const routed = createPerCallClassRouting({ routes: { synthesize: "anthropic/claude-sonnet-4-5", utility: "anthropic/claude-haiku-4-5", }, }).apply(base); ``` The routing map is recorded structurally on the config; a follow-up core slice will widen `SessionRuntimeConfig.perCallClassModelRouting` to the canonical shape and `RoutingDecisionEngine` will consume it. Forward-compatible today; honored end-to-end once the core widening ships. ## Why per-runtime only [#why-per-runtime-only] The counter is owned by the `TurnSynthesizeCounter` impl, which is constructed once at `SessionRuntime` boot and passed into `SynthesizeSeamHolder.init({...})`, which the synthesizer node consumes through the holder. The tool-loop's `createLlmDecisionNode` is `utility`-class and consumes a separate seam, so it never touches this counter — the singleton is per call class (D-72). A per-call override would mean: 1. The first call inside a turn sets `max: 1`. 2. A nested sub-agent call sets `max: 5`. 3. The counter's idempotency-on-`messageId` invariant (D-37) becomes ambiguous — which `max` wins? Per-runtime opt-in keeps the resolution flat: the counter knows its cap at boot, the cap doesn't change for the runtime's lifetime, and the per-`messageId` idempotency contract stays simple. ## Host support is conditional [#host-support-is-conditional] The field is honored only by a `TurnSynthesizeCounter` that implements the optional `setMaxPerTurn` method: * A `createCodingAgentPolicy({ maxSynthesizePerTurn: 5 })` configuration always produces a config object with the field set. * At `SessionRuntime` boot the field is read — but a counter that doesn't expose `setMaxPerTurn` silently ignores the value and stays at the D-50 baseline of one synthesize per turn. * A host that supplies a counter implementing `setMaxPerTurn` sees the multi-synthesize behavior end-to-end. The host wiring is small — one method on `TurnSynthesizeCounter` plus a constructor read of `config.maxSynthesizePerTurn`. ## What stays invariant [#what-stays-invariant] * **One seam per runtime.** `SynthesizeSeamHolder` continues to serve a single `ProviderSeam<"synthesize">` per `SessionRuntime`. The cap governs *how many times* the seam is invoked per turn, not how many seams exist. * **Idempotency on `messageId`.** The counter remains idempotent on `messageId` regardless of the cap value (D-37). A duplicate-delivery retry of the same `messageId` does not consume a budget slot. * **D-72 (singleton per call class).** The synthesizer node — and the recovery path — are the only consumers of the synthesize seam; the tool-loop decision is `utility`-class on its own seam. Multi-synth raises the per-turn invoke cap but doesn't change which node class reaches the seam. * **Family-strict cascade pivot.** When a synthesize call exhausts its in-family rungs, the cascade still emits `[UXParity:family-exhausted]` and surfaces the user-facing family-pick affordance. Multi-synth doesn't silently widen cross-family. ## What changes downstream [#what-changes-downstream] * **Event log.** Each synthesize invocation surfaces a distinct `synthesize.invoked` projection row. The audit ledger gets one row per call; the `messageId` field disambiguates calls within a turn. * **Cost attribution.** [`@pleach/observe`](/docs/observe)'s per-call cost attribution captures each synthesize independently — a 5-call turn surfaces as 5 rows tagged with the same `chatId` and `messageId`. * **Replay.** [`@pleach/replay`](/docs/replay) re-derives the full N-call sequence deterministically. The replayed turn's tool-call count and synthesize-call count both match the recorded turn. ## Related decisions [#related-decisions] | ID | Locked? | Summary | | ---- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | D-50 | Yes | Singleton synthesize seam per `SessionRuntime`. | | D-37 | Yes | `TurnSynthesizeCounter` idempotent on `messageId`. | | D-72 | Yes | Singleton is per call class — only the synthesizer (and recovery) consume the synthesize seam; the tool-loop decision is utility-class on its own seam. | ## Where to go next [#where-to-go-next] <Cards> <Card title="File tools" href="/docs/coding-agent/file-tools" description="The 8 file/diff/exec tools the multi-synth loop drives." /> <Card title="Long session context" href="/docs/coding-agent/long-session" description="Roadmap — the context-budget knob that pairs with multi-synth." /> <Card title="Sandbox providers" href="/docs/coding-agent/sandbox-providers" description="The provider + composite surface multi-synth flows reach for." /> <Card title="Call classes" href="/docs/call-classes" description="The synthesize / reasoning / utility / converse axis multi-synth lives on." /> </Cards> --- # Sandbox providers (/docs/coding-agent/sandbox-providers) `@pleach/coding-agent/sandbox` ships a small facade — `SandboxProvider` — that sits ABOVE the three endpoint-shape adapters in `@pleach/sandbox` (`httpStream` / `httpPoll` / `childProcess`). The adapters know how to talk to a vendor; the facade decides when to acquire, when to reuse a pooled session, and what high-level value the coding-agent loop holds across a turn. The shape this page documents is the layer ABOVE the `SandboxAdapter` contract in `@pleach/core` (see [Sandbox](/docs/sandbox) for the lower layer) — not a replacement. ## What you get [#what-you-get] Two factories. Both vendor-neutral; vendor coupling lives in the underlying `@pleach/sandbox` adapter. ```ts import { createPooledSandboxProvider, createSandboxComposite, } from "@pleach/coding-agent/sandbox"; ``` | Factory | What it returns | When you reach for it | | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | `createPooledSandboxProvider({ adapter, poolSize? })` | `SandboxProvider` with `acquire()` / `release()` | Once per runtime. The provider holds a bounded pool of warm sessions. | | `createSandboxComposite({ session })` | `SandboxComposite` with `install` / `upload` / `download` / `diff` / `env` / `gitClone` / `benchmark` | Once per acquired session, inside the tool loop. | The provider is the long-lived value. The composite is the per-session value. The coding-agent runtime holds the provider for its lifetime and constructs a composite each time the agent's loop enters a turn. ## The three endpoint shapes (below) [#the-three-endpoint-shapes-below] The provider's `adapter` field is one of the three endpoint-shape adapters from `@pleach/sandbox`: | Adapter | Wire shape | Where vendors compose in | | ----------------------------------- | ---------------------------------------------------------- | ---------------------------------------------- | | `createHttpStreamSandboxProvider` | REST + `text/event-stream` for `/execute/stream` | Modal, Vercel Sandbox, E2B (streaming variant) | | `createHttpPollSandboxProvider` | REST; `executeStream` buffers and emits one terminal chunk | Daytona, Azure Container Instances | | `createChildProcessSandboxProvider` | `child_process.spawn` + `node:fs/promises` | Local dev, in-CI sandboxing | Plus a first-class AWS Fargate provider (`createFargateSandboxProvider`) that owns the ECS task lifecycle and delegates the in-container surface to the canonical HTTP + SSE shape. Vendor wiring is one-page cookbook recipes — see `packages/sandbox/ADAPTERS.md` for full configurations covering Modal, E2B, Daytona, Vercel Sandbox, AWS Fargate, Azure Container Instances, and local Docker. Each entry shows the auth callback, path overrides, and capability hints for the vendor. ## Pooled provider [#pooled-provider] ```ts import { createPooledSandboxProvider } from "@pleach/coding-agent/sandbox"; import { createHttpStreamSandboxProvider } from "@pleach/sandbox"; const adapter = createHttpStreamSandboxProvider({ baseURL: process.env.MODAL_SANDBOX_BASE_URL!, auth: async () => ({ headers: { Authorization: `Bearer ${process.env.MODAL_TOKEN!}` }, }), }); export const provider = createPooledSandboxProvider({ adapter, poolSize: 1, // default — per-runtime singleton }); ``` `poolSize` defaults to `1`, which matches the chat-app baseline (one session warm at a time). Raise it for parallel-verify swarms — when each sub-agent in a swarm needs its own isolated workspace, set `poolSize: 4` (or however many sub-agents you fan out to). ### Acquire and release [#acquire-and-release] ```ts const session = await provider.acquire({ workspaceDir: "/workspace", runtime: { language: "node" }, }); try { await session.client.exec("npm install"); // ...do work... } finally { await provider.release(session); } ``` The session is a `{ id, client, runtime, handle }` value. The coding-agent tool factories ([file tools](/docs/coding-agent/file-tools)) consume `session.client` — the 3-method `SandboxClient` contract. `session.handle` is the underlying `@pleach/sandbox` handle, opaque for the typical case; reach for it only when you need the wider 8-method `@pleach/sandbox` `SandboxProvider` contract (`listFiles` / `isAlive` / `capabilities` / `executeStream`). ### Idempotency on `sessionId` [#idempotency-on-sessionid] ```ts const a = await provider.acquire({ sessionId: "chat-42" }); const b = await provider.acquire({ sessionId: "chat-42" }); // a === b — pool short-circuits on the caller-supplied id ``` When the caller supplies a `sessionId` that's already pooled, the provider returns the existing session whether idle or in-use. This is the load-bearing primitive for re-attaching to a session across a restart — pair it with a persistent `chatId` → `sandboxSessionId` mapping at the host layer. ## The composite tool surface [#the-composite-tool-surface] `SandboxComposite` is the layer the coding-agent loop holds for the session's lifetime. Seven composite ops, each a thin shell over `session.client.exec` + `readFile` + `writeFile`. ```ts import { createSandboxComposite } from "@pleach/coding-agent/sandbox"; const composite = createSandboxComposite({ session }); ``` | Op | Returns | Notes | | ------------------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `install(packages, opts?)` | `SandboxExecResult` | Probes for lockfile (`pnpm` / `yarn` / `bun` / `npm` / `poetry` / `uv` / `pip`) when `manager: "auto"`; falls back to `npm`. | | `upload(local, remote, opts?)` | `void` | Reads `local` via `node:fs/promises` (host) and writes via `session.client.writeFile`. | | `download(remote, local, opts?)` | `void` | Symmetric — reads from sandbox, writes to host. | | `diff(pathA, pathB, opts?)` | `{ unifiedDiff, differs }` | Shells out to `diff -u`; `differs` is `true` when exit code is `1`. | | `env.get(name)` / `env.set(name, value)` / `env.list()` | `string \| undefined` / `string` / `Record<string, string>` | `set` writes to `.pleach-env`; consumer sources it before subsequent execs. | | `gitClone(url, opts?)` | `SandboxExecResult` | Defaults: `--depth 1`, destination = repo basename. | | `benchmark(command, opts?)` | `BenchmarkResult` | N iterations (default 3); returns durations, mean, min, max, per-iteration exit codes. | ### `install` — package-manager probe [#install--package-manager-probe] The `auto` mode runs one probe exec, looking for lockfiles in order: `pnpm-lock.yaml`, `yarn.lock`, `bun.lockb`, `package-lock.json`, `poetry.lock`, `uv.lock`, `requirements.txt`. First hit wins; no match falls through to `npm`. Pin a manager to skip the probe: ```ts await composite.install(["zod", "valibot"], { manager: "pnpm", dev: true, }); ``` ### `env.set` and the `.pleach-env` file [#envset-and-the-pleach-env-file] The composite does NOT mutate session-wide env in-band — most sandbox transports don't expose a "persist env" wire op. Instead, `env.set` appends to a `.pleach-env` file at the workspace root; subsequent execs source it explicitly: ```ts await composite.env.set("FOO", "bar"); await session.client.exec("set -a; . .pleach-env; set +a && my-tool"); ``` `env.list` runs `printenv` against the live session — its result reflects what the sandbox sees right now, not what `.pleach-env` contains. Treat the two as orthogonal. ### `benchmark` for cost-aware loops [#benchmark-for-cost-aware-loops] ```ts const result = await composite.benchmark("pnpm vitest run", { iterations: 5, timeoutMs: 60_000, }); console.log(result.meanMs, result.minMs, result.maxMs); console.log(result.exitCodes); // any non-zero indicates a failed iteration ``` The composite does NOT warm up the workspace between iterations — callers handle that out of band. Pair with [eval and replay](/docs/eval-and-replay) to capture per-iteration durations into the audit ledger. ## Wiring into a coding-agent runtime [#wiring-into-a-coding-agent-runtime] ```ts import { createCodingAgentRuntime } from "@pleach/coding-agent/runtime"; import { createPooledSandboxProvider } from "@pleach/coding-agent/sandbox"; import { createHttpStreamSandboxProvider } from "@pleach/sandbox"; const adapter = createHttpStreamSandboxProvider({ baseURL: process.env.MODAL_SANDBOX_BASE_URL!, auth: async () => ({ headers: { Authorization: `Bearer ${process.env.MODAL_TOKEN!}` }, }), }); const provider = createPooledSandboxProvider({ adapter, poolSize: 1 }); const runtime = createCodingAgentRuntime({ sandboxProvider: provider, // ...rest of SessionRuntimeConfig }); await runtime.start({ sessionLabel: "chat-42" }); ``` The runtime owns the provider's lifetime — `runtime.stop()` invokes the provider's `dispose` / `destroy` / `shutdown` if present (duck-type dispatch). ## What's vendor-neutral, what isn't [#whats-vendor-neutral-what-isnt] The provider, the composite, and the tool factories are vendor-neutral by construction. The only vendor-coupling point is the `adapter` you pass to `createPooledSandboxProvider`. Swap the adapter — keep everything else. The composite does NOT branch on `session.handle.provider`. Vendor optimizations (a vendor's bulk-upload endpoint, a vendor's faster exec shape) belong on the underlying adapter, not in the composite. This keeps the surface stable as vendors come and go. ## Where to go next [#where-to-go-next] <Cards> <Card title="File tools" href="/docs/coding-agent/file-tools" description="The 8 file/diff/exec tools that consume SandboxClient." /> <Card title="Sandbox" href="/docs/sandbox" description="The lower-layer SandboxAdapter contract in @pleach/core." /> <Card title="Multi-synthesize per turn" href="/docs/coding-agent/multi-synthesize" description="Roadmap — the per-runtime maxSynthesizePerTurn knob." /> <Card title="SWE-Bench recipe" href="/docs/coding-agent/swe-bench-recipe" description="EvalLab + SWE-Bench Lite + DivergenceReporter composition." /> </Cards> --- # SWE-Bench Lite recipe (/docs/coding-agent/swe-bench-recipe) [SWE-Bench](https://www.swebench.com/) is a benchmark by Jimenez et al. (2023) that grades coding agents against real GitHub issues from popular Python repositories. **SWE-Bench Lite** is a 300-instance subset selected for tractable single-file edits. `@pleach/eval/benchmarks/swe-bench` ships a 10-case starter sample as inline TypeScript constants — enough to scaffold the pipeline against a model-swap matrix and prove the wiring works. The bundled scorer is a placeholder; real grading needs a Docker harness OR [`@pleach/sandbox`](/docs/sandbox)-driven code execution with a judge. This page walks both. ## Honest scope [#honest-scope] <Callout type="warn"> The bundled scorer returns `score: 0` with an explanation prompting you to wire a real harness. SWE-Bench grading requires applying a model-generated patch to the repo, running the repo's test suite, and comparing test outcomes to the "before patch" baseline. None of that is shipped in `@pleach/eval` — by design, the SKU is Docker-free and vendor-neutral. You wire the harness. The bundled sample is for pipeline scaffolding, not a SWE-Bench Lite official run. </Callout> Cite the upstream dataset when reporting results; do not claim the starter sample is a SWE-Bench Lite official run. ## Load the sample [#load-the-sample] ```ts import { loadSweBenchLite } from "@pleach/eval/benchmarks/swe-bench"; const cases = await loadSweBenchLite({ maxCases: 5 }); console.log(cases.length); // 5 console.log(cases[0].id); // "swebench-lite/django__django-11099" ``` Each case is an `EvalCase` of kind `"scored"`, with `input` containing: | Field | Shape | Example | | ------------------ | ---------------------- | ------------------------------------------------ | | `repo` | `string` | `"django/django"` | | `issueNumber` | `number` | `11099` | | `problemStatement` | `string` (paraphrased) | `"UsernameValidator allows trailing newline..."` | | `hints` | `string` (paraphrased) | `"Look at django/contrib/auth/validators.py..."` | | `source` | `string` | `"SWE-Bench Lite — Jimenez et al. 2023 (MIT)"` | The bundled cases reference real upstream repos + issue numbers; the `problemStatement` and `hints` fields are paraphrased to keep the bundle small. The 10 cases cover Django, SymPy, scikit-learn, matplotlib, pytest, requests, and Flask. ## Compose with `evalLab` [#compose-with-evallab] The `evalLab` recipe from `@pleach/recipes/eval-lab` wires a runtime * eval suite + (optional) replay client trio for research reproducibility. SWE-Bench fits the shape: ```ts import { evalLab } from "@pleach/recipes/eval-lab"; import { loadSweBenchLite } from "@pleach/eval/benchmarks/swe-bench"; import { EvalSuite } from "@pleach/eval"; const lab = evalLab({ suiteId: "swe-bench-lite-claude-sonnet-4-6", orchestratorConfig: { provider: "anthropic", model: "claude-sonnet-4-6", apiKey: process.env.ANTHROPIC_API_KEY!, }, evalSuiteFactory: ({ suiteId, runtime }) => new EvalSuite({ suiteId, runtime }), }); const cases = await loadSweBenchLite({ maxCases: 10 }); for (const c of cases) lab.addCase(c); const report = await lab.run(); console.log(`${report.summary.passed}/${report.summary.total}`); ``` The lab returns immediately; the suite is constructed eagerly so the consumer can add cases before the first `run()`. `evalSuiteFactory` avoids a hard build-time peer-dep on the suite class — `@pleach/eval` is an OPTIONAL peer of `@pleach/recipes`. ## Run across a model matrix [#run-across-a-model-matrix] `runMatrixBatch` from `@pleach/eval` swaps the model across N variants and runs the same cases against each, surfacing per-variant per-case outcomes: ```ts import { runMatrixBatch, createModelSwapSubjects } from "@pleach/eval"; import { loadSweBenchLite } from "@pleach/eval/benchmarks/swe-bench"; const cases = await loadSweBenchLite({ maxCases: 10 }); const subjects = createModelSwapSubjects({ models: ["claude-sonnet-4-6", "claude-opus-4-7", "claude-haiku-4-6"], buildSubject: (model) => ({ id: model, run: async (scenario) => runCaseAgainstModel(model, scenario), }), }); const matrixReport = await runMatrixBatch({ subjects, scenarios, scorers, }); ``` The matrix report carries one `RunCaseDivergence` row per `(subject, case)` pair. Three subjects × ten cases = thirty rows. ## Surface divergence with `DivergenceReporter` [#surface-divergence-with-divergencereporter] `createDivergenceReporter` projects the matrix report into a human-readable summary + per-case breakdown + heatmap-ready data: ```ts import { createDivergenceReporter } from "@pleach/eval"; const reporter = createDivergenceReporter({ runs }); console.log(reporter.summary()); // { // subjectCount: 3, // caseCount: 10, // agreementRate: 0.6, // 6 of 10 cases all 3 subjects agreed // divergenceRate: 0.4, // } for (const row of reporter.perCase()) { if (row.divergences.length > 0) { console.log(row.caseId, row.divergences); } } // For a downstream chart: const heatmap = reporter.export("markdown"); ``` The reporter is pure — given the same matrix report, it returns the same summary and per-case rows. Pair with [`@pleach/replay`](/docs/replay) to re-derive the matrix report from a captured event log months later without re-running the LLM. ## Wiring a real scorer [#wiring-a-real-scorer] The bundled placeholder scorer returns `score: 0`. To grade SWE-Bench Lite properly: ### Option A — Docker harness (canonical) [#option-a--docker-harness-canonical] Apply the model-generated patch to a checkout of the target repo at `baseCommit`, run the repo's test suite inside a Docker container, diff the test outcomes against the pre-patch baseline. Pass-to-fail transitions on the issue's target tests = success. This is what the upstream [SWE-Bench evaluation harness](https://github.com/princeton-nlp/SWE-bench) does. The harness is a separate concern from `@pleach/eval` and is not bundled. ```ts import { loadSweBenchLite } from "@pleach/eval/benchmarks/swe-bench"; const cases = await loadSweBenchLite(); const cases_with_real_scorer = cases.map((c) => ({ ...c, scorer: { kind: "custom", fn: async (actualPatch, ctx) => { // `EvalCase.input` is typed `string | Record<string, unknown>`; // narrow to the SWE-Bench row shape before reading fields. const input = c.input as { repo: string; issueNumber: number }; // Apply patch + run tests in your Docker harness. const result = await yourDockerHarness.grade({ repo: input.repo, issueNumber: input.issueNumber, patch: actualPatch, }); return { score: result.passToFail ? 1 : 0, explanation: result.summary, }; }, }, })); ``` ### Option B — `@pleach/sandbox`-driven judge [#option-b--pleachsandbox-driven-judge] For a lighter-weight option, drive the test run through a sandbox provider — the same provider your coding agent uses. Apply the patch via `apply_diff`, run the suite via `run_tests`, judge the outcome: ```ts import { createSandboxComposite } from "@pleach/coding-agent/sandbox"; import { createApplyDiffTool, createRunTestsTool } from "@pleach/coding-agent/tools"; const session = await provider.acquire(); const composite = createSandboxComposite({ session }); const cases_with_sandbox_scorer = cases.map((c) => ({ ...c, scorer: { kind: "custom", fn: async (actualPatch, ctx) => { // `EvalCase.input` is typed `string | Record<string, unknown>`; // narrow to the SWE-Bench row shape before reading fields. const input = c.input as { repo: string; baseCommit: string }; // 1. Clone repo at baseCommit. await composite.gitClone(`https://github.com/${input.repo}.git`, { ref: input.baseCommit, }); // 2. Apply the model's patch via apply_diff tool. const apply = await applyDiff.execute( { path: ".", diff: actualPatch }, { toolCallId: "swe-bench-grade" }, ); if (apply.conflicts.length > 0) { return { score: 0, explanation: `patch conflicts: ${apply.conflicts.join("; ")}` }; } // 3. Run tests via run_tests tool. const tests = await runTests.execute( { command: "pytest -xvs", cwd: input.repo.split("/")[1] }, { toolCallId: "swe-bench-grade" }, ); return { score: tests.failed === 0 && (tests.passed ?? 0) > 0 ? 1 : 0, explanation: `exit ${tests.exitCode}; passed ${tests.passed} failed ${tests.failed}`, }; }, }, })); ``` This is faster to set up than option A (no Docker daemon required) but less faithful to the upstream SWE-Bench evaluation methodology — in particular, network access, dependency installation, and test isolation are vendor-dependent in the sandbox case. ## What `@pleach/eval` does NOT ship [#what-pleacheval-does-not-ship] * A Docker harness. * The pre-computed `test_patch` + `baseCommit` + `expected_pass_to_fail` metadata that the official SWE-Bench dataset carries. The bundled sample paraphrases the `problemStatement`; full upstream metadata is left to the consumer to fetch from [`princeton-nlp/SWE-bench_Lite`](https://huggingface.co/datasets/princeton-nlp/SWE-bench_Lite). * A full 300-case Lite corpus. The 10-case starter is for pipeline scaffolding; production runs should load the full corpus from upstream. ## Where to go next [#where-to-go-next] <Cards> <Card title="Eval lab recipe" href="/docs/recipes/eval-lab" description="The full evalLab recipe — runtime + suite + replay trio." /> <Card title="Eval" href="/docs/eval" description="EvalSuite, runMatrixBatch, scorers, and the report shapes." /> <Card title="Replay" href="/docs/replay" description="Deterministic re-derivation from the captured event log." /> <Card title="File tools" href="/docs/coding-agent/file-tools" description="apply_diff and run_tests — the load-bearing tools for the sandbox judge." /> </Cards> --- # Compliance · GDPR right-to-erasure (/docs/compliance/gdpr-erasure) GDPR Article 17 ("right to be forgotten") obligates the controller to erase personal data on a qualifying request. Append-only hash chains — the substrate that gives `harness_event_log` cryptographic tamper-evidence — are **fundamentally at odds** with row-level deletion. This page explains the tension, the two production approaches, and the structural primitive `@pleach/compliance` ships for one of them. Shipped in an earlier release. ## The fundamental tension [#the-fundamental-tension] A hash chain's integrity depends on the **exact bytes** of every row participating in the next row's `row_hash`. Erase one row in the middle of the chain and every downstream row's `prev_hash` no longer matches the prior row's `row_hash` — the chain breaks for the entire suffix. A naive `DELETE` on a PII row, then, is structurally indistinguishable from substrate tamper. Any honest GDPR-compliant deployment of a hash chain has to choose between two reconciliation strategies, neither of which is free. Procurement conversations should NOT skip this distinction. ## Approach (a) — Crypto-shredding (recommended for production) [#approach-a--crypto-shredding-recommended-for-production] PII fields are stored ENCRYPTED at write time with a per-subject key (typically AES-GCM with a per-subject KEK in a managed KMS). Erasure deletes the per-subject key — the encrypted blob remains in place, the row hash is unchanged, the chain stays valid, and the data is cryptographically inaccessible. **Pros:** Chain integrity preserved indefinitely; pre-erasure attestations continue to verify; matches the deployment model auditors expect for highly regulated workloads. **Cons:** Requires upstream KEK management infrastructure (`@aws-sdk/client-kms`, GCP KMS, Azure Key Vault) and per-field envelope encryption discipline at every write site. Higher onboarding cost. This SKU does **not** ship crypto-shredding today. AEAD field encryption + OOB key shred lives in a future release or in `@pleach/compliance-pii` once the AEAD envelope contract is locked. ## Approach (b) — Row deletion + chain rebuild (this slice's primitive) [#approach-b--row-deletion--chain-rebuild-this-slices-primitive] PII rows are physically deleted; downstream rows are re-anchored against a fresh genesis seed. The rebuilt chain has a DIFFERENT genesis root than the pre-erasure chain. **Pros:** No upstream KEK infrastructure required; works against any event store with a `DELETE` capability. **Cons:** Pre-erasure attestations no longer verify against the post-erasure chain. **This is a feature, not a bug** — the genesis-root change IS the cryptographic proof of erasure. A verifier comparing pre-erasure and post-erasure roots sees the divergence and concludes "erasure happened here." Operator MUST accept this trade-off explicitly. Any deployment that needs both Article 17 + immutable pre-erasure verifiable attestations should prefer crypto-shredding (approach (a)). ## `executeErasureRequest` — the structural primitive [#executeerasurerequest--the-structural-primitive] ```ts import { executeErasureRequest } from "@pleach/compliance/hashchain"; const result = await executeErasureRequest({ request: { tenantId: "tenant-acme", subjectId: "user@example.com", scope: "all", reason: "DSAR-2026-06-15 Art 17(1)(a)", operator: "support-engineer@example.com", }, eventStore: { async eraseRows(filter) { return await pgEraseRowsForSubject(pool, filter); }, }, rebuildChainFromGenesis: async (tenantId, chatId) => { return await rebuildChainInPostgres(pool, tenantId, chatId); }, chatIds: ["chat-1", "chat-2"], // optional; defaults to [subjectId] }); console.log(result); // { // rowsErased: 142, // chainValidAfter: true, // attestation: { // timestamp: "2026-06-15T10:42:01.123Z", // reason: "DSAR-2026-06-15 Art 17(1)(a)", // operator: "support-engineer@example.com", // }, // } ``` ### `ErasureScope` [#erasurescope] | Scope | Coverage | | ------------ | ------------------------------------------------------------------------------------------------------------- | | `"messages"` | Every row tied to user-authored messages (`content.delta`, `content.finalized` payloads carrying user prose). | | `"calls"` | Every row tied to LLM call traces (`llm.turn`, `tool.invoked`). | | `"all"` | Every event-log row for the subject within the tenant. | The mapping from scope to a concrete filter is the **adapter's responsibility**. `ErasureEventStoreLike.eraseRows` receives the scope along with `tenantId` + `subjectId` and decides which `event_type`s participate. This keeps `@pleach/compliance` vendor-neutral — the package has no opinion about your event-type taxonomy. ### Injected callbacks — why the package stays vendor-neutral [#injected-callbacks--why-the-package-stays-vendor-neutral] Both `eventStore.eraseRows` and `rebuildChainFromGenesis` are consumer-supplied structural callbacks. The actual erasure is a database operation (Postgres `DELETE`, Supabase RLS-scoped soft-delete, or per-row tombstone); the chain rebuild is a substrate-specific recompute that walks every downstream row and re-canonicalizes it. Neither is portable across substrates, so neither belongs in the package. The structural primitive sequences the two operations, aggregates per-chat rebuild verdicts into the final `chainValidAfter` flag, and seals an attestation footer. ### Soft-fail vs throw [#soft-fail-vs-throw] * **Adapter error on `eraseRows`** — soft-failed. The helper returns `rowsErased: 0`, `chainValidAfter: false`, and an attestation tagged `<erasure-failed-soft>`. Chain integrity breaks are the only throw case. * **Rebuild callback throws** — soft-failed per chat. The helper sets `chainValidAfter: false` and continues to the next chat. * **`ChainVerificationError` from either callback** — re-thrown. Chain integrity breaks are hard failures by design. ## What the attestation proves [#what-the-attestation-proves] ```ts interface ErasureAttestation { readonly timestamp: string; // ISO-8601 seal time readonly reason: string; // operator-supplied audit reason readonly operator?: string; // operator id } ``` The attestation is the audit footer the operator presents downstream: "erasure executed at `timestamp`, scoped to `request.scope`, under lawful basis `request.reason`, by `operator`." Append-only — the helper does not retain the attestation; it is the operator's responsibility to persist it in the compliance ledger. The attestation is INTENTIONALLY minimal. It does NOT carry the new chain root or the count of rows erased — those are returned alongside in `ErasureResult` and persisted separately by the operator. A future slice may extend the attestation footer with a chain-root pointer; for now the operator wires the cross-reference. ## End-to-end example [#end-to-end-example] ```ts import { Pool } from "pg"; import { executeErasureRequest } from "@pleach/compliance/hashchain"; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); async function handleDsarRequest( tenantId: string, subjectEmail: string, caseId: string, operatorEmail: string, ) { // 1. Resolve which chats the subject participated in. Adapter-level // decision — typically a join against a per-tenant subject->chat // table. const chatIds = await resolveSubjectChats(pool, tenantId, subjectEmail); // 2. Execute the erasure + chain rebuild. const result = await executeErasureRequest({ request: { tenantId, subjectId: subjectEmail, scope: "all", reason: `${caseId} Art 17(1)(a)`, operator: operatorEmail, }, eventStore: { eraseRows: async (filter) => pgEraseSubjectRows(pool, filter), }, rebuildChainFromGenesis: async (t, c) => pgRebuildChainFromGenesis(pool, t, c), chatIds, }); // 3. Persist the attestation footer in the compliance ledger. await persistAttestation(pool, { caseId, erasureResult: result, }); return result; } ``` ## Cited source [#cited-source] * `packages/compliance/src/hashchain/erasure.ts` — `executeErasureRequest` factory. * `packages/compliance/src/hashchain/index.ts` — barrel re-export. * `packages/compliance/test/gdprErasure.smoke.test.mjs` — surface regression-lock. ## Where to go next [#where-to-go-next] <Cards> <Card title="HashChainMiddleware" href="/docs/compliance/hashchain-middleware" description="The companion helper that decorates each event with chain anchors before persistence." /> <Card title="Hash chain" href="/docs/hash-chain" description="Substrate-level walkthrough of @pleach/core's chain primitives." /> <Card title="Compliance" href="/docs/compliance" description="The full @pleach/compliance surface — redaction, scrubbers, audit query, PDF export." /> <Card title="Regulated domain agent" href="/docs/regulated-domain-agent" description="End-to-end recipe composing compliance + hash chain + erasure." /> </Cards> --- # Compliance · HashChainMiddleware (/docs/compliance/hashchain-middleware) `HashChainMiddleware` is the consumer-facing wire-up between the host's `EventLogWriter` substrate and the hash-chain primitives that live in `@pleach/core`'s `eventLog/hashChain.ts`. It tracks the prior `rowHash` per `(tenantId, chatId)` scope so each new event can stamp `prevHash` against the genuine prior anchor without the host plumbing hash state through its write path manually. Shipped in an earlier release. ## The factory [#the-factory] ```ts import { createHashChainMiddleware } from "@pleach/compliance/hashchain"; const middleware = createHashChainMiddleware({ complianceRuntime, tenantId: "tenant-acme", chatId: "chat-2026-06-15", }); ``` The middleware is **single-scope** — one `(tenantId, chatId)` pair per instance. Hosts that multiplex across chats construct one middleware per chat (construction is one closure capture; trivial cost). This single-scope shape keeps the chain anchor unambiguous: the closure-held `priorRowHash` slot only ever advances along one chain. ## `beforeWrite` — decorate each event [#beforewrite--decorate-each-event] ```ts const decorated = await middleware.beforeWrite(event); // decorated.rowHash + decorated.prevHash now stamped await writer.write(decorated); ``` `beforeWrite` is called BEFORE the host's `EventLogWriter` canonicalizes * persists. It decorates each `EventLogInput` with `rowHash` + `prevHash` slots. The first call per scope uses the genesis seed (`computeMiddlewareGenesisSeed(tenantId, chatId)`) as `prevHash`; every subsequent call carries the prior row's `rowHash`. ### Honest scope-limit — middleware vs writer canonicalization [#honest-scope-limit--middleware-vs-writer-canonicalization] The middleware's `rowHash` covers ONLY its local view: the event's `type` + a sorted-keys canonicalization of `payload`. This is INTENTIONALLY narrower than `@pleach/core`'s `canonicalizeRowForChain` walker, which covers the full 18-field `CanonicalRowFields` shape the writer persists. The middleware operates BEFORE persistence — substrate-controlled fields (`sequence_number`, `tenant_id`, server-assigned timestamps) are not yet assigned, so the middleware cannot reproduce the deep canonical form. The pre-persistence anchor here gives the writer a deterministic `prevHash` to thread into the row; the deep post-persistence canonical `rowHash` is computed by the writer at storage time. In practice: both anchors verify the chain. The middleware-stamped anchor catches tamper at the BEFORE-write window; the writer's deeper anchor catches tamper at the AFTER-write window. A two-tier verifier checks both. ## `verifyAfterBatch` — substrate integrity [#verifyafterbatch--substrate-integrity] ```ts const result = await middleware.verifyAfterBatch(rows); if (!result.chainValid) { // failedIndex is the row offset where divergence was detected console.error("chain divergence at index", result.failedIndex); } ``` The verifier delegates to `complianceRuntime.verifyChain({ chatId })`. Returns `{ chainValid: boolean; failedIndex?: number }`. ### Soft-fail vs throw [#soft-fail-vs-throw] The policy is explicit: * **Adapter / transport errors** — soft-failed. A transient blip in the substrate's verifier (network reset, Postgres pool exhausted, Supabase rate limit) returns `{ chainValid: true }` with no throw. The middleware MUST NOT cause a compliance gate to fail because the verifier was temporarily unreachable. * **Substrate-detected integrity break** — throws `ChainVerificationError`. When the verifier returns `{ valid: false, mismatchAtSequenceNumber: N }` with `N >= 0`, that's a real tamper signal. The error carries the failing sequence number + the chat scope. The runtime-error sentinel (`mismatchAtSequenceNumber: -1`) is soft-failed — that's the substrate's own internal-error escape hatch. ### Local pre-walk [#local-pre-walk] Before delegating to the runtime, `verifyAfterBatch` performs a local chain walk over the supplied batch — checking that each row's `prev_hash` matches the prior row's `row_hash`. This catches tamper at the row anchors themselves without round-tripping through the substrate. A mismatch throws `ChainVerificationError` with `reason: "prev_hash_mismatch"`. ## Configuring the runtime [#configuring-the-runtime] `HashChainMiddleware` requires a `ComplianceRuntime` — typed against `@pleach/compliance-contract` (the zero-dep cycle-break contract sub-SKU). Construct the runtime separately: ```ts import { createComplianceRuntime } from "@pleach/compliance"; import { createHashChainMiddleware } from "@pleach/compliance/hashchain"; const complianceRuntime = createComplianceRuntime({ tenantId: "tenant-acme", }); const middleware = createHashChainMiddleware({ complianceRuntime, tenantId, chatId, framework: "gdpr", // ComplianceProfile — diagnostic context }); ``` The optional `framework` field on `HashChainMiddlewareConfig` is diagnostic context — the middleware does not call `attestRun` itself (that's an explicit host decision). ## End-to-end example [#end-to-end-example] ```ts import { Pool } from "pg"; import { createComplianceRuntime } from "@pleach/compliance"; import { createHashChainMiddleware } from "@pleach/compliance/hashchain"; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const complianceRuntime = createComplianceRuntime({ tenantId: "tenant-acme", }); async function logTurn(tenantId: string, chatId: string, events: EventLogInput[]) { const middleware = createHashChainMiddleware({ complianceRuntime, tenantId, chatId, }); // Stamp each event with chain anchors before persistence. const stamped = []; for (const event of events) { stamped.push(await middleware.beforeWrite(event)); } // Host writer persists the stamped events. The writer canonicalizes // its own deeper rowHash at storage time; the middleware-stamped // anchor stays on the row as a second-tier verification anchor. await writer.writeBatch(stamped); // After the batch lands, verify chain integrity. const rows = await readRowsForChat(pool, tenantId, chatId); const { chainValid, failedIndex } = await middleware.verifyAfterBatch(rows); if (!chainValid) { throw new Error(`chain integrity break at index ${failedIndex}`); } } ``` ## Cited source [#cited-source] * `packages/compliance/src/hashchain/HashChainMiddleware.ts` — factory + contract. * `packages/compliance/src/hashchain/index.ts` — barrel re-export. * `packages/compliance/test/hashChainMiddleware.smoke.test.mjs` — surface regression-lock. * `packages/core/src/eventLog/hashChain.ts` — substrate primitives (`chainStep`, `computeGenesisSeed`, `verifyChainForChat`). * `packages/compliance-contract/src/index.ts` — `ComplianceRuntime` interface (cycle-break contract). ## Where to go next [#where-to-go-next] <Cards> <Card title="GDPR right-to-erasure" href="/docs/compliance/gdpr-erasure" description="The companion helper for Article 17 erasure requests against an append-only hash chain." /> <Card title="Hash chain" href="/docs/hash-chain" description="Substrate-level walkthrough of @pleach/core's chain primitives." /> <Card title="Compliance" href="/docs/compliance" description="The full @pleach/compliance surface — redaction, scrubbers, audit query, PDF export." /> <Card title="Audit ledger" href="/docs/audit-ledger" description="What the chain protects — the event-log ledger that the writer persists." /> </Cards> --- # Eval · Parity (/docs/eval/parity) `@pleach/eval/parity` ships two parity primitives that answer **orthogonal questions** about output stability: | Question | Primitive | | ------------------------------------------------------- | ----------------------------------- | | "Is my swarm fan-out deterministic across iterations?" | [`runSwarmParity`](#runswarmparity) | | "Does swapping the model materially change the output?" | [`runParitySuite`](#runparitysuite) | Both share `editDistance` from `packages/eval/src/divergence/distance.ts` as the underlying string comparison primitive, and both lean on the same divergence aggregator shape. The split exists because the **shape of the input** is genuinely different: `runSwarmParity` consumes `SwarmRunResult[]` (N iterations of one configuration), while `runParitySuite` consumes `ParitySuiteRun[]` (M configurations on K scenarios). ## `runSwarmParity` [#runswarmparity] The within-swarm stability runner. Given a swarm runner that produces `SwarmRunResult` from a scenario, `runSwarmParity` invokes the runner `iterations` times per scenario (default 3) and reports per-scenario divergence + outlier session IDs. ```ts import { runSwarmParity } from "@pleach/eval/parity"; const report = await runSwarmParity({ runner: { runScenario: async (scenario) => { // Spin up a swarm fan-out from the scenario; collect sub-agent // outputs in BFS order. return await mySwarmEntryPoint(scenario); }, }, scenarios: [scenarioA, scenarioB, scenarioC], iterations: 3, // default }); console.log(report.scenarios[0].divergence.outlierSessionIds); ``` ### Iteration semantics [#iteration-semantics] Two iterations is the practical minimum (anything less leaves "outlier" undefined); three is the default to surface a stable majority vs a single outlier. Consumers may pass higher `iterations` for high-variance workloads at proportional cost. ### Output shape [#output-shape] ```ts interface SwarmParityReport { readonly scenarioCount: number; readonly iterations: number; readonly scenarios: ReadonlyArray<{ readonly scenarioLabel: string; readonly divergence: SwarmDivergenceResult; // .outlierSessionIds lives here // ... }>; readonly meanPairwiseDistance: number; readonly outlierSessionIds: ReadonlyArray<string>; // union across scenarios } ``` `computeSwarmDivergence` reduces N iterations into pairwise distance + the outlier-session-id list. Outliers are flagged at a 1.5× threshold against the cohort mean — the structural minimum for a 3-config single-outlier cohort. ## `runParitySuite` [#runparitysuite] The across-configuration divergence runner. Given M `ParitySuiteRun[]` (each with its own `id` and `runner(scenario)`) and a list of scenarios, `runParitySuite` runs every configuration over every scenario and emits a scenario-keyed report. ```ts import { runParitySuite } from "@pleach/eval/parity"; const report = await runParitySuite({ runs: [ { id: "claude-opus", runner: scenarioRunnerOpus }, { id: "gpt-5", runner: scenarioRunnerGpt }, { id: "gemini-2", runner: scenarioRunnerGemini }, ], scenarios: [scenarioA, scenarioB, scenarioC], scenarioLabels: ["math-word-problem", "code-refactor", "summarization"], tolerance: { distance: 50 }, // characters of editDistance per scenario }); console.log(report.divergentScenarioIds); // ["code-refactor"] — the scenarios that exceeded tolerance ``` ### Tolerance semantics [#tolerance-semantics] `tolerance.distance` sets the maximum `editDistance` between any two configurations' outputs for one scenario before that scenario is flagged as divergent. Default: `0` (strict — any character-level difference flags). Per-scenario `ScenarioParity.divergent` is `true` when **any** pairwise distance exceeds the threshold. `report.divergentScenarioIds` is the union of flagged scenario IDs — the top-level summary the consumer typically pipes into a CI gate. ### Output shape [#output-shape-1] ```ts interface ParityReport { readonly runCount: number; readonly scenarioCount: number; readonly scenarios: ReadonlyArray<ScenarioParity>; readonly meanDistance: number; // cohort mean readonly maxDistance: number; // cohort worst pair readonly divergentScenarioIds: ReadonlyArray<string>; } interface ScenarioParity { readonly scenarioId: string; readonly outputs: ReadonlyArray<{ runId: string; output: string }>; readonly pairwise: ReadonlyArray<DivergencePair>; readonly maxDistance: number; readonly meanDistance: number; readonly divergent: boolean; } ``` ### Outlier detection — the 1.3× threshold [#outlier-detection--the-13-threshold] The cohort outlier set (the configurations whose mean per-scenario distance to peers exceeds a multiplier of the cohort mean) is exposed via the shared `computeDivergence` aggregator and surfaced as `outlierRunIds`. The multiplier is **1.3×** on the non-swarm runner. The brief on choosing 1.3× rather than the swarm runner's 1.5×: a 3-config single-outlier cohort with `editDistance` distribution `[10, 10, 30]` has a cohort mean of `(10 + 10 + 30) / 3 ≈ 16.67`; the outlier's per-peer mean is `30`, and `30 / 16.67 ≈ 1.8`. A 1.5× multiplier is the structural minimum to flag this cohort's outlier; 1.3× is the more sensitive setting for configuration-level parity, where a single character-level divergence already matters more than swarm-level row-level noise. ## Sequential dispatch by design [#sequential-dispatch-by-design] Both runners dispatch sequentially within and across iterations. Consumers own concurrency — wrap `runScenario` / `runner` in `Promise.all` if you want parallel fan-out, but be aware that: * **Deterministic test ordering** is preserved by sequential dispatch. * **Rate-limit budget** is easier to predict — N iterations × M scenarios maps directly to N × M provider calls. * **Replay safety** is straightforward — the dispatch order matches the recorded event-log order. Consumers who need parallel dispatch typically wrap the runner with their own concurrency adapter and accept that the report's ordering-sensitive fields become substrate-dependent. ## Cited source [#cited-source] * `packages/eval/src/parity/runSwarmParity.ts` — within-swarm runner. * `packages/eval/src/parity/runParitySuite.ts` — across-config runner. * `packages/eval/src/parity/swarmDivergenceMetrics.ts` — `computeSwarmDivergence` aggregator. * `packages/eval/src/parity/divergenceMetrics.ts` — `computeDivergence` aggregator. * `packages/eval/src/divergence/distance.ts` — shared `editDistance` primitive. * `packages/eval/src/parity/index.ts` — barrel re-export. ## Where to go next [#where-to-go-next] <Cards> <Card title="Eval" href="/docs/eval" description="The full @pleach/eval surface — divergence reporting, benchmark loaders, cost estimator." /> <Card title="Subagents" href="/docs/subagents" description="The canonical spawn substrate that runSwarmParity consumes." /> <Card title="Swarm agent" href="/docs/swarm-agent" description="End-to-end recipe for swarm fan-out with deterministic baseline + outlier review." /> <Card title="Replay" href="/docs/replay" description="StrictReplay pairs naturally with parity reports — replay the flagged scenarios with cache adapters." /> </Cards> --- # Gateway · BYOK credential routing (/docs/gateway/byok) A tenant supplies their own provider key for one or more model families, and the gateway routes that family's calls through the tenant's key (BYOK transport) rather than a platform-owned key. This page covers the storage substrate — where the key lives between calls — and the middleware that resolves it at route time. The single-file `byok.ts` utility at `@pleach/gateway/byok` ships the header-only [fingerprint helper](/docs/gateway#byok-is-header-only). The directory at `@pleach/gateway/byok/*` (this page) ships the storage * routing substrate. The two coexist on distinct subpaths. ## `TenantCredentialStore` [#tenantcredentialstore] The storage contract. Adapters implement it against a concrete backing store; the routing middleware reads from `get()` on every `route()` call. ```typescript import type { ProviderFamily } from "@pleach/gateway" interface TenantCredential { readonly family: ProviderFamily readonly apiKey: string readonly baseURL?: string readonly metadata?: Record<string, unknown> } interface TenantCredentialStore { get( tenantId: string, family: ProviderFamily, ): Promise<TenantCredential | null> put( tenantId: string, family: ProviderFamily, credential: TenantCredential, ): Promise<void> list(tenantId: string): Promise<TenantCredential[]> } ``` One tenant may have multiple entries — one per provider family. The primary key is `(tenantId, family)`. `apiKey` is opaque to the store; transport-level validation happens at the routing layer. `get()` returns `null` (not throws) when a credential is missing. The middleware treats `null` as "use platform fallback" by default; operators who want strict-BYOK-required semantics configure `onMissing: "throw"` (see below). ## Reference adapters [#reference-adapters] Three reference adapters ship under separate subpaths so consumers only pay the optional-peer-dependency cost when they actually wire one in. Each is structurally agnostic — no `pg` / `ioredis` / vendor SDK import at module-eval time. ### Postgres [#postgres] ```typescript import { Pool } from "pg" import { createPostgresCredentialStore } from "@pleach/gateway/byok/adapters/postgres" const pool = new Pool({ connectionString: process.env.DATABASE_URL }) const store = createPostgresCredentialStore({ pool }) ``` Schema (canonical — see `MIGRATIONS.md` in the source tree for the authoritative SQL): ```sql CREATE TABLE pleach_byok_credentials ( tenant_id TEXT NOT NULL, family TEXT NOT NULL, api_key BYTEA NOT NULL, base_url TEXT, metadata JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (tenant_id, family) ); CREATE INDEX pleach_byok_credentials_tenant_idx ON pleach_byok_credentials (tenant_id); ``` **Honest scope.** Encryption at rest is the host's responsibility. The adapter writes and reads `api_key` as plain bytes via parameterized queries; pair with a column-encryption extension (pgcrypto's `pgp_sym_encrypt` / Supabase Vault / RDS TDE) for compliance deployments. The factory does NOT apply encryption itself, which keeps the adapter dependency-free and lets hosts pick their own KMS integration. **`tableName` is validated at construction.** The adapter rejects anything that doesn't match `/^[A-Za-z_][A-Za-z0-9_]*$/`, throwing a `TypeError` from the factory before the first query runs. Construction fails fast so SQL injection through `tableName` is not reachable: ```typescript createPostgresCredentialStore({ pool, tableName: "drop; --", }) // → TypeError: invalid tableName "drop; --" // (must match /^[A-Za-z_][A-Za-z0-9_]*$/) ``` Source: `packages/gateway/src/byok/adapters/postgres.ts`, `assertSafeIdentifier`. The adapter is uncached by default. Hosts SHOULD wrap with a short-TTL in-memory cache (`lru-cache`, 60s TTL) at the middleware boundary if route-time latency matters. ### Redis [#redis] ```typescript import Redis from "ioredis" import { createRedisCredentialStore } from "@pleach/gateway/byok/adapters/redis" const client = new Redis(process.env.REDIS_URL) const store = createRedisCredentialStore({ client, keyPrefix: "pleach:byok:prod", }) ``` Auto-detects between `ioredis` and `node-redis` v4+ — the duck-typed client surface (`get` / `set` / `del` / `sadd` / `srem` / `smembers`) is identical at the lowest-common-denominator level. Key shape: ``` <prefix>:<tenantId>:<family> → JSON-encoded TenantCredential <prefix>:<tenantId>:__families → SET of families registered ``` The `__families` set is the load-bearing detail. Redis `KEYS pattern` is O(N) on the whole keyspace and **refused outright on managed Redis** (Upstash, ElastiCache cluster mode). Maintaining a per-tenant set means `list()` runs O(F) where F = number of families registered for the tenant — capped at 6 today (the six locked provider families). One `SMEMBERS` + N `GET`s, not a keyspace scan. `list()` heals the set lazily: if a credential key is missing while its family is still in `__families` (a delete that didn't `srem`), the adapter silently `srem`s the orphan and continues. Encryption at rest is the host's responsibility — pair with Redis TLS * ACL + an at-rest encrypted volume. The adapter writes plain JSON; do NOT operate this against an unencrypted Redis on a shared network. ### External secret manager [#external-secret-manager] Vendor-neutral facade for AWS Secrets Manager / HashiCorp Vault / GCP Secret Manager / Azure Key Vault / Doppler / 1Password Connect. The adapter delegates all storage operations to host-supplied callbacks; no SDK import here. Hosts pick their cloud SDK, wrap it, and pass the resolver. ```typescript import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager" import { createExternalSecretManagerStore } from "@pleach/gateway/byok/adapters/external-secret-manager" const sm = new SecretsManagerClient({ region: "us-east-1" }) const store = createExternalSecretManagerStore({ resolver: async (tenantId, family) => { const SecretId = `pleach/byok/${tenantId}/${family}` try { const { SecretString } = await sm.send(new GetSecretValueCommand({ SecretId })) return SecretString ? JSON.parse(SecretString) : null } catch (err: any) { if (err.name === "ResourceNotFoundException") return null throw err } }, }) ``` **`put` / `list` are optional.** Most external secret managers are managed out-of-band (Terraform, console UI, IaC pipelines). When the corresponding callback is omitted, calling `put()` / `list()` throws `ExternalSecretManagerNotImplementedError` rather than silently no-op'ing — operators see the failure at the admin-surface boundary. **Caching is the resolver's responsibility.** The adapter calls `resolver()` on every `get()`. Wrap with `lru-cache` if the vendor charges per-API-call (AWS Secrets Manager: $0.05 per 10k calls). ## `CredentialRoutingMiddleware` [#credentialroutingmiddleware] The wire-up. The middleware sits between the gateway's `route()` call and the underlying transport invocation. For each call it: 1. Looks up the tenant's credential for the resolved family via `store.get(tenantId, family)`. 2. If found, returns `{ apiKey, baseURL, metadata, byokActive: true }` for the routing layer to attach as headers. 3. If not found, falls back to the platform key and returns `byokActive: false`. ```typescript import { createCredentialRoutingMiddleware } from "@pleach/gateway" import { createPostgresCredentialStore } from "@pleach/gateway/byok/adapters/postgres" const store = createPostgresCredentialStore({ pool }) const resolve = createCredentialRoutingMiddleware({ store, fallbackKey: process.env.PLATFORM_ANTHROPIC_KEY, onMissing: "fallback", }) const { apiKey, baseURL, byokActive } = await resolve("tenant-acme", "anthropic") ``` ### `onMissing` policy [#onmissing-policy] ```typescript type OnMissingPolicy = "fallback" | "throw" ``` * `"fallback"` (default) — use the platform `fallbackKey`. Permissive; BYOK is opportunistic. * `"throw"` — throw `CredentialMissingError`. Strict; tenants without a registered credential cannot route. Use this for compliance-attestation workflows where every call MUST be attributable to a tenant-owned key. When `fallbackKey` is omitted from the config, the effective policy is always `"throw"` regardless of the `onMissing` setting — the middleware fails closed by construction: ```typescript const resolve = createCredentialRoutingMiddleware({ store }) // No fallback. Tenants without a registered credential will hit // CredentialMissingError every time. ``` ### `CredentialMissingError` [#credentialmissingerror] ```typescript class CredentialMissingError extends Error { readonly tenantId: string readonly family: ProviderFamily } ``` Caught at the `GatewayClient` layer and surfaced to the host as a structured rejection. Operators MUST handle this in the tenant-onboarding flow — typical UX is "you have not yet registered a key for this provider". ## Where governance lives [#where-governance-lives] The middleware is intentionally policy-free. Governance gates (`allowedFamilies` allowlist, plan-tier caps, per-tenant rate limits) live at the `GatewayClient` layer and run BEFORE this middleware. By the time `resolveCredential()` is called the family is already authorized; this layer's only job is "where does the key come from for this `(tenant, family)` pair". ## Security posture [#security-posture] * Stored credentials MUST be encrypted at rest. Postgres column encryption / Redis TLS+ACL+at-rest encryption / external secret manager. Adapter factory docs surface the expectation; the gateway does not enforce it. * Adapters MUST NOT log raw `apiKey` values. Logging the fingerprint (16-char sha256 prefix from `fingerprintByokKey`) is permitted; logging the plain key is a security incident. * The store interface returns the raw key for routing-time consumption only. Callers (the routing middleware) are responsible for header-only handling per the [BYOK header-only contract](/docs/gateway#byok-is-header-only). ## Cited source [#cited-source] * `packages/gateway/src/byok/TenantCredentialStore.ts` — contract. * `packages/gateway/src/byok/CredentialRoutingMiddleware.ts` — middleware factory. * `packages/gateway/src/byok/adapters/postgres.ts` — Postgres adapter. * `packages/gateway/src/byok/adapters/redis.ts` — Redis adapter. * `packages/gateway/src/byok/adapters/external-secret-manager.ts` — external secret manager facade. * `packages/gateway/test/byokSurface.smoke.test.mjs` — surface regression-lock (round-trip get/put/list + factory rejection cases). ## Where to go next [#where-to-go-next] <Cards> <Card title="Cost events" href="/docs/gateway/cost-events" description="Per-call cost emission with BYOK attribution — byokActive + byokKeyHash flow through the CostEvent." /> <Card title="Migration from `@pleach/core`" href="/docs/gateway/migration-from-core" description="When to introduce the gateway and how the BYOK middleware wires into an existing createPleachRuntime setup." /> <Card title="`@pleach/gateway` overview" href="/docs/gateway" description="The Phase A GatewayClient — route(), cost events, family-strict cascade, transport seam." /> </Cards> --- # Gateway · Cost events (/docs/gateway/cost-events) Every successful gateway call emits one `CostEvent` to the configured destination. The cadence is per-call, not batched: accuracy and reproducibility for compliance attestation outweigh the batching savings. A destination that wants its own batching implements it internally — and the four reference adapters that follow do exactly that. ## `CostEmitter` contract [#costemitter-contract] ```typescript interface CostEmitter { emit(event: CostEvent): Promise<void> | void flush?(): Promise<void> } ``` `CostEmitter` widens the existing `CostEventSink` primitive used by the Phase A `GatewayClient` — it adds an *optional* `flush()` for buffered adapters. Every adapter is structurally a `CostEventSink` too: code that holds a `CostEmitter` can pass it anywhere a `CostEventSink` is expected. **Implementations MUST:** * Never throw synchronously from `emit()` — failures inside an adapter MUST NOT propagate up the gateway call. Buffer + log, or swallow with an internal counter. * Tolerate being called from the host's async context. The middleware does not wrap the call in `try`/`catch`; adapters own their own failure surface. **Implementations MAY:** * Return a `Promise` from `emit()` to await a remote write. * Return `void` from `emit()` if they buffer internally and flush asynchronously (Postgres pool, Honeycomb batch). * Implement `flush()` if they buffer. Callers wire `flush()` into their shutdown hook to drain pending events before exit. ## `CostMiddleware` factory [#costmiddleware-factory] The wire-up. Takes a `CostEmitter` plus a `computeCost` mapper and returns a middleware function callable after each route response. `computeCost` is generic over the observation shape you pass it. The output of `runtime.routeChatCompletion(input)` carries only `{ providerInvoked, modelInvoked, content, usage, costUsd, routingDecisionId }` — it does **not** carry `tenantId`, `family`, `callClass`, `byokActive`, or the full `routingDecision`. Those come from the request `input` plus your own routing context, so merge them onto the observation at the call site and read them in `computeCost`: ```typescript import { createCostMiddleware, createOtelCostEmitter } from "@pleach/gateway/cost" import { asTenantId } from "@pleach/gateway" import type { RouteChatCompletionInput, RouteChatCompletionOutput, } from "@pleach/gateway" // The merged observation: route output + the fields the host supplies. type CostObservation = RouteChatCompletionOutput & { tenantId: string family: RouteChatCompletionInput["family"] callClass: "synthesize" | "reasoning" | "converse" | "utility" byokActive: boolean } const middleware = createCostMiddleware<CostObservation>({ emitter: createOtelCostEmitter({ meter }), computeCost: (route, response) => ({ type: "domain.gateway.cost.recorded", tenantId: asTenantId(response.tenantId), // non-empty: sourced from input.tenantId family: response.family, callClass: response.callClass, modelInvoked: response.modelInvoked, costUsd: response.costUsd, promptTokens: response.usage.promptTokens, completionTokens: response.usage.completionTokens, byokActive: response.byokActive, routingDecision: { family: response.family, call_class: response.callClass, selected_model: response.modelInvoked, transport: "openrouter", byok_active: response.byokActive, upgrade_path_invoked: false, fallback_chain: [], raw_provider_cost_usd: response.costUsd, markup_pct: 0, }, timestamp: new Date().toISOString(), }), }) const output = await runtime.routeChatCompletion(input) void middleware({ route: "chat.completion", response: { ...output, tenantId: input.tenantId, family: input.family, callClass: "converse", // host routing context — not on the route output byokActive: false, // host routing context — not on the route output }, }) ``` `routeChatCompletion` is the Pack-221 slice-2 stub today: it validates input and identifies the target provider but returns `costUsd: 0` and a stub `content` until the real transport lands. Wire the cost path now so it's ready; the cost numbers go live with the transport. The `route` field is a free-form string identifier — typically `"chat.completion"`, `"embedding"`, or a tenant-scoped slug. The middleware does not interpret it; it forwards verbatim to `computeCost`. **`computeCost` MAY return `null`** to skip emission for a given observation (e.g. cached responses where no provider cost was incurred). Returning `null` is structurally identical to a buffered no-op; do not throw to skip — throw indicates a real error. **`onError` is optional but recommended.** Default behavior when `computeCost` throws OR `emitter.emit` rejects is to swallow — gateway calls MUST NOT fail because the cost destination is unavailable. Set `onError` to forward to your observability layer. The factory returns `middleware & { flush }`, so `middleware.flush()` on shutdown drains buffered adapters before exit. ## Reference adapters [#reference-adapters] Four adapters cover the common cost destinations. All are structurally duck-typed against the host's existing client (`pg.Pool`, `@opentelemetry/api` `Meter`, global `fetch`) so no vendor SDK is a hard dependency. ### OTel [#otel] ```typescript import { metrics } from "@opentelemetry/api" import { createOtelCostEmitter } from "@pleach/gateway/cost" const meter = metrics.getMeter("@pleach/gateway") const emitter = createOtelCostEmitter({ meter }) ``` Emits two counters per `CostEvent`: * `pleach_gateway_cost_usd` (Counter, double) — incremented by `event.costUsd`. * `pleach_gateway_tokens_total` (Counter, long) — incremented by `event.promptTokens + event.completionTokens`, with attribute `token.kind = "prompt" | "completion"` recorded once per token-class so the dimension is preserved downstream. Attributes on every measurement: `tenant.id`, `provider.family`, `call.class`, `model.invoked`, `byok.active`, `cost.event.type`. The OTel SDK is an **optional peer**. The factory accepts a `meter` argument duck-typed against `@opentelemetry/api`'s `Meter` shape; the adapter never imports the SDK at module-eval time. Override counter names via `costCounterName` / `tokensCounterName`. ### Postgres [#postgres] ```typescript import { Pool } from "pg" import { createPostgresCostEmitter } from "@pleach/gateway/cost" const pool = new Pool({ connectionString: process.env.DATABASE_URL }) const emitter = createPostgresCostEmitter({ pool, table: "pleach_gateway_cost_events", batchSize: 100, flushIntervalMs: 5000, }) ``` Buffered. `emit()` pushes into an in-memory buffer and returns immediately; flush is either timer-driven (default 5s), threshold- driven (default 100 events), or manual via `flush()`. Suggested schema (the `byok_key_hash` column is nullable; everything else is `NOT NULL`): ```sql CREATE TABLE pleach_gateway_cost_events ( id BIGSERIAL PRIMARY KEY, event_type TEXT NOT NULL, tenant_id TEXT NOT NULL, family TEXT NOT NULL, call_class TEXT NOT NULL, model_invoked TEXT NOT NULL, cost_usd NUMERIC(18, 8) NOT NULL, prompt_tokens INTEGER NOT NULL, completion_tokens INTEGER NOT NULL, byok_active BOOLEAN NOT NULL, byok_key_hash TEXT, routing_decision JSONB NOT NULL, ts TIMESTAMPTZ NOT NULL ); CREATE INDEX ON pleach_gateway_cost_events (tenant_id, ts DESC); CREATE INDEX ON pleach_gateway_cost_events (family, call_class); ``` `tenant_id` is the canonical multi-tenant scoping column. Hosts with RLS MAY add a policy referencing `current_tenant()`; the adapter does NOT set per-connection session variables — that is the consumer's pool configuration. The `table` option is validated against `/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?$/` (bare identifier or `schema.table` form) at construction. Postgres parameterized identifiers are not supported by the protocol; the allowlist is the safe floor. ### Datadog [#datadog] ```typescript import { createDatadogCostEmitter } from "@pleach/gateway/cost" const emitter = createDatadogCostEmitter({ apiKey: process.env.DD_API_KEY!, site: "datadoghq.com", flushIntervalMs: 10_000, batchSize: 100, }) ``` POSTs to the Datadog `/api/v2/series` metrics submission endpoint using global `fetch` — no SDK dependency. EU customers use `datadoghq.eu`; US3 customers use `us3.datadoghq.com`; etc. The factory does NOT validate `site` against an allowlist (Datadog adds sites over time). Two series per event: * `pleach.gateway.cost.usd` (count, `event.costUsd`) * `pleach.gateway.tokens.total` (count, prompt + completion) Both series carry the tags `tenant_id`, `family`, `call_class`, `model`, `byok`. The token series additionally records prompt / completion as two separate points tagged `token_kind:prompt` / `token_kind:completion`. ### Honeycomb [#honeycomb] ```typescript import { createHoneycombCostEmitter } from "@pleach/gateway/cost" const emitter = createHoneycombCostEmitter({ apiKey: process.env.HONEYCOMB_API_KEY!, dataset: "pleach-gateway", apiHost: "api.honeycomb.io", flushIntervalMs: 5_000, batchSize: 50, }) ``` POSTs to `https://<apiHost>/1/batch/<dataset>` using global `fetch` — no SDK dependency. EU customers may use `api.eu1.honeycomb.io`. Each `CostEvent` becomes one Honeycomb event with flat top-level fields (`tenant_id`, `family`, `call_class`, `model_invoked`, `cost_usd`, `prompt_tokens`, `completion_tokens`, `tokens_total`, `byok_active`, `byok_key_hash`, `event_type`) — Honeycomb auto-indexes them all. The full `routingDecision` is serialized as `routing_decision_json` so Honeycomb users can derive query fields without changing the adapter. ## Production-readiness notes [#production-readiness-notes] The three buffered adapters (Postgres, Datadog, Honeycomb) share a small set of production hardening tactics worth understanding before operating them at scale: * **`unref()` on interval timers.** Each adapter calls `.unref()` on its periodic-flush `setInterval` handle. The host process exits cleanly when the cost adapter is the only outstanding handle — important for CLI tools and short-lived workers. Non-Node runtimes (Workers, Deno) don't expose `unref()`; the adapter type-guards the call so this is harmless cross-runtime. * **Failed batches re-buffer at the head.** When a flush fails (HTTP 5xx, pool exhausted, network reset), the failed batch is `unshift()`-ed back to the front of the buffer. Ordering is preserved across the retry — the next flush replays the same batch in the same sequence. `onError` is invoked on every failure for observability. * **1000-iter `flush()` safety cap.** On graceful shutdown, `flush()` clears the interval timer and drains repeatedly until the buffer is empty. A safety counter caps the drain loop at 1000 iterations so a pathological re-buffer (every batch fails forever) cannot wedge shutdown. Hosts whose drain budget exceeds `1000 × batchSize` events should log and force-exit. * **Emit-side never throws.** `emit()` is `void` for the buffered adapters and never propagates a synchronous failure. The middleware's `void middleware(...)` call is fire-and-forget by design; cost emission is structurally decoupled from the gateway's hot path. ## Enforcing a per-root-turn cost ceiling [#enforcing-a-per-root-turn-cost-ceiling] To cap swarm spend, compose `RootTurnCostAggregator` with `CostCeilingMiddleware` from `@pleach/gateway/swarm` at your gateway integration seam. The aggregator rolls per-sub-agent cost events up under the originating `rootTurnId`; the middleware runs a pre- and post-call check, fires a once-per-`rootTurnId` warning at a configured soft threshold, and throws `CostCeilingExceededError` on a hard breach. Per-root-turn aggregation is the right granularity for swarm deployments: a single user message may fan out to N sub-agents, each emitting M cost events. The aggregator rolls per-sub-agent events up under the originating `rootTurnId` so the ceiling applies to the **user-visible turn cost**, not the per-sub-agent slice. ## Cited source [#cited-source] * `packages/gateway/src/cost/CostEmitter.ts` — contract. * `packages/gateway/src/cost/CostMiddleware.ts` — middleware factory. * `packages/gateway/src/cost/adapters/otel.ts` — OTel adapter. * `packages/gateway/src/cost/adapters/postgres.ts` — Postgres adapter. * `packages/gateway/src/cost/adapters/datadog.ts` — Datadog adapter. * `packages/gateway/src/cost/adapters/honeycomb.ts` — Honeycomb adapter. * `packages/gateway/test/costEmitterSurface.smoke.test.mjs` — surface regression-lock. ## Where to go next [#where-to-go-next] <Cards> <Card title="OpenTelemetry integration" href="/docs/gateway/otel" description="Today's OTel surface is the cost-event adapter; full Tracer + span emission is roadmap." /> <Card title="BYOK credential routing" href="/docs/gateway/byok" description="byokActive + byokKeyHash flow through every CostEvent for attribution." /> <Card title="`@pleach/gateway` overview" href="/docs/gateway" description="The Phase A GatewayClient — route(), cost events, family-strict cascade, transport seam." /> </Cards> --- # Gateway · Migration from `@pleach/core` (/docs/gateway/migration-from-core) `@pleach/core`'s model-family substrate is sufficient for single-tenant agents that drive their own provider keys. The gateway layers on top when the deployment shape changes — when calls need to be attributed per tenant, BYOK per tenant, cost-rolled per tenant, or routed through region / failover policies. ## When to introduce the gateway [#when-to-introduce-the-gateway] Add `@pleach/gateway` when any of the following is true: * **Multi-tenant.** The same runtime serves multiple end customers (or multiple internal cost centers) and every call must be attributable to one of them. * **Per-tenant BYOK.** Tenants supply their own provider keys for one or more families, and the platform routes via the tenant's key rather than a shared platform key. * **Cost rollup.** A `GROUP BY tenant_id` over emitted cost events drives billing, internal showback, or quota enforcement. * **Region failover.** Calls must be routed through a primary region with automatic failover to a DR region on regional outage. * **Identity federation.** Provider keys are derived per-call from an OIDC / SAML / federated identity (rather than long-lived API keys). When none of these apply, stay on `@pleach/core`. Don't bolt on the gateway speculatively — it adds a per-call indirection and a per-tenant client lifecycle that single-tenant agents don't need. ## What stays the same [#what-stays-the-same] Your existing `createPleachRuntime()` calls do not change. The runtime's model-family resolution, in-family cascade, and provider seam contracts are unchanged. The gateway is a wrapper, not a replacement. The matrix, the cascade walk order, the `(family × callClass)` cells — all of that lives in `@pleach/core` and the gateway re-exports `CallClass`, `ProviderFamily`, and `RoutingDecision` so consumers that talk only to the gateway don't need a second import line for the substrate types. ## The minimum migration [#the-minimum-migration] A single-tenant `@pleach/core` consumer typically looks like this: ```typescript import { createPleachRuntime } from "@pleach/core" const runtime = createPleachRuntime({ providers: { /* … */ }, }) const { id: sessionId } = await runtime.sessions.create() for await (const event of runtime.executeMessage(sessionId, "…")) { // handle each StreamEvent } ``` The gateway-wrapped form keeps the runtime intact and introduces a per-tenant `GatewayClient` that resolves `(tenant → credential → region → transport)` ahead of the runtime call: ```typescript import { createPleachRuntime } from "@pleach/core" import { GatewayClient, asTenantId, } from "@pleach/gateway" import { createCredentialRoutingMiddleware } from "@pleach/gateway" import { createPostgresCredentialStore } from "@pleach/gateway/byok/adapters/postgres" import { createCostMiddleware, createPostgresCostEmitter } from "@pleach/gateway/cost" // 1. One runtime, unchanged from your single-tenant setup. const runtime = createPleachRuntime({ providers: { /* … */ } }) // 2. Per-tenant BYOK resolution. const credentialStore = createPostgresCredentialStore({ pool }) const resolveCredential = createCredentialRoutingMiddleware({ store: credentialStore, fallbackKey: process.env.PLATFORM_ANTHROPIC_KEY, onMissing: "fallback", }) // 3. Per-call cost emission. const costEmitter = createPostgresCostEmitter({ pool, table: "pleach_gateway_cost_events", }) const recordCost = createCostMiddleware({ emitter: costEmitter, computeCost: (route, response) => ({ type: "domain.gateway.cost.recorded", tenantId: asTenantId(response.tenantId), family: response.family, callClass: response.callClass, modelInvoked: response.modelInvoked, costUsd: response.costUsd, promptTokens: response.usage.promptTokens, completionTokens: response.usage.completionTokens, byokActive: response.byokActive, routingDecision: response.routingDecision, timestamp: new Date().toISOString(), }), }) // 4. One GatewayClient per tenant (SaaS pattern) or per cost center // (internal-enterprise pattern). transports + allowedFamilies are // the operator's governance hooks. function buildGatewayForTenant(tenantId: string): GatewayClient { return new GatewayClient({ tenantId: asTenantId(tenantId), transports: tenantTransportMap, allowedFamilies: tenantAllowedFamilies(tenantId), costEventEmitter: { emit: (e) => costEmitter.emit(e) }, }) } ``` The runtime continues to do the in-family cascade math, the plan-generation rung, the streaming pipeline — everything it did before. The gateway is a layer in front of the transport seam that swaps in the tenant's credential and accounts for the call after the fact. ## Per-tenant client lifecycle [#per-tenant-client-lifecycle] One `GatewayClient` per tenant is the canonical pattern. The constructor captures `tenantId`, `allowedFamilies`, the transport map, and the cost-event sink — all scoped to the instance. Don't share one client across tenants; the cost attribution will collapse. For high-tenant-count deployments, cache `GatewayClient` instances in an LRU keyed by `tenantId`. `close()` on eviction is a state flag today (Phase A has no underlying resources to release — transports are caller-supplied) — but call it anyway so future versions can release region-scoped pools or identity-cache entries. ## Where the routing decision happens [#where-the-routing-decision-happens] In a single-tenant `@pleach/core` setup, the runtime picks the model inside its provider seam: ``` runtime → provider seam → @pleach/core matrix → cascade walk ``` With the gateway in front: ``` GatewayClient.route() → governance gate (allowedFamilies allowlist) → credential gate (BYOK middleware: tenant store → fallback) → transport pick (Map<family, GatewayTransport>) → cascade walk (pickNextInFamily on 503 / timeout / 429) → cost event (one per successful call) ``` The runtime's internal cascade math is reused — gateway calls `pickNextInFamily` from the substrate directly. There is no duplicated walk order, no second matrix. ## The bundled route factory [#the-bundled-route-factory] A bundled `createGatewayRoute` factory **ships today** at `@pleach/gateway/next`. It is a Web-standard `(req: Request) => Promise<Response>` handler (no `next` peer dependency — the `/next` subpath name is cosmetic, so it works in App Router, Workers, Bun, and Hono) that wraps a fully-built `GatewayClient`. It collapses the manual "resolve tenant → parse body → call `route()` → shape a Response" wiring into one factory: ```typescript import { createGatewayRoute } from "@pleach/gateway/next" // Multi-tenant: a GatewayClient is constructed per request with the // resolved tenant id, so cost events + governance are tenant-scoped. export const POST = createGatewayRoute({ clientOptions: { transports, // Map<family, GatewayTransport> allowedFamilies: new Set(["anthropic", "openai"]), }, tenantId: (req) => req.headers.get("x-tenant-id") ?? "anonymous", }) ``` For a single-tenant route, pass a pre-built `client` instead of `clientOptions` + `tenantId`: ```typescript export const POST = createGatewayRoute({ client }) ``` The handler accepts a JSON body (`{ family, callClass, model, prompt, byokKey? }`) and returns the resolved `GatewayResponse` as one `application/json` object. It is NOT a streaming transport — `GatewayClient.route()` buffers the transport call. Supply `resolveRoute(req, body)` to map a different request schema onto `GatewayRouteOptions`. The route does NOT re-implement any routing math or the in-family cascade — those live entirely inside `GatewayClient.route()`. The explicit composition shown above remains available when you want every layer auditable by hand — exactly the property that matters for compliance attestation. ## Cited source [#cited-source] * `packages/gateway/src/index.ts` — `GatewayClient` Phase A. * `packages/gateway/src/byok/CredentialRoutingMiddleware.ts` — BYOK middleware factory. * `packages/gateway/src/cost/CostMiddleware.ts` — cost middleware factory. ## Where to go next [#where-to-go-next] <Cards> <Card title="BYOK credential routing" href="/docs/gateway/byok" description="The store contract + three reference adapters (Postgres, Redis, external secret manager)." /> <Card title="Cost events" href="/docs/gateway/cost-events" description="The CostEmitter contract + four reference adapters (OTel, Postgres, Datadog, Honeycomb)." /> <Card title="Multi-tenant deployments" href="/docs/multi-tenant" description="Where tenantId lives in the substrate — RLS, fingerprint, audit row, OTel attribute." /> <Card title="`@pleach/gateway` overview" href="/docs/gateway" description="The Phase A GatewayClient — route(), cost events, family-strict cascade, transport seam." /> </Cards> --- # Gateway · OpenTelemetry integration (/docs/gateway/otel) OpenTelemetry support in `@pleach/gateway` is **partial today**. The cost-event substrate ships an OTel adapter that records two counters per call; a dedicated tracer that emits an `llm.invocation` span per call is roadmap. This page documents what's actually shipped — be explicit about the gap so consumers wire their own span context until the tracer rung lands. ## What ships today: OTel cost counters [#what-ships-today-otel-cost-counters] The OTel surface is delivered via the cost-event adapter at `@pleach/gateway/cost`. It emits two counters per `CostEvent`: ``` pleach_gateway_cost_usd # Counter, double, USD pleach_gateway_tokens_total # Counter, long, {token} ``` `pleach_gateway_tokens_total` carries an extra dimension `token.kind = "prompt" | "completion"` so the prompt-vs-completion breakdown is preserved as a separate dimension rather than collapsed into a single sum. ### Wiring [#wiring] `routeChatCompletion`'s output carries `{ providerInvoked, modelInvoked, content, usage, costUsd, routingDecisionId }` — not `tenantId`, `family`, `callClass`, `byokActive`, or the full `routingDecision`. Merge those onto the observation from the request `input` plus your routing context, then read them in `computeCost` (see [Cost events](/docs/gateway/cost-events#costmiddleware-factory) for the full mapper): ```typescript import { metrics } from "@opentelemetry/api" import { createOtelCostEmitter, createCostMiddleware } from "@pleach/gateway/cost" import { asTenantId } from "@pleach/gateway" import type { RouteChatCompletionInput, RouteChatCompletionOutput } from "@pleach/gateway" const meter = metrics.getMeter("@pleach/gateway") const emitter = createOtelCostEmitter({ meter }) type CostObservation = RouteChatCompletionOutput & { tenantId: string family: RouteChatCompletionInput["family"] callClass: "synthesize" | "reasoning" | "converse" | "utility" byokActive: boolean } const cost = createCostMiddleware<CostObservation>({ emitter, computeCost: (route, response) => ({ type: "domain.gateway.cost.recorded", tenantId: asTenantId(response.tenantId), // non-empty: sourced from input.tenantId family: response.family, callClass: response.callClass, modelInvoked: response.modelInvoked, costUsd: response.costUsd, promptTokens: response.usage.promptTokens, completionTokens: response.usage.completionTokens, byokActive: response.byokActive, routingDecision: { family: response.family, call_class: response.callClass, selected_model: response.modelInvoked, transport: "openrouter", byok_active: response.byokActive, upgrade_path_invoked: false, fallback_chain: [], raw_provider_cost_usd: response.costUsd, markup_pct: 0, }, timestamp: new Date().toISOString(), }), }) const output = await runtime.routeChatCompletion(input) void cost({ route: "chat.completion", response: { ...output, tenantId: input.tenantId, family: input.family, callClass: "converse", // host routing context — not on the route output byokActive: false, // host routing context — not on the route output }, }) ``` `routeChatCompletion` is the Pack-221 slice-2 stub today: it returns `costUsd: 0` and a stub `content` until the real transport lands. ### Attributes recorded on every measurement [#attributes-recorded-on-every-measurement] | Attribute | Source | | ----------------- | -------------------------------- | | `tenant.id` | `event.tenantId` | | `provider.family` | `event.family` | | `call.class` | `event.callClass` | | `model.invoked` | `event.modelInvoked` | | `byok.active` | `event.byokActive` | | `cost.event.type` | `"domain.gateway.cost.recorded"` | `token.kind` (`"prompt"` / `"completion"`) is added to `pleach_gateway_tokens_total` measurements only. ### The OTel SDK is an optional peer [#the-otel-sdk-is-an-optional-peer] `createOtelCostEmitter` accepts the `meter` as a duck-typed argument mirroring `@opentelemetry/api`'s `Meter` shape. The adapter never imports `@opentelemetry/api` at module-eval time — the duck-typed shape is lifted to a local `OtelMeter` interface so callers without an `@opentelemetry/api` type ambient can still satisfy it (a stub in tests, a custom in-house meter facade). The factory validates only that the supplied `meter` exposes `createCounter(name, options?)`: ```typescript if (!opts.meter || typeof opts.meter.createCounter !== "function") { throw new TypeError( "@pleach/gateway/cost/adapters/otel: options.meter must implement createCounter(name, options?)", ) } ``` ### Override counter names [#override-counter-names] For deployments that prefix metrics by team or service: ```typescript const emitter = createOtelCostEmitter({ meter, costCounterName: "myco.pleach.gateway.cost.usd", tokensCounterName: "myco.pleach.gateway.tokens.total", }) ``` ### Soft-fail semantics [#soft-fail-semantics] Per the cost-adapter contract, `emit()` swallows internal exceptions — a metric backend hiccup MUST NOT propagate up the gateway call: ```typescript try { costCounter.add(event.costUsd, baseAttrs) // ... tokensCounter.add(...) ... } catch { // Soft-fail per cost-adapter contract — never propagate. } ``` ## What's roadmap: dedicated tracer + `llm.invocation` spans [#whats-roadmap-dedicated-tracer--llminvocation-spans] A full OTel tracer integration is `v1.x` work. The C7 telemetry rung that records every gateway call as an OTel span lands once `runtime.otel` is consumable from `@pleach/core`'s exported surface. Until then, callers thread their own span context if they need distributed tracing across the gateway boundary: ```typescript import { trace, context, SpanKind } from "@opentelemetry/api" const tracer = trace.getTracer("my-app") await tracer.startActiveSpan( "llm.invocation", { kind: SpanKind.CLIENT, attributes: { "tenant.id": tenantId, "provider.family": family, "call.class": callClass, "model.requested": model, }}, async (span) => { try { const response = await runtime.routeChatCompletion(input) span.setAttribute("model.invoked", response.modelInvoked) span.setAttribute("cost.usd", response.costUsd) span.setAttribute("tokens.prompt", response.usage.promptTokens) span.setAttribute("tokens.completion", response.usage.completionTokens) // byok.active is not on the route output — set it from your // routing context (input / BYOK resolver), not from response. return response } catch (err) { span.recordException(err as Error) throw err } finally { span.end() } }, ) ``` This shim is **not** what we want long-term — span attributes are inferred from response shape rather than emitted at the routing decision point, cascade walks are not captured per-rung, and BYOK hash provenance is missing. The dedicated `Tracer` SKU will own all of this. Track the work under the gateway punch list. ## Cited source [#cited-source] * `packages/gateway/src/cost/adapters/otel.ts` — `OtelMeter` / `OtelCounter` duck-typed interfaces and `createOtelCostEmitter` factory. * `packages/gateway/src/cost/CostMiddleware.ts` — middleware that composes the OTel adapter with any route response shape. ## Where to go next [#where-to-go-next] <Cards> <Card title="Cost events" href="/docs/gateway/cost-events" description="The full CostEmitter contract + the other three adapters (Postgres, Datadog, Honeycomb)." /> <Card title="OTel observability" href="/docs/otel-observability" description="The platform-wide OTel posture — telemetry boundaries, span conventions, and the `runtime.otel` facet roadmap." /> <Card title="`@pleach/gateway` overview" href="/docs/gateway" description="The Phase A GatewayClient — route(), cost events, family-strict cascade, transport seam." /> </Cards> --- # Gateway · Rate limiting (/docs/gateway/rate-limit) The gateway's rate-limit substrate is the seam between a host's shared quota store (Redis, Upstash, DynamoDB, Postgres) and the per-tenant / per-user / per-route throttling decision. Today the package ships a contract + one in-process reference adapter; production adapters follow on additive subpaths. ## `RateLimiter` contract [#ratelimiter-contract] ```typescript interface RateLimitCheckOptions { readonly windowMs?: number readonly maxRequests?: number } interface RateLimitDecision { readonly allowed: boolean readonly remaining: number readonly resetAt: number // epoch ms; window reset readonly retryAfterMs?: number // populated only when !allowed } interface RateLimiter { check(key: string, opts?: RateLimitCheckOptions): Promise<RateLimitDecision> } ``` The contract is intentionally minimal: given a key plus optional per-call overrides, return a decision indicating whether the call is allowed, the remaining quota in the current window, and the reset time. The decision shape mirrors what production adapters (Upstash, AWS Throttle Tokens) emit. Callers render HTTP `429` responses with accurate `Retry-After` + `X-RateLimit-*` headers without per-adapter glue. ### Implementation requirements [#implementation-requirements] * `check()` MUST be atomic — the count increment + comparison against the ceiling MUST happen as a single observable operation. The in-process reference adapter achieves this via the single-threaded JavaScript event loop; future Redis / Upstash adapters use Lua scripts or `INCR`+`EXPIRE` pipelines. * `check()` MUST NOT throw under normal load — return a conservative `allowed: false` if the underlying substrate misbehaves and let the caller decide whether to fail open or closed. * Distinct `key` values MUST be independent — a denied tenant does not affect a peer tenant's quota. ## In-process reference adapter [#in-process-reference-adapter] ```typescript import { createInMemoryRateLimiter } from "@pleach/gateway/rateLimit" const limiter = createInMemoryRateLimiter({ defaultWindowMs: 60_000, defaultMaxRequests: 100, }) const decision = await limiter.check(`tenant:${tenantId}`) if (!decision.allowed) { return new Response("Too Many Requests", { status: 429, headers: { "Retry-After": String(Math.ceil((decision.retryAfterMs ?? 0) / 1000)), "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": String(Math.ceil(decision.resetAt / 1000)), }, }) } ``` ### Configuration [#configuration] ```typescript interface InMemoryRateLimiterConfig { readonly defaultWindowMs?: number // default 60_000 readonly defaultMaxRequests?: number // default 60 } ``` Per-call overrides are passed to `check()`: ```typescript // Tighter limit for an expensive route, same limiter instance. await limiter.check(`tenant:${tenantId}:expensive-llm`, { windowMs: 60_000, maxRequests: 5, }) ``` ### What it is [#what-it-is] Fixed-window counter stored in an in-process `Map`. Simpler than a sliding-window log and sufficient for local dev, single-node deployments, and unit tests. ### What it is NOT [#what-it-is-not] **Not distributed-safe.** Counters live in the calling process's heap. Multiple Node.js instances behind a load balancer will each track an independent count, so a request might pass the limiter on instance A while instance B is at the ceiling. For multi-node production deployments use a shared substrate — see the roadmap below. **Memory bound is lazy.** Idle keys are reaped only when a `check()` call observes that the stored window expired. A long tail of one-shot keys can accumulate; hosts that route via short-lived per-user keys should prefer a production adapter with explicit eviction policy. ## Roadmap [#roadmap] Production adapters ship as additive subpaths so the base package stays dependency-free. None of these are in the current `0.x` cut — they are planned for `v1.x`: * `@pleach/gateway/rateLimit/redis` — `ioredis` / `redis` v4+. Atomic `INCR`+`EXPIRE` pipeline; sliding-window via Lua script. * `@pleach/gateway/rateLimit/upstash` — `@upstash/ratelimit`. HTTP REST API, fits Workers / Edge runtime. * `@pleach/gateway/rateLimit/dynamodb` — AWS SDK v3. Conditional writes + TTL-based window reset. Per-tenant-call-class policy (different windows for `utility` vs `reasoning` vs `synthesize` calls inside one tenant) is also a `v1.x` item. Today, hosts that need this layer pass distinct keys at the call site: ```typescript // Workaround pre-v1.x: encode the call-class in the key. await limiter.check(`tenant:${tenantId}:callClass:${callClass}`, { maxRequests: callClass === "synthesize" ? 10 : 100, }) ``` ## Cited source [#cited-source] * `packages/gateway/src/rateLimit/types.ts` — `RateLimiter` contract. * `packages/gateway/src/rateLimit/inMemoryLimiter.ts` — fixed-window in-process adapter. ## Where to go next [#where-to-go-next] <Cards> <Card title="Cost events" href="/docs/gateway/cost-events" description="Per-call cost emission — pairs with rate limiting on the same per-tenant key dimension." /> <Card title="BYOK credential routing" href="/docs/gateway/byok" description="Per-tenant key resolution. Rate-limit keys typically share the same tenant scope as BYOK credentials." /> <Card title="`@pleach/gateway` overview" href="/docs/gateway" description="The Phase A GatewayClient — route(), cost events, family-strict cascade, transport seam." /> </Cards> --- # Authoring a HarnessPlugin (/docs/plugins/authoring) A `HarnessPlugin` is the typed extension surface for `@pleach/core`. Every sibling SKU — `@pleach/compliance`, `@pleach/gateway`, `@pleach/eval`, `@pleach/replay`, `@pleach/mcp`, `@pleach/observe`, `@pleach/recipes`, `@pleach/coding-agent`, `@pleach/sandbox`, `@pleach/langchain`, `@pleach/base-tools` — is a plugin that implements this contract. Your own consumer code extends the runtime through the same surface. This page covers the practical authoring story: import shape, hook taxonomy, the canonical no-op for each return type, and the worked starting point. For the structural-invariant view (what plugins can and can't do, the lattice slots, the verdict ladder), see [Plugin contract](/docs/plugin-contract). <SourceMeta source="{ label: "examples/plugins/empty-plugin/", href: "https://github.com/pleachhq/core/tree/main/examples/plugins/empty-plugin" }" /> ## The canonical shape [#the-canonical-shape] ```typescript import { definePleachPlugin, type HarnessPlugin } from "@pleach/core"; export const myPlugin = definePleachPlugin("my-plugin", { // structured cluster — 8 most-used hooks prompts: [personaBlock], streamObservers: [redactPiiObserver], safetyPolicies: [refusalPolicy], tools: [searchCorpus], // … _raw: { version: "0.1.0", // forward-compat escape hatch — any hook not in the cluster contributePostToolTier: () => myEntityRecognizer, }, }); ``` The raw `HarnessPlugin` object literal still works — the type is public and existing plugins keep loading. `definePleachPlugin()` is the recommended path; it surfaces "what can my plugin contribute?" through autocomplete instead of paging the 1100-line interface. ## The 74-hook surface at a glance [#the-74-hook-surface-at-a-glance] The full `HarnessPlugin` contract today exposes **74 hooks** — 67 `contribute*` methods plus 7 top-level lifecycle / extension points. The table below groups them informally so you can scan for the one you need. For the canonical, audit-gated layout — every hook resolved to exactly one of nine namespaces — see [Contribution namespaces](/docs/plugins/namespaces). | Bucket | Count | Examples | | -------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Prompts | 5 | `contributePrompts`, `contributeRuntimeAwarePrompts`, `contributePromptHints`, `contributePromptSections`, `contributePromptSectionGeneratorBundle` | | Safety + scrubbing + synthesis directives | 4 | `contributeSafetyPolicies`, `contributeScrubbers`, `contributeSynthesisDirectiveBlocks`, `contributeFinalizationPasses` | | Tools + planning + intent | 13 | `contributeTools`, `contributeIntentToolMap`, `contributeToolCouplingHints`, `contributeBatchingHints`, `contributeIntentClassifiers`, `contributeIntentParameterResolvers`, `contributeIntentKeywordClasses`, `contributeDomainClassifierPatterns`, `contributePlanPrefixes`, `contributeGuestDeniedTools`, `contributeDefaultAgentGraphRegistries`, `contributeToolFollowUpIntents`, … | | Stream observers + chunk handlers | 8 | `contributeStreamObservers`, `contributeObserverConsumers`, `contributeStreamChunkHandlers`, `contributeStreamEventHandlersAdapters`, `contributeStreamEndDiagnosticsAnalyzer`, `contributeDetectionRules`, `contributeHallucinatedToolDetectors`, `contributeGarbledOutputRecorder` | | Synthesis + fabrication + post-tool | 7 | `contributeFabricationDetectors`, `contributeFabricationDetectorRules`, `contributeFabricationGuard`, `contributeRefClassValidators`, `contributeContinuationPolicy`, `contributeContinuationShadowResolver`, `contributePostToolTier` | | Citations + entities | 5 | `contributeCitationEntityExtractor`, `contributeCitationInjector`, `contributeCitationRuleSet`, `contributeEntityExtractors`, `contributeEntityNameCounter` | | Sandbox + interrupts + approval | 5 | `contributeSandboxBridge`, `contributeSandboxInitialization`, `contributeSandboxAvailabilityEvaluator`, `contributeInterruptUIHandlers`, `contributeApprovalFlow` | | Family routing + retry | 3 | `contributeFamilyPivot`, `contributeFamilyExhaustedSurface`, `contributeRetryPolicy` | | Middleware + prompt bridge + domain | 7 | `contributeMiddleware`, `contributeRuntimeAwareMiddleware`, `contributePromptContextBridge`, `contributeFallbackSystemPromptBuilder`, `contributeDomainSynonyms`, `contributeChainingFieldNames`, `contributeIntentMentionDetector` | | Audit + meta-learning + manifest | 6 | `contributeAuditEmitter`, `contributeMetaLearningContext`, `contributeChatManifestProvider`, `contributeEventTypes`, `contributePreserveDataRefFields`, `contributeProbes` | | Misc (artifact cache, prefetcher, get\_data) | 4 | `contributeArtifactCacheReader`, `contributeStructurePrefetcher`, `contributeGetDataHandlerFactory`, `contributeDataChannelRefetch` | | Top-level non-`contribute*` | 7 | `extraGraphNodes`, `prePlanPrimer`, `postSynthesisGuard`, `onJobDispatch`, `onJobComplete`, `registerAsyncExecutors`, `eventResolver` | | **Total** | **74** | | **Honest scope-limit.** Most authors fill 1–5 hooks. The 74-hook surface is the substrate's total contribution ceiling, not the typical author's checklist. The structured `capabilities` cluster covers the 8 most-reached-for hooks; `_raw` catches the rest. The full hook list with one-line descriptions is in [`examples/plugins/empty-plugin/plugin.mjs`](https://github.com/pleachhq/core/tree/main/examples/plugins/empty-plugin/plugin.mjs) — a copy-paste no-op scaffold where every hook is present with the canonical empty return. ## Canonical no-op returns [#canonical-no-op-returns] Each hook shape has one canonical "I don't contribute here" return. The collector ignores the hook entirely; nothing is dispatched. | Hook return type | Canonical no-op | | ---------------------------------------------------- | --------------- | | `readonly Array<T>` (multi-contribution) | `[]` | | `Record<string, T>` (named map) | `{}` | | `T \| null` (first-wins) | `null` | | `T \| undefined` (first-wins) | `undefined` | | `void` (lifecycle: `onJobDispatch`, `onJobComplete`) | bare `return` | The empty-plugin scaffold uses the canonical form for every hook — copy it, then put real bodies on the ones you need and delete the rest. ## The minimal walk-through [#the-minimal-walk-through] ```typescript import { definePleachPlugin } from "@pleach/core"; // 1. Pick the hooks you need. // 2. Provide bodies for them. // 3. Register the plugin once at runtime construction. export const auditPlugin = definePleachPlugin("audit-plugin", { _raw: { version: "0.1.0" }, streamObservers: [ { when: { callClass: "synthesize" }, factory: () => ({ observerId: "audit-stream", onChunk(chunk) { // mirror chunk into your audit log return { kind: "continue" }; }, }), }, ], }); ``` Register at runtime construction: ```typescript const runtime = new SessionRuntime({ storage: new MemoryStorage(), plugins: [auditPlugin, anotherPlugin], }); ``` Registration order is the dispatch order. The substrate doesn't re-order plugins; two non-commuting plugins (e.g. a PII redactor and a metrics observer) should be sequenced explicitly at the construction site. See [Plugin contract — registration order](/docs/plugin-contract) for the canonical non-commuting example. ## The structured `capabilities` cluster [#the-structured-capabilities-cluster] `PluginCapabilities` surfaces the 8 most-used hooks as a flat structured menu so authors don't page the full interface. Each field takes a **value** (not a thunk), and the factory forwards it into the corresponding `contribute*` method. | Capability key | Hook it maps to | | ---------------------- | ----------------------------------------------------------------- | | `prompts` | `contributePrompts` | | `runtimeAwarePrompts` | `contributeRuntimeAwarePrompts` (accepts function OR fixed array) | | `safetyPolicies` | `contributeSafetyPolicies` | | `fabricationDetectors` | `contributeFabricationDetectors` | | `tools` | `contributeTools` | | `streamObservers` | `contributeStreamObservers` | | `intentToolMap` | `contributeIntentToolMap` | | `toolCouplingHints` | `contributeToolCouplingHints` | Any hook outside the structured cluster goes in `_raw` (typed as `Partial<HarnessPlugin>`). Keys in `_raw` win on collision with the structured cluster (last-write-wins), so authors migrating bespoke plugins keep full control. ## The 5 top-level (non-`contribute*`) hooks [#the-5-top-level-non-contribute-hooks] ```typescript import type { HarnessPlugin } from "@pleach/core"; export const myPlugin: HarnessPlugin = { name: "my-plugin", version: "0.1.0", // Register custom nodes into the compiled graph. extraGraphNodes: () => [], // Inject a one-shot system message before plan generation. prePlanPrimer: (_ctx) => null, // Inspect assistant content; emit a corrective system message. postSynthesisGuard: (_ctx) => null, // Lifecycle: paired job-dispatch + job-complete callbacks. onJobDispatch: (_ctx) => {}, onJobComplete: (_ctx) => {}, // Register async executors for long-running job-backed tools. registerAsyncExecutors: (_registrar) => {}, // Resolve domain-specific event types when folding the event log. eventResolver: undefined, }; ``` These seven are top-level rather than `contribute*` because they either return a typed array of graph nodes (`extraGraphNodes`), operate on a per-call context with a single first-wins return (`prePlanPrimer`, `postSynthesisGuard`), fire side-effect-only lifecycle callbacks (`onJobDispatch`, `onJobComplete`), register async executors (`registerAsyncExecutors`), or supply a domain-event resolver property rather than a collector method (`eventResolver`). ## Capability breadcrumbs [#capability-breadcrumbs] Eleven `contribute*` collectors emit a one-shot `[Pleach:capability-not-contributed]` console line when they aggregate to an empty array across all registered plugins. The breadcrumb is dedup-keyed on `${sessionId}:${capability}` and surfaces a missing plugin without spamming logs. | Capability hook | | ------------------------------------ | | `contributeStreamObservers` | | `contributeStreamFilters` | | `contributeSafetyPolicies` | | `contributeFabricationDetectors` | | `contributePostToolTier` | | `contributeRefClassValidators` | | `contributeMiddleware` | | `contributeRuntimeAwareMiddleware` | | `contributeToolCouplingHints` | | `contributeIntentToolMap` | | `contributeIntentParameterResolvers` | To surface the same gaps at construction time, call `inspectRuntime(runtime)` and walk `report.capabilities` for rows whose `wiredCount === 0`. See [Runtime inspector](/docs/runtime-inspector). ## Canonical reference [#canonical-reference] | What | Where | | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | Empty-plugin scaffold (every hook, every canonical no-op) | [`examples/plugins/empty-plugin/`](https://github.com/pleachhq/core/tree/main/examples/plugins/empty-plugin) | | Stream observer with `amend` verdict | [`examples/observers/dialect-normalization/`](https://github.com/pleachhq/core/tree/main/examples/observers/dialect-normalization) | | Stream observer with `stop` verdict | [`examples/observers/halt-on-pattern/`](https://github.com/pleachhq/core/tree/main/examples/observers/halt-on-pattern) | | Vertical entity recognizer via `contributePostToolTier` | [`examples/plugins/domain-entity-recognition/`](https://github.com/pleachhq/core/tree/main/examples/plugins/domain-entity-recognition) | | Custom event channel via `emit` verdict | [`examples/plugins/custom-event-channel/`](https://github.com/pleachhq/core/tree/main/examples/plugins/custom-event-channel) | | Full type surface | `packages/core/src/plugins/types.ts` | Each example is a standalone npm package with a `node --test` smoke. Clone, run, modify. ## Where to go next [#where-to-go-next] <Cards> <Card title="Plugin contract" href="/docs/plugin-contract" description="The structural-invariant view — what plugins can and can't do, lattice slots, verdict ladder." /> <Card title="Stream observers" href="/docs/plugins/stream-observers" description="The contributeStreamObservers hook in detail — verdicts, factory pattern, replay determinism." /> <Card title="Post-tool tier" href="/docs/plugins/post-tool-tier" description="The contributePostToolTier hook — uniform enrichment across tool batches." /> <Card title="Plugin bundles" href="/docs/plugin-bundles" description="composePlugin() for assembling a larger plugin across multiple files." /> </Cards> --- # Contribution namespaces (/docs/plugins/namespaces) A `HarnessPlugin` plugs into the runtime by filling named slots. There are a lot of slots — prompt contributors, stream observers, tool definitions, safety policies, retry strategies, lifecycle callbacks. This page is the map: every contribution hook belongs to exactly one of **nine namespaces**, grouped by *when* and *against what* it runs. The grouping isn't cosmetic. It's the contract layout an `audit:plugin-hook-category-assigned` gate enforces — every hook on the `HarnessPlugin` interface must resolve to exactly one namespace, and a new hook with no namespace fails the build. So the nine namespaces stay an exhaustive, drift-free partition of the contribution surface. <SourceMeta source="{ label: "src/plugins/", href: "https://github.com/pleachhq/core/tree/main/src/plugins" }" /> This is the orthogonal view to [Authoring a HarnessPlugin](/docs/plugins/authoring). The authoring page groups hooks informally so you can scan for the one you need; this page is the canonical, audit-gated layout the substrate organizes them by. Same hooks, one map. ## The nine namespaces [#the-nine-namespaces] | Namespace | When it runs | What lives here | | ------------ | ----------------------------------- | -------------------------------------------------------------------------------- | | `prompts` | Prompt-construction time | System-prompt assembly, context bridges, section generators | | `stream` | Against the live token stream | Per-chunk observers/filters/handlers, end-of-turn analyzers, finalization passes | | `safety` | Before something is allowed to fire | Policy gating, PII redaction, approval flows | | `audit` | When recording an event | Event-log emitters, event-type registries, domain projection | | `intent` | At dispatch-classification time | Intent detection, classifiers, parameter resolvers, keyword/synonym registries | | `tools` | Around tool dispatch | The tool catalog, tool-loop strategy, post-tool enrichment | | `middleware` | Wrapping a lifecycle boundary | Model-call, tool-call, and sandbox interception bundles | | `policy` | When the runtime reads a decision | Retry, continuation, family-pivot, citation rules — data the runtime consults | | `lifecycle` | At a discrete lifecycle event | Chat start, job dispatch/complete, sandbox readiness, interrupts | The line between two adjacent namespaces is *what the hook does*, not *what it's about*. `middleware` intercepts (wraps a boundary with before/after); `policy` is data the runtime reads. `safety` prevents (gates before execution); `audit` records (after the fact). `intent` classifies which tool to consider; `tools` is the catalog itself. ## `prompts` [#prompts] Everything that feeds the system-prompt builder or runs at prompt-construction time. Static blocks, per-turn runtime-aware sections, synthesis-time directive blocks, and the host-config → agnostic-context bridges that make a runtime-aware prompt authorable. Representative hooks: `contributePrompts`, `contributeRuntimeAwarePrompts`, `contributePromptHints`, `contributePromptSections`, `contributePromptContextBridge`, `contributeSynthesisDirectiveBlocks`, `prePlanPrimer`. See [Prompts](/docs/prompts) and [Prompt builder](/docs/prompt-builder). ## `stream` [#stream] Everything that runs against the live stream — inside the chunk loop (the synchronous `onChunk` contract), at end-of-turn aggregation, or at the post-stream sanitizer pass. Observers, filters, chunk handlers, hallucination and fabrication detectors, garble recorders, finalization passes. Representative hooks: `contributeStreamObservers`, `contributeStreamChunkHandlers`, `contributeFinalizationPasses`, `contributeHallucinatedToolDetectors`, `contributeFabricationDetectors`, `contributeFabricationGuard`. Stream hooks are deliberately co-located despite distinct contract shapes (read-only observe vs 1:1 amend vs verdict) because they share the lifecycle. See [Stream observers](/docs/plugins/stream-observers) and [Fabrication detection](/docs/fabrication-detection). ## `safety` [#safety] Hooks that gate whether something is allowed to fire, or shape what gets recorded for redaction and compliance — preventative, as distinct from `stream` (post-emit shaping) and `audit` (recording). Representative hooks: `contributeSafetyPolicies`, `contributeScrubbers`, `contributeApprovalFlow`, `contributeFabricationDetectorRules`. The split inside the fabrication family is by execution, not topic: the detectors and guard that fire *against stream content* live in `stream`; the pure rule-data slot (`contributeFabricationDetectorRules`) lives in `safety`. See [Scrubbers](/docs/scrubbers) and [Safety](/docs/safety). ## `audit` [#audit] Hooks that bind an event-log emitter, register the event types the emitter accepts without warning, or contribute domain-specific event projection. Distinct from `safety` — `safety` is preventative, `audit` is recording. Representative hooks: `contributeAuditEmitter`, `contributeEventTypes`, `eventResolver`, `contributeRefClassValidators`. What a plugin contributes here is exactly what the [config manifest](/docs/config-manifest)'s `plugin_manifest` snapshot records — so the namespace a hook lives in is also the lens you audit it through. See [Event log](/docs/event-log) and [Audit ledger](/docs/audit-ledger). ## `intent` [#intent] The dispatch-classification layer — hooks that feed the intent-detection stage or the tools that consult an intent label. Distinct from `tools` (the catalog); `intent` decides which tool to consider. Representative hooks: `contributeDetectionRules`, `contributeIntentClassifiers`, `contributeIntentToolMap`, `contributeIntentParameterResolvers`, `contributeIntentKeywordClasses`, `contributeDomainClassifierPatterns`. See [Routing decisions](/docs/plugins/routing-decisions). ## `tools` [#tools] The widest namespace, because tool dispatch is the runtime's primary substrate. Hooks here contribute to the tool registry, the plan-to-tool ordering, per-tool execution strategy, or post-tool result enrichment. Representative hooks: `contributeTools`, `contributeToolCouplingHints`, `contributePostToolTier`, `contributeEntityExtractors`, `contributeGuestDeniedTools`, `extraGraphNodes`, `registerAsyncExecutors`. See [Tools](/docs/tools) and [Post-tool tier](/docs/plugins/post-tool-tier). ## `middleware` [#middleware] Hooks that return an object whose methods wrap a lifecycle boundary — model calls, tool calls, sandbox completion, sandbox init. Distinct from `policy`: `middleware` intercepts behavior, `policy` is data the runtime reads. Representative hooks: `contributeMiddleware`, `contributeRuntimeAwareMiddleware`, `contributeSandboxBridge`, `contributeSandboxInitialization`. ## `policy` [#policy] Hooks that return a value or strategy the runtime *reads* — a retry policy, a continuation decision, a family-pivot ladder, a citation rule set, a chat-manifest provider. Distinct from `middleware` (which wraps) and `intent` (which classifies). Representative hooks: `contributeRetryPolicy`, `contributeContinuationPolicy`, `contributeFamilyPivot`, `contributeCitationRuleSet`, `contributeChatManifestProvider`. See [Family lock](/docs/family-lock) for the pivot ladder. ## `lifecycle` [#lifecycle] Hooks that fire at a discrete lifecycle event — chat start, job dispatch, job complete, sandbox readiness, data-channel refetch, interrupt — rather than per-token, per-chunk, or per-tool. Representative hooks: `onJobDispatch`, `onJobComplete`, `postSynthesisGuard`, `contributeSandboxAvailabilityEvaluator`, `contributeGetDataHandlerFactory`, `contributeInterruptUIHandlers`. See [Interrupts](/docs/interrupts) and [Async tasks](/docs/async-tasks). ## The typed namespaced form [#the-typed-namespaced-form] <StatusBadge status="in-flight"> additive · rolling out </StatusBadge> Today every hook is a flat field on `HarnessPlugin` (`contributeStreamObservers`, `contributeTools`, …), and that flat form stays valid. The nine namespaces are landing additively as a typed accessor form so a plugin author gets nine-bucket autocomplete instead of paging the full interface: ```typescript // Both forms compile clean during the overlap window. definePleachPlugin({ contributeStreamObservers: () => [/* ... */], // flat (today) }); definePleachPlugin({ stream: { contributeStreamObservers: () => [/* ... */] }, // namespaced (rolling out) }); ``` <Callout title="Additive, with a deprecation runway"> The namespaced form lands alongside the flat form, not instead of it. During the overlap window the collector reads the namespace bucket first and falls back to the flat field, so a hook is reachable from either path and existing plugins keep loading unchanged. The flat form gets a deprecation warning, then retires, over a multi-minor cadence — the same path `contributeTools` already took to replace the bare `tools` field. </Callout> ## Where to go next [#where-to-go-next] <Cards> <Card title="Authoring a HarnessPlugin" href="/docs/plugins/authoring" description="The practical authoring story — the canonical no-op for each hook shape and the empty-plugin scaffold." /> <Card title="Plugin contract" href="/docs/plugin-contract" description="The structural-invariant view — what plugins can and can't do." /> <Card title="Config manifest" href="/docs/config-manifest" description="The snapshot that records which plugins contributed which hooks." /> <Card title="Plugin bundles" href="/docs/plugin-bundles" description="composePlugin() and the thematic facet sub-paths for larger plugins." /> </Cards> --- # Post-tool tier (/docs/plugins/post-tool-tier) `contributePostToolTier` is the plugin slot for uniform enrichment that runs after every batch of tool calls completes. The same hook covers citations, entity extraction, quality scoring, safety flags — anything that should fire across whatever tool ran without baking the logic into each tool definition. It's the seam vertical-AI-startup plugins reach for when they want to add a vertical-specific enrichment pass without forking the substrate or touching every tool. <SourceMeta source="{ label: "examples/plugins/domain-entity-recognition/", href: "https://github.com/pleachhq/core/tree/main/examples/plugins/domain-entity-recognition" }" /> ## The contract [#the-contract] ```typescript contributePostToolTier?(): PostToolTierExecutor | null | undefined type PostToolTierExecutor = ( completedTools: readonly CompletedToolCall[], ) => Promise<readonly PostToolTierRow[]>; ``` One contribution per plugin (first-wins on the hook level — multiple plugins compose at the registration-order level inside the substrate's compiled graph). The executor receives the full batch of completed tool calls for the turn and returns one or more typed rows. The substrate writes those rows alongside the tool results in the event log. ## The 4 stage hooks [#the-4-stage-hooks] Inside the compiled graph, the post-tool tier dispatches into 4 named stages — each one is agnostic-by-injection: the node body is domain-free; the host (or a plugin) supplies the executor at runtime. | Stage | Executor field | What it produces | | -------------- | ----------------------------- | ---------------------------------------------------------------------- | | `enrichment` | `config.enrichmentExecutor` | Domain-specific enrichment rows (entity recognition, vertical lookups) | | `safetyReview` | `config.safetyReviewExecutor` | Safety flags, refusal triggers, policy-bound rewrites | | `quality` | `config.qualityEvaluator` | Quality scores against the tool batch | | `citation` | `config.citationExtractor` | Citation rows attributed to source tool calls | `contributePostToolTier` is the plugin-facing form; the four `config.*Executor` fields are the equivalent at runtime construction time. Hosts can wire either way: ```typescript // Plugin-facing const myPlugin = definePleachPlugin({ capabilities: { _raw: { contributePostToolTier: () => myEntityRecognizer, }, }, }); // Runtime-construction-facing (host strategy) const runtime = new SessionRuntime({ enrichmentExecutor: myEntityRecognizer, citationExtractor: myCitationExtractor, // … }); ``` The plugin-facing path is the recommended one for any plugin that ships outside the host repository — it composes the same as any other contribution, and the host doesn't have to know about the plugin's enrichment to wire it. ## Worked example: vertical entity recognizer [#worked-example-vertical-entity-recognizer] The canonical reference is [`examples/plugins/domain-entity-recognition/`](https://github.com/pleachhq/core/tree/main/examples/plugins/domain-entity-recognition) — a fictional "legal contracts" vertical that recognizes three entity types: | Entity type | What it matches | | ----------- | ---------------------------------------------------------------------------------- | | `party` | Capitalized name(s) ending in `Corp.` / `Inc.` / `LLC` / `Industries` / `Holdings` | | `money` | `$` followed by digits with commas + optional decimals | | `date` | ISO-8601 `YYYY-MM-DD` | ```javascript const PARTY_RE = /\b([A-Z][A-Za-z&.'-]*(?:\s+[A-Z][A-Za-z&.'-]*)*\s+(?:Corp\.?|Inc\.?|LLC|Ltd\.?|Industries|Holdings|Group|Partners|Company|Co\.))/g; const MONEY_RE = /\$\d{1,3}(?:,\d{3})*(?:\.\d+)?/g; const DATE_RE = /\b\d{4}-\d{2}-\d{2}\b/g; const contributePostToolTier = () => { return async (completedTools) => { const out = []; completedTools.forEach((tool, toolIndex) => { const result = extractResult(tool); const text = extractText(result); if (text === null) return; const toolCallId = extractToolCallId(tool, toolIndex); for (const m of text.matchAll(PARTY_RE)) { out.push({ kind: "entity", type: "party", value: m[1].trim(), toolCallId, index: m.index ?? 0, }); } for (const m of text.matchAll(MONEY_RE)) { out.push({ kind: "entity", type: "money", value: m[0], toolCallId, index: m.index ?? 0, }); } for (const m of text.matchAll(DATE_RE)) { out.push({ kind: "entity", type: "date", value: m[0], toolCallId, index: m.index ?? 0, }); } }); return out; }; }; export const legalContractEntityPlugin = { name: "example-legal-contract-entity-recognition", version: "1.0.0", contributePostToolTier, }; ``` The runnable version (with the duck-typed result extractor and a `node --test` smoke that extracts entities from a sample contract) lives at [`examples/plugins/domain-entity-recognition/`](https://github.com/pleachhq/core/tree/main/examples/plugins/domain-entity-recognition). ## The `toolCallId` attribution invariant [#the-toolcallid-attribution-invariant] Every row the executor returns SHOULD include a `toolCallId` field. The runtime narrows the enrichment rows back to their originating tool call when projecting the event log — multi-tool batches without `toolCallId` attribution collapse to "produced by some tool in this batch," and consumer-facing surfaces (citations, lineage diagrams, audit rows) can't render attribution cleanly. The example's `extractToolCallId(tool, fallbackIndex)` returns either the tool's own `toolCallId`, its `id`, or a `unknown-${index}` fallback so production code never silently drops attribution. ## The duck-typed result reader [#the-duck-typed-result-reader] Tool results across the substrate have several shapes: ```typescript function extractText(toolResult) { if (typeof toolResult === "string") return toolResult; if (toolResult === null || typeof toolResult !== "object") return null; if (typeof toolResult.text === "string") return toolResult.text; if (typeof toolResult.content === "string") return toolResult.content; if (toolResult.result && typeof toolResult.result === "object" && typeof toolResult.result.text === "string") { return toolResult.result.text; } if (toolResult.output && typeof toolResult.output === "object" && typeof toolResult.output.text === "string") { return toolResult.output.text; } return null; } ``` Production post-tool-tier executors cover at least these four shapes. Different tools normalize to different result envelopes, and the post-tool-tier seam is the one place that has to handle all of them in one place. ## Composition across the 4 stages [#composition-across-the-4-stages] The four stages dispatch in fixed order: `enrichment → safetyReview → quality → citation`. Each stage's output is visible to the next stage; the substrate threads them through a shared context. Plugins targeting one stage don't collide with plugins targeting another — a vertical entity recognizer (enrichment) composes cleanly with a refusal-pattern detector (safetyReview) and a citation extractor (citation). If two plugins target the same stage, registration order wins — the first plugin's executor fires first; the second sees the output of the first as context but produces its own rows independently. ## Honest scope-limits [#honest-scope-limits] * **The hook is async.** Unlike `onChunk` (which is sync for replay-determinism reasons), `contributePostToolTier` runs after the tool batch completes — there's no streaming constraint, and the executor can call out to an API, a model, a database, etc. * **One enrichment pass per turn.** The executor fires once after the tool batch, not once per tool. If your vertical needs per-tool dispatch, do it inside the executor by walking `completedTools` (the example does this). * **No mutation of tool results.** The executor produces new rows; it doesn't rewrite the tool results that went into the event log. If you need to rewrite tool output before the substrate reads it, that's a [stream observer](/docs/plugins/stream-observers) `amend` verdict, not a post-tool-tier executor. * **Not a graph-node replacement.** The four stages exist as named slots in the compiled graph. `extraGraphNodes` is the hook for adding *new* nodes to the lattice; `contributePostToolTier` fills *existing* slots in the lattice. ## What this enables for verticals [#what-this-enables-for-verticals] The four stages cover the typical vertical's enrichment needs: | Vertical | Stage(s) reached | | --------------------- | ------------------------------------------------------------------------------------------- | | Legal contract review | `enrichment` (entity recognition: parties / money / dates) + `citation` (clause references) | | Medical chart review | `enrichment` (ICD codes / drug names) + `safetyReview` (drug-interaction warnings) | | Regulatory filing | `enrichment` (form fields / dates) + `quality` (completeness scoring) | | Code review | `enrichment` (symbol names / file paths) + `safetyReview` (license violations) | The pattern is identical: swap the recognizer body, keep the seam. The plugin contract guarantees the host doesn't have to know about the vertical for the wiring to work. ## Where to go next [#where-to-go-next] <Cards> <Card title="Authoring" href="/docs/plugins/authoring" description="The full HarnessPlugin hook surface." /> <Card title="Stream observers" href="/docs/plugins/stream-observers" description="Per-chunk inspection — the other half of the plugin contract." /> <Card title="Auditable call row" href="/docs/auditable-call-row" description="The audit row enrichment rows ride alongside." /> <Card title="Plugin contract" href="/docs/plugin-contract" description="The structural-invariant view — what plugins can and can't do." /> </Cards> --- # Routing decisions (/docs/plugins/routing-decisions) > **Status: v1.x roadmap.** The `registerIntentLabel()` API and > the matching plugin-driven routing-override surface are not yet > landed. This page documents the intent and scope so plugin > authors targeting vertical AI startups can plan > against it. > > The underlying substrate already exists: every routing decision > in `@pleach/core` goes through `routingDecision.ts`, which is > the host-supplied seam that resolves a model from > `(ProviderFamily × CallClass × intent)`. This page describes > the plugin-facing API that will sit on top of that seam. ## The problem this page solves [#the-problem-this-page-solves] Today, intent labels in `@pleach/core` are a closed enum: a fixed list of canonical intents the planner recognizes, plus the substrate's per-intent affinity defaults. Vertical plugins (`legal-contract-review`, `medical-imaging`, `regulatory-filing`, `code-review`, etc.) need to register their own intent labels — *and* the routing affinities those intents should resolve to — without forking the substrate. ## The intended surface [#the-intended-surface] Plugins will register intent labels via a top-level helper plus two paired contribution hooks. ```typescript // v1.x roadmap — `definePleachPlugin` ships today; `registerIntentLabel` // is the proposed helper, not yet exported from any `@pleach/core` subpath. import { definePleachPlugin } from "@pleach/core"; // Proposed v1.x signature (shown here for illustration only): declare function registerIntentLabel(spec: { id: string; description: string; }): string; const legalReviewIntent = registerIntentLabel({ id: "legal.contract-review", description: "Vertical intent for legal-contract review", }); export const legalReviewPlugin = definePleachPlugin("legal-contract-review", { // Affinity table — which tools should fire for this intent. intentToolMap: [ { intent: legalReviewIntent, tools: ["search_contracts", "fetch_clause"] }, ], _raw: { version: "0.1.0", // Per-intent classifier — recognizes user text → this intent. contributeIntentClassifiers: () => [ { id: "legal-contract-review", classify(ctx) { return /\b(contract|clause|jurisdiction|indemnif)/i.test(ctx.userText) ? [{ intent: legalReviewIntent, confidence: 0.85 }] : []; }, }, ], // Routing override — pinning an intent to a specific family + call // class is the v1.x roadmap surface described above; it is not yet a // shipping `HarnessPlugin` hook. Today, routing resolves through the // host-supplied `routingDecision.ts` seam. }, }); ``` ## The three pieces [#the-three-pieces] | Piece | What it does | | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `registerIntentLabel({ id, description })` | Returns a typed `IntentLabel` token. Plugins exchange tokens, not raw strings — the substrate can detect duplicate registrations and emit a warning. | | `contributeIntentClassifiers` (existing hook) | Returns classifiers that recognize user text → intent label. Already shipped; v1.x widens it to accept intent-label tokens. | | `contributeFamilyPivot` (existing hook) | First-wins routing override. Already shipped; the v1.x widening lets the pivot read the intent label from the routing context. | `registerIntentLabel()` is the new piece. The two `contribute*` hooks exist today; they widen to thread the typed intent label through. ## Honest scope-limit — what's covered, what isn't [#honest-scope-limit--whats-covered-what-isnt] **In scope for the v1.x cut:** * Intent-label registration (the new helper). * Reading the active intent label in `contributeFamilyPivot`. * Reading the active intent label in `contributeIntentToolMap` entries. * A capability breadcrumb when no classifier maps user text to a registered intent. **Out of scope:** * Cross-plugin intent inheritance (one plugin registers `legal.contract-review`, another registers `legal.contract-review.high-stakes` and inherits the parent's routing). The intent registry is flat by default. * Runtime intent registration (registration happens at plugin construction time, before `SessionRuntime` boots). Late-binding intents at session start are not supported. * Direct override of the seam-level routing (`callClass` literals are still seam-only — see [Plugin contract — what a plugin cannot do](/docs/plugin-contract#what-a-plugin-cannot-do)). ## The substrate site [#the-substrate-site] The routing-decision seam lives in the host layer today, not in `@pleach/core` itself. The seam takes a `(ProviderFamily, CallClass, model)` triple and returns the model that should fire. Plugins reach the seam through `contributeFamilyPivot` — a first-wins override that runs before the substrate's family-strict cascade. The v1.x cut wires the intent label into the pivot's input context (`context.intentId`) so vertical plugins can branch on their own registered labels without parsing user text inside the pivot itself. The `routingDecision` seam itself is bounded by structural invariants: it cannot widen `callClass` to a value outside the four canonical classes, and it cannot escape the family lock for the active session (see [Call classes](/docs/call-classes) and [Family lock](/docs/family-lock)). ## What plugin authors can do today [#what-plugin-authors-can-do-today] While the typed `registerIntentLabel()` API is landing, plugin authors can already: 1. Register **intent affinities** via `contributeIntentToolMap` — the substrate honors these in plan generation. 2. Register **family pivots** via `contributeFamilyPivot` — the pivot reads `context.callClass` and `context.family` and can already override on those axes. The widening adds an `intentId` field to the context. 3. Register **intent classifiers** via `contributeIntentClassifiers` — these dispatch alongside the default classifier today; the widening lets them return a typed intent label. For a working example of all three hooks against the existing substrate, see [`@pleach/recipes` `verticalAgent` factory](/docs/recipes/vertical-agent) — the vertical-agent recipe scaffolds intent registration as a plain string key today and will lift to typed labels as part of the v1.x cut. ## What this is NOT [#what-this-is-not] * **Not a per-call model picker.** Plugins can't choose between GPT-4 and Claude on a per-call basis outside the family lock. The family is locked at session start; pivots route within the family. * **Not a cost-aware router.** Cost routing lives in `@pleach/gateway` and reads from the substrate's family-strict cascade. Plugins don't override gateway-level cost decisions. * **Not an intent-classifier-only hook.** Intent classification is already pluggable today via `contributeIntentClassifiers`. This page covers the *registration* surface — declaring the labels — not the recognition surface. ## Where to go next [#where-to-go-next] <Cards> <Card title="Family lock" href="/docs/family-lock" description="Why model family is locked at session start and how cascade pivots work within it." /> <Card title="Call classes" href="/docs/call-classes" description="The four canonical call classes — utility, reasoning, converse, synthesize." /> <Card title="Model resolution matrix" href="/docs/model-resolution-matrix" description="The substrate's (Family × CallClass) → model resolution table." /> <Card title="Authoring" href="/docs/plugins/authoring" description="The full HarnessPlugin hook surface." /> </Cards> --- # Stream observers (/docs/plugins/stream-observers) `contributeStreamObservers` is the plugin slot for per-chunk inspection of provider seam output. Every plugin that registers an observer gets each inbound chunk in registration order, and each observer returns a verdict that the seam acts on before the next observer runs (and before the chunk reaches downstream graph nodes). It's the substrate's most-reached-for extension hook — redaction, dialect normalization, refusal detection, mid-stream metrics, prompt-leak detection, off-topic detection all fit the shape. <SourceMeta source="{ label: "examples/observers/", href: "https://github.com/pleachhq/core/tree/main/examples/observers" }" /> ## The contract [#the-contract] ```typescript contributeStreamObservers?(): readonly StreamObserverRegistration[] interface StreamObserverRegistration { when: { callClass: CallClass | "*" }; factory: (factoryContext: ObserverFactoryContext) => StreamObserver; } interface StreamObserver { observerId: string; onChunk(chunk: StreamChunk, ctx: ObserverContext): ObserverVerdict; onEndInvocation?(result: InvocationResult, ctx: ObserverContext): void; } ``` **The factory is called fresh per seam invocation.** Per- invocation state (counters, rolling buffers, dedup sets) belongs inside the factory closure and does not leak across concurrent seam invocations on the same runtime. This is the seam-side half of the replay-determinism contract. ## Honest scope-limit: `onChunk` is SYNC ONLY [#honest-scope-limit-onchunk-is-sync-only] ```typescript // This is rejected by the contract type. onChunk(chunk, ctx): Promise<ObserverVerdict> { /* … */ } ``` There is no `Promise<ObserverVerdict>` overload. The reason is the replay-determinism story: an observer that returns a promise introduces non-determinism into the stream, and replay determinism is the load-bearing property the `@pleach/eval@0.1.0` and `@pleach/replay@0.1.0` SKUs are built on. If you need async work (an API call, a model call, a side-effect ship), use the `emit` verdict to fan the structured chunk out to a named channel where the consumer runs async out-of-band. Same goes for `onEndInvocation` — sync only, by the same property. ## The 6 verdicts [#the-6-verdicts] The verdict ladder is enumerated in the source as a discriminated union. The substrate exposes four widely-used verdicts plus two backpressure verdicts. | Verdict | Effect | | ---------------------------------------------------------------- | ----------------------------------------------------------- | | `{ kind: "continue" }` | Pass through unchanged. The observer-default. | | `{ kind: "amend", chunk }` | Replace chunk content 1:1 — strict, no multiplex. | | `{ kind: "emit", events }` | Pass through AND fan out a side-event onto a named channel. | | `{ kind: "stop", reason, chunkIndex, accumulatedContentLength }` | Stop the stream. Downstream reads the stop sentinel. | | `{ kind: "buffer" }` | Hold the chunk for later release (backpressure). | | `{ kind: "release", chunks }` | Emit previously buffered chunks. | `amend` being 1:1 is deliberate. A one-chunk-in, many-chunks-out observer would break the byte-replay property. Plugins that need fan-out emit named envelopes on a channel via `emit`, not extra stream chunks. Concretely: a redaction observer that replaces a span with `[REDACTED]` returns an `amend` verdict whose `chunk.text` is the cleaned string; the chunk count stays the same. A metrics observer that wants to ship a structured side-effect returns an `emit` verdict with an envelope on a named channel (e.g. `metrics`), and the main stream sees the original chunk pass through unchanged. ## Worked example: dialect normalization (`amend`) [#worked-example-dialect-normalization-amend] Some providers emit XML-flavored tool-call markers (`<tool name="search">{...}</tool>`) or tuple-keyed JSON (`{0:"search", 1:{...}}`) instead of the harness's canonical `tool_call_delta` shape. A normalization observer detects the variants and substitutes a canonicalized chunk so downstream graph nodes see one shape regardless of provider quirks. ```javascript const XML_TOOL_RE = /<tool\s+name=["']([^"']+)["']\s*>([\s\S]*?)<\/tool>/g; const TUPLE_TOOL_RE = /\{\s*0:\s*["']([^"']+)["']\s*,\s*1:\s*(\{[^}]*\})\s*\}/g; function makeCanonicalToolCallDelta({ name, argsJson, observerId }) { return { kind: "tool_call_delta", payload: { name, args: argsJson, _amendedBy: observerId }, }; } function makeObserver(_factoryContext) { let chunkCount = 0; return { observerId: "example-dialect-normalization", onChunk(chunk, _ctx) { chunkCount += 1; if (chunk.kind !== "content_delta") return { kind: "continue" }; const text = extractText(chunk.payload); if (text === null) return { kind: "continue" }; const xmlMatch = XML_TOOL_RE.exec(text); XML_TOOL_RE.lastIndex = 0; if (xmlMatch !== null) { return { kind: "amend", chunk: makeCanonicalToolCallDelta({ name: xmlMatch[1], argsJson: xmlMatch[2].trim(), observerId: "example-dialect-normalization", }), }; } // …tuple fallback… return { kind: "continue" }; }, }; } export const dialectNormalizationPlugin = { name: "example-dialect-normalization", version: "1.0.0", contributeStreamObservers: () => [ { when: { callClass: "*" }, factory: makeObserver }, ], }; ``` The runnable version (with the tuple fallback, the duck-typed text extractor, and a `node --test` smoke driving it with synthetic chunks) lives at [`examples/observers/dialect-normalization/`](https://github.com/pleachhq/core/tree/main/examples/observers/dialect-normalization). **Things to notice:** * `factory: makeObserver` — the seam calls this once per invocation. `chunkCount` is per-invocation closure state. * `when: { callClass: "*" }` — fire on every call class. Narrow with `{ callClass: "synthesize" }` if your normalization is synthesis-specific. * The `_amendedBy: observerId` field tags the amended chunk for downstream debugging. The substrate doesn't require it; it's a load-bearing diagnostic in production observers. ## Worked example: stop on refusal pattern (`stop`) [#worked-example-stop-on-refusal-pattern-stop] Cancel a streaming generation as early as possible when the model emits a generic refusal so the host can fail-over to another provider, rewrite the prompt, or surface the refusal to the user without paying for the rest of the wasted tokens. ```javascript const DEFAULT_REFUSAL_RE = /\b(?:I cannot|I'm not able to|I am unable to|Sorry, I can'?t|I won'?t be able to)\s+(?:help|assist|comply|provide)\b/i; const ACCUMULATOR_CAP_BYTES = 16 * 1024; export function makeHaltObserver(options = {}) { const pattern = options.pattern ?? DEFAULT_REFUSAL_RE; const observerId = options.observerId ?? "example-halt-on-pattern"; return (_factoryContext) => { let chunkCount = 0; let accumulator = ""; return { observerId, onChunk(chunk, _ctx) { chunkCount += 1; if (chunk.kind !== "content_delta") return { kind: "continue" }; const text = extractText(chunk.payload); if (text === null) return { kind: "continue" }; accumulator += text; if (accumulator.length > ACCUMULATOR_CAP_BYTES) { accumulator = accumulator.slice(-ACCUMULATOR_CAP_BYTES); } const match = pattern.exec(accumulator); pattern.lastIndex = 0; if (match !== null) { return { kind: "stop", reason: `pattern matched: ${JSON.stringify(match[0])}`, chunkIndex: chunkCount, accumulatedContentLength: accumulator.length, }; } return { kind: "continue" }; }, }; }; } export const haltOnPatternPlugin = { name: "example-halt-on-pattern", version: "1.0.0", contributeStreamObservers: () => [ { when: { callClass: "synthesize" }, factory: makeHaltObserver() }, ], }; ``` The runnable version lives at [`examples/observers/halt-on-pattern/`](https://github.com/pleachhq/core/tree/main/examples/observers/halt-on-pattern). **Things to notice:** * **Memory cap** — `ACCUMULATOR_CAP_BYTES = 16 * 1024`. Generic refusals fire in the first few hundred bytes; longer windows blow memory if the stream degenerates. Production observers cap any rolling buffer they maintain. * **Pattern is configurable.** The default is a generic refusal detector. Swap for your own — off-topic detection, jailbreak detection, prompt-leak detection, etc. * **The seam owns abort semantics.** The observer just signals `stop`; the seam handles the cancel. Downstream recovery handlers position themselves at the stop point using `chunkIndex` + `accumulatedContentLength`. ## Per-`callClass` registration [#per-callclass-registration] The `when` clause narrows which seam invocations fire the observer: | `when.callClass` | Fires for | | ---------------- | ------------------------------------------------------------- | | `"synthesize"` | The final assistant-message synthesis seam | | `"reasoning"` | Mid-turn reasoning calls (chain-of-thought style) | | `"utility"` | Lightweight provider calls (re-ranking, classification, etc.) | | `"converse"` | Multi-turn conversational chunks | | `"*"` | Every seam — broad match | Narrowing to one call class is a real performance win when the observer's pattern only applies in one context. The dialect- normalization example uses `"*"` because canonicalization is universal; the halt-on-refusal example uses `"synthesize"` because that's where refusals surface. ## Per-invocation freshness — what the seam guarantees [#per-invocation-freshness--what-the-seam-guarantees] ```typescript factory: (factoryContext: ObserverFactoryContext) => StreamObserver; ``` The seam calls `factory(factoryContext)` once per seam invocation. The returned `StreamObserver` lives only for that invocation. Any state inside the factory closure — counters, rolling buffers, dedup sets, partial matches — is per-invocation by construction. Two concurrent invocations on the same runtime get two separate observer instances with two separate closures, no shared state. Authors should NOT capture state at plugin construction time and expect it to be per-invocation: ```typescript // WRONG — `chunkCount` is shared across every invocation. let chunkCount = 0; export const myPlugin = { contributeStreamObservers: () => [ { factory: () => ({ onChunk(chunk) { chunkCount += 1; /* … */ }, }), }, ], }; // RIGHT — `chunkCount` is per-invocation. export const myPlugin = { contributeStreamObservers: () => [ { factory: () => { let chunkCount = 0; return { onChunk(chunk) { chunkCount += 1; /* … */ }, }; }, }, ], }; ``` ## Composing multiple observers [#composing-multiple-observers] Multiple plugins (or one plugin registering multiple `StreamObserverRegistration` entries) compose in registration order. The first observer sees the original chunk; the second observer sees whatever the first amended (or skipped); and so on. A `stop` verdict from any observer terminates the stream for all subsequent observers. The canonical non-commuting pair is a PII-redaction observer and a metrics observer that records chunk lengths: register redaction first and metrics sees the redacted length; register metrics first and it sees the original length. Both orderings are legal; the construction site is the place to make the choice visible. ## Where to go next [#where-to-go-next] <Cards> <Card title="Authoring" href="/docs/plugins/authoring" description="The full 74-hook authoring surface — when each hook fires, canonical no-ops." /> <Card title="Plugin contract" href="/docs/plugin-contract" description="The structural-invariant view — what plugins can and can't do." /> <Card title="Stream events" href="/docs/stream-events" description="The substrate's chunk type system — content_delta, tool_call_delta, etc." /> <Card title="Determinism" href="/docs/determinism" description="The replay-determinism property and why onChunk is sync-only." /> </Cards> --- # Provider detection (/docs/quickstart/provider-detection) The `createPleachRoute()` handler from `@pleach/core/quickstart` auto-detects which provider to use based on which env var you set. This page documents the resolution matrix, the locked default priority, and how to override it. ## The resolution matrix [#the-resolution-matrix] The four cases the regression-lock test at `packages/core/test/quickstart/providerDetection.test.mjs` enforces: | `OPENROUTER_API_KEY` | `ANTHROPIC_API_KEY` | `detectProvider()` returns | | -------------------- | ------------------- | ------------------------------- | | set | unset | `"openrouter"` | | unset | set | `"anthropic"` | | set | set | `"anthropic"` (alphabetical) | | unset | unset | `null` → route returns HTTP 503 | The third row is the locked behavior consumers most often ask about. **Default priority is alphabetical, not preference-biased.** Anthropic precedes OpenRouter alphabetically, so it wins. This is intentional. The alphabetical default avoids encoding any OpenRouter-primary preference into the "I just installed Anthropic SDK and it works" experience. Operators that want a specific priority pass it explicitly. ## Full default priority [#full-default-priority] ``` anthropic → deepseek → google → mistral → moonshot → openai → openrouter ``` These map to six of the seven `ProviderFamily` values exported from `@pleach/core/modelfamily` (`xai` has no standalone env-var detection) plus `openrouter` as a gateway transport. The resolution matrix itself is host-supplied — reached through the `AgentAdapter.resolveModel<C>()` seam, not shipped in the package. Default env-var aliases per provider (from `DEFAULT_PROVIDER_ENV_VARS`): | Provider | Env var(s) | | ------------ | ------------------------------------------------ | | `anthropic` | `ANTHROPIC_API_KEY` | | `deepseek` | `DEEPSEEK_API_KEY` | | `google` | `GOOGLE_GENERATIVE_AI_API_KEY`, `GOOGLE_API_KEY` | | `mistral` | `MISTRAL_API_KEY` | | `moonshot` | `MOONSHOT_API_KEY` | | `openai` | `OPENAI_API_KEY` | | `openrouter` | `OPENROUTER_API_KEY` | Google ships keys under two names — the newer `GOOGLE_GENERATIVE_AI_API_KEY` (used by `@google/generative-ai`) and the legacy `GOOGLE_API_KEY`. The newer name is checked first. ## Custom priority [#custom-priority] Pass `providers` to override: ```typescript import { detectProvider } from "@pleach/core/quickstart"; // OpenRouter wins when both keys are set. const provider = detectProvider({ providers: ["openrouter", "anthropic", "openai"], }); ``` Pass a subset to restrict detection — providers not in the list are ignored even if their env vars are set: ```typescript // Only consider Anthropic and OpenAI. OpenRouter is ignored. const provider = detectProvider({ providers: ["anthropic", "openai"], }); ``` ## Custom env-var aliases [#custom-env-var-aliases] Override per-provider env-var names via `envVars`: ```typescript const provider = detectProvider({ envVars: { anthropic: ["MY_CORP_ANTHROPIC_KEY", "ANTHROPIC_API_KEY"], }, }); ``` The override replaces the provider's entire alias list. Providers not in `envVars` inherit defaults. ## Test-friendly: supply a synthetic env [#test-friendly-supply-a-synthetic-env] By default `detectProvider` reads from `process.env`. Pass `env` to supply your own map — useful in tests, edge environments, and custom config sources: ```typescript const provider = detectProvider({ env: { ANTHROPIC_API_KEY: "sk-ant-test" }, }); // → "anthropic" ``` Pass `env: {}` to force a "no provider detected" path without mutating `process.env`. ## Listing all detected providers [#listing-all-detected-providers] `detectAvailableProviders()` returns every provider with a non-empty env-var value, in priority order. Useful for diagnostics and for `createPleachRoute` callers that want to enumerate fallbacks: ```typescript import { detectAvailableProviders } from "@pleach/core/quickstart"; const available = detectAvailableProviders(); // e.g. ["anthropic", "openrouter"] when both keys are set ``` ## Pinning a provider explicitly [#pinning-a-provider-explicitly] If you want to skip detection entirely, pass `provider` to `createPleachRoute`: ```typescript export const POST = createPleachRoute({ provider: "anthropic", }); ``` Detection is skipped; the route uses Anthropic regardless of which other env vars are set. ## Honest scope-limit [#honest-scope-limit] Provider detection works for the seven default providers above. **Custom providers** (your own gateway, a self-hosted LLM, a forked SDK) require `orchestratorConfig` on `createPleachRoute` or direct `SessionRuntime` construction — see [Upgrading to @pleach/core](/docs/quickstart/upgrading-to-core). The detection helpers are pure functions over an env map; they do NOT validate that the key works against the provider. A wrong-key failure surfaces later as a provider exception inside the stream, which `ChatStreamError.cause` preserves. ## Where to go next [#where-to-go-next] <Cards> <Card title="Quickstart" href="/docs/quickstart" description="One install, one env var, three files." /> <Card title="Providers" href="/docs/providers" description="Provider switching, family-strict cascade, model resolution." /> <Card title="Env vars" href="/docs/env-vars" description="Full env var reference across all SKUs." /> <Card title="Upgrading to @pleach/core" href="/docs/quickstart/upgrading-to-core" description="When the quickstart surface is too tight." /> </Cards> --- # Upgrading to @pleach/core (/docs/quickstart/upgrading-to-core) The quickstart surface — `createPleachRoute()` and `simpleChatbot()` — covers most "I want a streaming chat" cases. When you need more control, you drop one layer down to `@pleach/core` directly. This page walks the upgrade. ## When to upgrade [#when-to-upgrade] Reach for `@pleach/core` directly when you need: * **A `HarnessPlugin`** — custom prompt contributions, intent resolvers, tool-call validators, stream observers, post-tool enrichment. See [Plugin contract](/docs/plugin-contract). * **Custom tools** — anything you'd write as a tool definition the agent can invoke. See [Tools](/docs/tools) and [Base tools](/docs/base-tools). * **Custom `orchestratorConfig`** — pinned model, family-strict cascade rules, provider hedge policies, custom transport (Bedrock, Vertex, Azure OpenAI). See [Providers](/docs/providers). * **Custom storage** — Postgres / Supabase / your own `SessionStorageAdapter`. See [Storage](/docs/storage). * **Custom rendering** — your own React UI, your own streaming protocol, your own non-HTTP transport. * **Multi-tenant isolation** — `tenant_id` scoping, per-tenant rate limiting, per-tenant cost rollup. See [Multi-tenant](/docs/multi-tenant). * **Interrupts** — pause-and-edit flows, human-in-the-loop. See [Interrupts](/docs/interrupts). * **Checkpointing + time travel** — rollback, replay, divergent history. See [Checkpointing](/docs/checkpointing) and [Time travel](/docs/time-travel). If none of the above fit, you don't need to upgrade. Stay on the quickstart surface. ## Escape hatch: `bot.runtime` [#escape-hatch-botruntime] If you're already using `simpleChatbot` from `@pleach/recipes` and just need to reach under the hood, the `Chatbot` returned by `simpleChatbot()` exposes its underlying `SessionRuntime` directly: ```typescript import { simpleChatbot } from "@pleach/recipes/chatbot"; const bot = simpleChatbot({ /* ... */ }); // `bot.runtime` IS the SessionRuntime — same type, same identity. // Reach for it directly: const session = await bot.runtime.createSession(); const stream = bot.runtime.executeMessage(session.id, "hello"); for await (const event of stream) { // handle the StreamEvent discriminated union yourself } ``` The escape hatch is the documented progressive-disclosure path. The factory contract at `packages/recipes/src/chatbot.ts` line 80 declares `readonly runtime: SessionRuntime` and the README marks it as the supported escape route. You can mix-and-match: use `bot.ask()` for the easy path, reach into `bot.runtime` for the hard parts. ## Direct `createPleachRuntime` use [#direct-createpleachruntime-use] Skip the recipes wrapper entirely: ```typescript import { createPleachRuntime, setOrchestratorAdapterCtor, } from "@pleach/core/runtime"; // At app boot: register your provider adapter ctor once. setOrchestratorAdapterCtor(MyOrchestratorAdapter); const runtime = createPleachRuntime({ host: { strategies: { orchestratorConfig: { provider: "anthropic", model: "claude-sonnet-4", apiKey: process.env.ANTHROPIC_API_KEY!, }, }, }, plugins: [myPlugin], }); const session = await runtime.createSession(); for await (const event of runtime.executeMessage(session.id, "hello")) { switch (event.type) { case "text": process.stdout.write(event.delta); break; case "tool-call": console.log("tool:", event.toolName, event.args); break; case "error": console.error("stream error:", event.message); break; } } ``` The `SessionRuntime` returned is the same type `simpleChatbot()` wraps. Session semantics are preserved: `createSession()` mints a session id; `executeMessage(sessionId, message)` appends a user turn and streams the assistant response; multi-turn memory is the default (use a fresh `createSession()` to start over). ## Why a subpath for `createPleachRuntime`? [#why-a-subpath-for-createpleachruntime] The factory imports from `@pleach/core/runtime`, not the top-level `@pleach/core` barrel. This is intentional and documented at `packages/recipes/src/chatbot.ts` lines 18-26. The bundle-local `_registeredOrchestratorAdapterCtor` state that the factory reads is the SAME state that consumers register via `setOrchestratorAdapterCtor`, which is also exposed on the `@pleach/core/runtime` subpath. The top-level `@pleach/core` ESM bundle does NOT re-export the setter today; the subpath does. Using the subpath in both places makes the orchestrator-config plumbing actually wire end-to-end. See [Subpath exports](/docs/subpath-exports) for the full subpath surface. ## Trade-off: route handler vs direct runtime [#trade-off-route-handler-vs-direct-runtime] | Want | Use | | ----------------------------------------------------- | -------------------------------------------- | | HTTP boundary, streaming over fetch | `createPleachRoute()` | | Node/CLI/script, in-process | `simpleChatbot()` or `createPleachRuntime()` | | React UI, no custom transport | `<ChatBox />` + `createPleachRoute()` | | Custom transport (WebSocket, SSE-with-custom-framing) | `createPleachRuntime()` directly | | Custom plugins, tools, intent resolvers | `createPleachRuntime({ plugins })` | Mix freely. The route handler is a thin shell around `createPleachRuntime`; the recipes `simpleChatbot` is a thin shell around `createPleachRuntime`; both pass options through. ## Where to go next [#where-to-go-next] <Cards> <Card title="Runtime construction" href="/docs/runtime-construction" description="`SessionRuntime` constructor + `createPleachRuntime` factory in full detail." /> <Card title="Session runtime" href="/docs/session-runtime" description="The 17-method `SessionRuntime` surface — sessions, messages, plugins, observers." /> <Card title="Plugin contract" href="/docs/plugin-contract" description="`HarnessPlugin` — the 50+ contribution hooks for customizing runtime behavior." /> <Card title="Subpath exports" href="/docs/subpath-exports" description="`@pleach/core` ships 40+ subpaths. Which one to import from." /> <Card title="Providers" href="/docs/providers" description="Provider switching, family-strict cascade, model resolution." /> </Cards> --- # agentInstrumentation (/docs/recipes/agent-instrumentation) This is the do-it-yourself instrumentation path. You hold a `SessionRuntime` and you want to watch how each turn runs — stage transitions, recovery dispatches, retries, per-LLM-call cost — without adopting a recipe wrapper or threading a recorder argument through your code. Two surfaces carry the signal. The durable `runtime.events` bus carries lifecycle and per-call events for cross-cutting subscribers. The per-turn `StreamEvent` iterator carries the same lifecycle kinds inline with the turn, for UIs that render from the stream. Pick the one that matches where your subscriber lives. Best fit: **any `@pleach/core` consumer** who wants metrics or audit rows from an existing runtime without rewriting the turn loop. For the wrapped-chatbot version of this story, see [`observableChatbot`](/docs/recipes/observable-chatbot). For the no-`@pleach/core` brownfield version, see [BYOK observability](/docs/recipes/byok-observability). ## Subscribe to the runtime bus [#subscribe-to-the-runtime-bus] `runtime.events.on(kind, handler)` registers a handler for one event kind on the durable bus. It returns an unsubscribe function. The bus is the right surface for a metrics exporter, a cost meter, or an alerting hook — anything that outlives a single turn. ```typescript const offModel = runtime.events.on("model.called", (e) => { // { provider, model, callClass, inputTokens, outputTokens, costUSD, latencyMs } meter.record(e.model, e.latencyMs); }); const offError = runtime.events.on("error", (e) => { // { error, context } alert(e.error); }); // later, on teardown: offModel(); offError(); ``` `model.called` fires once per LLM call. As of the seam-path landing it fires for **both** call sites in a turn: * the main agentic turn (the converse / synthesize loop), and * each seam call — the dedicated `synthesize`, `reasoning`, and `utility` seam invocations the graph makes around the main turn. That distinction matters for cost. See [What fires today](#what-fires-today) below before you sum `costUSD` across rows. ## Subscribe inline with the turn [#subscribe-inline-with-the-turn] When your subscriber already owns the turn loop — a React UI, an SSE forwarder — read the same lifecycle kinds off the `StreamEvent` iterator instead. `@pleach/react`'s `useChat` exposes them through `onEvent`: ```typescript useChat({ onEvent: (e) => { if (e.type === "stage.transition") { // { from: StageId | null, to: StageId } track("stage", e.to); } if (e.type === "turn.completed") { // { outcome: "ok" | "error" | "interrupted", durationMs } track("turn", e.outcome, e.durationMs); } }, }); ``` The same kinds arrive on the raw iterator if you do not use `useChat`: ```typescript for await (const e of runtime.executeMessage(sessionId, prompt)) { if (e.type === "recovery.fired") { // { arm: RecoveryDispatchArm } track("recovery", e.arm); } } ``` These are `StreamEvent` members, not `runtime.events` bus events. The full catalog — payload shapes and when each fires — is on [Stream events → Execution lifecycle](/docs/stream-events#execution-lifecycle). `model.called` is the exception: it lives on the durable bus, not the stream, because cost attribution is a long-lived concern. ## Bridge model.called to a destination [#bridge-modelcalled-to-a-destination] `observeSink` turns the `runtime.events.on("model.called", ...)` subscription into audit rows with one line. It is a handler factory: `observeSink({ destinations })` returns a `model.called` handler that maps each event to an `ObserveRow` and writes it to every destination you pass. ```typescript import { observeSink } from "@pleach/observe"; import { memory, postgres } from "@pleach/observe/destinations"; const store = memory(); runtime.events.on( "model.called", observeSink({ destinations: [store, postgres({ pgClient, tableName: "pleach_observe_calls" })], }), ); // every LLM call now writes one ObserveRow to both destinations ``` The mapping is `runtimeEventToObserveRow` — a pure function from a `model.called` event to an `ObserveRow`. `observeSink` calls it for you; import it directly if you want the row without the write (to enrich `tags` before handing it to your own sink, say): ```typescript import { runtimeEventToObserveRow } from "@pleach/observe"; runtime.events.on("model.called", (e) => { const row = runtimeEventToObserveRow(e); myKafkaTopic.publish({ ...row, tags: { region: "us-east-1" } }); }); ``` `ObserveRow` is the same shape the brownfield `recordCall` path writes — a strict subset of `@pleach/core`'s `AuditableCall` v13 record. See [`@pleach/observe`](/docs/observe) for the row fields, the four destinations, redaction, and sampling. ## What fires today [#what-fires-today] `model.called` fires for both the main turn and the seam calls, but the two carry cost differently. | Source | When it fires | Tokens + latency | costUSD | | --------------------------------------------------------- | ------------- | ---------------- | ----------------------------------------------------------------- | | Seam calls (`synthesize` / `reasoning` / `utility` seams) | Every turn | Real | Real — computed from the family's per-1M-token rates | | Main agentic turn (converse / synthesize loop) | Every turn | Real | `0` sentinel — derive downstream from the `llm.turn` token counts | The seam-call rows are the ones with real `costUSD`. The main-turn row reports tokens and latency but carries `0` for `costUSD` by design — cost for the main turn is derived downstream from `llm.turn` token metadata, not stamped on the `model.called` event. If you sum `costUSD` naively across all `model.called` rows in a turn, the main-turn row contributes nothing; that is expected, not a dropped row. The lifecycle stream events fire every turn regardless of the above: * `turn.started` / `turn.completed` * `stage.transition` (and `stage.lattice_violation` when a transition leaves the advertised lattice) * `recovery.fired` — `arm` ∈ `"zeroToolRecovery"` | `"allToolsFailedMissingParams"` | `"maxStepsHit"` * `retry.attempted` — `{ reason, attempt }` * `stream.first_chunk` / `stream.completed` — TTFB and total stream duration So a UI can reconstruct the per-turn lattice walk and report TTFB and total latency from the stream alone, with no separate metrics pipeline. ## Common gotchas [#common-gotchas] * **`model.called` is a bus event, not a stream event.** Subscribe with `runtime.events.on("model.called", ...)`. It does not arrive in the `executeMessage` iterator or in `useChat({ onEvent })` — those carry the lifecycle stream kinds (`stage.*`, `turn.*`, `recovery.fired`, `retry.attempted`, `stream.*`), not the per-call cost signal. * **The main-turn `costUSD` is a `0` sentinel.** Real cost on the main turn is derived from `llm.turn` token counts downstream; the seam-call rows are the ones carrying computed `costUSD`. Don't treat a `0`-cost main-turn row as a bug. * **`observeSink` needs no `init`.** Unlike the brownfield `recordCall` path, `observeSink({ destinations })` writes straight to the destinations you pass — there is no process-singleton to initialize first. * **`runtime.events.on` returns an unsubscribe function.** Call it on teardown for long-lived processes; a leaked handler keeps the destination write alive past the runtime you meant to retire. ## See also [#see-also] * [Stream events](/docs/stream-events) — the full `StreamEvent` catalog, including the execution-lifecycle kinds this page subscribes to. * [`@pleach/observe`](/docs/observe) — `observeSink`, `runtimeEventToObserveRow`, the `ObserveRow` shape, and the four destinations. * [`observableChatbot`](/docs/recipes/observable-chatbot) — the same observability story as a chatbot recipe wrapper. * [BYOK observability](/docs/recipes/byok-observability) — the brownfield path for loops not yet on `@pleach/core`. * [Observability](/docs/observability) — the runtime-side observability surface. --- # BYOK observability (/docs/recipes/byok-observability) `@pleach/observe` is the brownfield entry point to the Pleach audit ledger. You add roughly 15 lines around your existing LLM calls and get one typed audit row per call, written to a backend you pick. This page walks the BYOK (bring-your-own backend) path: no `@pleach/core` runtime, no migration, no hosted control plane. The row written through the SDK is a strict subset of `@pleach/core`'s `AuditableCall` v13 record plus the `TokenCostRecord` field set, so the audit history stays forward-compatible if you later adopt the runtime. ## When to reach for this [#when-to-reach-for-this] * You already run an agent loop — Vercel AI SDK, LangChain, the OpenAI or Anthropic SDK called directly, an in-house orchestrator — and rewriting it isn't on the roadmap. * You want one auditable row per LLM call, written to infrastructure you already operate (Postgres, Supabase, your OpenTelemetry collector). * You don't need replay determinism, family-locked routing, reactive channels, or checkpoint / restore. Those are runtime properties — `@pleach/core` carries them. The SDK does not, by design. ## Quickstart — Postgres [#quickstart--postgres] Wire `@pleach/observe` against your existing `pg` pool. No new dependency is added to the SDK side; the SDK uses structural types only. ```ts import { Pool } from "pg"; import { init, recordCall } from "@pleach/observe"; import { postgres } from "@pleach/observe/destinations"; const pgClient = new Pool({ connectionString: process.env.DATABASE_URL }); init({ destination: postgres({ pgClient, tableName: "audit_rows", }), }); // Inside your existing agent loop, after each LLM call: recordCall({ turnId: "turn_001", providerId: "openai", callClass: "synthesize", family: "openai", model: "gpt-5", inputTokens: 850, outputTokens: 320, costUSD: 0.0091, startedAt: Date.now() - 900, completedAt: Date.now(), }); ``` The `CREATE TABLE` shape (column names, types, indexes) ships in the package's `docs/postgres.md`. The parameterized `INSERT` targets exactly that column set; the SDK never alters your schema. ## Quickstart — Supabase [#quickstart--supabase] For managed Postgres with RLS. Same row shape; the round trip goes through PostgREST. ```ts import { createClient } from "@supabase/supabase-js"; import { init, recordCall } from "@pleach/observe"; import { supabase } from "@pleach/observe/destinations"; const client = createClient( process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!, ); init({ destination: supabase({ client, tableName: "audit_rows" }), }); recordCall({ turnId: "turn_supabase_001", providerId: "anthropic", callClass: "synthesize", family: "anthropic", model: "claude-sonnet-4-6", inputTokens: 1500, outputTokens: 600, costUSD: 0.0228, startedAt: Date.now() - 1700, completedAt: Date.now(), }); ``` An RLS template policy ships in the package's `docs/supabase.md`. At >100 rows/s sustained, switch to the Postgres destination directly and keep Supabase Auth + Storage as separate concerns. ## Quickstart — OpenTelemetry [#quickstart--opentelemetry] For shops already running a collector (Honeycomb, Datadog, Grafana, your own OTLP receiver). Buyer-callback only — your OTel SDK stays on your side; the SDK hands you a GenAI semantic-convention envelope and your callback ships it. ```ts import { init, recordCall } from "@pleach/observe"; import { otel } from "@pleach/observe/destinations"; init({ destination: otel({ serviceName: "my-agent", exportSpan: (envelope) => { myExporter.export(envelope); }, }), }); recordCall({ turnId: "turn_otel_001", providerId: "anthropic", callClass: "synthesize", family: "anthropic", model: "claude-sonnet-4-5", inputTokens: 1500, outputTokens: 600, costUSD: 0.0228, startedAt: Date.now() - 1700, completedAt: Date.now(), }); ``` Zero `@opentelemetry/*` packages added on the SDK side. The envelope follows GenAI semantic conventions so it lands in the same tables as the rest of your AI telemetry. ## Wiring inside an existing loop [#wiring-inside-an-existing-loop] `init` is called once per process. `recordCall`, `subagent`, and the destination factories are bound to the module singleton that `init` configures. Reach for `subagent` when you need per-tenant or per-step attribution paths inside one turn. ```ts import { subagent, recordCall } from "@pleach/observe"; await subagent("tenant-abc").run(async () => { await subagent("planner").run(async () => { const start = Date.now(); const reply = await myLLM.complete(prompt); recordCall({ turnId, providerId: "openai", callClass: "reasoning", family: "openai", model: "o4-mini", inputTokens: reply.usage.prompt_tokens, outputTokens: reply.usage.completion_tokens, costUSD: estimateCost(reply.usage), startedAt: start, completedAt: Date.now(), }); return reply; }); }); // Row carries attributionPath: ["tenant-abc", "planner"] ``` Attribution paths are stored on the row directly — joinable to your billing schema with one `GROUP BY tenant_id` or one `GROUP BY (tenant_id, subagent_path[1])`. ## Common gotchas [#common-gotchas] * **The SDK is a process-singleton.** `init({destination})` is called once at app boot, before any `recordCall(...)`. A second `init(...)` call throws — the SDK is singleton-per-process, so call it exactly once at boot rather than relying on a last-write-wins replacement. * **`recordCall` is sync-fire, async-write.** It enqueues to the destination and returns synchronously. The destination decides whether the actual write is sync (memory) or async (Postgres, Supabase, OTel). By default both synchronous destination throws and async rejections are swallowed (soft-fail) so a destination failure never aborts the turn. To surface them, opt in with `init({ destination, throwOnDestinationError: true })`. * **No retry, no batching at the SDK boundary.** That's the destination's job. The Postgres destination uses your pool's connection management; the Supabase destination inherits PostgREST behavior; the OTel destination is whatever your callback does. * **Costs are buyer-computed.** `costUSD` on the row is the cost as YOU computed it — the SDK is provider-agnostic and does not maintain a price catalog. Compute from the provider's per-call usage report and ship. * **`sampling` is validated at `init` time.** A `samplingRatio` outside `[0, 1]` (or `NaN`, non-finite) throws at boot, not silently at write time. Default is no sampling — every call writes. ## See also [#see-also] * [`@pleach/observe`](/docs/observe) — full surface reference: `init`, `recordCall`, `subagent`, the four destinations, the `ObserveRow` schema. * [`Audit ledger`](/docs/audit-ledger) — the row shape and the `AuditableCall` v13 record this SDK writes a subset of. * [`Adoption paths`](/docs/adoption-paths) — when the brownfield SDK is the right entry point and when `@pleach/core` is. * [`OTel observability`](/docs/otel-observability) — the OTel destination's envelope shape and semantic conventions. * [`observableChatbot`](/docs/recipes-pleach-recipes#observablechatbot) — the matching recipe for greenfield consumers building on `@pleach/core`. --- # compliantChatbot (/docs/recipes/compliant-chatbot) `compliantChatbot` wraps `simpleChatbot` with per-profile scrubbers. The `profile` field selects a curated bundle from `@pleach/compliance/scrubbers`; the bundle (plus any `extraScrubbers`) is applied to the user message string before `ask()` forwards it to the runtime. When `supabase` is supplied, the recipe also constructs an `@pleach/core` `EventLogWriter` configured with the same scrubbers and plumbs it through `host.modules.eventLogWriter` — so the persisted copy of every event row is scrubbed at the substrate boundary too. Best fit: **compliance-bound** or **regulated-industry** deployments. Reach for this when HIPAA, GDPR, PCI-DSS, or SOC 2 requires the event log persisted or exported to be free of unredacted PII. ## Quickstart [#quickstart] Message-content scrubbing only (v0.2 baseline): ```ts import { compliantChatbot } from "@pleach/recipes/compliance"; const bot = compliantChatbot({ profile: "hipaa" }); await bot.ask("patient called about prescription refill"); ``` Opt in to EventLogWriter-boundary scrubbing by passing a Supabase client: ```ts import { createClient } from "@supabase/supabase-js"; import { compliantChatbot } from "@pleach/recipes/compliance"; const supabase = createClient( process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!, ); const bot = compliantChatbot({ profile: "hipaa", supabase, }); ``` ## What it does [#what-it-does] Profile → scrubber bundle mapping: * `hipaa` → `SsnUsScrubber` + `UsDriverLicenseScrubber` + `CreditCardScrubber` * `gdpr` → `SsnUsScrubber` + `CreditCardScrubber` * `pci-dss` → `CreditCardScrubber` * `soc2` → `SsnUsScrubber` + `CreditCardScrubber` On each `ask(message)` the resolved bundle (plus any `extraScrubbers`) runs against the message string before the recipe delegates to `simpleChatbot.ask()`. Matches are replaced with the scrubber's redaction marker. When `supabase` is supplied, the recipe additionally constructs an `EventLogWriter` with `onScrubberError: "fail-closed"` and forwards it through `createPleachRuntime({host: {modules: {eventLogWriter}}})`. Every `write()` invocation runs the `DefaultScrubberChain` against the per-event-type allowlist (`content.delta`, `interrupt.edited`, `audit.recorded`, `domain.ivy.job.*`) before the row lands in the durable buffer. ## Config reference [#config-reference] ```ts type ComplianceProfile = "hipaa" | "gdpr" | "pci-dss" | "soc2"; interface ComplianceScrubber { readonly id: string; scrub( input: string, ctx?: { readonly eventType?: string; readonly fieldPath?: readonly string[]; readonly tenantId?: string; }, ): { readonly redacted: string; readonly matchCount: number; readonly matchedScrubberIds?: readonly string[]; }; } interface CompliantChatbotConfig extends SimpleChatbotConfig { profile: ComplianceProfile; /** Run AFTER the profile bundle (last-wins on overlap). */ extraScrubbers?: readonly ComplianceScrubber[]; /** Opt-in to EventLogWriter-boundary scrubbing. */ supabase?: { from: (table: string) => any }; } ``` ## Common gotchas [#common-gotchas] * **Assistant responses are not scrubbed.** Scrubbing applies to the user message before the runtime sees it, and (with `supabase`) to event-log rows at write time. The model's reply itself is not post-processed. If you need to redact PII from generated text, run a scrubber pass in your UI layer or compose `ComplianceRuntime` from `@pleach/compliance` directly. * **`@pleach/compliance` is an optional peer.** The recipe loads scrubbers via `require.resolve("@pleach/compliance")` at construction. Install `@pleach/compliance` alongside `@pleach/recipes` when using this recipe. * **The recipe stays storage-agnostic.** It does NOT call `createClient(...)`. You construct the Supabase client and pass it; the recipe uses only the `.from(table)` chain entry and runtime-validates deeper access inside `@pleach/core`'s `EventLogWriter`. * **Profile change requires a new instance.** Profiles resolve at construction; there is no `bot.setProfile()`. * **`extraScrubbers` runs after the profile bundle.** Last-wins on overlap. If a custom scrubber must run first, prepend by wrapping it as the new bundle and dropping the profile — or open a feature request. ## See also [#see-also] * [`@pleach/recipes` overview](/docs/recipes-pleach-recipes) — every recipe in one page. * [`@pleach/compliance`](/docs/compliance) — the underlying scrubber library and `ComplianceRuntime`. * [`Scrubbers`](/docs/scrubbers) — the redaction contract, per-event-type allowlist, and authoring guide. * [`Regulated domain agent`](/docs/regulated-domain-agent) — full-system pattern with hash-chain attestation. --- # enterpriseAgent (/docs/recipes/enterprise-agent) `enterpriseAgent` stacks two of the three pillars frontier-lab direct-enterprise buyers purchase Pleach for into a single factory: compliance scrubbing on the user message boundary, and sub-agent attribution paths for joinable cost and usage rows. The third pillar — gateway-side provider failover — is procurement-visible only at this layer: the recipe stamps a declared `permittedFamilies` envelope on the runtime via a sentinel symbol so a gateway-aware host wiring can read it and enforce the cascade. Best fit: **a frontier-lab direct enterprise**. The defining question is *"We already pay Anthropic / OpenAI directly. What does Pleach give us that the SDK doesn't?"* This recipe is the in-process answer to two thirds of that question. ## Quickstart [#quickstart] ```ts import { init } from "@pleach/observe"; import { memory } from "@pleach/observe/destinations"; import { enterpriseAgent, ENTERPRISE_PERMITTED_FAMILIES_TAG } from "@pleach/recipes/enterprise-agent"; init({ destination: memory() }); const bot = enterpriseAgent({ profile: "soc2", // hipaa | gdpr | pci-dss | soc2 — P16 canonical baseline is soc2 serviceName: "notion-ai", subTenantId: "workspace-abc", permittedFamilies: ["anthropic", "openai"], // declared failover envelope orchestratorConfig: { provider: "anthropic", model: "claude-sonnet-4-6", apiKey: process.env.ANTHROPIC_API_KEY!, }, }); await bot.ask("Summarize this PR for the eng review channel."); // A gateway-aware host wiring reads the declared envelope: const envelope = bot.runtime[ENTERPRISE_PERMITTED_FAMILIES_TAG]; // -> Object.freeze(["anthropic", "openai"]) ``` ## What it does [#what-it-does] The recipe layers three things on top of `simpleChatbot`: 1. **Compliance scrubbing** — delegates to `compliantChatbot({profile})` for PHI/PII redaction on user messages. The profile selects a curated bundle from `@pleach/compliance/scrubbers`; `extraScrubbers` runs after the profile bundle (last-wins on overlap). 2. **Sub-agent attribution** — wraps `ask()` in `subagent(serviceName).run(...)` from `@pleach/observe`. When `subTenantId` is set, a nested `subagent(subTenantId).run(...)` runs inside the outer scope, so the attribution path becomes `[serviceName, subTenantId]`. 3. **Declared failover envelope** — when `permittedFamilies` is supplied, the recipe stamps the frozen array on the returned `runtime` via the `ENTERPRISE_PERMITTED_FAMILIES_TAG` `Symbol.for(...)` key. A gateway-aware host reads `runtime[ENTERPRISE_PERMITTED_FAMILIES_TAG]` to enforce the cascade. The recipe itself does NOT enforce. ## Config reference [#config-reference] ```ts type ComplianceProfile = "hipaa" | "gdpr" | "pci-dss" | "soc2"; interface EnterpriseAgentConfig extends Omit<CompliantChatbotConfig, "profile"> { /** * Compliance scrubber profile — same enum as compliantChatbot. * Defaults to "soc2" (the canonical enterprise baseline). Buyers with * stricter regulatory needs set explicitly. */ profile?: ComplianceProfile; /** * Sub-agent attribution label — the buyer's service name as it * appears on the dashboard. Defaults to "pleach-enterprise". */ serviceName?: string; /** * Optional sub-tenant identifier appended to the attribution * path. When set, the path becomes [serviceName, subTenantId]. * The recipe does NOT validate the sub-tenant against a tenant * store; that's @pleach/gateway's TenantResolver boundary. */ subTenantId?: string; /** * Declared provider-failover envelope. Procurement-visible * only — the recipe surfaces this on the runtime via * ENTERPRISE_PERMITTED_FAMILIES_TAG so a gateway-aware host * can enforce the cascade. The recipe itself does NOT * enforce. Canonical values: subset of ["anthropic", * "openai", "google", "deepseek", "moonshot", "mistral"]. */ permittedFamilies?: readonly string[]; /** Extra scrubbers run AFTER the profile bundle. */ extraScrubbers?: readonly ComplianceScrubber[]; } export const ENTERPRISE_PERMITTED_FAMILIES_TAG: unique symbol; ``` ## Common gotchas [#common-gotchas] * **`permittedFamilies` is gateway-aware, not gateway-bound.** Without `@pleach/gateway` wired into your host, the envelope is informational only — the recipe stamps it on the runtime, but no in-process code enforces the cascade. The gateway is the commercial SKU; reach for it when the failover claim needs to be enforced at the provider boundary. * **`subTenantId` nests inside `serviceName`.** The attribution path is `[serviceName, subTenantId]` — outer first, inner second. Pick the order to match the `GROUP BY` your billing schema runs. * **Compliance scope-limit: user messages only, not assistant responses.** Scrubbing applies to the user message before the runtime sees it. The model's reply is not post-processed. If you need to redact PII from generated text, run a scrubber pass in your UI layer or compose `ComplianceRuntime` from `@pleach/compliance` directly. * **`init({destination})` is a process-singleton.** Call once at app boot, before any `ask()`. The recipe does NOT call `init` itself. * **The recipe does NOT bundle an `EvalSuite`.** Provider neutrality is unverified without a parity validator. Use [`evalLab`](/docs/recipes/eval-lab) from `@pleach/recipes/eval-lab` alongside this recipe for the validation loop. * **Two optional peers.** `@pleach/compliance` and `@pleach/observe` are OPTIONAL peers of `@pleach/recipes`. Install both alongside this recipe. The compliance peer gracefully degrades (warns and returns a no-redaction chatbot per the `compliantChatbot` contract); the observe peer must be installed for the subpath import to resolve. ## See also [#see-also] * [`@pleach/recipes` overview](/docs/recipes-pleach-recipes) — every recipe in one page. * [`@pleach/compliance`](/docs/compliance) — scrubber library and `ComplianceRuntime`. * [`@pleach/observe`](/docs/observe) — `init`, `recordCall`, `subagent`, the four destinations. * [`@pleach/gateway`](/docs/gateway) — the commercial SKU whose cascade reads the declared envelope. * [`compliantChatbot`](/docs/recipes/compliant-chatbot) — the underlying compliance recipe `enterpriseAgent` composes. * [`evalLab`](/docs/recipes/eval-lab) — the provider-parity validator that pairs with `enterpriseAgent` for the failover-claim verification loop. * [`Regulated domain agent`](/docs/regulated-domain-agent) — full-system pattern with hash-chain attestation. --- # evalLab (/docs/recipes/eval-lab) `evalLab` bakes the canonical wiring of `@pleach/core` + `@pleach/eval` + `@pleach/replay` for research-grade eval runs. The factory constructs a `SessionRuntime`, hands it to a consumer-supplied `EvalSuite` factory, and optionally constructs a `ReplayClient` for deterministic re-derivation of a past run's report from its captured event log. Best fit: **an eval / research lab**. The load-bearing constraint is that the SAME suite must reproduce results bit-exactly months later, often for peer review or regulatory audit. The factory bakes the wiring order so the consumer just supplies the model config, the suite cases, and (optionally) the replay-cache factory. ## Quickstart [#quickstart] ```ts import { evalLab } from "@pleach/recipes/eval-lab"; import { EvalSuite } from "@pleach/eval"; import { ReplayClient } from "@pleach/replay"; const lab = evalLab({ suiteId: "claude-vs-gpt5-2026-Q3", orchestratorConfig: { provider: "anthropic", model: "claude-sonnet-4-6", apiKey: process.env.ANTHROPIC_API_KEY!, }, evalSuiteFactory: (opts) => new EvalSuite(opts), replayClientFactory: (runtime) => new ReplayClient({ runtime, tenantId: "lab-001", eventSource: myEventSource }), }); lab.addCase({ id: "qa-pubmed-001", input: "What gene is implicated in cystic fibrosis?", scorer: { type: "expected", expected: "CFTR" }, }); const report = await lab.run(); console.log(`Passed: ${report.summary.passed}/${report.summary.total}`); // Months later, re-derive the report without re-running the LLM: const replayed = await lab.replay(report.summary.runId); console.log(`Identical: ${replayed.summary.passed === report.summary.passed}`); ``` ## What it does [#what-it-does] The recipe constructs three things in sequence: 1. A `SessionRuntime` via `createPleachRuntime` from `@pleach/core/runtime`, with `orchestratorConfig` plumbed through `host.strategies.orchestratorConfig` (mirrors `simpleChatbot`). 2. An `EvalSuite` by calling the consumer-supplied `evalSuiteFactory({suiteId, runtime})`. Calling the factory eagerly at construction lets the consumer call `lab.addCase(...)` before the first `lab.run()`. 3. (Optional) A `ReplayClient` by calling the consumer-supplied `replayClientFactory(runtime)`. Without it, `lab.replay()` throws — research consumers who need deterministic re-derivation must supply one. `lab.run()` delegates to `suite.run()`. `lab.replay(runId)` delegates to `replayClient.fromRun(runId)`. `addCase` is an identity pass-through to the suite. ## Config reference [#config-reference] ```ts type OrchestratorConfigPassthrough = Record<string, unknown>; interface EvalSuiteLike { addCase(c: unknown): void; run(): Promise<{ summary: { runId: string; total: number; passed: number; failed: number }; cases: readonly unknown[]; }>; } interface ReplayClientLike { fromRun(runId: string): Promise<{ summary: { runId: string; total: number; passed: number; failed: number }; }>; } interface EvalLabConfig extends CreatePleachRuntimeConfig { /** Identifier for the suite — surfaces in per-case report rows. */ suiteId: string; /** Provider/orchestrator wiring forwarded to the runtime. */ orchestratorConfig?: OrchestratorConfigPassthrough; /** * REQUIRED. Pass `(opts) => new EvalSuite(opts)` from * @pleach/eval. The recipe does NOT hard-import the class * — that would force a build-time peer dep on the suite * class for every consumer of @pleach/recipes. */ evalSuiteFactory: (opts: { suiteId: string; runtime: SessionRuntime; }) => EvalSuiteLike; /** * Optional. Pass `(runtime) => new ReplayClient({...})` from * @pleach/replay. When omitted, lab.replay() throws. */ replayClientFactory?: (runtime: SessionRuntime) => ReplayClientLike; } interface EvalLab { readonly runtime: SessionRuntime; readonly suite: EvalSuiteLike; addCase(c: unknown): void; run(): ReturnType<EvalSuiteLike["run"]>; replay(runId: string): Promise<{ summary: { runId: string; total: number; passed: number; failed: number }; }>; } ``` ## Common gotchas [#common-gotchas] * **`evalSuiteFactory` is REQUIRED.** The recipe does NOT hard-import `EvalSuite` from `@pleach/eval` — that would force a build-time peer dep on every `@pleach/recipes` consumer. You supply `(opts) => new EvalSuite(opts)` and the recipe wires it in. The structural type `EvalSuiteLike` is the contract. * **`replayClientFactory` is OPTIONAL but load-bearing for the eval / research lab use case.** Without it, `lab.replay(runId)` throws with a message explaining the contract. Research consumers whose value proposition is reproducible re-derivation must supply one — typically `(runtime) => new ReplayClient({ runtime, tenantId, eventSource })`. * **The suite is constructed eagerly.** `evalSuiteFactory` fires at `evalLab(...)` call time, not at first `run()`. This lets you call `addCase(...)` before the first run; it also means a factory that throws will surface at construction. * **`addCase` is an identity pass-through.** The recipe does not validate or transform the case payload. The shape is whatever `EvalSuite.addCase(c)` accepts at the `@pleach/eval` version you installed. * **Two optional peers, both load-bearing.** `@pleach/eval` is required at runtime for `evalSuiteFactory` to resolve. `@pleach/replay` is required only when `replayClientFactory` is supplied. Install the pair alongside `@pleach/recipes`. * **Subpath import is load-bearing.** Import from `@pleach/recipes/eval-lab`, not the root barrel. ## See also [#see-also] * [`@pleach/recipes` overview](/docs/recipes-pleach-recipes) — every recipe in one page. * [`@pleach/eval`](/docs/eval) — `EvalSuite`, scorer types, suite shape. * [`@pleach/replay`](/docs/replay) — `ReplayClient`, `fromRun`, the deterministic re-derivation contract and the divergence error hierarchy. * [`Eval and Replay`](/docs/eval-and-replay) — the composition pattern this recipe wires. * [`Determinism`](/docs/determinism) — the substrate guarantee replay relies on. * [`enterpriseAgent`](/docs/recipes/enterprise-agent) — composes cleanly alongside `evalLab` for the enterprise provider-parity validation loop. --- # instrumentedCodingAgent (/docs/recipes/instrumented-coding-agent) `instrumentedCodingAgent` wraps `createCodingAgentRuntime` from `@pleach/coding-agent` with a `subagent(name).run(...)` scope around each `executeStep(...)` call. The decomposed sub-roles inside a coding turn — planner, executor, critic, and the sandbox-backed tool calls in between — all sit inside that AsyncLocalStorage scope, so per-call cost rows on the observability dashboard come back tagged by the sub-agent that issued the LLM call. Best fit: **a coding-agent shop** (the canonical audience), **a frontier-lab direct enterprise** (when the coding agent is one product line inside a large contract), and a **sub-agent swarm** (when each swarm worker is itself a coding agent and needs its own attribution leaf). ## Quickstart [#quickstart] ```ts import { init } from "@pleach/observe"; import { memory } from "@pleach/observe/destinations"; import { createInMemorySandboxProvider } from "@pleach/sandbox/testing"; import { instrumentedCodingAgent } from "@pleach/recipes/coding-agent"; const dest = memory(); init({ destination: dest }); const agent = instrumentedCodingAgent({ sandboxProvider: createInMemorySandboxProvider({ execHandlers: new Map() }), subagent: "coding-agent", }); await agent.start(); try { const result = await agent.executeStep({ userMessage: "fix the failing test in lib/parser.ts", stepId: "step-1", }); console.log(result.ok, dest.rows); // rows tagged subagent: "coding-agent" } finally { await agent.stop(); } ``` Regression-locked end-to-end in `__tests__/cross-sku/codingAgentInstrumentedByObserve.integration.test.ts`. ## What it does [#what-it-does] The recipe forwards every config field except `subagent` to `createCodingAgentRuntime({...})` from `@pleach/coding-agent`, then wraps the returned runtime's `executeStep(...)` in `observeSubagent(subagentName).run(...)`. The other lifecycle methods (`start`, `stop`, `getContextSnapshot`) pass through unchanged. Compose freely with outer scopes — the recipe's inner `subagent(subagent)` scope nests beneath any outer scope: ```ts import { subagent } from "@pleach/observe"; await subagent("tenant-abc").run(async () => { await agent.executeStep({ ... }); // path: ["tenant-abc", "coding-agent"] }); ``` This is the load-bearing composition pattern for multi-tenant coding-agent shops: the tenant is the outer scope, the coding-agent role is the inner scope, and the attribution path joins cleanly to billing on either column. ## Config reference [#config-reference] ```ts interface InstrumentedCodingAgentConfig extends CodingAgentRuntimeConfig { /** * Sub-agent attribution label applied to all rows recorded * inside each executeStep(...) call. Defaults to * "coding-agent". Set to false to disable the ALS scope * (useful when the buyer is composing the recipe inside * their own outer scope). */ subagent?: string | false; } interface InstrumentedCodingAgent extends CodingAgentRuntime { /** * The underlying coding-agent runtime — escape hatch for * advanced consumers (dispose, snapshot, ...). */ readonly runtime: CodingAgentRuntime; } ``` The remaining config fields (`sandboxProvider`, `orchestratorConfig`, plan / tool / system-prompt overrides, …) are forwarded to `createCodingAgentRuntime` from `@pleach/coding-agent/runtime`. See the SKU's own docs for the full surface. ## Common gotchas [#common-gotchas] * **Nesting order matters.** When wrapping inside an outer scope, the attribution path is `[outer, subagent]` — the outermost wrap is the leftmost path element. Reverse it and you'll group billing rows by sub-role instead of by tenant. Pick the order to match your downstream `GROUP BY`. * **`init({destination})` is a process-singleton.** Call once at app boot, before driving any `executeStep`. The recipe does NOT call `init` itself; without it the `subagent(...).run(...)` wrap is a no-op pass-through. * **`subagent: false` disables the inner scope.** Use this when you are composing the recipe inside your own outer scope and don't want the recipe's default `"coding-agent"` label appearing on rows. Outer scopes still apply. * **`sandboxProvider` is consumer-owned.** The recipe is sandbox-agnostic — pass `createInMemorySandboxProvider` from `@pleach/sandbox/testing` for tests, or a real provider in production. The provider is forwarded unchanged. * **Subpath import is load-bearing.** Import from `@pleach/recipes/coding-agent`, not the root barrel. * **Three optional peers.** `@pleach/coding-agent`, `@pleach/sandbox`, and `@pleach/observe` are all OPTIONAL peers of `@pleach/recipes`. Install the trio alongside this recipe. ## See also [#see-also] * [`@pleach/recipes` overview](/docs/recipes-pleach-recipes#instrumentedcodingagent) — every recipe in one page. * [`@pleach/coding-agent`](/docs/coding-agent) — the underlying runtime, `CodingAgentRuntimeConfig`, `executeStep` shape. * [`@pleach/sandbox`](/docs/sandbox) — the sandbox provider contract the recipe forwards. * [`@pleach/observe`](/docs/observe) — `init`, `recordCall`, `subagent`, the four destinations. * [`Coding agent`](/docs/coding-agent) — full-system pattern. * [`Subagents`](/docs/subagents) — the underlying attribution primitive. --- # observableChatbot (/docs/recipes/observable-chatbot) `observableChatbot` wraps `simpleChatbot` with an `@pleach/observe` `subagent(serviceName).run(...)` scope. Every `recordCall(...)` issued downstream of `runtime.executeMessage(...)` — by the runtime, by a plugin observer, by the consumer's outer loop — inherits the `[serviceName, ...]` attribution path automatically. No recorder argument threaded through call sites. Best fit: **a SaaS adding chat** (the production upgrade once OTel is wired) and **an AI consultancy** (one runtime per tenant; the dashboard rows are tagged by the consuming app's service name). ## Quickstart [#quickstart] ```ts import { init } from "@pleach/observe"; import { memory } from "@pleach/observe/destinations"; import { observableChatbot } from "@pleach/recipes/observability"; // Once at app boot — the SDK is a process-singleton. const dest = memory(); init({ destination: dest }); const bot = observableChatbot({ serviceName: "my-app-chatbot", }); await bot.ask("hello"); // rows on `dest` carry subagent: "my-app-chatbot" ``` Compose freely with outer scopes — the recipe's inner `subagent(serviceName)` nests beneath any outer scope the consumer wraps around the `ask()` call: ```ts import { subagent } from "@pleach/observe"; await subagent("tenant-abc").run(async () => { await bot.ask("..."); // attribution path: ["tenant-abc", "my-app-chatbot"] }); ``` ## What it does [#what-it-does] The recipe constructs a `simpleChatbot` and replaces its `ask()` with one wrapped in `subagent(serviceName).run(...)` from `@pleach/observe`. The runtime, plugins, and any code the runtime call reaches all sit inside that AsyncLocalStorage scope, so `recordCall(...)` invocations issued downstream inherit the attribution path without an explicit recorder argument. The wrap is a no-op pass-through when the consumer has not called `init({destination})`. The recipe does NOT call `init` itself — that would clash with hosts that already initialized observe in the parent process. ## Config reference [#config-reference] ```ts interface ObservableChatbotConfig extends SimpleChatbotConfig { /** * Sub-agent attribution label applied to all rows recorded * inside each ask(...) call. Defaults to "pleach-chatbot". * Set to false to disable the ALS scope (useful when the * consumer is composing the recipe inside their own outer * subagent(...).run(...) scope). */ serviceName?: string | false; /** * Forward-looking — currently inert. Documented so the * config shape stabilizes before a future recipe version * takes ownership of init for hosts that don't bring * their own. */ otlpEndpoint?: string; sampleRatio?: number; } ``` The returned shape is the same `Chatbot` interface that `simpleChatbot` returns — `runtime`, `ask`, `newSession`, `reset`. The `runtime` escape hatch is unchanged. ## Common gotchas [#common-gotchas] * **`init({destination})` is a process-singleton.** Call it once at app boot, before any `ask()`. A second `init(...)` call throws, so call it exactly once. The recipe does NOT call `init` itself — without it, the `subagent(...).run(...)` wrap is a no-op pass-through and no rows land on a destination. * **Outer scopes nest, they do not replace.** When the consumer wraps `bot.ask(...)` inside an outer `subagent("tenant-abc").run(...)`, the attribution path becomes `["tenant-abc", "my-app-chatbot"]` — the recipe's inner scope appends, it does not overwrite. * **`otlpEndpoint` and `sampleRatio` are forward-looking config.** Accepted on the type today, but inert at runtime. Wire `init({destination, sampling})` directly for sampling and destination control in v0.2. * **Subpath import is load-bearing.** Import from `@pleach/recipes/observability`, not the root barrel. * **`@pleach/observe` is an optional peer.** Install it alongside `@pleach/recipes` when using this recipe. Without the install, the subpath import fails — the documented missing-peer signal. ## Live lifecycle events [#live-lifecycle-events] The recipe gives you per-call attribution rows. If you also want the per-turn lifecycle — stage transitions, recovery dispatches, retries, stream timing — subscribe to those directly off the `runtime` escape hatch. `runtime.events.on("model.called", ...)` carries the per-call cost signal on the durable bus; the `stage.*` / `turn.*` / `recovery.fired` / `retry.attempted` / `stream.*` kinds arrive on the `StreamEvent` iterator. ```ts const bot = observableChatbot({ serviceName: "my-app-chatbot" }); bot.runtime.events.on("model.called", (e) => { // { provider, model, callClass, inputTokens, outputTokens, costUSD, latencyMs } meter.record(e.model, e.latencyMs); }); ``` That bare-subscription path — no recipe wrapper, plus the `observeSink` one-liner that bridges `model.called` straight to a destination — is the focus of [Agent instrumentation](/docs/recipes/agent-instrumentation). ## See also [#see-also] * [`@pleach/recipes` overview](/docs/recipes-pleach-recipes#observablechatbot) — every recipe in one page. * [Agent instrumentation](/docs/recipes/agent-instrumentation) — the DIY lifecycle-subscription path with `runtime.events.on(...)` and the `observeSink({ destinations })` bridge. * [`@pleach/observe`](/docs/observe) — `init`, `recordCall`, `subagent`, the four destinations. * [`Observability`](/docs/observability) — the runtime-side observability surface. * [`Subagents`](/docs/subagents) — the underlying attribution primitive. * [`BYOK observability`](/docs/recipes/byok-observability) — the brownfield path for consumers who are not yet on `@pleach/core`. --- # ragChatbot (/docs/recipes/rag-chatbot) `ragChatbot` is `simpleChatbot` with a retrieval preamble. It takes a consumer-supplied `retriever`, calls it once per `ask()`, prepends the returned chunks to the user message as `Context:\n[1] ...\n[2] ...`, then delegates to the underlying `simpleChatbot`. When the retriever returns no chunks or throws, the message passes through unmodified — the bot stays useful if retrieval is down. Best fit: **a knowledge-base assistant** or **docs assistant**. Reach for this when answers must be grounded in your documents and you already have a vector store or full-text index. ## Quickstart [#quickstart] ```ts import { ragChatbot } from "@pleach/recipes/rag"; const bot = ragChatbot({ retriever: async (query, { topK = 4 } = {}) => { return await myVectorDb.search(query, { topK }); }, }); console.log(await bot.ask("what's our refund policy?")); ``` The recipe does not bind a vector store. Bring your own — pgvector, Pinecone, Weaviate, a Postgres FTS index, an S3-backed JSON snapshot, anything that satisfies the `Retriever` signature. ## What it does [#what-it-does] On each `ask(message)`: 1. Invoke `retriever(message, { topK })` once. 2. If the result is a non-empty array, build a context preamble of the form `Context:\n[1] (id) content\n[2] (id) content\n...` and prepend it to the user message. 3. Delegate to the underlying `simpleChatbot.ask()` with the augmented message. If the retriever throws or returns `[]`, step 2 is skipped and the raw user message is forwarded. The retriever runs exactly once per turn — no automatic re-query, no streaming retrieval. ## Config reference [#config-reference] ```ts interface RetrievedChunk { /** Stable identifier for citation rendering. */ id: string; /** Raw text content surfaced to the LLM. */ content: string; /** Optional similarity score (recipe is score-agnostic). */ score?: number; /** Free-form metadata — source URL, page number, section. */ metadata?: Record<string, unknown>; } type Retriever = ( query: string, opts?: { topK?: number }, ) => Promise<RetrievedChunk[]>; interface RagChatbotConfig extends SimpleChatbotConfig { retriever: Retriever; /** Default topK passed to the retriever. Defaults to 4. */ topK?: number; } ``` `RagChatbotConfig` extends `SimpleChatbotConfig`, so `systemPrompt`, `orchestratorConfig`, and all `CreatePleachRuntimeConfig` fields work identically. ## Common gotchas [#common-gotchas] * **The retriever is consumer-owned.** The recipe does not rate-limit, cache, deduplicate, or retry. If your vector store has its own back-pressure or budget rules, enforce them inside the retriever. * **`score` is not used for ordering.** Chunks are inserted in the order the retriever returns them. If you want a different ranking, sort inside the retriever before returning. * **No automatic citation rendering.** The numbered `[N]` preamble lets the LLM cite chunks by index in its reply, but the recipe does not parse `[N]` references out of the assistant text. Build that surface in your UI layer. * **Retriever errors are swallowed.** A thrown error falls through to the plain-delegation path. If you need observability on retrieval failures, log inside the retriever or wrap it. ## See also [#see-also] * [`@pleach/recipes` overview](/docs/recipes-pleach-recipes) — every recipe in one page. * [`simpleChatbot`](/docs/recipes/simple-chatbot) — the base this recipe extends. * [`Internal knowledge agent`](/docs/internal-knowledge-agent) — full-system pattern for RAG with audit and lineage. --- # simpleChatbot (/docs/recipes/simple-chatbot) `simpleChatbot` is the smallest recipe in the package. It composes only `@pleach/core` and returns a `Chatbot` wrapper around a real `SessionRuntime`. The factory does not bundle a provider — you register an `OrchestratorAdapter` ctor once at app boot and the recipe forwards your `orchestratorConfig` through. Best fit: **a SaaS adding chat**. Reach for this when you want a working chatbot in under ten lines and don't yet need retrieval, observability, or compliance scrubbing. ## Quickstart [#quickstart] ```ts import { simpleChatbot } from "@pleach/recipes/chatbot"; import { setOrchestratorAdapterCtor } from "@pleach/core/runtime"; import { MyOrchestratorAdapter } from "./my-provider"; // Register the OrchestratorAdapter ctor once at app boot. setOrchestratorAdapterCtor(MyOrchestratorAdapter); const bot = simpleChatbot({ systemPrompt: "You are a helpful assistant.", orchestratorConfig: { provider: "openai", model: "gpt-4o-mini", apiKey: process.env.OPENAI_API_KEY!, }, }); console.log(await bot.ask("hello")); console.log(await bot.ask("what did I just say?")); ``` A single `bot` instance holds the session across `ask()` calls, so the second turn sees the first turn's history. Call `await bot.reset()` (or its alias `await bot.newSession()`) to discard the memoized session id; the next `ask()` lazily creates a fresh one. ## What it does [#what-it-does] The factory constructs a `SessionRuntime` via `createPleachRuntime(...)` and stores the `orchestratorConfig` on `host.strategies.orchestratorConfig`. On the first `ask()`, the runtime walks the registered adapter ctor and binds your provider. Each subsequent `ask()` reuses the same session id, so conversation state accumulates in the underlying event log. `ask()` returns the final assistant text. It collects the `message.delta` and `message.complete` events from `runtime.executeMessage()` and concatenates the deltas. Streaming consumers should reach into `bot.runtime` directly. ## Config reference [#config-reference] ```ts interface SimpleChatbotConfig { /** Optional system prompt prepended to every conversation. */ systemPrompt?: string; /** * Provider/orchestrator config forwarded to the underlying * SessionRuntime. Wires the LLM provider (model id, API key, * etc.). When omitted, ask() returns the substrate placeholder. */ orchestratorConfig?: Record<string, unknown>; // Plus the standard CreatePleachRuntimeConfig fields // (host, plugins, storage, etc.) from @pleach/core. } interface Chatbot { readonly runtime: SessionRuntime; ask(message: string): Promise<string>; newSession(): Promise<void>; reset(): Promise<void>; } ``` ## Common gotchas [#common-gotchas] * **`setOrchestratorAdapterCtor` runs once per process.** It registers a module-level ctor inside `@pleach/core/runtime`. Call it at app boot, before any `simpleChatbot({orchestratorConfig: ...})`. Calling it again with a different ctor is a no-op after the first registration in v0.2. * **Without `orchestratorConfig`, `ask()` returns a placeholder.** The string is `"This is a placeholder response from the harness runtime."` This is the documented no-provider contract — useful for shape tests, not for production. * **`ChatStreamError` carries the original failure on `.cause`.** If the stream emits `{type: "error"}` or throws, `ask()` rejects with `ChatStreamError`. Branch on `error.cause` to distinguish provider errors from abort signals from network timeouts. * **Subpath import is load-bearing.** Import from `@pleach/recipes/chatbot`, not the root barrel. The factory references `createPleachRuntime` from `@pleach/core/runtime` (the subpath the setter is also registered against). The top-level `@pleach/core` ESM bundle does not currently re-export the setter. ## See also [#see-also] * [`@pleach/recipes` overview](/docs/recipes-pleach-recipes) — every recipe in one page. * [`SessionRuntime`](/docs/session-runtime) — the substrate every recipe wraps. * [`Adoption paths`](/docs/adoption-paths) — when to reach for `simpleChatbot` vs construct `SessionRuntime` directly. --- # subagentSwarm (/docs/recipes/subagent-swarm) `subagentSwarm` composes a pool of worker `Chatbot` instances into a bounded-parallel work queue. You pass the workers (any recipe-returned `Chatbot` satisfies the contract — `simpleChatbot`, `observableChatbot`, `compliantChatbot`, even custom shapes that implement `ask()`) plus a `decompose(userTask, workerCount)` that maps the user task to per-worker prompts. The recipe runs the prompts under a `maxFanOut` concurrency cap, polls an optional cost ceiling between worker launches, and reduces the results. Best fit: **a sub-agent swarm consumer**. The defining pain is the runaway-loop case: a buggy fan-out or stuck sequential loop can spend $10K in one user turn. This recipe bakes the two application-layer guardrails. ## Quickstart [#quickstart] ```ts import { simpleChatbot } from "@pleach/recipes/chatbot"; import { subagentSwarm } from "@pleach/recipes/subagent-swarm"; const swarm = subagentSwarm({ workers: [ simpleChatbot({ systemPrompt: "You research equities. Be terse." }), simpleChatbot({ systemPrompt: "You research equities. Be terse." }), simpleChatbot({ systemPrompt: "You research equities. Be terse." }), ], maxFanOut: 2, // at most 2 workers in flight at once decompose: (task, workerCount) => { const symbols = ["AAPL", "GOOG", "MSFT"]; return symbols.slice(0, workerCount).map((s) => `${task}: ${s}`); }, reduce: (results) => results.map((r) => `- ${r}`).join("\n"), // Optional: application-layer runaway-loop guardrail perRootTurnCostCeilingUsd: 50, costReporter: () => myProviderCostMeter.getTotalUsd(), }); console.log(await swarm.dispatch("Research big-tech earnings")); ``` ## What it does [#what-it-does] `dispatch(userTask)` runs the following sequence: 1. Call `decompose(userTask, workers.length)` to get the per-worker prompts. 2. Walk the prompts in order; launch at most `maxFanOut` concurrent workers (`Math.max(1, Math.min(maxFanOut, workers.length))`). 3. Before launching each new worker, poll `costReporter()` (if supplied) and compare against `perRootTurnCostCeilingUsd`. Over-ceiling stops launches; already-running workers finish. 4. Pass surviving results (output-order preserved) to `reduce(results, userTask)`. The default reduce is newline-join. `maxFanOut` defaults to `1` (sequential) to preserve replay determinism for buyers who haven't yet adopted the greenfield runtime with byte-identical spawn-order normalization. Swarm buyers tuning for throughput should set explicitly. ## Config reference [#config-reference] ```ts interface SubagentSwarmConfig { workers: readonly Chatbot[]; maxFanOut?: number; // default 1 decompose: ( userTask: string, workerCount: number, ) => readonly string[] | Promise<readonly string[]>; reduce?: (results: readonly string[], userTask: string) => string; perRootTurnCostCeilingUsd?: number; costReporter?: () => number | Promise<number>; } interface SubagentSwarm { readonly workers: readonly Chatbot[]; dispatch(userTask: string): Promise<string>; reset(): Promise<void>; } ``` ## Common gotchas [#common-gotchas] * **`workers` must be non-empty.** Construction throws otherwise. * **Excess decompose prompts are dropped with a console warning.** If `decompose` returns more prompts than there are workers, the recipe does not silently round-robin — the excess are dropped and a warning fires. * **The cost ceiling is application-layer best-effort.** This is the OSS-tier defense. The load-bearing swarm guardrail is `@pleach/gateway`'s `maxCostUsdPerRootTurn` (the commercial SKU). If a worker is mid-flight when the ceiling crosses, it still runs to completion — the ceiling only gates new launches. * **`costReporter` is consumer-owned.** The recipe is provider-agnostic. Wire `costReporter` to your provider's per-call cost report or your billing meter. Omitting both `costReporter` and `perRootTurnCostCeilingUsd` disables ceiling enforcement entirely. * **Default `maxFanOut: 1` is sequential.** Set explicitly when tuning for throughput. Higher values trade determinism for latency. * **Worker sessions are NOT reset between dispatches by default.** Workers keep their conversational state across `dispatch()` calls. Call `await swarm.reset()` to force a fresh session on every worker on the next dispatch. * **No `@pleach/core/spawn` substrate.** This recipe does not implement `spawn / race / waitAll / waitN / bounded` or the `SpawnEvent` schema — those are v1.x core-substrate changes. ## See also [#see-also] * [`@pleach/recipes` overview](/docs/recipes-pleach-recipes) — every recipe in one page. * [`Swarm agent`](/docs/swarm-agent) — full-system pattern. * [`@pleach/gateway`](/docs/gateway) — the commercial SKU whose `maxCostUsdPerRootTurn` is the load-bearing runaway- loop guardrail. * [`Subagents`](/docs/subagents) — the underlying attribution primitive. --- # verticalAgent (/docs/recipes/vertical-agent) `verticalAgent` is the wiring layer for vertical-AI products built on `@pleach/core`. You author a `HarnessPlugin` for your domain — the plugin contributes stream observers, tool policies, prompt context, post-tool enrichers, or custom event channels — and the recipe forwards it (plus any `extraPlugins`) to `createPleachRuntime({plugins: [...]})`. The returned `Chatbot` shape matches `simpleChatbot`. Best fit: **a vertical AI startup**. The defining constraint is *"We'll write a plugin; we won't write a runtime."* This recipe is that wiring layer. ## Quickstart [#quickstart] ```ts import { verticalAgent } from "@pleach/recipes/vertical-agent"; import { myLegalDomainPlugin } from "./plugins/legal"; const bot = verticalAgent({ domainPlugin: myLegalDomainPlugin, systemPrompt: "You are a contract-review assistant.", orchestratorConfig: { provider: "anthropic", model: "claude-sonnet-4-6", apiKey: process.env.ANTHROPIC_API_KEY!, }, }); console.log(await bot.ask("Review clause 4.2 of the attached contract.")); ``` Compose cross-cutting concerns via `extraPlugins`: ```ts const bot = verticalAgent({ domainPlugin: myLegalDomainPlugin, extraPlugins: [observePlugin, metricsPlugin], }); ``` ## What it does [#what-it-does] The factory destructures `domainPlugin` and `extraPlugins` from config, concatenates them with any `config.plugins` the caller already supplied, and forwards the merged array as the `plugins:` field of `createPleachRuntime(...)`. Plugin order is: 1. Caller-supplied `config.plugins` (if any). 2. `domainPlugin`. 3. `extraPlugins` in order. Every `ask()` call walks the same hook ladder a hand-rolled `SessionRuntime` would — `contributeStreamObservers`, `contributeApprovalFlow`, `contributeContinuationPolicy` (its `allowedTools`), `contributePromptContextBridge`, post-tool-tier enrichers, custom event channels — with the domain plugin's verdicts composing first in the verdict ladder. The recipe itself adds no domain logic. ## Config reference [#config-reference] ```ts interface DomainPluginLike { readonly id?: string; readonly name?: string; // All contributeX hooks are optional; not enumerated here // because that would couple the recipe to a specific // @pleach/core version. The installed core enforces the // real HarnessPlugin contract at construction time. readonly [key: string]: unknown; } interface VerticalAgentConfig extends CreatePleachRuntimeConfig { /** The consumer's HarnessPlugin for their domain. */ domainPlugin: DomainPluginLike; /** Cross-cutting plugins registered AFTER the domain plugin. */ extraPlugins?: readonly DomainPluginLike[]; systemPrompt?: string; orchestratorConfig?: Record<string, unknown>; } ``` ## Common gotchas [#common-gotchas] * **The plugin contract lives in `@pleach/core`, not here.** `DomainPluginLike` is intentionally loose so the recipe does not couple to a specific core version. The installed core validates the plugin's hook shapes at construction time; runtime errors surface there, not in the recipe. * **The recipe does not author the plugin.** That's the vertical builder's value-add. See the canonical 4-example plugin template at [`Plugin authoring standards`](/docs/plugin-authoring-standards) and [`Plugin contract`](/docs/plugin-contract). * **Verdict order is plugin-array order.** The domain plugin registers before `extraPlugins`, so its `contributeStreamObservers` amend verdicts compose first. If a cross-cutting plugin (observability, compliance) needs first-look, put it in `config.plugins` instead of `extraPlugins`. * **Plugin instances are not deduplicated.** Pass the same plugin object twice and it will register twice. Hooks fire in registration order. ## See also [#see-also] * [`@pleach/recipes` overview](/docs/recipes-pleach-recipes) — every recipe in one page. * [`Plugin contract`](/docs/plugin-contract) — the `HarnessPlugin` interface this recipe forwards. * [`Plugin authoring standards`](/docs/plugin-authoring-standards) — conventions for `id`, `name`, and hook scope. * [`Plugin bundles`](/docs/plugin-bundles) — packaging patterns for distributing a domain plugin as a sibling SKU. --- # Education (/docs/use-cases/education) The education use case covers K-12 districts, university research groups, ed-tech startups, and learning-platform vendors building AI tutors, homework help, study-skill coaches, or curriculum-authoring assistants. Budget owner is usually a product or curriculum director, not a CTO. Procurement gates the buy on three things: identifier redaction (so a teacher pasting a transcript doesn't leak student PII into the LLM), a hard per-session spend cap (so a runaway loop in a classroom can't burn the district's quota), and per-district tenancy (so District A's data and spend can't cross into District B). This page is the landing for the education use case. The three sub-pages go deeper: * [Student safety](/docs/use-cases/education/student-safety) — the shipped redaction patterns plus the composable `KeyedRegexScrubber` shape for district-specific extensions. * [FERPA / COPPA scope](/docs/use-cases/education/ferpa-coppa) — what the current compliance posture covers and what it doesn't. Read this before signing a procurement form. * [Per-district tenant + cost-cap](/docs/use-cases/education/per-district-tenant) — multi-tenant composition and how cost ceilings actually get enforced at the gateway boundary. ## Who this is for [#who-this-is-for] * A district-internal team standing up an AI homework helper for one or more schools, gated by a per-student or per-session spend ceiling. * An ed-tech vendor adding a tutoring assistant to an existing LMS or curriculum product, where the contractual customer is the district and the data subject is the student. * A university research group running pedagogical-replay studies where every chat session is captured for semester-over-semester comparison. * A learning-platform startup whose buyer expects a FERPA / COPPA conversation during the sales cycle and needs a concrete answer to "what do you redact before the model sees it?" ## Quickstart [#quickstart] ```bash npm install @pleach/recipes @pleach/compliance ``` ```ts import { educationAgent } from "@pleach/recipes/education"; const bot = educationAgent({ districtId: "district-1234", complianceProfile: "ferpa+coppa", contentSafety: ["self-harm", "profanity"], costCapUsdPerSession: 0.25, }); const reply = await bot.ask( "Hi Mrs. Johnson — Student ID 9876543 needs help with fractions", ); // LLM sees: "Hi Mrs. Johnson — [STUDENT] needs help with fractions" ``` That five-line config wires three pieces of substrate that ship today: 1. The **student-safety scrubber** from `@pleach/compliance/education` (see [Student safety](/docs/use-cases/education/student-safety)). Runs before `ask()` forwards to the model, so the LLM-bound copy is redacted regardless of where you later persist the transcript. 2. A **per-session cost-cap policy** keyed at the configured ceiling. The policy is registered on the runtime so consumers can drive `canSpend()` / `recordSpend()` from their per-turn gate, and the same shape is used by the gateway-side enforcer (see [Per-district tenant + cost-cap](/docs/use-cases/education/per-district-tenant)). 3. A **compliance profile plugin** that maps the FERPA / COPPA labels to the closest shipped analog (currently `gdpr` — data-minimization plus identifier redaction). The substitution is surfaced on the runtime tag and a `warn-once` notice fires to your console. ## What you get [#what-you-get] * Identifier redaction (student ID patterns, grade bands, school-issued email domains, labeled parent contact lines) applied to the user message before the runtime sees it. * A per-session cost ceiling exposed on the runtime via the `EDUCATION_RUNTIME_TAG` symbol — drive the policy from your turn-gate to fail closed when a classroom session blows the ceiling. * A `districtId` tag on the runtime that downstream code (audit ledger, gateway cost aggregator, your billing pipeline) can read to scope every write to one district. * A drop-in compatible `Chatbot` shape (`ask` / `newSession` / `reset` / `runtime`) — same surface as [`simpleChatbot`](/docs/recipes/simple-chatbot). ## Honest scope-limits [#honest-scope-limits] These belong on the procurement page. Don't surprise a district counsel with them three weeks into a pilot. 1. **There is no `ferpa-coppa` profile literal in `@pleach/compliance` today.** The shipped [`ComplianceProfile`](/docs/compliance) union is `"hipaa" | "gdpr" | "pci-dss" | "soc2"`. `educationAgent` accepts the education-facing strings (`ferpa`, `coppa`, `ferpa+coppa`) and substitutes `gdpr` as the closest shipped analog. The substitution is recorded on the runtime tag (`requestedProfile` + `mappedProfile`) so your audit ledger can show what was asked for and what was actually applied. This is NOT full FERPA / COPPA adequacy — see [FERPA / COPPA scope](/docs/use-cases/education/ferpa-coppa) for the gap list. 2. **The cost-cap is registered for telemetry; the gateway is the enforcement point.** The policy is exposed on the runtime tag so callers can use the same shape as the gateway-side ceiling, but `educationAgent.ask()` does not consult the policy before invoking the model — there is no substrate hook today for the recipe to observe per-turn USD spend. Wire `CostCeilingMiddleware` from [`@pleach/gateway/swarm`](/docs/gateway) at the routing boundary if you need hard cutoff. See [Per-district tenant + cost-cap](/docs/use-cases/education/per-district-tenant). 3. **Pedagogical replay is recorded on the tag only.** Byte-identical replay requires greenfield runtime work not yet shipped. The `pedagogicalReplay` field is accepted for forward-compat but does not change runtime behavior today. 4. **The shipped student-safety scrubber redacts identifiers, not content categories.** When you pass `contentSafety: ["self-harm", "profanity"]`, the categories are surfaced on the runtime tag for your telemetry, but the shipped scrubber currently redacts student-identifying fragments (IDs, grade bands, school-issued emails, labeled parent contact lines). Per-category content moderation is tracked but not shipped — wire a separate moderation API (Anthropic / OpenAI moderation endpoints, or your own classifier) for category-level enforcement. ## Config reference [#config-reference] ```ts type EducationContentSafetyCategory = | "self-harm" | "age-inappropriate" | "profanity" | "off-curriculum"; type EducationComplianceProfile = "ferpa" | "coppa" | "ferpa+coppa"; interface EducationAgentConfig extends SimpleChatbotConfig { /** Stable district / institutional tenant id. Required. */ districtId: string; /** Pseudonymous per-student attribution id. Optional. */ studentId?: string; /** FERPA / COPPA profile. Maps to `gdpr` bundle today. */ complianceProfile?: EducationComplianceProfile; /** Recorded on the runtime tag. */ contentSafety?: readonly EducationContentSafetyCategory[]; /** Recorded only; gateway is the enforcement point. */ perDistrictMonthlyBudgetUsd?: number; /** Recorded only; greenfield runtime required. */ pedagogicalReplay?: { enabled: boolean }; /** Default 0.50. Drives the in-memory cost-cap ceiling. */ costCapUsdPerSession?: number; /** Default true. Pass false to skip student-safety scrubbing. */ studentSafetyEnabled?: boolean; } ``` The runtime tag is symbol-keyed so cross-bundle consumers (the symbol obtained via `require("@pleach/recipes")` vs `require("@pleach/recipes/education")` in different consumer bundles) resolve to the same identity: ```ts import { EDUCATION_RUNTIME_TAG } from "@pleach/recipes/education"; const tag = (bot.runtime as unknown as Record<symbol, any>)[ EDUCATION_RUNTIME_TAG ]; // tag.districtId : string // tag.studentId? : string // tag.requestedProfile : "ferpa" | "coppa" | "ferpa+coppa" | null // tag.mappedProfile : "gdpr" | null // tag.contentSafety : readonly EducationContentSafetyCategory[] // tag.costCap : CostCapPolicy // tag.costCapCeilingUsd : number // tag.studentSafetyEnabled: boolean ``` ## Source [#source] The factory body lives at `packages/recipes/src/education.ts`. The education-facet helpers it composes live at `packages/compliance/src/education/studentSafety.ts`. The per-root-turn cost cap referenced by [Per-district tenant + cost-cap](/docs/use-cases/education/per-district-tenant) lives at `packages/core/src/spawn/rootTurnCostCap.ts`. ## See also [#see-also] * [Student safety](/docs/use-cases/education/student-safety) * [FERPA / COPPA scope](/docs/use-cases/education/ferpa-coppa) * [Per-district tenant + cost-cap](/docs/use-cases/education/per-district-tenant) * [`@pleach/recipes` overview](/docs/recipes-pleach-recipes) * [`compliantChatbot`](/docs/recipes/compliant-chatbot) — the sibling recipe for HIPAA / GDPR / PCI-DSS / SOC 2. --- # Government & public sector (/docs/use-cases/government) This page is the landing for federal agencies, state and local government (SLG), government contractors, and SLG IT teams evaluating Pleach for procurement. It is written conservatively. Pleach has shipped a useful v1 floor for this audience, but it is **not FedRAMP-authorized today**, IL5/IL6 is not in scope at v1, and the roadmap items (SBOM, Sigstore, full offline install) are tracked honestly below rather than collapsed into a checkmark grid. ## Who this is for [#who-this-is-for] * **Federal agencies** — civilian (GSA, HHS, IRS, Treasury) and defense-related (DoD program offices, defense primes, FFRDCs). * **State and local government** — state IT (CalDGS, NYC OTI), city digital-services teams, county and municipal procurement. * **Government contractors** — defense primes (Lockheed, Raytheon, Booz Allen, SAIC) and integrators delivering to federal agencies under a flowdown. * **SLG IT** — managed-service providers serving multi-tenant SLG customers under shared compliance posture. Common shape: 1,000–100,000+ person organizations, multi-million-dollar AI budgets, 12–24 month procurement cycles, hard requirements on license, hosting topology, identity federation, and audit posture. ## v1 scope — what ships today [#v1-scope--what-ships-today] Three concrete capabilities, each tested and on `vine`: ### 1. License clarity for procurement [#1-license-clarity-for-procurement] All 13 publishable SKUs ship under **FSL-1.1-Apache-2.0** (Functional Source License 1.1 with a future-license clause to Apache-2.0 at the 2-year mark). This is the single biggest procurement unblocker for government buyers — there is a written, dated path from source-available to OSI-compliant Apache-2.0. See [License compatibility](/docs/use-cases/government/license-compatibility) for the procurement-facing detail. ### 2. Air-gapped endpoint redirection [#2-air-gapped-endpoint-redirection] `@pleach/core` enforces a fail-closed allowlist on every outbound provider URL when `airGapped: true` is set on `SessionRuntimeConfig`. The check lives next to the `fetch()` it guards, so a host cannot bypass it by forgetting to wire something at the edge. Operator misconfiguration (empty allowlist + air-gapped mode) is surfaced at construction time, not at first call. See [Air-gapped architecture](/docs/use-cases/government/air-gapped-architecture) for the call-site map and the v2+ gaps (dependency vendoring, full offline install). ### 3. IAM-federated identity (AWS GovCloud) [#3-iam-federated-identity-aws-govcloud] `@pleach/gateway/identity/providers/iam` ships an `IdentityProvider` backed by STS `AssumeRole`. The buyer supplies their own `STSClient` (region-scoped to `us-gov-east-1` / `us-gov-west-1`), and the provider templates a per-tenant role ARN so IAM policies scope model access per tenant. The adapter takes no build-time dependency on `@aws-sdk/*` — the STS client is injected. Azure Government and GCP IL2 identity adapters exist at `@pleach/gateway/identity/providers/{azure,gcp}` but are not wired by the `governmentAgent` recipe at v1. AWS GovCloud is the explicit v1 target. ## v2+ roadmap [#v2-roadmap] The honest list of what is not in v1: * **FedRAMP authorization (Moderate, then High).** This is a 12–18 month paperwork track. The runtime surfaces declared `fedrampBaseline` as procurement-visible metadata today; the authorization itself is not. * **DoD Impact Level 5 / Impact Level 6.** Out of scope at v1. Tracked for v2+ as a separate program parallel to FedRAMP High. * **Fully air-gapped offline install.** v1 ships the runtime enforcement (URL allowlist + env-var override of the OpenRouter base URL) but `npm install` still requires reaching a private registry or a vendored snapshot. The vendoring recipe is v2+ scope. * **SBOM artifacts (CycloneDX 1.5 / SPDX 2.3).** The `governmentAgent` recipe surfaces declared `sbomFormat` as procurement-visible metadata today, but the publish pipeline does not yet emit the artifact. v1.x scope. * **SLSA provenance + Sigstore signing.** Same status as SBOM — declared on the agent, not yet emitted by the publish pipeline. v1.x scope. See [Supply-chain risk & SBOM](/docs/use-cases/government/scrm-and-sbom) for the SCRM stance and the per-SKU dependency posture. ## Quickstart [#quickstart] ```ts import { governmentAgent } from "@pleach/recipes" ``` The minimal air-gapped + IAM example: ```ts import { STSClient, AssumeRoleCommand } from "@aws-sdk/client-sts" import { governmentAgent } from "@pleach/recipes" const stsClient = new STSClient({ region: "us-gov-east-1" }) const bot = governmentAgent({ agencyId: "gsa-tts", airGapped: true, airGappedAllowedHosts: ["llm.internal.gsa.gov"], classification: "fouo", fedrampBaseline: "moderate", onPremProvider: { kind: "iam", endpoint: "https://llm.internal.gsa.gov", }, iamConfig: { stsClient, roleArnTemplate: "arn:aws-us-gov:iam::123456789012:role/pleach-gateway/{tenantId}", assumeRoleCommandFactory: (input) => new AssumeRoleCommand(input), }, auditPackage: { format: "fedramp", retentionYears: 7 }, }) const answer = await bot.ask( "Summarize current FedRAMP boundary changes.", ) ``` What this wires: * `airGapped: true` + a single-host allowlist — every outbound provider URL is checked against `llm.internal.gsa.gov`; anything else throws `AirGappedHostRejectedError` from `@pleach/core/runtime/AirGappedRuntimeOption`. * `onPremProvider.kind: "iam"` + `iamConfig.stsClient` — the recipe lazy-loads `createIamIdentityProvider` from `@pleach/gateway/identity/providers/iam` and exposes the provider + token cache on `bot.iamProvider` / `bot.tokenCache` so the host can thread them into its gateway wiring. * `classification: "fouo"` + `fedrampBaseline: "moderate"` — procurement-visible metadata stamped on the runtime under the `GOVERNMENT_AGENT_TAG` symbol. The metadata does not enforce authorization; it surfaces the operator's declared posture for audit. ## Fail-closed by design [#fail-closed-by-design] If `airGapped: true` is supplied with an empty or absent `airGappedAllowedHosts`, the allowlist rejects **every** outbound URL. `@pleach/core` enforces this fail-closed: the first provider-URL resolution throws `AirGappedHostRejectedError`, so a misconfigured air-gapped deployment fails on its first call — during smoke test — rather than leaking silently in production. ## What this recipe does not do [#what-this-recipe-does-not-do] * Does not enforce FedRAMP / IL5 / TS authorization. Those are paperwork tracks; the recipe surfaces the declared posture but does not validate it against an ATO. * Does not bundle a fed-cloud provider. The buyer constructs the `STSClient` (or equivalent) and injects it. * Does not generate SBOM or SLSA provenance artifacts. Those are publish-pipeline concerns, tracked for v1.x. * Does not call `init({ destination })` for `@pleach/observe` — the observe SDK is process-singleton; the host owns init. ## Related [#related] * [License compatibility](/docs/use-cases/government/license-compatibility) * [Air-gapped architecture](/docs/use-cases/government/air-gapped-architecture) * [Supply-chain risk & SBOM](/docs/use-cases/government/scrm-and-sbom) * [Air-gapped deployment (general)](/docs/air-gapped-deployment) * [Compliance](/docs/compliance) * [Gateway](/docs/gateway) --- # ISV (embedded vendor) (/docs/use-cases/isv) This page is for independent software vendors who want to embed Pleach into a commercial product and ship it to their own customers under their own brand — Salesforce-style CRM add-ons, HubSpot AI features, vertical SaaS products that include AI as part of their core value proposition. If you're using Pleach internally — even at scale, even on a multi-tenant SaaS surface — the [multi-tenant SaaS agent](/docs/multi-tenant-saas-agent) page is the more direct fit. ISV-embedded specifically means **redistribution** — your customer pays you, you pay nobody for the embedded engine, and Pleach travels with your product into your customer's deployment. ## What v1 supports today [#what-v1-supports-today] ISVs can embed every `@pleach/*` SKU into their commercial product as of v1.0.0. The mechanics are: * **License.** Every shipping SKU is published under [FSL-1.1-Apache-2.0](/docs/versioning) — the Functional Source License with Apache 2.0 as the future license. FSL-1.1 permits commercial use including bundling and re-sale; the Apache 2.0 transition fires automatically two years after first stable publish. * **Source availability.** Every package's source lives in the monorepo at `packages/<sku>/src/` and ships in the published tarball so customer security reviews (SCRM, SBOM, attestation) can read what they're running. * **LICENSE + NOTICE in every tarball.** Each SKU carries its own `LICENSE` + `NOTICE` file inside the published package (locked by `audit:sku-license-fsl-lock` running on every PR). * **No call-home, no telemetry.** Nothing the runtime does reaches back to a Pleach-controlled endpoint. The runtime is a library, not a service. * **Provider neutrality.** Your customers can point the embedded engine at whichever providers they've already contracted (Anthropic, OpenAI, Bedrock, Vertex, Azure, a private gateway). Your product doesn't have to mediate the inference contract. That's the v1 surface. If your embedded shape needs only those properties, you can ship today. ## What v1 does NOT include [#what-v1-does-not-include] Three things ISVs commonly ask for that v1 does not yet deliver — they're roadmap, not shipped: * **LTS branch.** v1 is rolling-release; minor versions ship roughly monthly. There is no `v1.x` long-term-support branch with backports. See [LTS roadmap](/docs/use-cases/isv/lts-roadmap) for the v2+ plan. * **24/7 dedicated support tier.** Bootstrap-stage maintainers cannot credibly commit to 24/7 SLAs. Support today is best-effort community + GitHub issues; a paid support tier is v2+ contingent on hiring a support organization. * **Signed ISV master-agreement template.** v1 ships under the FSL-1.1-Apache-2.0 LICENSE text alone. There is no pre-drafted master agreement, revenue-share addendum, or co-marketing addendum. ISVs negotiating bespoke terms — volume pricing, revenue share on FSL-licensed downstream SKUs, co-branding rights — are doing it one-off through email until those templates ship. If any of those three are non-negotiable for your deal, v1 is not the right entry point. The honest answer is "wait for v2 or talk to us about a custom arrangement." ## Quickstart — ISV embedding [#quickstart--isv-embedding] The mechanics are the same as any other Pleach project. The ISV distinction is in the LICENSE compliance you carry through to your customer, not the install command. ```bash npm install @pleach/core @pleach/react # Add whichever SKUs your embedded product needs: npm install @pleach/observe # if you instrument npm install @pleach/compliance # if your customers are regulated npm install @pleach/gateway # if you mediate inference for them ``` Inside your product, build the runtime as you would for any other deployment: ```typescript import { SessionRuntime } from "@pleach/core"; export function buildEmbeddedRuntime(req: YourRequest) { return new SessionRuntime({ provider: yourCustomerProvider(req), storage: yourCustomerStorage(req), plugins: [yourEmbeddedPlugin], tenantId: req.endCustomerTenantId, // your customer's customer }); } ``` When you publish your commercial product, three LICENSE-side obligations travel with it: * Preserve `LICENSE` + `NOTICE` from every embedded `@pleach/*` tarball. The files are already in the tarballs; don't strip them. * Surface the FSL-1.1-Apache-2.0 license in your own product's "third-party software" disclosure (most customers' procurement teams ask for one). * Honor the Apache 2.0 transition at +2 years per [redistribution rights](/docs/use-cases/isv/redistribution-rights). That is the full v1 ISV compliance surface. ## When you need an actual master agreement [#when-you-need-an-actual-master-agreement] Some commercial relationships need more than LICENSE text — for example: * You want volume pricing on `@pleach/gateway` or `@pleach/observe` because your product makes millions of inference calls per day. * You want a revenue share on FSL-licensed SKUs that downstream Pleach maintainers continue to develop. * Your customer wants Pleach-signed indemnification or a vendor contract showing a direct relationship with the upstream maintainers. * You want co-marketing — joint case studies, joint conference presence, logo placement. These conversations are bespoke today. Reach out via the contact channel in the README of any `@pleach/*` package on npm. The master-agreement template, revenue-share addendum, and co-marketing addendum are tracked as v2 deliverables; until they ship, every ISV deal is negotiated one-off. ## Roadmap — what v2+ adds [#roadmap--what-v2-adds] The v2+ scope for embedded ISVs is built around three deliverables: * **LTS branch infrastructure.** Annual `v1.x` LTS cuts with 24-month security backport windows. See the [LTS roadmap](/docs/use-cases/isv/lts-roadmap) for the full cadence and SLA plan. * **Dedicated support tier.** A paid support contract with named contacts, on-call rotation, and SLA-backed response times. Requires Pleach to hire a support organization (year 3+ scope). * **`EmbeddedRuntime`.** A variant of `SessionRuntime` with no module-level state, optimized memory footprint, and a faster cold start path. Designed for ISVs whose product instantiates many concurrent runtimes per process. Today, `SessionRuntime` is single-instance-friendly but not zero-module-state. The `EmbeddedRuntime` work is scoped but not started. If you have a use case that depends on any of the above, the honest framing is: tell us. The v2+ plan compresses if there's demonstrable demand. ## Related pages [#related-pages] <Cards> <Card title="Redistribution rights" href="/docs/use-cases/isv/redistribution-rights" description="What FSL-1.1-Apache-2.0 lets you do today. Per-SKU license matrix and the 2-year Apache transition clause." /> <Card title="LTS roadmap" href="/docs/use-cases/isv/lts-roadmap" description="Today's rolling-release cadence and the v2+ LTS branch plan." /> <Card title="Versioning" href="/docs/versioning" description="The per-SKU semver policy and how breaking changes are scoped." /> <Card title="Licensing" href="/docs/versioning#license" description="FSL-1.1-Apache-2.0 specifics and the two-year Apache transition." /> </Cards> --- # FERPA / COPPA scope (/docs/use-cases/education/ferpa-coppa) This page is for the procurement conversation. Read it before signing a district agreement that names FERPA or COPPA as the governing framework. ## TL;DR [#tldr] `@pleach/compliance` profiles that ship today are `"hipaa" | "gdpr" | "pci-dss" | "soc2"`. There is no `ferpa` or `coppa` profile literal. The [`educationAgent`](/docs/use-cases/education) factory accepts the education-facing strings (`ferpa`, `coppa`, `ferpa+coppa`) and substitutes the `gdpr` profile bundle as the closest shipped analog. The substitution is recorded on the runtime tag, fires a `warn-once` notice to your console, and is documented in the factory JSDoc. It is **not** full FERPA or COPPA adequacy. ## What you DO get when you pass `ferpa+coppa` [#what-you-do-get-when-you-pass-ferpacoppa] 1. **The student-safety scrubber** runs against the user message string before the runtime sees it. See [Student safety](/docs/use-cases/education/student-safety) for the redaction patterns (student-id numbers, grade bands, school-issued emails, labeled parent-contact lines). 2. **The `gdpr` profile bundle** runs on top of the student-safety scrubber. The `gdpr` bundle today is `SsnUsScrubber + CreditCardScrubber` — see [`compliantChatbot` config](/docs/recipes/compliant-chatbot) for the canonical bundle mapping. The substitution is the closest shipped analog because FERPA and COPPA both lean heavily on data-minimization plus identifier redaction, and the `gdpr` bundle is the data-minimization scrubber stack we ship today. 3. **A per-session cost-cap policy** registered on the runtime so a runaway loop in a classroom can be cut off at the gateway boundary (see [Per-district tenant + cost-cap](/docs/use-cases/education/per-district-tenant)). 4. **An audit ledger** (when you wire [`@pleach/compliance/audit`](/docs/audit-ledger)) that records every event with a `tenantId` you can scope to `districtId` and pull at end-of-year for retention review. 5. **Substitution transparency**: the runtime tag carries both `requestedProfile` (what you asked for) and `mappedProfile` (what the substrate applied). Your audit ledger can record both. A district auditor can see at row level that the `gdpr` bundle was applied because `ferpa+coppa` was requested. ## What you DO NOT get [#what-you-do-not-get] These are the items that would belong in a full FERPA / COPPA posture and are NOT shipped today. ### FERPA-specific gaps [#ferpa-specific-gaps] * **Directory Information mediation.** FERPA distinguishes "directory information" (name, address, phone, dates of attendance — disclosable unless the parent has opted out) from protected educational records. The shipped scrubber redacts conservatively on identifier shape (IDs, grade bands, school-issued emails); it does not consult a per-student directory-information opt-out registry. If your buyer needs per-student opt-out enforcement, wire it as a pre-`ask()` classifier or as a custom `Scrubber`. * **Educational-record retention rules.** FERPA retention is state-by-state and district-by-district. The audit ledger records `recordedAt` so you can prune on a schedule, but the retention policy itself is operator-owned. No built-in retention enforcement. * **Vendor data-processing addendums (DPAs).** Pleach does not ship a FERPA-equivalent vendor DPA. If your buyer's procurement requires one, you'll need to author it; the audit-ledger + scrubber evidence in this stack supports a DPA but does not constitute one. * **Joint custodial / parent-of-record gating.** FERPA rights transfer to the student at the age of majority OR upon enrollment in a post-secondary institution. The runtime tag records `studentId`; it does NOT enforce a parent-of-record identity gate. The consumer's auth layer owns that. ### COPPA-specific gaps [#coppa-specific-gaps] * **Verifiable Parental Consent (VPC) flow.** COPPA Section 312.5 requires verifiable parental consent before collecting personal information from a child under 13. Pleach does not ship a VPC flow (no parental-credit-card verification, no signed-consent-form intake, no government-ID check). The consumer-facing application is the consent boundary. * **Under-13 age gating.** No age-of-user determination layer. If your platform admits under-13 users, your front door decides whether COPPA applies; Pleach treats every session as identical at the data plane. * **Safe-harbor program enrollment.** Pleach is not enrolled in a COPPA Safe Harbor program (kidSAFE, iKeepSafe, ESRB, etc.). * **Operator-vs-school-official classification.** COPPA permits school-authorized collection of PI without parental consent under the "school-official" doctrine when the school has designated the vendor as a school official under FERPA. That designation is contractual — Pleach does not assert it on your behalf. ### Cross-framework gaps [#cross-framework-gaps] * **No State-Student-Privacy-Pledge co-signature.** Major districts increasingly require vendors to co-sign the Student Privacy Pledge (studentprivacypledge.org). Pleach has not signed it. * **No formal FERPA / COPPA compliance attestation.** The `@pleach/compliance` audit ledger surfaces hash-chained evidence usable in an attestation; an attestation report itself is operator-authored. * **Model providers may not be configured for education workloads.** OpenAI, Anthropic, and Google Vertex each have separate enterprise terms that may or may not be appropriate for under-13 data. Pleach does not enforce a per-provider age-of-data gate. If your provider's standard terms forbid under-13 data, you must route around that contractually (e.g. enterprise contract, BYOK with a provider whose terms permit it, or a self-hosted model). ## What the `warn-once` notice says [#what-the-warn-once-notice-says] When you construct an `educationAgent` with a `complianceProfile`, the factory logs: ``` [educationAgent] requested compliance profile 'ferpa+coppa' is not yet shipped — mapped to 'gdpr' (closest shipped analog). See module JSDoc honest scope-limit. ``` The warning fires once per process (latched). It is intentional — suppressing it hides a substitution that procurement should see. If you want the warning routed to your audit ledger instead of stderr, wrap the factory and rewrite `console.warn` for that construction call. ## How to compose a stronger posture today [#how-to-compose-a-stronger-posture-today] If you need to ship to a district that gates the buy on FERPA / COPPA evidence and can't wait for a dedicated profile cut, three patterns compose well on top of `educationAgent`: 1. **Pre-`ask()` content moderation API.** Wire Anthropic / OpenAI moderation, or a kid-safety classifier (e.g. a self-hosted toxicity model), as a pre-`ask()` filter. Reject the turn before the model sees it on a category flag. 2. **District-extended scrubber.** Pass an `extra` patterns array on `createStudentSafetyScrubber` (see [Student safety](/docs/use-cases/education/student-safety)) to redact district-specific identifiers — SIS IDs, locker numbers, IEP filenames — the shipped scrubber doesn't know about. 3. **EventLogWriter-boundary scrubbing.** Compose a `ComplianceRuntime` directly (as [`compliantChatbot`](/docs/recipes/compliant-chatbot) does internally) and pass `host.modules.eventLogWriter` so the persisted copy of every event row is scrubbed at the substrate boundary too. The `educationAgent` factory does NOT do this automatically today; the scrubbing is at the user-message boundary only. ## Roadmap [#roadmap] A dedicated `ferpa-coppa` compliance profile cut is on the education wave. The shape it will likely take: * A `ferpa+coppa` scrubber bundle distinct from `gdpr`: student-safety scrubber + parent / guardian PII patterns + school-staff PII patterns + state-program identifiers (free / reduced lunch, IEP, 504-plan numbers). * A per-event-type allowlist tuned for classroom-transcript shapes (`tutor.turn`, `feedback.recorded`, `assignment.draft`) in addition to the generic `content.delta` / `interrupt.edited` / `audit.recorded` allowlist `compliantChatbot` ships with today. * An optional VPC stub that records consent evidence on the audit ledger (consumer-supplied; the substrate does not perform verification). Until that cut ships, the `educationAgent` factory makes the substitution explicit so it can be cited cleanly in a procurement review. The factory will continue to accept `ferpa` / `coppa` / `ferpa+coppa` after the dedicated profile ships — the runtime tag's `mappedProfile` will then read `ferpa-coppa` instead of `gdpr`. ## Source [#source] * `packages/recipes/src/education.ts` — substitution map and `warn-once` latch. * `packages/compliance/src/runtime/` — the `ComplianceRuntime` and shipped profile bundles. ## See also [#see-also] * [Education](/docs/use-cases/education) — the landing page. * [Student safety](/docs/use-cases/education/student-safety) — the shipped scrubber. * [Per-district tenant + cost-cap](/docs/use-cases/education/per-district-tenant) — multi-tenant composition for districts. * [`compliantChatbot`](/docs/recipes/compliant-chatbot) — the HIPAA / GDPR / PCI-DSS / SOC 2 sibling recipe. * [`@pleach/compliance`](/docs/compliance) — the full compliance reference. * [Audit ledger](/docs/audit-ledger) — hash-chained evidence for compliance review. --- # 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. --- # Student safety (/docs/use-cases/education/student-safety) `createStudentSafetyScrubber` is the conservative default for redacting student-identifying fragments before they cross the LLM boundary. It composes [`KeyedRegexScrubber`](/docs/scrubbers) — same shape as the shipped SSN / credit-card / driver's-license scrubbers — so the result drops into any `ComplianceRuntime` scrubber chain without new types. The scrubber is wired automatically when you use [`educationAgent`](/docs/use-cases/education). This page is for when you need to extend it, tune it, or wire it into a different runtime (e.g. a teacher-facing transcription pipeline that runs outside `@pleach/recipes`). ## What it redacts today [#what-it-redacts-today] Four pattern categories ship as defaults. The match is tuned to favor false positives over false negatives — a missed student-ID leakage is a worse failure than over-redaction in a classroom transcript. | Pattern name | Regex (illustrative) | Example match | | ------------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------- | | `student-id-number` | `\b(?:student\s*id\|id#\|id\s*number)\s*:?\s*\d{4,10}\b` | `Student ID 9876543`, `ID# 1234567` | | `grade-band` | `\b(?:K\|[1-9]\|1[0-2])(?:st\|nd\|rd\|th)?[\s-]+grad(?:e\|er)\b` | `5th grade`, `11th-grader`, `grade 9` | | `school-issued-email` | `\b[a-z0-9._%+-]+@[a-z0-9.-]*\.(?:k12\|edu\|sch)\.[a-z.]{2,}\b` | `jane.doe12@district.k12.ca.us` | | `labeled-parent-contact` | `\b(?:parent\|guardian)[\s\w]{0,20}(?:phone\|contact\|number)\s*:?\s*\+?[\d\s().\-]{7,}\b` | `Parent phone: (555) 123-4567` | Default replacement is `[STUDENT]`. Override per scrubber, or per-entry on `extra` patterns. A district that wants to NARROW the default match — for example, to disable `grade-band` redaction in a research-only context where grade is part of the analysis dimension — should compose its own `KeyedRegexScrubber` directly rather than try to subtract from the default set. ## Minimal usage [#minimal-usage] ```ts import { createStudentSafetyScrubber } from "@pleach/compliance/education"; const scrubber = createStudentSafetyScrubber(); const result = scrubber.scrub( "Hi — Student ID 9876543 needs help with 5th grade fractions", { eventType: "user.message", fieldPath: ["content"], tenantId: "district-1234", }, ); // result.redacted : "Hi — [STUDENT] needs help with [STUDENT] fractions" // result.matchCount : 2 // result.matchedScrubberIds : ["student-safety:student-id-number", // "student-safety:grade-band"] ``` The scrubber result shape mirrors every other [`Scrubber`](/docs/scrubbers) in `@pleach/compliance` — `{ redacted, matchCount, matchedScrubberIds }`. Audit ledgers can read `matchedScrubberIds` to attribute which pattern fired, without needing to re-run the scrubber. ## Extending with district-specific patterns [#extending-with-district-specific-patterns] Districts and platforms accumulate their own identifier shapes: internal student-information-system IDs, locker numbers, bus-route codes, IEP-document filename prefixes. Pass them through `extra`: ```ts import { createStudentSafetyScrubber } from "@pleach/compliance/education"; const scrubber = createStudentSafetyScrubber({ id: "district-1234-student-safety", replacement: "[STUDENT]", extra: [ // District-issued 8-digit student ID with leading `D`. { name: "district-id", pattern: /\bD\d{8}\b/g, replacement: "[DID]" }, // Bus route on a transportation transcript. { name: "bus-route", pattern: /\bRoute\s+\d{1,3}[A-Z]?\b/gi }, // IEP filename pattern. Replacement defaults to the scrubber's. { name: "iep-doc", pattern: /\bIEP-\d{6}\.pdf\b/g }, ], }); ``` `extra` runs AFTER the defaults in insertion order. Use a per-entry `replacement` to override the scrubber-level default (useful when downstream code distinguishes redaction markers by shape — `[STUDENT]` vs `[DID]`). ## Composing with `KeyedRegexScrubber` directly [#composing-with-keyedregexscrubber-directly] `createStudentSafetyScrubber()` is a thin factory over [`KeyedRegexScrubber`](/docs/scrubbers). When you need full control over pattern ordering, attribution, or want to stack multiple keyed-regex scrubbers in a deterministic order, construct `KeyedRegexScrubber` directly: ```ts import { KeyedRegexScrubber } from "@pleach/compliance/scrubbers"; const districtScrubber = new KeyedRegexScrubber({ id: "district-1234-overrides", entries: [ { name: "internal-ticket", pattern: /TKT-\d{6}/g, replacement: "[TKT]" }, { name: "lunch-account", pattern: /LUNCH\d{8}/g, replacement: "[LUNCH]" }, ], }); // Chain in your ComplianceRuntime: // scrubbers: [createStudentSafetyScrubber(), districtScrubber, ...] ``` Per the `KeyedRegexScrubber` contract, the consumer owns the false-positive rate on its patterns. Idempotency is a consumer responsibility — chained pathological patterns (e.g. `{ pattern: /\d+/, replacement: "0" }`) are a config error, not a scrubber bug. ## Wiring into a custom `ComplianceRuntime` [#wiring-into-a-custom-complianceruntime] When you're not using `educationAgent` — for example, a teacher-facing transcript-correction pipeline that runs separately from the chatbot — compose the scrubber into a `ComplianceRuntime` directly: ```ts import { createComplianceRuntime, SsnUsScrubber, } from "@pleach/compliance"; import { createStudentSafetyScrubber } from "@pleach/compliance/education"; const runtime = createComplianceRuntime({ scrubbers: [ createStudentSafetyScrubber(), new SsnUsScrubber(), // belt-and-suspenders for parent SSNs on emergency forms ], }); // In your per-event boundary: const { redacted, matchCount } = runtime.scrub(transcript, { eventType: "transcript.captured", fieldPath: ["content"], tenantId: districtId, }); ``` ## Honest gap — content moderation [#honest-gap--content-moderation] The shipped scrubber redacts **identifiers**. Per-category content safety (e.g. "self-harm", "violence", "age-inappropriate language") is a separate concern that is NOT covered today. `educationAgent` accepts a `contentSafety` array but the values are surfaced on the runtime tag for telemetry only. The shipped scrubber does NOT have per-category branches. Two paths if you need category moderation today: 1. **External moderation API.** Wire the Anthropic moderation endpoint, the OpenAI moderation endpoint, or a vendor like Hive / Perspective in front of `ask()`. Reject or escalate based on the verdict before the message reaches the model. 2. **Custom `Scrubber` implementation.** Implement the [`Scrubber`](/docs/scrubbers) contract directly with your own classifier inside `scrub()`. The contract is structurally minimal — `id` field plus a `scrub(input, ctx)` method — so a classifier-backed scrubber drops into the same chain. The category-level enforcement gap is tracked on the roadmap. ## Source [#source] * `packages/compliance/src/education/studentSafety.ts` — the `createStudentSafetyScrubber` + `createCostCapPolicy` implementations. * `packages/compliance/src/scrubbers/keyedRegex.ts` — the underlying composable scrubber shape. ## See also [#see-also] * [Education](/docs/use-cases/education) — the landing page. * [FERPA / COPPA scope](/docs/use-cases/education/ferpa-coppa) — what this scrubber does NOT cover. * [Scrubbers](/docs/scrubbers) — the redaction contract, per-event-type allowlist, and authoring guide. * [`@pleach/compliance`](/docs/compliance) — the underlying scrubber library and `ComplianceRuntime`. --- # LTS roadmap (/docs/use-cases/isv/lts-roadmap) This page is the honest answer to "how long do you support a given release?" Short form today: rolling releases on the trunk, no LTS branch, best-effort community support. The longer form covers the v2+ LTS plan, the support-tier dependency, and the concrete cadence ISVs can plan against if they're stitching Pleach into a multi-year product roadmap. ## Today — rolling releases on trunk [#today--rolling-releases-on-trunk] `@pleach/*` ships from a single trunk (`vine`). Minor versions ship roughly monthly; patches ship as needed, sometimes multiple per week during active development. There is no `v1.x` LTS branch. What this means in practice for ISVs: * **You pin a specific version range in your `package.json`.** Standard semver-range behavior — `^1.0.0` picks up minor + patch updates, `~1.2.0` picks up patch only, `1.2.3` freezes. * **Patch versions are additive and non-breaking by policy.** Per `docs/pack-117-per-sku-semver-policy.md` in the repo, patch releases never introduce breaking changes. ISVs pinning at `~1.x.y` granularity get security and bug fixes without surface drift. * **Minor versions are additive on the public surface.** Internal refactors and new features can land, but the public exports declared in `package.json:exports` stay back-compat. Minor-version pins (`^1.2.0`) are the recommended floor for most ISV embeddings. * **Major versions can change the public surface.** Major cuts are rare (the first major was `1.0.0`); they're coordinated across SKUs per the per-SKU semver policy. Support today is community-driven: GitHub issues, README + docs site at `getpleach.com`, the public-facing docs you're reading now. Maintainers respond as bandwidth allows. There is no SLA. If that posture is acceptable for your product, you can build against trunk today. The [redistribution rights](/docs/use-cases/isv/redistribution-rights) page covers the LICENSE side. ## Why no LTS today [#why-no-lts-today] LTS branches are not a documentation deliverable — they're an organizational commitment. A real LTS branch means: * Backport automation that picks security and critical-bug patches from trunk onto an `lts-v1.x` branch. * A dedicated changelog and security-advisory channel for the LTS branch. * A support team capable of triaging customer-reported bugs against an LTS-branch reproduction (which often differs from trunk). * A CVE-tracking process with named owners who can ship a signed patch on a 24h window when a security disclosure lands. Pleach's bootstrap-stage organization (small core team, no dedicated support engineers) cannot credibly commit to any of those four today. Announcing an LTS branch without the underlying organization to support it would be worse than the current "trunk + community support" honesty — it'd be a promise ISVs would make commitments against and we'd then break. The v1 framing is therefore preservation-only: build the substrate that makes LTS *possible* in v2 (wire-format stability protocol, semver discipline, audit gates that catch breaking changes), but don't ship the LTS branch itself. ## What v1 already locks in to make LTS possible later [#what-v1-already-locks-in-to-make-lts-possible-later] Three v1 substrates are LTS prerequisites and they're already in place: * **Wire-format stability protocol (C11).** The protocol for bumping any wire format the runtime emits or consumes — event log shape, checkpoint shape, stream-event taxonomy. Every breaking-shape change ships behind a compat shim, never a hard cut. Tracked in `03-core-changes/C11-wire-format-bump-protocol.md` in the design pack. * **Per-SKU semver discipline.** Each `@pleach/*` SKU versions independently; a major bump in one SKU doesn't cascade through the cohort. Documented at `docs/pack-117-per-sku-semver-policy.md` in the repo. This matters for LTS because backporting a fix from `@pleach/core` trunk into `@pleach/core@1.x` doesn't require coordinating with every other SKU's release cycle. * **`audit:sku-license-fsl-lock` and the gate cohort.** The pre-merge gates that catch LICENSE / package-shape / export-surface drift run on every PR. This is the infrastructure that makes "LTS branch with backported fixes" a non-chaotic exercise — every backport candidate runs through the same gate matrix that protects trunk. If you're an ISV planning a multi-year product roadmap and the question is "will Pleach not break my product without warning?", these three substrates are the v1 commitment we can honor today. ## v2+ — the LTS branch plan [#v2--the-lts-branch-plan] The v2+ scope for LTS: * **Annual LTS cuts.** Each calendar year, one minor version is designated as the LTS for that year — for example, `@pleach/core@1.x` where `x` is the LTS-designated minor. * **24-month security backport window.** Once a version is LTS-designated, security fixes and critical bug fixes are backported to that branch for 24 months. Feature work does not backport. * **Overlapping LTS windows.** At any point in time, at most two LTS branches are active (the current LTS and the previous LTS during its grace window). ISVs always have a supported upgrade path that doesn't require jumping more than one LTS at a time. * **Documented end-of-life dates.** Every LTS cut publishes its EOL date at the time it's cut. No surprise sunsets. The cadence above is the working plan, not a binding promise. The plan ships when: 1. A support organization exists at Pleach to backport, triage, and respond to LTS-branch customer reports. This is the year-3+ hire scope. 2. There is at least one signed ISV master agreement that covers LTS commitments. ISV demand drives the LTS investment; we don't ship a paid-support tier on speculation. Until both of those are true, the v2+ LTS plan is published intent, not a contract. ## v2+ — the support tier [#v2--the-support-tier] LTS is half the story. The other half is **paid support** — a contract that gives ISVs named contacts, an escalation path, and SLA-backed response times. The v2+ scope: * A support team of roughly 3-5 engineers, hired and trained on the Pleach internals. * A 24/7 escalation runbook covering security disclosures, production-down incidents, and contract-bound performance issues. * An SLA tracker that measures actual response times against contract commitments and reports them quarterly. * A customer portal (likely; not committed) for ticket tracking, status updates, and runbook access. The economics are concrete: \~$500K–$2M/year of investment to maintain a credible support organization at that scale. Bootstrap revenue does not fund that investment; the investment fires only after sufficient signed-contract revenue is in hand to justify it. If you're an ISV who needs 24/7 paid support today, the v1 posture is "we can't credibly offer that yet." If your embedded product can be built against community-supported trunk for now with an eye toward a paid contract once Pleach stands up the support organization, then v1 is workable and the v2 transition will be smoother for both sides. ## What ISVs can plan against today [#what-isvs-can-plan-against-today] A practical summary of what an ISV planning a multi-year embedded-Pleach product roadmap can commit to: * **Today through v2+ cutover.** Pin a specific semver range per SKU (`^1.x` is the safe default for most embeddings). Watch CHANGELOG entries on each SKU. Upgrade on your schedule. * **Wire formats are stable across minor bumps.** The C11 protocol guarantees compat shims for any wire-format change. Persistent state — event log rows, checkpoints, audit records — survives upgrades. * **Security fixes ship as patches.** Even in the pre-LTS-branch era, security disclosures get patch releases. * **No surprise license cuts.** FSL-1.1-Apache-2.0 across the cohort, locked by `audit:sku-license-fsl-lock` on every PR. The Apache 2.0 transition at +2 years is automatic via the future-license clause. * **An LTS branch is roadmap, not committed.** Build your product against the assumption that you'll need to upgrade minor versions periodically; treat any future LTS branch as a bonus that reduces upgrade pressure. ## Related pages [#related-pages] <Cards> <Card title="ISV (embedded vendor)" href="/docs/use-cases/isv" description="The parent page on what v1 supports today and what v2+ adds." /> <Card title="Redistribution rights" href="/docs/use-cases/isv/redistribution-rights" description="LICENSE-level details and per-SKU matrix." /> <Card title="Versioning" href="/docs/versioning" description="Per-SKU semver policy and the breaking-change protocol." /> </Cards> --- # Redistribution rights (/docs/use-cases/isv/redistribution-rights) This page is the technical answer to "can my customer's procurement team approve a product that embeds Pleach?" Short form: yes, under [FSL-1.1-Apache-2.0](/docs/versioning), commercial bundling and re-sale are permitted by license terms. The longer form covers which SKUs ship under which license, what changes at the 2-year transition, and the gaps between LICENSE-level permissions and an actual ISV master agreement. ## The license [#the-license] Every shipping `@pleach/*` SKU is published under **FSL-1.1-Apache-2.0** — the Functional Source License version 1.1 with Apache License 2.0 as the future license. Two properties matter for ISV embedding: * **Commercial use is permitted today.** FSL-1.1 specifically allows commercial use, including bundling Pleach into a commercial product and re-selling that product to your own customers. The restriction in FSL-1.1 is on **competing products** — you cannot take Pleach, fork it, and sell it as a competing agent runtime. Embedding it inside a CRM, a vertical SaaS, an analytics tool, a developer product, or any other non-competing offering is in-scope. * **Apache 2.0 transition is automatic at +2 years.** Two years after first stable publish of any given version, that version's license transitions to Apache License 2.0 automatically. No re-licensing ceremony, no separate agreement — the future-license clause inside FSL-1.1 carries the transition. This is why the SPDX identifier is `FSL-1.1-Apache-2.0` rather than just `FSL-1.1`. For most ISV embedding scenarios, the FSL-1.1 commercial-use permission is sufficient on day one. The 2-year clock matters mostly for procurement teams that prefer to see a recognized OSI-approved license on a roadmap. ## Per-SKU LICENSE files in the tarball [#per-sku-license-files-in-the-tarball] Every published tarball carries its own LICENSE and NOTICE file. The structure is uniform across all 13 source-bearing SKUs: ``` node_modules/@pleach/<sku>/ ├── LICENSE # FSL-1.1-Apache-2.0 full text ├── NOTICE # Copyright notice ├── package.json # license: "FSL-1.1-Apache-2.0" ├── README.md └── dist/ ├── index.cjs ├── index.js └── index.d.ts ``` The license-file presence is regression-locked. Every PR runs `audit:sku-license-fsl-lock` — it walks the publishable SKUs, verifies the `package.json:license` field, checks for canonical LICENSE byte-identity, confirms NOTICE files are present, and fails if any SKU drifts off the FSL-1.1-Apache-2.0 baseline. ISVs can rely on the license posture being stable across releases without re-auditing. ## Your obligations when embedding [#your-obligations-when-embedding] The FSL-1.1-Apache-2.0 license imposes three lightweight obligations on embedders. None are bespoke to Pleach — they're standard for permissive-ish licenses: * **Preserve the LICENSE file.** Don't strip `node_modules/@pleach/<sku>/LICENSE` when you package your product. Standard `npm publish` / `pnpm pack` / webpack bundler behavior preserves it; you'd have to actively delete it to drop it. * **Preserve the NOTICE file.** Same as above. The NOTICE file carries the upstream copyright notice and any required attribution text. * **Surface FSL-1.1-Apache-2.0 in your third-party software disclosure.** Most enterprise customers' procurement teams ask for a "third-party software list" with each entry's license. Include `@pleach/<sku>` rows with their declared license. That's the complete embedder obligation set under v1. ## What's NOT yet shipped [#whats-not-yet-shipped] The README + LICENSE-level mechanics above let ISVs embed today. What's not yet shipped: * **`LICENSE-MATRIX.md` aggregator.** A single document enumerating every `@pleach/*` SKU with its license, intended audience, and redistribution notes. Today the per-SKU `package.json:license` fields are the source of truth; the matrix doc is roadmap. * **`LEGAL/ISV-MASTER-AGREEMENT-TEMPLATE.md`.** A pre-drafted master agreement that ISVs can use as a starting point for bespoke negotiations. Today every ISV deal that needs more than LICENSE text is negotiated one-off. * **`LEGAL/ISV-REVENUE-SHARE-ADDENDUM.md`.** Pre-drafted revenue-share terms for FSL-licensed SKUs that downstream Pleach maintainers continue to develop. Roadmap. * **`LEGAL/ISV-CO-MARKETING-ADDENDUM.md`.** Pre-drafted co-marketing terms (joint case studies, conference co-sponsorship, logo placement). Roadmap. * **`scripts/audit/license-clarity.ts`.** A CI gate that cross-checks every SKU's declared license against an authoritative matrix and fails on drift. Today the `audit:sku-license-fsl-lock` gate covers the FSL-1.1 uniformity check; the broader per-SKU clarity audit is roadmap. If you're an ISV who needs any of these before signing, the honest answer is the same as in the parent page: tell us. The templates are punchlist items with non-trivial legal-review costs; they ship faster with concrete deals attached. ## The 2-year Apache 2.0 transition [#the-2-year-apache-20-transition] The future-license clause in FSL-1.1 reads (paraphrasing): two years after the publication date of a given software version, that version is also made available to the public under the designated future license. The designated future license for every `@pleach/*` SKU is Apache 2.0. Practical consequences for ISV embedding: * **No re-licensing ceremony is required.** The transition is automatic. The same FSL-1.1-Apache-2.0 LICENSE file in the tarball is also a grant of Apache 2.0 at +2 years for that version. * **The Apache 2.0 grant is per-version, not global.** When `@pleach/core@1.0.0` hits its 2-year anniversary, the `1.0.0` version transitions to Apache 2.0. The currently shipping `1.x.y` releases remain on FSL-1.1 until their own 2-year anniversaries roll around. * **There is no separate Apache 2.0 license cut planned.** Earlier scoping considered cutting `@pleach/core@1.0.0` directly under Apache 2.0 to give ISVs that prefer Apache-only an unambiguous entry point. That plan was reverted (see `docs/d-pa-9-license-revert-2026-06-14.md` in the repo); the future-license clause is the canonical transition path. ## Per-SKU snapshot [#per-sku-snapshot] All 13 source-bearing publishable SKUs ship under FSL-1.1-Apache-2.0 today: | SKU | License | Redistribution notes | | ----------------------------- | ------------------ | -------------------------------------------------------------------------------------------------- | | `@pleach/core` | FSL-1.1-Apache-2.0 | The agent runtime substrate. Required by every embedded deployment. | | `@pleach/compliance` | FSL-1.1-Apache-2.0 | HIPAA / GDPR / SOC 2 / PCI-DSS scrubbers. Embed if your customers are regulated. | | `@pleach/compliance-contract` | FSL-1.1-Apache-2.0 | Zero-dep contract sub-SKU. Auto-pulled by `@pleach/core` + `@pleach/compliance`. | | `@pleach/gateway` | FSL-1.1-Apache-2.0 | Multi-tenant gateway, BYOK, cost events. Embed if you mediate provider routing for your customers. | | `@pleach/replay` | FSL-1.1-Apache-2.0 | Replay-deterministic regression and fork. Embed for evals shipping with your product. | | `@pleach/eval` | FSL-1.1-Apache-2.0 | Eval lab, benchmark loaders, divergence reporting. | | `@pleach/mcp` | FSL-1.1-Apache-2.0 | MCP-compatible transport. SDK-free pluggable handlers. | | `@pleach/coding-agent` | FSL-1.1-Apache-2.0 | File/diff/exec tools + sandbox facade. | | `@pleach/tools` | FSL-1.1-Apache-2.0 | Tool-authoring helpers. | | `@pleach/base-tools` | FSL-1.1-Apache-2.0 | Curated base tool set. | | `@pleach/react` | FSL-1.1-Apache-2.0 | React bindings + `<ChatBox />` + hooks. | | `@pleach/observe` | FSL-1.1-Apache-2.0 | OTel + structured-row observability. | | `@pleach/langchain` | FSL-1.1-Apache-2.0 | LangChain adapters (Checkpointer, Provider, Store). | | `@pleach/recipes` | FSL-1.1-Apache-2.0 | Use-case-targeted composition recipes. | `@pleach/trust-pack` is a reserved namespace today — a `0.0.1` placeholder is published but there is no source-bearing release yet. Future trust-pack releases ship under FSL-1.1-Apache-2.0. ## Related pages [#related-pages] <Cards> <Card title="ISV (embedded vendor)" href="/docs/use-cases/isv" description="The parent page on what v1 supports and what v2+ adds." /> <Card title="LTS roadmap" href="/docs/use-cases/isv/lts-roadmap" description="LTS branch cadence and the v2+ support tier plan." /> <Card title="Versioning" href="/docs/versioning" description="Per-SKU semver policy and the FSL-1.1-Apache-2.0 license details." /> </Cards> --- # Air-gapped architecture (/docs/use-cases/government/air-gapped-architecture) This page describes the air-gapped enforcement that `@pleach/core` ships in v1, the three call-sites where it lands, and the v2+ gaps (dependency vendoring, SBOM, signed artifacts) that operators should plan around. The general air-gapped story for any Pleach deployment lives at [Air-gapped deployment](/docs/air-gapped-deployment). This page is the government-procurement-facing detail: what enforcement lives inside `@pleach/core` (so hosts cannot accidentally bypass it), what the allowlist semantics are, and how the `governmentAgent` recipe surfaces misconfiguration eagerly. ## v1 enforcement substrate [#v1-enforcement-substrate] The air-gapped option is a two-field mixin on `SessionRuntimeConfig`, declared at `packages/core/src/runtime/AirGappedRuntimeOption.ts`: ```ts export interface AirGappedRuntimeOption { airGapped: boolean airGappedAllowedHosts?: string[] } ``` Default is `airGapped: false` (legacy / non-air-gapped). Operators opt in by setting both fields explicitly. ### Allowlist semantics [#allowlist-semantics] `checkAirGappedHost(url, opts)` runs the check. A URL is permitted when its `URL(url).host`: * matches an allowlist entry **exactly**, OR * ends with `.<entry>` (subdomain match). Host comparison is case-insensitive (DNS names are case-insensitive). Allowlist entries with a leading `.` are normalized — operators can write either `corp.local` or `.corp.local` and the match is the same. ```ts import { checkAirGappedHost } from "@pleach/core/runtime/AirGappedRuntimeOption" // No-op in legacy mode. checkAirGappedHost("https://api.openai.com/v1/...", { airGapped: false }) // Throws AirGappedHostRejectedError — empty allowlist rejects everything. checkAirGappedHost("https://api.openai.com/v1/...", { airGapped: true }) // Permitted — host matches an allowlist entry. checkAirGappedHost("https://llm.corp.local/v1/...", { airGapped: true, airGappedAllowedHosts: ["llm.corp.local"], }) // Permitted — subdomain match on `.corp.local`. checkAirGappedHost("https://gateway.corp.local/v1/...", { airGapped: true, airGappedAllowedHosts: ["corp.local"], }) ``` ### Fail-closed by design [#fail-closed-by-design] The default `airGappedAllowedHosts: []` rejects **every** URL when `airGapped: true`. This is intentional. An operator that turns on air-gapped mode without enumerating the on-prem proxy / private gateway / mirror they intend to reach has misconfigured the deployment, and the runtime surfaces the misconfiguration at first call rather than allowing a silent leak through a forgotten entry. The `governmentAgent` recipe passes `airGappedAllowedHosts` straight through to `@pleach/core` — it adds no validation layer of its own. The core-level fail-closed check is what enforces the allowlist: with an empty list, the first outbound URL resolution throws `AirGappedHostRejectedError` (see [Error shape](#error-shape) below), so a misconfigured deployment fails on its first call — during smoke test — rather than leaking silently in production. ### Malformed URLs [#malformed-urls] Anything `new URL(...)` rejects throws `AirGappedHostRejectedError` unconditionally. An unparseable URL in air-gapped mode is treated as a leak candidate — there is no host to check, so the runtime refuses to make the call. ### Error shape [#error-shape] ```ts export class AirGappedHostRejectedError extends Error { public readonly url: string public readonly allowedHosts: readonly string[] // .cause carries the rejected URL string } ``` Hosts can catch and inspect the structured fields without re-parsing the error message. The `.cause` field carries the rejected URL through the standard Node `Error.cause` channel. ## Enforcement sites in `@pleach/core` [#enforcement-sites-in-pleachcore] The check is enforced at **3 URL-resolution sites** inside `SessionRuntime.ts`. The check lives next to the `fetch()` it guards, so a host cannot legitimately bypass it by forgetting to wire something at the edge. In addition to the runtime check, the env-var override of the OpenRouter base URL is honored at the same 3 sites: * `PLEACH_OPENROUTER_BASE_URL` (process env) * `SessionRuntimeConfig.openrouterBaseURL` (per-runtime config) Operators in air-gapped deployments typically set `PLEACH_OPENROUTER_BASE_URL` to point at the on-prem proxy that satisfies the OpenRouter protocol (an in-perimeter inference endpoint behind an OpenAI-compatible gateway, for example), and add that proxy's host to `airGappedAllowedHosts`. ## v2+ gaps [#v2-gaps] The v1 enforcement substrate addresses the runtime call boundary. It does **not** address install-time supply chain. Three honest gaps: ### 1. Dependency vendoring [#1-dependency-vendoring] `npm install` today requires reaching a registry. Operators can point npm at a **private** registry inside the perimeter, or vendor a snapshot of `node_modules` and install from that snapshot. Neither path is documented as a runbook today. v1.x scope: a documented, reproducible recipe for vendoring `node_modules` from a publish-time tarball into an offline mirror, and a recipe for installing from a private npm registry behind a perimeter proxy. ### 2. SBOM generation [#2-sbom-generation] No SBOM is emitted with the npm publish today. The `governmentAgent` recipe accepts `sbomFormat: "cyclonedx-1.5" | "spdx-2.3"` and stamps it on the runtime as procurement-visible metadata, but the artifact itself is not produced. v1.x scope: CycloneDX 1.5 / SPDX 2.3 SBOM attached to each npm publish under a known artifact name, with the dependency graph derived from `package.json` + the resolved lockfile. ### 3. Signed artifacts [#3-signed-artifacts] Tarballs published to npm carry the npm-default signature today. No Sigstore signing is wired into the publish pipeline. v1.x scope: Sigstore keyless signing of every published tarball under the project's trusted-publisher identity, plus SLSA Build Level 3 provenance attestation. See [Supply-chain risk & SBOM](/docs/use-cases/government/scrm-and-sbom) for the SCRM-facing detail. ## Reference architecture [#reference-architecture] A typical air-gapped deployment looks like: <Mermaid chart="flowchart TB subgraph Perimeter direction TB H["Pleach host (Node.js)<br/>airGapped: true<br/>allowedHosts: [proxy]"] L["On-prem LLM proxy / gateway<br/>vLLM / TGI / Ollama / Bedrock GCv"] R["Private npm registry /<br/>vendored snap"] SIEM["Private SIEM<br/>(observe SDK destination)"] H --- L end" /> Three perimeter boundaries the runtime relies on: 1. **Outbound LLM calls** — gated by `checkAirGappedHost()` at the 3 enforcement sites in `SessionRuntime.ts`. The allowlist is the only list of permitted hosts. 2. **Install-time dependencies** — operator-owned today via private registry or vendored snapshot. v1.x runbook scope. 3. **Observability destinations** — `@pleach/observe` ships data where the host configures it to ship. For air-gapped deployments, point it at a private SIEM or log aggregator inside the perimeter. See [Observability](/docs/observability). ## Verifying enforcement [#verifying-enforcement] The recommended verification is a negative test: ```ts import { governmentAgent } from "@pleach/recipes" import { AirGappedHostRejectedError } from "@pleach/core/runtime/AirGappedRuntimeOption" const bot = governmentAgent({ agencyId: "test", airGapped: true, airGappedAllowedHosts: ["llm.internal.example.gov"], onPremProvider: { kind: "ollama", endpoint: "https://llm.internal.example.gov", }, }) // In a smoke test, point the provider at a public URL and verify // the runtime rejects it: try { await bot.ask("hello") // routes through a public endpoint throw new Error("air-gapped enforcement bypassed") } catch (err) { if (err instanceof AirGappedHostRejectedError) { // Expected — the allowlist is doing its job. } else { throw err } } ``` ## Related [#related] * [Air-gapped deployment (general)](/docs/air-gapped-deployment) * [Government & public sector landing](/docs/use-cases/government) * [Supply-chain risk & SBOM](/docs/use-cases/government/scrm-and-sbom) * [License compatibility](/docs/use-cases/government/license-compatibility) * [Session runtime](/docs/session-runtime) * [Gateway](/docs/gateway) --- # License compatibility for procurement (/docs/use-cases/government/license-compatibility) This page is for government procurement, contract counsel, and OSPO reviewers evaluating Pleach against an open-source license policy. The short version: **all 13 publishable `@pleach/*` SKUs ship under FSL-1.1-Apache-2.0**, which is source-available today and transitions automatically to OSI-compliant Apache-2.0 at the 2-year mark per the FSL future-license clause. ## License posture today [#license-posture-today] `FSL-1.1-Apache-2.0` is the Functional Source License v1.1 with Apache-2.0 as the named future license. Two properties matter for procurement: 1. **Source-available today.** The source is published on npmjs.org and mirrored on GitHub. Customers, contractors, and integrators may read, modify, and use the source for any non-competing purpose. 2. **Apache-2.0 transition is automatic and dated.** The FSL-1.1 clause specifies that each release transitions to the named future license (Apache-2.0) two years after that release was first published. There is no separate ceremony — the date is anchored to the publish date in npm. Per-SKU `LICENSE` files carry the full FSL-1.1-Apache-2.0 text and the specific publish date that anchors the 2-year clock for each release. ## What this means for procurement [#what-this-means-for-procurement] **Today (source-available):** * Read, modify, and run the source on government or contractor infrastructure. The FSL-1.1 grant covers all internal and operational use cases that a regulated buyer typically needs. * Audit the source against a security review board, an OSPO, or a third-party assessor. * Vendor a snapshot of `node_modules` for air-gapped install (see [Air-gapped architecture](/docs/use-cases/government/air-gapped-architecture)). **Two years after a given release publishes (Apache-2.0):** * That release is OSI-approved Apache-2.0 under the standard Apache patent and contribution grants. * No license re-negotiation or vendor sign-off is required for that release after the transition fires. * Government OSPO policies that explicitly enumerate Apache-2.0 as acceptable (most federal OSPO policies do) cover the release without exception. **FSL-1.1 carve-outs that procurement should know about:** * The "competing use" carve-out applies to running a hosted service that competes with Pleach itself. It does not restrict internal, operational, or government-program use. * The future-license clause is one-directional. Once a release transitions to Apache-2.0, the Apache grant is final for that release. ## Per-SKU license matrix [#per-sku-license-matrix] All 13 publishable SKUs ship under the same license today. The full list, with the publish date that anchors each SKU's 2-year transition clock, is intended to live in `LICENSE-MATRIX.md` at the repository root. **Honest scope note (2026-06-15):** the consolidated `LICENSE-MATRIX.md` file at the repo root is a tracked-but-unlanded punch list item. Today, the authoritative per-SKU license is the `LICENSE` file inside each `packages/<sku>/` directory. The matrix view aggregates those for procurement convenience and is on the v1.x runway. The 13 publishable SKUs: | SKU | License | Source path | | ----------------------------- | ------------------ | -------------------------------------- | | `@pleach/core` | FSL-1.1-Apache-2.0 | `packages/core/LICENSE` | | `@pleach/compliance` | FSL-1.1-Apache-2.0 | `packages/compliance/LICENSE` | | `@pleach/compliance-contract` | FSL-1.1-Apache-2.0 | `packages/compliance-contract/LICENSE` | | `@pleach/gateway` | FSL-1.1-Apache-2.0 | `packages/gateway/LICENSE` | | `@pleach/eval` | FSL-1.1-Apache-2.0 | `packages/eval/LICENSE` | | `@pleach/replay` | FSL-1.1-Apache-2.0 | `packages/replay/LICENSE` | | `@pleach/mcp` | FSL-1.1-Apache-2.0 | `packages/mcp/LICENSE` | | `@pleach/coding-agent` | FSL-1.1-Apache-2.0 | `packages/coding-agent/LICENSE` | | `@pleach/tools` | FSL-1.1-Apache-2.0 | `packages/tools/LICENSE` | | `@pleach/base-tools` | FSL-1.1-Apache-2.0 | `packages/base-tools/LICENSE` | | `@pleach/react` | FSL-1.1-Apache-2.0 | `packages/react/LICENSE` | | `@pleach/observe` | FSL-1.1-Apache-2.0 | `packages/observe/LICENSE` | | `@pleach/langchain` | FSL-1.1-Apache-2.0 | `packages/langchain/LICENSE` | | `@pleach/recipes` | FSL-1.1-Apache-2.0 | `packages/recipes/LICENSE` | The `audit:sku-license-fsl-lock` gate at the repo root verifies all 14 locations (13 SKUs + the repo root) carry the canonical FSL-1.1 header. It runs on every PR and currently reports 18/18 clean (the extra 4 cover the reserved-namespace `@pleach/trust-pack` placeholder and the root + sub-license attribution files). ## SBOM and signed artifacts [#sbom-and-signed-artifacts] **Today:** build artifacts are produced by `tsup` and published with npm-default signatures. There is no SBOM emitted with the publish, and artifacts are not signed by Sigstore yet. **Roadmap (v1.x):** * SBOM emission in CycloneDX 1.5 or SPDX 2.3 format, attached to each npm publish. * Sigstore signing of the published tarballs. * SLSA Build Level 3 provenance attestation. The `governmentAgent` recipe accepts `sbomFormat` and `slsaProvenance` config fields and stamps them on the runtime as procurement-visible metadata, so a host that wires SBOM emission externally can still declare the intended format today. See [Supply-chain risk & SBOM](/docs/use-cases/government/scrm-and-sbom) for the full SCRM picture. ## Honest gaps [#honest-gaps] The following are tracked punch-list items, not shipped today: * **`LICENSE-MATRIX.md` at the repo root.** Per-SKU LICENSE files exist; the consolidated matrix view is v1.x. * **Per-SKU SBOM workflow.** No SBOM is emitted at publish today. * **Sigstore signing.** Not wired into the publish pipeline today. * **SLSA provenance attestation.** Not wired today. These are all paperwork-and-pipeline items, not architectural ones. The runtime and SKU layout already support them; the publish ceremony is what is missing. ## Related [#related] * [Government & public sector landing](/docs/use-cases/government) * [Air-gapped architecture](/docs/use-cases/government/air-gapped-architecture) * [Supply-chain risk & SBOM](/docs/use-cases/government/scrm-and-sbom) * [Versioning](/docs/versioning) * [Publishing contract](/docs/publishing-contract) --- # Supply-chain risk & SBOM (/docs/use-cases/government/scrm-and-sbom) This page is for SCRM (supply-chain risk management) reviewers, contract counsel evaluating EO 14028 / NIST SP 800-218 alignment, and government OSPO teams. It documents the current Pleach build pipeline posture honestly, including what is not yet in place. ## Current state (2026-06-15) [#current-state-2026-06-15] Build artifacts are produced by `tsup` (the standard TypeScript bundle tool) and published to npmjs.org with npm-default signatures. There is no SBOM emitted with the publish, and tarballs are not signed by Sigstore yet. Two things **are** in place today that materially reduce SCRM exposure: ### 1. Vendor-neutrality at the SKU boundary [#1-vendor-neutrality-at-the-sku-boundary] `@pleach/core` and `@pleach/compliance` carry **zero cloud-SDK runtime dependencies**. This is enforced at the SKU boundary, not by convention: * `@pleach/core` declares no dependency on `@aws-sdk/*`, `@azure/*`, `@google-cloud/*`, or `@anthropic-ai/sdk`. * `@pleach/compliance` declares no dependency on any cloud SDK. * Provider integrations live in **separate SKUs** that the host opts into. For example, the IAM identity provider at `@pleach/gateway/identity/providers/iam` accepts an injected `STSClient` and does not declare `@aws-sdk/client-sts` as a build-time dep. For an air-gapped or fed-cloud deployment, this means the core runtime ships without dragging in a transitive dependency tree of cloud SDKs the operator does not want. A vendored snapshot of `@pleach/core` is small, auditable, and has no cloud-vendor side imports. ### 2. Per-SKU dependency review [#2-per-sku-dependency-review] Every `@pleach/*` SKU's `package.json` carries an explicit `dependencies` / `peerDependencies` / `optionalDependencies` block. Optional peers (like `pdfkit` for PDF export in `@pleach/compliance`) are declared as optional and the runtime gracefully degrades when they are absent. There is no implicit transitive runtime dependency that the operator is not asked to install. A current `npm ls` snapshot for any SKU is available on request and is the operator's audit anchor today, pending the SBOM workflow below. ## Build pipeline [#build-pipeline] <Mermaid chart="flowchart TD S["source (git SHA)"] S --> TSC["tsc --emitDeclarationOnly"] S --> TSUP["tsup (esbuild)"] TSC --> DTS[".d.ts type artifacts"] TSUP --> BUNDLE["ESM + CJS bundle"] BUNDLE --> PUB["npm publish"] PUB --> TAR["npmjs.org tarball<br/>(npm-default signature)"]" /> The pipeline produces deterministic output (modulo timestamp embeddings) and is reproducible from a clean checkout at the publish SHA. The `audit:harness-package:local-clone` nightly gate verifies this end-to-end — it packs `@pleach/core` as a tarball, installs it in a tmpdir scratch environment, and smoke-imports `SessionRuntime` + `createPleachRuntime`. It reports a clean 8.8 MB tarball, 3.7 s install time, no runtime warnings. ## v1.x roadmap [#v1x-roadmap] Three SCRM-relevant deliverables are explicitly v1.x (post-1.0 minor release): ### SLSA provenance attestation [#slsa-provenance-attestation] Target: SLSA Build Level 3 provenance for every published tarball. Build Level 3 requires hosted build (so the build environment is audit-anchored) and provenance signed by the build platform. The `governmentAgent` recipe accepts `slsaProvenance: boolean` and stamps it on the runtime as procurement-visible metadata today. The attestation artifact itself is not yet produced — that is a publish- pipeline addition, not a runtime addition. ### Sigstore signing [#sigstore-signing] Target: keyless Sigstore signing of every published tarball under the project's trusted-publisher identity (npm OIDC trusted publishing). Sigstore provides a public, append-only transparency log of every signed publish, so a downstream consumer can verify the tarball they installed matches what the project published. Pending operator-attended setup of the OIDC trusted-publisher relationship on the npm side. ### SBOM emission [#sbom-emission] Target: CycloneDX 1.5 or SPDX 2.3 SBOM attached to each npm publish under a known artifact name. The dependency graph is derivable from `package.json` + the resolved lockfile, so the workflow is mechanical once Sigstore signing is in place (so the SBOM itself can be signed under the same trusted-publisher identity). The `governmentAgent` recipe accepts `sbomFormat: "cyclonedx-1.5" | "spdx-2.3"` and surfaces it as procurement-visible metadata, so a host that wires SBOM emission externally can still declare the intended format. ## Vendor-neutrality regression-lock [#vendor-neutrality-regression-lock] Two audit gates enforce that the SCRM posture above does not regress: * `audit:no-cross-sku-type-import-in-core` — `packages/core/src/` cannot import types from any sibling `@pleach/*` SKU outside the explicit allowlist (`@pleach/core` self + `@pleach/compliance- contract`). This defends against accidental coupling that would drag cloud SDKs into the core runtime via a transitive type-graph path. * `audit:domain-string-purity` — `@pleach/core` cannot carry any host-specific or cloud-vendor-specific brand string. Both gates run on every PR. They are mechanical regression-locks, not prescriptive design rules — they fail loud the moment a PR introduces a coupling the SKU boundary forbids. ## Operator items [#operator-items] For SCRM reviewers, the following operator items are available today on request: * **`npm ls` snapshot** for any `@pleach/*` SKU at any published version. Captures the exact dependency tree at publish time. * **Third-party dependency review schedule.** The project reviews third-party runtime dependencies quarterly against known-CVE feeds and against the project's vendor-neutrality posture. * **Build environment anchor.** The publish git SHA + `tsup` config * `tsconfig.json` are all under version control; a third-party reviewer can rebuild from source at any published version and byte-compare the bundle. * **License snapshot.** Per-SKU `LICENSE` files; see [License compatibility](/docs/use-cases/government/license-compatibility). ## What this page does not claim [#what-this-page-does-not-claim] * Does **not** claim SLSA Build Level 3 today. The build is currently Level 1 (versioned source + scripted build); Level 3 is roadmap. * Does **not** claim Sigstore-signed tarballs today. Roadmap. * Does **not** claim SBOM emission today. Roadmap. * Does **not** claim FedRAMP / IL5 / IL6 authorization. See the [Government & public sector landing](/docs/use-cases/government) for the honest authorization posture. ## Related [#related] * [Government & public sector landing](/docs/use-cases/government) * [License compatibility](/docs/use-cases/government/license-compatibility) * [Air-gapped architecture](/docs/use-cases/government/air-gapped-architecture) * [Publishing contract](/docs/publishing-contract) * [Versioning](/docs/versioning)