# 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