pleach
Architecture

Session lifecycle

How a session is minted, persisted, resumed, aborted, time-traveled, and deleted — the unit of identity that outlives any single turn.

A session is the unit of persistence and identity. It carries the locked provider and model, the message history, the channel state, and the checkpoint chain. The runtime is per-process; the session is per-conversation. Every turn runs against one session id, and every audit row joins back to it.

This page walks the session through its lifecycle. Per-turn execution flow — the stream events, the tool loop, the synthesis stage — lives on Turn lifecycle.

The runtime-lifecycle cluster

Session lifecycle is one of three concepts paired with Turn lifecycle (the per-message arc) and Event log (the append-only stream both arcs write into). Sessions outlive turns, turns outlive their stream frames, and the event log outlives both. The full triplet framing lives at Concept clusters → Runtime-lifecycle; the rest of this page is the deep dive on the session arc itself.

Minting a session

const session = await runtime.createSession({
  provider: { type: "anthropic" },
  model:    { id: "claude-sonnet-4-20250514" },
  tools:    { enabled: ["search", "calculator"] },
})

createSession takes a Partial<SessionConfig> and writes the new session to the storage adapter before resolving. The returned Session is your handle — session.id is a UUIDv7 the runtime generates, and it's what every subsequent call takes.

Three things land on storage during createSession:

  • The session row itself (id, owner, createdAt, locked provider+model).
  • An initial version-vector entry keyed on the runtime's clientId.
  • An empty channel set ready for the first turn's writes.

Provider and model are locked at this point. A session keeps the same (provider, model) pair for its lifetime — switching models mid-conversation means a new session, not a mutation. See SessionRuntime for the full config table and POST /api/harness/sessions for the HTTP wire shape.

Resuming a session

const session = await runtime.resumeSession(sessionId)

Resume rebuilds a session into the runtime from three layers:

  1. The storage row. The adapter reads the session record — owner, locked provider/model, version vector, last-active timestamp, and the durable SessionState (messages, pending tool calls, artifacts, jobs). This is the base state.
  2. The latest checkpoint. When a checkpointer is wired and holds a snapshot at least as recent as the storage row, the runtime overlays it — the recovery path for a crash that landed a checkpoint but lost the row write. The version guard means an older checkpoint never rolls back a fresher row; the checkpoint snapshot is authoritative when it wins. (Making the event log the canonical message/state reader is a separate forward-looking direction — that projection currently runs as a behavior-free shadow, gated off by default.)
  3. The event log (server only). When the runtime can read the persisted log (a service-role server adapter), hydrateFromEvents rebuilds the ephemeral card-lifecycle state the SessionState row does not carry — resolved interrupts, subagents, sandbox exports, and user cards. Because that state is derived from the durable log, it rides the returned Session's transient hydratedHarnessState field and is not written back to the row. The browser / in-memory path skips this layer. See Event log for the walk.

Resume reconstructs only what storage durably held. The default in-memory adapter keeps everything in this process's heap — restart the process (or land on a fresh serverless instance) and there is nothing to resume. Durability is the host's storage-adapter choice, not an automatic guarantee; wire a durable adapter (Supabase / Postgres / Redis) for resume to survive a restart. See Storage backends below.

Resume is idempotent. Calling it twice with the same id returns two Session instances pointing at the same persisted state — the later call wins as the runtime's getActiveSession().

A resumed session keeps its original id, its clientId lineage, and every prior audit row. The audit ledger doesn't fork on resume; it appends.

Aborting an in-flight turn

const ctrl = new AbortController()
setTimeout(() => ctrl.abort(), 30_000)

for await (const event of runtime.executeMessage(
  session.id,
  prompt,
  { abortSignal: ctrl.signal },
)) {
  // ...
}

The AbortController propagates into the per-call provider stream and into every active subagent under the turn. The runtime cancels in-flight network calls, unwinds the tool loop, and flushes a final audit row with outcome.status: "user-aborted" — recorded distinctly from provider-error at both the tool-loop decision call and the synthesis terminal, so cost rollups and compliance can tell caller cancellation apart from real provider failure.

Aborting does not delete the session. The session row stays; the partial turn lands an audit row carrying the same (sessionId, turnId, stageId, seqWithinTurn) identity tuple as a successful row — so per-turn cost rollups still count the prefill tokens the provider already consumed. The next executeMessage on the same sessionId continues from the post-abort state.

See SessionRuntime · Aborting a turn for the code path inside the runtime.

Time-travel and rollback

