# Time travel (/docs/time-travel)



`TimeTravelAPI` is the navigable view over the checkpoint history a
`Checkpointer` writes. It reconstructs `StateSnapshot` records from
raw checkpoint rows, walks history newest-first, applies synthetic
supersteps for replay, and forks a checkpoint into a new session.

```typescript
import {
  TimeTravelAPI,
  type StateSnapshot,
  type StateSnapshotMetadata,
  type StateSnapshotConfig,
  type GetStateHistoryOptions,
} from "@pleach/core/time-travel";
```

This is the substrate-level fork surface. `runtime.checkpoints.rollback`
is the higher-level path that rebuilds session state in place and bumps
the version vector;`TimeTravelAPI` is what you reach for when you want
to branch into a new session id or inspect snapshot internals without
touching the live session.

The canonical runtime entry point is `runtime.timeTravel.api` — the
facet returns the lazily-constructed `TimeTravelAPI` instance (or
`undefined` when the runtime has no checkpointer). All four
methods — `getState`, `getStateHistory`, `bulkUpdateState`, `fork` —
live on that instance.

```typescript
const api = runtime.timeTravel.api;
if (api) {
  const fork = await api.fork(sourceSessionId, checkpointId, newSessionId);
}
```

<SourceMeta source="{ label: &#x22;src/time-travel/&#x22;, href: &#x22;https://github.com/pleachhq/core/tree/main/src/time-travel&#x22; }" />

## How it differs from `runtime.checkpoints.rollback` [#how-it-differs-from-runtimecheckpointsrollback]

|                  | `runtime.checkpoints.rollback`                      | `TimeTravelAPI.fork`                                                                |
| ---------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------- |
| Target session   | Same session id                                     | New session id                                                                      |
| Live session     | Mutated in place                                    | Untouched                                                                           |
| Version vector   | Bumps; writes a new `source: "rollback"` checkpoint | Forked checkpoint has `source: "fork"`, `parent_id: source.id` (cross-session edge) |
| Lineage edge     | `parentCheckpointId` on the new rollback checkpoint | `metadata.forkedFrom: { sessionId, checkpointId }` + cross-session `parent_id`      |
| Event-log cursor | Inherited from the rollback target                  | Inherited from the source checkpoint's `metadata.lastEventSequence`                 |
| Use case         | Recover from a stuck turn, retry from earlier state | Explore an alternative branch without losing the original                           |

Both write to the same checkpointer. Forks land in the new session's
namespace; the source session keeps its own history intact.

## Constructor [#constructor]

```typescript
new TimeTravelAPI(
  checkpointer:      CheckpointerLike,
  createChannels:    () => Record<string, ChannelLike>,
  nodeSubscriptions: Record<string, string[]> = {},
);
```

`createChannels` is a factory that returns a fresh channel map keyed by
channel name — the API uses it to replay reducer logic when applying
synthetic supersteps. `nodeSubscriptions` maps node name to the set of
channels each node subscribes to; it drives the `next` array on every
returned `StateSnapshot`.

If you only need read methods (`getState`, `getStateHistory`, `fork`),
an empty `nodeSubscriptions` is fine — `next` will simply be empty.

## Methods [#methods]

| Method                                              | Returns                          | Notes                                                                                              |
| --------------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------- |
| `getState(sessionId, checkpointId?)`                | `Promise<StateSnapshot \| null>` | Latest when `checkpointId` is omitted.                                                             |
| `getStateHistory(sessionId, options?)`              | `AsyncGenerator<StateSnapshot>`  | Newest-first; `limit` caps iteration, `before` cursors, `source` filters by node.                  |
| `bulkUpdateState(sessionId, supersteps)`            | `Promise<StateSnapshot>`         | Replays writes through `createChannels()`, persists with `source: "bulk_update"`.                  |
| `fork(sourceSessionId, checkpointId, newSessionId)` | `Promise<StateSnapshot>`         | Copies channel state into a new session; sets `parent_id: source.id` (cross-session lineage edge). |

