# @pleach/replay (/docs/replay)



`@pleach/replay` is the event-granular replay client for the
`@pleach/core` agent runtime. It opens a `ReplayHandle` over a
chat's event log, walks rows in `sequence_number` order via the
canonical `runtime.events.iterate` + `runtime.events.fold` surface,
and reconstructs a `HydratedHarnessState` deterministically.

Walking the rows in order is walking the lattice — every branch
the agent grew, re-folded through the same reducer for
byte-identical reconstructed state. (That byte-identity is the
deterministic *fold* of a recorded log; reproducing the original
*execution* is version-pinned, not bit-guaranteed — see
[Re-execution reproducibility](#re-execution-reproducibility).)

This page is the SKU reference. For the DIY workflow using
`@pleach/core` primitives directly today (without this SKU), see
[Eval and replay](/docs/eval-and-replay).

<SourceMeta pkg="{ name: &#x22;@pleach/replay&#x22;, href: &#x22;https://www.npmjs.com/package/@pleach/replay&#x22; }" source="{ label: &#x22;github.com/pleachhq/replay&#x22;, href: &#x22;https://github.com/pleachhq/replay&#x22; }" />

## Status [#status]

<StatusBadge status="in-flight">
  Phase A + Phase B
</StatusBadge>

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

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

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

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

If you need a working replay loop right now, the workflow
documented in [Eval and replay](/docs/eval-and-replay) covers it
with the substrate primitives alone — and the
`createReplayRuntime` factory above is the typed wrapper that
lands on top of that workflow.

## Install [#install]

<Tabs items="['npm', 'pnpm', 'yarn', 'bun']" groupId="pkg">
  <Tab value="npm">
    ```bash
    npm install @pleach/replay
    ```
  </Tab>

  <Tab value="pnpm">
    ```bash
    pnpm add @pleach/replay
    ```
  </Tab>

  <Tab value="yarn">
    ```bash
    yarn add @pleach/replay
    ```
  </Tab>

  <Tab value="bun">
    ```bash
    bun add @pleach/replay
    ```
  </Tab>
</Tabs>

`@pleach/core` is a peer dependency.

```typescript
import {
  ReplayClient,
  createDefaultReplayClient,
  ReplayError,
  ReplayDivergenceError,
  ReplayCacheMissError,
  ReplayUnknownEventError,
  NotImplementedError,
} from "@pleach/replay";

import type {
  ReplayHandle,
  ReplayStepResult,
  ReplayDoneResult,
  FromEventLogOptions,
  CacheReadMode,
} from "@pleach/replay";
```

## `ReplayClient` [#replayclient]

`ReplayClient` is constructed against a `SessionRuntime` and an
optional `cacheBackend`. The runtime is the only required
dependency — the cache backend is consulted by `step()` for rows
carrying a `payload.cacheKey` (see [Cache miss policy](#cache-miss-policy));
omit it for a pure event-log replay.

```typescript
import { SessionRuntime } from "@pleach/core";
import { ReplayClient, type ReplayRuntimeFacet } from "@pleach/replay";

const runtime = new SessionRuntime({ /* ... */ });
// ReplayClient reads only the runtime's `events` facet (iterate + fold).
const replay = new ReplayClient({
  runtime: runtime as unknown as ReplayRuntimeFacet,
});
```

The constructor validates that `runtime` is present and throws
`TypeError` otherwise. The error message is precise at the boundary — the
client is meaningless without a runtime to fold against.

### Open a handle — `fromEventLog(chatId, opts)` [#open-a-handle--fromeventlogchatid-opts]

```typescript
const handle = await replay.fromEventLog("chat_abc", {
  tenantId:           "tenant_xyz",   // REQUIRED — no default
  fromSequenceNumber: 0,              // exclusive lower bound
  upToSequenceNumber: 42,             // inclusive upper bound
  cacheReadMode:      "cross-mode-readable",
});
```

`fromEventLog` validates its inputs eagerly: `chatId` must be a
non-empty string, `tenantId` must be a non-empty string, the
options object must be present. Each failure throws `TypeError`
with a message naming the offending field, so the bad call site
is the failure site.

`cacheReadMode` defaults to `cross-mode-readable` — the
production-fidelity mode where cache misses throw. See
[Cache miss policy](#cache-miss-policy) below.

The handle is single-use. Don't `fromEventLog` the same
`(chatId, tenantId)` twice and expect the second call to resume
the first — each call returns a fresh handle. To re-position
inside an existing replay, use `seek()`.

## `tenantId` is required, with no default [#tenantid-is-required-with-no-default]

`tenantId` is mandatory on `FromEventLogOptions` and there is no
inference from `chatId`. The contract is enforced at the type
level (`readonly tenantId: string`) and at runtime (an empty
string, `null`, or `undefined` throws `TypeError`).

```typescript
// ❌ Throws TypeError — tenantId is REQUIRED.
await replay.fromEventLog("chat_abc", {} as any);

// ❌ Throws TypeError — empty string is not a valid tenant.
await replay.fromEventLog("chat_abc", { tenantId: "" });

// ✅ Explicit tenant from the host's auth context.
await replay.fromEventLog("chat_abc", { tenantId: req.user.orgId });
```

This is deliberate. Replay inherits the substrate's RLS posture:
a cross-tenant replay is a cross-tenant read, and silently
defaulting `tenantId` to anything (the chat's recorded tenant,
the current request, `"default"`) would turn a forensic tool
into a tenant-isolation vulnerability. The host's auth context
is the only place the answer can come from, so the package
makes you pass it.

If you want to replay another tenant's chat as an admin, that's
a per-host policy decision — fetch the tenant from your admin
context and pass it explicitly. The package doesn't ship an
"escape hatch" that would let an unscoped replay leak past a
distracted code reviewer.

## `ReplayHandle` [#replayhandle]

The handle is the per-replay context. Six members, all wired:

| Member                                                  | Status                                                                                          |
| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `step(): Promise<ReplayStepResult \| ReplayDoneResult>` | Live — advances one event, folds, returns the step result (or `{ done: true }` past the window) |
| `seek(sequenceNumber: number): Promise<void>`           | Live — deterministic re-fold from `fromSequenceNumber` to the target (idempotent)               |
| `replayTurn(messageId?): Promise<ReplayTurnResult>`     | Live — loops `step()` to the next `turn.completed` boundary                                     |
| `currentState(): HydratedHarnessState`                  | Live — zero-state before the first step, folded state thereafter                                |
| `currentSequenceNumber(): number`                       | Live — the most-recently-processed `sequence_number` (or `fromSequenceNumber` pre-step)         |
| `close(): Promise<void>`                                | Live — releases the iterator, idempotent                                                        |

The Phase B `ReplayRuntime` factory (`createReplayRuntime`) exposes a
parallel surface for snapshot-rooted replay, fork-from-prefix, and
hash-chain verification. Its state at the current cut:

| `ReplayRuntime` method                    | Status                                                                                                                                                                                                                                                       |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `replayTurn(input)`                       | Live — real event-walk via `runtime.events.iterate`                                                                                                                                                                                                          |
| `fromSnapshot(input)`                     | Live — real `HydratedHarnessState` deserialization via `runtime.events.iterate` + `hydrateFromEvents` when `config.sessionRuntime` is provided; deterministic-projection fallback otherwise                                                                  |
| `verifyChainIntegrity(input, { reader })` | Live — delegates to `verifyChainForChat` from `@pleach/core/eventLog`                                                                                                                                                                                        |
| `fork(opts)`                              | Live — walks the original event stream to `forkAtSequenceNumber`, splices synthetic events in-memory, applies `hydrateFromEvents` for the spliced-branch state when `config.sessionRuntime` is provided; slice-4 deterministic-projection fallback otherwise |
| `aggregateMultiTenant(opts)`              | Live — fans out per `opts.tenantIds`, walks each tenant's events via the additive `tenantId?` iterate API, folds the per-tenant projections; `aggregation: "merged"` folds tenant outcomes into one merged state slot                                        |

`verifyChainIntegrity` pairs with the `verifyChainForChat` and
`generateProof` exports from `@pleach/core/eventLog` —
see [Tamper-evident hash chain](/docs/hash-chain) for the chain
verification surface and proof-generation contract.

### `currentState()` [#currentstate]

Returns the immutable folded state at the most recent step.
Before any `step()` call, this is the canonical zero-state — the
same shape `hydrateFromEvents` produces for a fresh session:

```typescript
{
  interrupts: [],
  subagents:  [],
  exports:    [],
  userCards:  [],
  eventCount: 0,
}
```

The fresh-handle zero-state is frozen; you can read it across
replay steps without defensive copies. Each `step()` returns a
new `nextState` snapshot and `currentState()` reflects the
latest folded state.

### `currentSequenceNumber()` [#currentsequencenumber]

Returns the `sequence_number` of the most recently processed
event, or `fromSequenceNumber` for a fresh handle. Useful for
progress reporting and for choosing a `seek()` target.

### `close()` [#close]

Releases the underlying iterator. Idempotent — calling `close()`
twice never throws. Call it from a `finally` block when wrapping
a replay in `try`/`catch`, the same way you'd close any async
resource.

### `step()`, `seek()`, and `replayTurn()` [#step-seek-and-replayturn]

```typescript
// Walk the log event by event:
for (let r = await handle.step(); !("done" in r); r = await handle.step()) {
  console.log(r.eventType, r.sequenceNumber);
  console.log(handle.currentState()); // folded state as-of this event
}

// Jump to an absolute sequence (idempotent re-fold from the window start):
await handle.seek(10);

// Advance a whole assistant turn at once:
const turn = await handle.replayTurn();        // next turn.completed
console.log(turn.messageId, turn.stepResults.length);
```

`step()` advances one row, folds it through `hydrateFromEvents`,
and returns a `ReplayStepResult` — or `{ done: true }` once the
iterator (or the `upToSequenceNumber` window) is exhausted. It is
the only forward-mutating path.

`seek(n)` does **not** replay forward from the current cursor — it
re-folds deterministically from `fromSequenceNumber` to `n`. That
keeps `seek(n)` idempotent and independent of the order in which a
caller `step()`s and `seek()`s; a subsequent `step()` continues
from `n + 1`. Because incremental `step()` and full `seek()`
re-fold share **one** reducer, N steps produce a state
byte-identical to `seek(N)` — the determinism invariant below.

`replayTurn(messageId?)` is a turn-granular convenience: it loops
`step()` until the next `turn.completed` row (matching
`payload.message_id` when supplied, or the next boundary overall
when omitted), returning every intermediate `ReplayStepResult`
plus the post-turn state. (`turn.completed` is the event-log row
that records an assistant-turn boundary; `message.added` is a
live-stream event, not a persisted log row.)

## Cache miss policy [#cache-miss-policy]

The `cacheReadMode` option locks the handle's behavior when a
provider-cache lookup misses. Three modes:

| Mode                            | Cache state                                    | Miss behavior                             | Use case                                                   |
| ------------------------------- | ---------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------- |
| `cross-mode-readable` (default) | Reads prod-mode cache from a test-mode runtime | Throws `ReplayCacheMissError`             | Eval, forensics — production-fidelity replay               |
| `strict-mode`                   | Throws on ANY read (hit or miss)               | Throws `ReplayCacheMissError`             | Forensics — replay must derive from event log alone        |
| `best-effort`                   | Returns `null` on miss                         | Continues with `cacheLookup.via = "live"` | Education, partial replay, semester-over-semester analyses |

The default throws because the typical replay caller — an eval
or a forensic walk — wants to know when the recorded world
diverges from what the cache can satisfy today. A silent
fall-through to a live call masks the divergence.

`best-effort` is the opt-in for callers who explicitly want
partial replay to continue past a miss. The handle still
records the lookup result on each `ReplayStepResult` so the
caller can count `via: "live"` events after the fact.

```typescript
// Forensic posture — fail-loud on any cache state.
await replay.fromEventLog("chat_abc", {
  tenantId:      req.user.orgId,
  cacheReadMode: "strict-mode",
});

// Education posture — keep going past misses.
await replay.fromEventLog("chat_abc", {
  tenantId:      req.user.orgId,
  cacheReadMode: "best-effort",
});
```

Mode is locked at handle creation. To switch modes for the same
chat, open a fresh handle — there's no `setCacheReadMode()`
mutator because mid-replay mode flips would make the divergence
signal incoherent.

## Canonical surface — `runtime.events.iterate` / `fold` [#canonical-surface--runtimeeventsiterate--fold]

`ReplayClient` consumes only the public `SessionRuntime` surface.
The handle reads through `runtime.events.iterate({ chatId,
fromSequenceNumber, tenantId })` and folds each row through
`hydrateFromEvents` from `@pleach/core/eventLog` — the same reducer
`runtime.events.fold` uses — so no raw DB access, no
`harness_event_log` schema coupling, no private accessors.

This isolates replay from the substrate's storage layer. The
dual-write → dual-read → snapshot-retire migration ladder
documented in [Event-log projections](/docs/event-log-projections)
happens behind the canonical surface; replay inherits each step
transparently as the substrate flips. A consumer who depends on
`runtime.events.fold` today picks up the eventual snapshot-table
retirement without a code change.

The structural contract `ReplayClient` requires is small enough
to mock in tests:

```typescript
import type { ReplayRuntimeFacet } from "@pleach/replay";

const mockRuntime: ReplayRuntimeFacet = {
  events: {
    iterate: ({ chatId, fromSequenceNumber }) => /* ... */,
    fold:    async (projection) => /* ... */,
  },
};

const client = new ReplayClient({ runtime: mockRuntime });
```

A real `SessionRuntime` from `@pleach/core` satisfies this
structurally — no extra adapter is needed.

## Error hierarchy [#error-hierarchy]

<Mermaid
  chart="classDiagram
    class ReplayError {
        +chatId
        +tenantId
    }
    class ReplayDivergenceError {
        live world diverged
    }
    class ReplayCacheMissError {
        +key
        +mode
        +sequenceNumber
    }
    class ReplayUnknownEventError {
        unknown event type
    }
    class NotImplementedError {
        implementation gap
    }
    ReplayError <|-- ReplayDivergenceError
    ReplayDivergenceError <|-- ReplayCacheMissError
    ReplayError <|-- ReplayUnknownEventError"
/>

`NotImplementedError` is a *separate* base — an implementation gap,
not a divergence. It remains exported for any surface still landing,
but the core `step()` / `seek()` / `replayTurn()` stepper no longer
throws it; those methods are wired. Callers that catch `ReplayError`
will not catch `NotImplementedError`, and that's deliberate: a
missing handler is a bug to fix, not a replay outcome to attribute.

Every replay error subclass carries `chatId` + `tenantId` so a
caller catching at the boundary can attribute the failure back
to the originating replay without re-deriving the scope.

`ReplayCacheMissError` adds `key`, `mode`, and `sequenceNumber`
so a caller can log which provider-cache key was missed at which
point in the walk:

```typescript
try {
  await handle.step();
} catch (err) {
  if (err instanceof ReplayCacheMissError) {
    log.warn({
      chatId:         err.chatId,
      tenantId:       err.tenantId,
      key:            err.key,
      mode:           err.mode,
      sequenceNumber: err.sequenceNumber,
    }, "replay cache miss");
  }
  throw err;
}
```

`ReplayUnknownEventError` carries `eventType` + `sequenceNumber`.
It signals a row the stepper cannot progress past — one with no
usable `event_type` discriminator. Rows whose `event_type` the
`hydrateFromEvents` projections don't model are not errors: they
fold as no-ops (the reducer is intentionally lossy for event types
it doesn't track), so the walk continues. To surface a custom
domain event in the folded state, register a projection (see
[Event-log projections](/docs/event-log-projections#custom-projections)).

## Eval coupling — DI via constructor [#eval-coupling--di-via-constructor]

`@pleach/replay` does **not** import `@pleach/eval`. The
dependency is the other direction: `@pleach/eval` accepts a
`ReplayClient` instance through `EvalSuite`'s `setReplayClient()`
method and delegates replay to it. (`EvalSuiteOptions` requires
`suiteId`; it has no `replay` field.)

```typescript
import { SessionRuntime } from "@pleach/core";
import { createReplayRuntime } from "@pleach/replay";
import { EvalSuite, type EvalReplayClient } from "@pleach/eval";

const runtime = new SessionRuntime({ /* ... */ });
const tenantId = "tenant_xyz";
const replayRuntime = createReplayRuntime({ tenantId, sessionRuntime: runtime });

// The eval side consumes replay through the structural `EvalReplayClient`
// contract — `replay(chatId) => { output }`. Adapt the turn-granular
// `ReplayRuntime` to that shape.
const replay: EvalReplayClient = {
  async replay(chatId) {
    const { state } = await replayRuntime.replayTurn({ chatId, tenantId });
    return { output: String(state ?? "") };
  },
};

const suite = new EvalSuite({ suiteId: "regression", runtime });
suite.setReplayClient(replay);
```

`createReplayRuntime({ tenantId, sessionRuntime })` builds the
turn-granular `ReplayRuntime` from a `SessionRuntime`; the small
adapter above is what satisfies `@pleach/eval`'s `EvalReplayClient`
DI slot. The two SKUs stay decoupled — `@pleach/replay` never
imports `@pleach/eval`, and the adapter lives in host code.

Two consequences of the DI direction:

* **`@pleach/replay` is installable without `@pleach/eval`.** A
  host that only needs replay (forensics, audit walks,
  fork-from-checkpoint) takes the smaller surface.
* **Replay's tests do not depend on eval.** The contract gates
  shipped with `@pleach/replay` are self-contained — see the
  `replayClient.test.ts` suite in the package repo.

## Determinism contract [#determinism-contract]

Two `fromEventLog()` calls with identical arguments produce
handles that walk byte-identically: stepping each to `done`
yields the same `HydratedHarnessState` at every step, and that
state equals a single `hydrateFromEvents` over the same rows.
Incremental `step()` and full `seek()` re-fold share one reducer,
so there is no second code path to drift — `seek(N)` equals N
steps equals a full fold, byte for byte.

This is the load-bearing guarantee replay rests on. If two
replays of the same recorded slice diverge, the divergence is
the signal — something non-deterministic slipped into the chain.
See [Determinism](/docs/determinism) for the four ways the
chain can break.

`createStrictHandleReplay` (from `@pleach/replay/strict`) is the
callable gate for exactly this: it opens N independent handles
over the same `(chatId, tenantId, window)`, walks each to `done`,
and reports `{ deterministic, steps, firstDivergenceAt? }` — the
step index where the per-step state first diverges.

```typescript
import { createStrictHandleReplay } from "@pleach/replay/strict";

const strict = createStrictHandleReplay({ client: replayClient });
const verdict = await strict.replay({ chatId, tenantId });
if (!verdict.deterministic) {
  console.error("non-determinism at step", verdict.firstDivergenceAt);
}
```

(`createStrictReplay` is the sibling gate over the
`ReplayRuntime.replayTurn` surface; `createStrictHandleReplay`
gates the event-granular `ReplayHandle` stepper.)

## Re-execution reproducibility [#re-execution-reproducibility]

The determinism contract above is about replaying the **recorded
log** — re-folding immutable rows is byte-identical because there is
one reducer and no second code path. Reproducing the original
**execution** — re-running the agent and getting the same model
bytes back — is a different, weaker guarantee, and it is important
to be precise about it.

True bit-for-bit re-execution is **not generally achievable against
a hosted model provider**. The provider can change a model, a
tokenizer, a sampler, or its internal routing without surfacing a
version you can pin to (OpenRouter silently swapping an upstream is
the canonical example). What replay *can* do is make every
**controllable** variable a stamped version, so a divergence becomes
*attributable* rather than mysterious. Record, as versions on the
run, every end-to-end variable property:

* **model** id and **provider** (and transport: native / openrouter / byok)
* **`@pleach/core` + SKU package versions** and the upstream **provider SDK version**
* **client / host package version** making the call
* **config version** — the sampling params (`temperature`, `topP`,
  `maxTokens`, `stopSequences`, `seed`) plus system-prompt and
  safety-policy identity (these are exactly what the seam cache
  fingerprint keys on)
* **plugin versions** — each registered `HarnessPlugin`

With all of these pinned, replay reconstructs the same *decision
context*; what it cannot pin is provider-internal change that ships
without a version. **The closer you control the stack, the closer you
get to true byte-identity** — a **locally hosted model** (fixed
weights, fixed inference engine, pinned sampler + seed) can approach
bit-for-bit re-execution, because there is no unversioned third party
left in the loop.

## What's NOT in this package today [#whats-not-in-this-package-today]

Honest about the current scope:

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

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

## Related SKUs [#related-skus]

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

For the full SKU map see [Which SKU do I need?](/docs/which-sku).

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Eval and replay" href="/docs/eval-and-replay" description="The DIY workflow against `@pleach/core` primitives — fingerprint, audit ledger, checkpoints, runtimeMode. The path you can adopt today without this SKU." />

  <Card title="@pleach/eval" href="/docs/eval" description="The DI consumer SKU — EvalSuite wraps a ReplayClient for fixture-based regression eval." />

  <Card title="Event-log projections" href="/docs/event-log-projections" description="GraphProjection<T>, runtime.events.iterate, and runtime.events.fold — the canonical surface this client consumes." />

  <Card title="Tamper-evident hash chain" href="/docs/hash-chain" description="prev_hash / row_hash columns — the integrity guarantee that lets replay results stand as evidence." />
</Cards>