The rollback API itself and the rollback-vs-fork choice live on Checkpointing. Two session-level invariants matter here:

  • Audit rows are never rolled back. The ledger is append-only by contract — ProviderDecisionLedger has no update or delete primitive. A rolled-back turn's calls stay in the ledger; the rollback is a new event downstream of them.
  • Subagent provenance is preserved. Audit rows for subagents nested under the rolled-back point keep their parentTurnId and subagentDepth, so a rollback can be replayed and diffed against the original branch.

Fork is the non-destructive sibling — it mints a new sessionId pointing at the same checkpoint, so the original transcript stays intact. See Checkpointing for the rollback and fork APIs, and Time travel for the eval and replay use cases.

Conflict resolution (multi-client)

Two clients writing the same session bump their respective version-vector entries. When one pushes to the server, the coordinator compares vectors — a concurrent outcome is settled by SyncCoordinator.resolveConflicts(local, remote) using last-writer-wins on updatedAt, rather than clobbering by arrival order. This is the self-host resolution path in open @pleach/core.

Interactive conflict resolution is enterprise-tier / planned, not shipped in the open package:

// Enterprise-tier / planned — NOT functional in open @pleach/core.
await runtime.resolveConflict(sessionId, conflictId, "local")
// or "remote"

In @pleach/core, runtime.resolveConflict is a stub: it logs and returns a receipt without merging or persisting, and no sync.conflict stream event is emitted to hand it a conflictId. CRDT merge and the interactive conflict surface are the enterprise upgrade. For the working self-host path, call SyncCoordinator.resolveConflicts(local, remote) on your push handler. See Sync for the version-vector math, the durable outbox, and the 3xxx error code range.

Deleting a session

await runtime.deleteSession(sessionId)

Delete cascades into the checkpointer when one is wired — every checkpoint for the session drops. The session row is removed from storage and getActiveSession() returns null if it pointed at the deleted id.

The audit ledger is the exception. Audit rows survive delete because the ledger is append-only by contract; that's what makes ProviderDecisionLedger replayable and what gives finance and compliance a row set that can't be silently retroactively edited.

Subject-key-derived deletion of audit data goes through the GDPRSoftDelete plug-point. It leaves the recordId in place (so the hash chain holds) and clears the identifying fields under the subject key — satisfying a deletion request without breaking the append-only invariant. See Audit ledger · The three compliance plug-points.

Storage backends

The storage adapter is what createSession writes to and what resumeSession reads from. Pick by environment.

AdapterEnvironmentUse for
MemoryAdapterAnyTests, local dev, ephemeral demos
IndexedDBAdapterBrowserOffline-first apps, browser extensions, multi-device drafts
SupabaseAdapterServer (Node)Persistent storage, multi-tenant RLS, production

MemoryAdapter is the zero-config default — and it is non-durable: session state (messages, checkpoints, event log) lives in the process heap, is lost on restart, and is not shared across instances. On serverless, each cold start or new instance begins from empty state. The quickstart route (createPleachRoute) emits a one-time startup warning when it falls back to this adapter. Production hosts should supply a durable adapter — durability is the host's storage-adapter choice, not something the runtime provides automatically.

All three implement the same StorageAdapter interface, so swapping is a one-line constructor change. See Storage for the per-adapter config and the RLS template that SupabaseAdapter parameterizes.

Lifecycle method index

MethodSignatureWhen to reach for it
createSession(config?: Partial<SessionConfig>) => Promise<Session>Starting a new conversation — locks provider and model
resumeSession(sessionId: string) => Promise<Session>Loading a session from a prior process or another tab
saveSession(state: SessionState) => Promise<void>Flushing an explicit state mutation outside the normal turn write path
deleteSession(sessionId: string) => Promise<void>Removing the session and its checkpoints (audit rows stay)
getActiveSession() => Session | nullReading the last session the runtime loaded
rollbackToCheckpoint(sessionId, checkpointId) => Promise<Session>Rewinding a session in place (deprecated alias for runtime.checkpoints.rollback)
resolveConflict(sessionId, conflictId, "local" | "remote") => Promise<…>Enterprise-tier / planned — a stub in open @pleach/core (logs, no merge). Use SyncCoordinator.resolveConflicts for the working last-writer-wins path
destroy() => Promise<void>Tearing the runtime down — stream manager, interrupt manager, listeners

The same flat-method-to-facet deprecation contract applies here: rollbackToCheckpoint redirects to runtime.checkpoints.rollback, and flat methods stay callable through one minor cycle before the next major drops them. See SessionRuntime · Session lifecycle methods for the canonical signature reference.

Where to go next

On this page