## `StateSnapshot` shape [#statesnapshot-shape]

```typescript
interface StateSnapshot<S = Record<string, unknown>> {
  values:          S;                                  // reconstructed channel values
  pendingTasks:    Array<{ id; name; input }>;          // in-flight at snapshot time
  pendingWrites:   Array<{ taskId; channel; value }>;   // buffered but not committed
  metadata:        StateSnapshotMetadata;
  config:          StateSnapshotConfig;
  next:            string[];                            // nodes eligible to run next
  channelVersions: Record<string, number>;
  manifest?:       ChatManifestSnapshot;                // chat-manifest ledger AS-OF this checkpoint (inspection-only)
}

interface StateSnapshotMetadata {
  step:               number;
  timestamp:          string;        // ISO-8601
  source:             string;        // node or trigger that produced this state
  writtenBy:          string[];
  parents:            string[];      // parent checkpoint ids; cross-session for forks
  lastEventSequence?: number;        // event-log cursor at snapshot time
}
```

`next` is computed from `channelVersions` + `versions_seen` against
the constructor's `nodeSubscriptions` — a node appears if any channel
it subscribes to has advanced past the version it last saw. That's
what makes a snapshot replayable: you can pick up exactly where the
graph left off without re-running already-applied work.

`metadata.parents` is the parent-link chain — a single-element array
for ordinary checkpoints, multi-element for merge scenarios. Forked
snapshots populate `parents` with the source checkpoint id (a
cross-session edge), so traversals walking `metadata.parents` no
longer hide the fork's origin.

`manifest` is the chat-manifest ledger (tool/job invocations, notices,
provider switches) AS-OF the checkpoint — surfaced for **inspection**
(`getState`/`getStateHistory`/`fork`/`bulkUpdate`). It is OPTIONAL
(`undefined` for an empty ledger or a legacy checkpoint). Inspection-only:
it does NOT feed the next generation — that functional rehydration runs
through `rollbackToCheckpoint`/`resumeSession` into the live
`ChatManifestProvider`. The snapshot rides both the checkpoint `metadata`
(this field) and `state.extensions` (the functional path) for the two
distinct purposes.

### Event-log cursor on the snapshot [#event-log-cursor-on-the-snapshot]

`metadata.lastEventSequence` carries the event-log `sequence_number`
the snapshot was taken at. A host hydrating from this snapshot can
fast-forward intermediate state without re-folding the event log from
genesis: pass the cursor straight to `runtime.events.iterate({
chatId, fromSequenceNumber: snapshot.metadata.lastEventSequence })`
and replay events forward from there. The same value flows into
`hydrateFromEvents()` as `fromSequenceNumber`.

The field is `undefined` on checkpoints written before the cursor
field landed — consumers must back-compat to "no skip" when it's
missing.

## Fork semantics [#fork-semantics]

Which channels survive a fork is decided by the underlying
`channel_values` blob: every channel in the source checkpoint copies
verbatim into the new session. There is no per-channel survival
policy at this layer — that lives in the reducer definition for each
channel, and the fork respects whatever the snapshot already has.

```typescript
const api = new TimeTravelAPI(checkpointer, createDefaultChannels);

const snapshot = await api.fork(
  sourceSessionId,
  "cp_018f...",
  crypto.randomUUID(),
);

snapshot.config.sessionId;              // new session id
snapshot.metadata.source;               // "fork"
snapshot.metadata.parents;              // [source.id] — cross-session edge
snapshot.metadata.lastEventSequence;    // inherited from the source checkpoint
// the lineage edge on the raw checkpoint metadata:
// metadata.forkedFrom = { sessionId: sourceSessionId, checkpointId: "cp_018f..." }
```

### Fork lineage [#fork-lineage]

A forked checkpoint records its origin in two places:

* **`metadata.forkedFrom: { sessionId, checkpointId }`** — the
  semantic lineage edge. Audit-ledger queries join against this to
  walk forked sessions back to their origin. The forked session keeps
  its own audit rows under the new `sessionId`; `forkedFrom` on the
  first checkpoint is the one place the cross-session relationship is
  recorded.
