@pleach/replay
The `@pleach/replay` package — ReplayClient + ReplayHandle walk the canonical event log via runtime.events.iterate/fold, with tenantId required and a typed cache-miss policy.
@pleach/replay is the event-granular replay client for the
@pleach/core agent runtime. It opens a ReplayHandle over a
chat's event log, walks rows in sequence_number order via the
canonical runtime.events.iterate + runtime.events.fold surface,
and reconstructs a HydratedHarnessState deterministically.
Walking the rows in order is walking the lattice — every branch the agent grew, re-folded through the same reducer for byte-identical reconstructed state. (That byte-identity is the deterministic fold of a recorded log; reproducing the original execution is version-pinned, not bit-guaranteed — see Re-execution reproducibility.)
This page is the SKU reference. For the DIY workflow using
@pleach/core primitives directly today (without this SKU), see
Eval and replay.
Status
Phase A + Phase BThe ReplayClient / ReplayHandle contract is stable and wired:
ReplayClient, ReplayHandle, the ReplayStepResult /
ReplayDoneResult return shapes, the error hierarchy, and the
observation surface (currentState(), currentSequenceNumber(),
close()) all ship in their final form. ReplayHandle.step(),
seek(), and replayTurn() are live — they walk the canonical
event log via runtime.events.iterate and fold each row through
the same hydrateFromEvents reducer @pleach/core uses, so the
incremental step state is byte-identical to a full fold.
The Phase B ReplayRuntime factory (createReplayRuntime) ships
alongside with real bodies for all four entry-point surfaces.
replayTurn(input) walks the canonical event log via
runtime.events.iterate. fromSnapshot(input)
deserializes a real HydratedHarnessState via the same iterator
hydrateFromEventsfrom@pleach/core/eventLogwhenconfig.sessionRuntimeis provided; withoutsessionRuntime, it falls back to the slice-4 minimal deterministic projection.fork(opts)walks the original event stream up toforkAtSequenceNumberand splices in-memory synthetic events to materialize a spliced-branch state (IMMUTABLE — the original log is read-only).aggregateMultiTenant(opts)fans out peropts.tenantIds, walks each tenant's events via the additivetenantId?iterate API, and folds the per-tenantHydratedHarnessStateprojections — withaggregation: "merged"the body folds tenant outcomes into one merged state slot.verifyChainIntegrity(input, { reader })delegates toverifyChainForChatfrom@pleach/core/eventLog.
Zero throw sites remain on createReplayRuntime's four entry
points. PACK_148_FIRST_SLICE_NOT_IMPLEMENTED_MESSAGE stays
exported for back-compat detection only.
If you need a working replay loop right now, the workflow
documented in Eval and replay covers it
with the substrate primitives alone — and the
createReplayRuntime factory above is the typed wrapper that
lands on top of that workflow.
Install
npm install @pleach/replaypnpm add @pleach/replayyarn add @pleach/replaybun add @pleach/replay@pleach/core is a peer dependency.
import {
ReplayClient,
createDefaultReplayClient,
ReplayError,
ReplayDivergenceError,
ReplayCacheMissError,
ReplayUnknownEventError,
NotImplementedError,
} from "@pleach/replay";
import type {
ReplayHandle,
ReplayStepResult,
ReplayDoneResult,
FromEventLogOptions,
CacheReadMode,
} from "@pleach/replay";ReplayClient
ReplayClient is constructed against a SessionRuntime and an
optional cacheBackend. The runtime is the only required
dependency — the cache backend is consulted by step() for rows
carrying a payload.cacheKey (see Cache miss policy);
omit it for a pure event-log replay.
import { SessionRuntime } from "@pleach/core";
import { ReplayClient, type ReplayRuntimeFacet } from "@pleach/replay";
const runtime = new SessionRuntime({ /* ... */ });
// ReplayClient reads only the runtime's `events` facet (iterate + fold).
const replay = new ReplayClient({
runtime: runtime as unknown as ReplayRuntimeFacet,
});The constructor validates that runtime is present and throws
TypeError otherwise. The error message is precise at the boundary — the
client is meaningless without a runtime to fold against.
Open a handle — fromEventLog(chatId, opts)
const handle = await replay.fromEventLog("chat_abc", {
tenantId: "tenant_xyz", // REQUIRED — no default
fromSequenceNumber: 0, // exclusive lower bound
upToSequenceNumber: 42, // inclusive upper bound
cacheReadMode: "cross-mode-readable",
});fromEventLog validates its inputs eagerly: chatId must be a
non-empty string, tenantId must be a non-empty string, the
options object must be present. Each failure throws TypeError
with a message naming the offending field, so the bad call site
is the failure site.
cacheReadMode defaults to cross-mode-readable — the
production-fidelity mode where cache misses throw. See
Cache miss policy below.
The handle is single-use. Don't fromEventLog the same
(chatId, tenantId) twice and expect the second call to resume
the first — each call returns a fresh handle. To re-position
inside an existing replay, use seek().
tenantId is required, with no default
tenantId is mandatory on FromEventLogOptions and there is no
inference from chatId. The contract is enforced at the type
level (readonly tenantId: string) and at runtime (an empty
string, null, or undefined throws TypeError).
// ❌ Throws TypeError — tenantId is REQUIRED.
await replay.fromEventLog("chat_abc", {} as any);
// ❌ Throws TypeError — empty string is not a valid tenant.
await replay.fromEventLog("chat_abc", { tenantId: "" });
// ✅ Explicit tenant from the host's auth context.
await replay.fromEventLog("chat_abc", { tenantId: req.user.orgId });This is deliberate. Replay inherits the substrate's RLS posture:
a cross-tenant replay is a cross-tenant read, and silently
defaulting tenantId to anything (the chat's recorded tenant,
the current request, "default") would turn a forensic tool
into a tenant-isolation vulnerability. The host's auth context
is the only place the answer can come from, so the package
makes you pass it.
If you want to replay another tenant's chat as an admin, that's a per-host policy decision — fetch the tenant from your admin context and pass it explicitly. The package doesn't ship an "escape hatch" that would let an unscoped replay leak past a distracted code reviewer.
ReplayHandle
The handle is the per-replay context. Six members, all wired:
| Member | Status |
|---|---|
step(): Promise<ReplayStepResult | ReplayDoneResult> | Live — advances one event, folds, returns the step result (or { done: true } past the window) |
seek(sequenceNumber: number): Promise<void> | Live — deterministic re-fold from fromSequenceNumber to the target (idempotent) |
replayTurn(messageId?): Promise<ReplayTurnResult> | Live — loops step() to the next turn.completed boundary |
currentState(): HydratedHarnessState | Live — zero-state before the first step, folded state thereafter |
currentSequenceNumber(): number | Live — the most-recently-processed sequence_number (or fromSequenceNumber pre-step) |
close(): Promise<void> | Live — releases the iterator, idempotent |
The Phase B ReplayRuntime factory (createReplayRuntime) exposes a
parallel surface for snapshot-rooted replay, fork-from-prefix, and
hash-chain verification. Its state at the current cut:
ReplayRuntime method | Status |
|---|---|
replayTurn(input) | Live — real event-walk via runtime.events.iterate |
fromSnapshot(input) | Live — real HydratedHarnessState deserialization via runtime.events.iterate + hydrateFromEvents when config.sessionRuntime is provided; deterministic-projection fallback otherwise |
verifyChainIntegrity(input, { reader }) | Live — delegates to verifyChainForChat from @pleach/core/eventLog |
fork(opts) | Live — walks the original event stream to forkAtSequenceNumber, splices synthetic events in-memory, applies hydrateFromEvents for the spliced-branch state when config.sessionRuntime is provided; slice-4 deterministic-projection fallback otherwise |
aggregateMultiTenant(opts) | Live — fans out per opts.tenantIds, walks each tenant's events via the additive tenantId? iterate API, folds the per-tenant projections; aggregation: "merged" folds tenant outcomes into one merged state slot |
verifyChainIntegrity pairs with the verifyChainForChat and
generateProof exports from @pleach/core/eventLog —
see Tamper-evident hash chain for the chain
verification surface and proof-generation contract.
currentState()
Returns the immutable folded state at the most recent step.
Before any step() call, this is the canonical zero-state — the
same shape hydrateFromEvents produces for a fresh session:
{
interrupts: [],
subagents: [],
exports: [],
userCards: [],
eventCount: 0,
}The fresh-handle zero-state is frozen; you can read it across
replay steps without defensive copies. Each step() returns a
new nextState snapshot and currentState() reflects the
latest folded state.
currentSequenceNumber()
Returns the sequence_number of the most recently processed
event, or fromSequenceNumber for a fresh handle. Useful for
progress reporting and for choosing a seek() target.
close()
Releases the underlying iterator. Idempotent — calling close()
twice never throws. Call it from a finally block when wrapping
a replay in try/catch, the same way you'd close any async
resource.
step(), seek(), and replayTurn()
// Walk the log event by event:
for (let r = await handle.step(); !("done" in r); r = await handle.step()) {
console.log(r.eventType, r.sequenceNumber);
console.log(handle.currentState()); // folded state as-of this event
}
// Jump to an absolute sequence (idempotent re-fold from the window start):
await handle.seek(10);
// Advance a whole assistant turn at once:
const turn = await handle.replayTurn(); // next turn.completed
console.log(turn.messageId, turn.stepResults.length);step() advances one row, folds it through hydrateFromEvents,
and returns a ReplayStepResult — or { done: true } once the
iterator (or the upToSequenceNumber window) is exhausted. It is
the only forward-mutating path.
seek(n) does not replay forward from the current cursor — it
re-folds deterministically from fromSequenceNumber to n. That
keeps seek(n) idempotent and independent of the order in which a
caller step()s and seek()s; a subsequent step() continues
from n + 1. Because incremental step() and full seek()
re-fold share one reducer, N steps produce a state
byte-identical to seek(N) — the determinism invariant below.
replayTurn(messageId?) is a turn-granular convenience: it loops
step() until the next turn.completed row (matching
payload.message_id when supplied, or the next boundary overall
when omitted), returning every intermediate ReplayStepResult
plus the post-turn state. (turn.completed is the event-log row
that records an assistant-turn boundary; message.added is a
live-stream event, not a persisted log row.)
Cache miss policy
The cacheReadMode option locks the handle's behavior when a
provider-cache lookup misses. Three modes:
| Mode | Cache state | Miss behavior | Use case |
|---|---|---|---|
cross-mode-readable (default) | Reads prod-mode cache from a test-mode runtime | Throws ReplayCacheMissError | Eval, forensics — production-fidelity replay |
strict-mode | Throws on ANY read (hit or miss) | Throws ReplayCacheMissError | Forensics — replay must derive from event log alone |
best-effort | Returns null on miss | Continues with cacheLookup.via = "live" | Education, partial replay, semester-over-semester analyses |
The default throws because the typical replay caller — an eval or a forensic walk — wants to know when the recorded world diverges from what the cache can satisfy today. A silent fall-through to a live call masks the divergence.
best-effort is the opt-in for callers who explicitly want
partial replay to continue past a miss. The handle still
records the lookup result on each ReplayStepResult so the
caller can count via: "live" events after the fact.
// Forensic posture — fail-loud on any cache state.
await replay.fromEventLog("chat_abc", {
tenantId: req.user.orgId,
cacheReadMode: "strict-mode",
});
// Education posture — keep going past misses.
await replay.fromEventLog("chat_abc", {
tenantId: req.user.orgId,
cacheReadMode: "best-effort",
});Mode is locked at handle creation. To switch modes for the same
chat, open a fresh handle — there's no setCacheReadMode()
mutator because mid-replay mode flips would make the divergence
signal incoherent.
Canonical surface — runtime.events.iterate / fold
ReplayClient consumes only the public SessionRuntime surface.
The handle reads through runtime.events.iterate({ chatId, fromSequenceNumber, tenantId }) and folds each row through
hydrateFromEvents from @pleach/core/eventLog — the same reducer
runtime.events.fold uses — so no raw DB access, no
harness_event_log schema coupling, no private accessors.
This isolates replay from the substrate's storage layer. The
dual-write → dual-read → snapshot-retire migration ladder
documented in Event-log projections
happens behind the canonical surface; replay inherits each step
transparently as the substrate flips. A consumer who depends on
runtime.events.fold today picks up the eventual snapshot-table
retirement without a code change.
The structural contract ReplayClient requires is small enough
to mock in tests:
import type { ReplayRuntimeFacet } from "@pleach/replay";
const mockRuntime: ReplayRuntimeFacet = {
events: {
iterate: ({ chatId, fromSequenceNumber }) => /* ... */,
fold: async (projection) => /* ... */,
},
};
const client = new ReplayClient({ runtime: mockRuntime });A real SessionRuntime from @pleach/core satisfies this
structurally — no extra adapter is needed.
Error hierarchy
NotImplementedError is a separate base — an implementation gap,
not a divergence. It remains exported for any surface still landing,
but the core step() / seek() / replayTurn() stepper no longer
throws it; those methods are wired. Callers that catch ReplayError
will not catch NotImplementedError, and that's deliberate: a
missing handler is a bug to fix, not a replay outcome to attribute.
Every replay error subclass carries chatId + tenantId so a
caller catching at the boundary can attribute the failure back
to the originating replay without re-deriving the scope.
ReplayCacheMissError adds key, mode, and sequenceNumber
so a caller can log which provider-cache key was missed at which
point in the walk:
try {
await handle.step();
} catch (err) {
if (err instanceof ReplayCacheMissError) {
log.warn({
chatId: err.chatId,
tenantId: err.tenantId,
key: err.key,
mode: err.mode,
sequenceNumber: err.sequenceNumber,
}, "replay cache miss");
}
throw err;
}ReplayUnknownEventError carries eventType + sequenceNumber.
It signals a row the stepper cannot progress past — one with no
usable event_type discriminator. Rows whose event_type the
hydrateFromEvents projections don't model are not errors: they
fold as no-ops (the reducer is intentionally lossy for event types
it doesn't track), so the walk continues. To surface a custom
domain event in the folded state, register a projection (see
Event-log projections).
Eval coupling — DI via constructor
@pleach/replay does not import @pleach/eval. The
dependency is the other direction: @pleach/eval accepts a
ReplayClient instance through EvalSuite's setReplayClient()
method and delegates replay to it. (EvalSuiteOptions requires
suiteId; it has no replay field.)
import { SessionRuntime } from "@pleach/core";
import { createReplayRuntime } from "@pleach/replay";
import { EvalSuite, type EvalReplayClient } from "@pleach/eval";
const runtime = new SessionRuntime({ /* ... */ });
const tenantId = "tenant_xyz";
const replayRuntime = createReplayRuntime({ tenantId, sessionRuntime: runtime });
// The eval side consumes replay through the structural `EvalReplayClient`
// contract — `replay(chatId) => { output }`. Adapt the turn-granular
// `ReplayRuntime` to that shape.
const replay: EvalReplayClient = {
async replay(chatId) {
const { state } = await replayRuntime.replayTurn({ chatId, tenantId });
return { output: String(state ?? "") };
},
};
const suite = new EvalSuite({ suiteId: "regression", runtime });
suite.setReplayClient(replay);createReplayRuntime({ tenantId, sessionRuntime }) builds the
turn-granular ReplayRuntime from a SessionRuntime; the small
adapter above is what satisfies @pleach/eval's EvalReplayClient
DI slot. The two SKUs stay decoupled — @pleach/replay never
imports @pleach/eval, and the adapter lives in host code.
Two consequences of the DI direction:
@pleach/replayis installable without@pleach/eval. A host that only needs replay (forensics, audit walks, fork-from-checkpoint) takes the smaller surface.- Replay's tests do not depend on eval. The contract gates
shipped with
@pleach/replayare self-contained — see thereplayClient.test.tssuite in the package repo.
Determinism contract
Two fromEventLog() calls with identical arguments produce
handles that walk byte-identically: stepping each to done
yields the same HydratedHarnessState at every step, and that
state equals a single hydrateFromEvents over the same rows.
Incremental step() and full seek() re-fold share one reducer,
so there is no second code path to drift — seek(N) equals N
steps equals a full fold, byte for byte.
This is the load-bearing guarantee replay rests on. If two replays of the same recorded slice diverge, the divergence is the signal — something non-deterministic slipped into the chain. See Determinism for the four ways the chain can break.
createStrictHandleReplay (from @pleach/replay/strict) is the
callable gate for exactly this: it opens N independent handles
over the same (chatId, tenantId, window), walks each to done,
and reports { deterministic, steps, firstDivergenceAt? } — the
step index where the per-step state first diverges.
import { createStrictHandleReplay } from "@pleach/replay/strict";
const strict = createStrictHandleReplay({ client: replayClient });
const verdict = await strict.replay({ chatId, tenantId });
if (!verdict.deterministic) {
console.error("non-determinism at step", verdict.firstDivergenceAt);
}(createStrictReplay is the sibling gate over the
ReplayRuntime.replayTurn surface; createStrictHandleReplay
gates the event-granular ReplayHandle stepper.)
Re-execution reproducibility
The determinism contract above is about replaying the recorded log — re-folding immutable rows is byte-identical because there is one reducer and no second code path. Reproducing the original execution — re-running the agent and getting the same model bytes back — is a different, weaker guarantee, and it is important to be precise about it.
True bit-for-bit re-execution is not generally achievable against a hosted model provider. The provider can change a model, a tokenizer, a sampler, or its internal routing without surfacing a version you can pin to (OpenRouter silently swapping an upstream is the canonical example). What replay can do is make every controllable variable a stamped version, so a divergence becomes attributable rather than mysterious. Record, as versions on the run, every end-to-end variable property:
- model id and provider (and transport: native / openrouter / byok)
@pleach/core+ SKU package versions and the upstream provider SDK version- client / host package version making the call
- config version — the sampling params (
temperature,topP,maxTokens,stopSequences,seed) plus system-prompt and safety-policy identity (these are exactly what the seam cache fingerprint keys on) - plugin versions — each registered
HarnessPlugin
With all of these pinned, replay reconstructs the same decision context; what it cannot pin is provider-internal change that ships without a version. The closer you control the stack, the closer you get to true byte-identity — a locally hosted model (fixed weights, fixed inference engine, pinned sampler + seed) can approach bit-for-bit re-execution, because there is no unversioned third party left in the loop.
What's NOT in this package today
Honest about the current scope:
- No
StrictReplayexecution diff. The handle determinism gate (createStrictHandleReplay) walks N independent handles over the same log and asserts byte-identical per-step state — it catches non-determinism in the replay itself. Comparing a replay against a fresh execution and reporting the first divergence (the live-vs- recorded diff) is still a follow-up slice. - No automatic cache-key population (yet). The
ReplayHandlecache posture consults acacheBackendfor rows carrying an explicitpayload.cacheKey, andtool.completed/turn.completedexpose an optional typedcacheKeyslot for it. The content-hash deriver (deriveContentHashKey) now lives in@pleach/core/cache(relocated there so core can use it without the forbiddencore → replayimport;@pleach/replay/cachere-exports it). The remaining step is wiring the runtime to derive + stamp the key at turn-emit time — left as an opt-in follow-up because hashing the full message set every turn is a real cost for a replay-only benefit. Until then the key is host- /createCacheMiddleware-populated. - No verification CLI. The tamper-evidence walk over the
event log's hash chain (see Tamper-evident hash chain)
ships as live runtime methods today
(
verifyChainIntegrity+ the underlyingverifyChainForChat/generateProofexports from@pleach/core/eventLog); apleach-replay verify-chainCLI wrapper lands alongside the typed fork API.
The practical surface today: construct a ReplayClient, open a
handle for a (chatId, tenantId) pair, and step() / seek() /
replayTurn() through the event log — reading currentState()
for the folded HydratedHarnessState at each point. The
substrate-only workflow at Eval and replay
covers the DIY path against @pleach/core primitives.
Related SKUs
@pleach/core— the substrate replay reads through.runtime.events.iterate,runtime.events.fold, andhydrateFromEventsare the canonical surfaces replay consumes; no rawharness_event_logaccess.@pleach/eval— the canonical DI consumer.EvalSuitetakes aReplayClientthrough constructor; replay does not import eval. The dependency direction is one-way.@pleach/compliance— writes the scrubbed, tenant-scoped rows replay walks.verifyChainIntegrityreads the hash chain@pleach/core/eventLogstamps underc9PhaseBEnabled.
For the full SKU map see Which SKU do I need?.
Where to go next
Eval and replay
The DIY workflow against `@pleach/core` primitives — fingerprint, audit ledger, checkpoints, runtimeMode. The path you can adopt today without this SKU.
@pleach/eval
The DI consumer SKU — EvalSuite wraps a ReplayClient for fixture-based regression eval.
Event-log projections
GraphProjection<T>, runtime.events.iterate, and runtime.events.fold — the canonical surface this client consumes.
Tamper-evident hash chain
prev_hash / row_hash columns — the integrity guarantee that lets replay results stand as evidence.
@pleach/sandbox
Vendor-neutral SandboxProvider contract — the seam @pleach/coding-agent reads, with adapter packages slotting under it for Docker, Firecracker, E2B, Modal, or your own runner.
Gateway · BYOK credential routing
Per-tenant BYOK credential storage and routing — TenantCredentialStore contract, Postgres / Redis / external-secret-manager adapters, and CredentialRoutingMiddleware.