pleach
Build

@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 B

The ReplayClient / ReplayHandle contract is stable and wired: ReplayClient, ReplayHandle, the ReplayStepResult / ReplayDoneResult return shapes, the error hierarchy, and the observation surface (currentState(), currentSequenceNumber(), close()) all ship in their final form. ReplayHandle.step(), seek(), and replayTurn() are live — they walk the canonical event log via runtime.events.iterate and fold each row through the same hydrateFromEvents reducer @pleach/core uses, so the incremental step state is byte-identical to a full fold.

The Phase B ReplayRuntime factory (createReplayRuntime) ships alongside with real bodies for all four entry-point surfaces. replayTurn(input) walks the canonical event log via runtime.events.iterate. fromSnapshot(input) deserializes a real HydratedHarnessState via the same iterator

  • hydrateFromEvents from @pleach/core/eventLog when config.sessionRuntime is provided; without sessionRuntime, it falls back to the slice-4 minimal deterministic projection. fork(opts) walks the original event stream up to forkAtSequenceNumber and splices in-memory synthetic events to materialize a spliced-branch state (IMMUTABLE — the original log is read-only). aggregateMultiTenant(opts) fans out per opts.tenantIds, walks each tenant's events via the additive tenantId? iterate API, and folds the per-tenant HydratedHarnessState projections — with aggregation: "merged" the body folds tenant outcomes into one merged state slot. verifyChainIntegrity(input, { reader }) delegates to verifyChainForChat from @pleach/core/eventLog.

Zero throw sites remain on createReplayRuntime's four entry points. PACK_148_FIRST_SLICE_NOT_IMPLEMENTED_MESSAGE stays exported for back-compat detection only.

If you need a working replay loop right now, the workflow documented in Eval and replay 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/replay
pnpm add @pleach/replay
yarn add @pleach/replay
bun 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:

MemberStatus
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(): HydratedHarnessStateLive — zero-state before the first step, folded state thereafter
currentSequenceNumber(): numberLive — 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 methodStatus
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:

ModeCache stateMiss behaviorUse case
cross-mode-readable (default)Reads prod-mode cache from a test-mode runtimeThrows ReplayCacheMissErrorEval, forensics — production-fidelity replay
strict-modeThrows on ANY read (hit or miss)Throws ReplayCacheMissErrorForensics — replay must derive from event log alone
best-effortReturns null on missContinues 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/replay is installable without @pleach/eval. A host that only needs replay (forensics, audit walks, fork-from-checkpoint) takes the smaller surface.
  • Replay's tests do not depend on eval. The contract gates shipped with @pleach/replay are self-contained — see the replayClient.test.ts suite in the package repo.

Determinism contract

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 StrictReplay execution diff. The handle determinism gate (createStrictHandleReplay) walks N independent handles over the same log and asserts byte-identical per-step state — it catches non-determinism in the replay itself. Comparing a replay against a fresh execution and reporting the first divergence (the live-vs- recorded diff) is still a follow-up slice.
  • No automatic cache-key population (yet). The ReplayHandle cache posture consults a cacheBackend for rows carrying an explicit payload.cacheKey, and tool.completed / turn.completed expose an optional typed cacheKey slot for it. The content-hash deriver (deriveContentHashKey) now lives in @pleach/core/cache (relocated there so core can use it without the forbidden core → replay import; @pleach/replay/cache re-exports it). The remaining step is wiring the runtime to derive + stamp the key at turn-emit time — left as an opt-in follow-up because hashing the full message set every turn is a real cost for a replay-only benefit. Until then the key is host- / createCacheMiddleware-populated.
  • No verification CLI. The tamper-evidence walk over the event log's hash chain (see Tamper-evident hash chain) ships as live runtime methods today (verifyChainIntegrity + the underlying verifyChainForChat / generateProof exports from @pleach/core/eventLog); a pleach-replay verify-chain CLI wrapper lands alongside the typed fork API.

The practical surface today: construct a ReplayClient, open a handle for a (chatId, tenantId) pair, and step() / seek() / replayTurn() through the event log — reading currentState() for the folded HydratedHarnessState at each point. The substrate-only workflow at Eval and replay covers the DIY path against @pleach/core primitives.

  • @pleach/core — the substrate replay reads through. runtime.events.iterate, runtime.events.fold, and hydrateFromEvents are the canonical surfaces replay consumes; no raw harness_event_log access.
  • @pleach/eval — the canonical DI consumer. EvalSuite takes a ReplayClient through constructor; replay does not import eval. The dependency direction is one-way.
  • @pleach/compliance — writes the scrubbed, tenant-scoped rows replay walks. verifyChainIntegrity reads the hash chain @pleach/core/eventLog stamps under c9PhaseBEnabled.

For the full SKU map see Which SKU do I need?.

Where to go next

On this page