* **`parent_id: source.id`** on the raw checkpoint row (surfaced as
  `metadata.parents: [source.id]` on the snapshot). This is a
  cross-session parent edge — distinct from `forkedFrom` only by
  shape (a parent id vs. a `{sessionId, checkpointId}` pair).
  Same-session supersteps already set `parent_id` to the previous
  checkpoint; fork now sets it to the source checkpoint so traversals
  walking `metadata.parents` see the origin instead of treating
  forked snapshots as orphan roots.

### Event-log cursor carry [#event-log-cursor-carry]

The fork inherits `metadata.lastEventSequence` from the source
checkpoint. A consumer hydrating the forked session passes that
cursor to `runtime.events.iterate({ chatId, fromSequenceNumber })`
exactly as it would for the source — the cursor is the join point
between snapshot state and the event log.

### Materializing the forked session row [#materializing-the-forked-session-row]

`TimeTravelAPI` accepts an optional `createForkedSession` hook on
its constructor options. After the forked checkpoint lands, the API
invokes the hook so the host can materialize a `Session` record at
`newSessionId` — typically by cloning the source session's
`SessionConfig`. Without the hook, a subsequent
`runtime.sessions.resume(newSessionId)` will throw on a missing
session row; legacy callers must materialize the row themselves
before resume.

The hook is best-effort: a thrown error is caught and logged via
`console.warn`, and the fork's checkpoint write is not rolled back.
The checkpoint is the durable artifact; the session row is a derived
convenience.

## Walking history [#walking-history]

```typescript
for await (const snapshot of api.getStateHistory(sessionId, { limit: 50 })) {
  console.log(snapshot.metadata.step, snapshot.metadata.source, snapshot.config.checkpointId);
}
```

Pass `source: "tool-loop"` to filter to checkpoints written by the
tool-loop stage; pass `before: "cp_..."` to page backwards from a
known checkpoint id.

## Replaying synthetic supersteps [#replaying-synthetic-supersteps]

`bulkUpdateState` is how a test harness or migration script writes a
sequence of channel updates without running the graph. Each
superstep is an array of writes; the API replays them through
`createChannels()` so reducers fire normally, then persists a
single `source: "bulk_update"` checkpoint at the end.

```typescript
const snapshot = await api.bulkUpdateState(sessionId, [
  // Superstep 1 — seed the corpus channel.
  [{ channel: "search_results", value: { docs: [{ id: "doc_001" }] } }],
  // Superstep 2 — append a summary.
  [{ channel: "summary", value: "Initial pass complete." }],
]);

snapshot.metadata.source;            // "bulk_update"
snapshot.channelVersions.summary;    // bumped once per superstep that touched it
```

`bulkUpdateState` throws if the target session has no prior
checkpoint — it needs an anchor to write `parent_id` against. Use
`fork` for the first checkpoint in a new session id.

## Side effects don't roll back [#side-effects-dont-roll-back]

A snapshot captures channel state, not external writes. If a tool
called between the target snapshot and now wrote to a database, sent
an email, or charged a card, rolling back or forking from the earlier
snapshot does not unwind those writes. Tools that need true rollback
have to implement compensating actions themselves — Pleach doesn't
have a transactional surface across third-party systems.

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

<Cards>
  <Card title="Checkpointing" href="/docs/checkpointing" description="The savers that produce the history this API navigates; `lastEventSequence` is the join point." />

  <Card title="Event-log projections" href="/docs/event-log-projections" description="Replay events forward from `snapshot.metadata.lastEventSequence` via `runtime.events.iterate({ fromSequenceNumber })`." />

  <Card title="Channels" href="/docs/channels" description="The reducers `createChannels()` reconstructs during bulkUpdateState." />

  <Card title="Lineage" href="/docs/lineage" description="How fork edges and audit rows join across forked sessions." />

  <Card title="DevTools" href="/docs/devtools" description="`__HARNESS_DEVTOOLS__.rollback` calls through to this API in the browser." />
</Cards>
