Skip to content
pleach
Build

SessionRuntime

The main entry point — construct a runtime, create sessions, execute messages, and consume the stream.

A SessionRuntime owns one user's sessions, the per-turn graph, the stream pipeline, the AuditableCall ledger, and the plugin set — it's the one class you instantiate from @pleach/core. Everything else is configuration passed to its constructor or methods called on the instance. See Storage for adapter choices and Stream events for what executeMessage yields.

SessionRuntime is the implementation surface of the runtime-lifecycle cluster — it hosts the three lifecycle concepts (session lifecycle, turn lifecycle, event log) under one constructor.

import { SessionRuntime } from "@pleach/core";

Construction

const runtime = new SessionRuntime(config);

All fields are optional. With no config, you get an in-memory runtime with userId: "anonymous" — fine for a tutorial, never fine for production.

SessionRuntimeConfig fields

FieldTypeDefaultPurpose
storageStorageAdapterfresh MemoryAdapterWhere sessions persist. See Storage.
checkpointerCheckpointernoneWhere checkpoints persist. See Checkpointing.
eventLogWriterHarnessEventWriternoneWrite side of the durable event log.
eventReaderHarnessEventReadernoneRead side of the durable event log — backs runtime.events.iterate / .fold and resumeSession's Layer-3 hydration. Store-agnostic; the host casts its store into it at construction. See Backing the reader.
cacheStorageAdapternoneOptional hot-data cache adapter
userIdstring"anonymous"Session owner. Production deployments must pass this.
organizationIdstring–Multi-tenant scoping
clientIdstringgeneratedSync version-vector key
providerAgentProvider–LLM provider implementation
pluginsHarnessPlugin[][]Domain extensions. See Plugin contract.
metaToolNamesSet<string>empty setMeta-tool names the runtime treats as flow-control (e.g. set_step_complete, wait_for_jobs)
enabledSafetyPoliciesreadonly SafetyPolicyId[][]Operator opt-in for safety policies plugins registered. Canonical read surface is runtime.safety.{listAvailable, getActive, getRegistry}.
seedCoreDefaultsbooleantrueAuto-seed the 11 core.* prompt fragments
interruptInterruptConfig–Human-in-the-loop configuration
enableSubagentConcurrencybooleanfalseAllow parallel subagents
maxConcurrentSubagentsnumber4Concurrency cap when enabled
maxGraphStepsnumberhard bound (22, or 16 for guests)Per-turn graph step cap. Lowering-only — see Bounding a turn.
chatIdstring–Provenance link to an upstream chat row
isGuest / guestIdboolean / string–Guest-mode session ownership
authTokenstring–Bearer token forwarded to API calls
jobDispatchEndpointstring–URL the runtime POSTs queued jobs to

enabledSafetyPolicies participates in the fingerprint cache key — toggling a policy invalidates the cache automatically.

metaToolNames is load-bearing after R-1.A. Hosts that don't pass it see a one-shot [UXParity:metaToolNames-config-missing] probe plus a startup warning, and continuation guards silently disable. The continuation guards are the runtime nodes that decide whether a named flow-control tool (set_step_complete, wait_for_jobs) signals "this step is done" or "this is just another tool call"; when the guards disable, the tool loop keeps iterating on what should have been a terminal signal, which is the symptom most hosts notice before they read the probe.

Strategy injection

SessionRuntimeConfig also carries a second set of fields the substrate calls strategies — typed slots the host fills with domain code, each with one well-defined shape and one consumer. A strategy that's unset gracefully degrades; the runtime stays functional and the strategy-backed behavior goes inert.

Representative slots include summaryExtractor, toolCatalog, subagentExecutor, fabricationGuardStrategy, hallucinationDetectorFactory, and the orchestrator-side provider-fallback registry. The full surface, the wiring patterns, and the per-turn vs. per-runtime allocation rule live on Runtime strategies.

Facets

Each facet returns a typed slice of the runtime — runtime.sessions for session lifecycle, runtime.events for the event log, runtime.sync for bidirectional sync, runtime.dev for diagnostics. Reading a facet gets you the methods grouped under that name and the typed receipts those methods return.

Runtime-level facet inventory:

FacetCovers
runtime.sessionsSession lifecycle — create, resume, find, save, delete, updateProviderModel
runtime.syncBidirectional sync — execute, resolveConflict, subscribeToStream. Callable: runtime.sync(sessionId) preserves the legacy shape.
runtime.eventsEvent-log iteration + fold + the live event bus (on, once, off)
runtime.spansSpan start / flush / shutdown plus inFlightCount, isShutdown, snapshot introspection
runtime.tenantTenant identity — frozen at construction; identity-stable across reads
runtime.pluginsPluginManager access plus the full collect* cohort and registry lookups
runtime.promptsPrompt-contribution introspection (list, get, getAll, listByOrigin)
runtime.safetylistActivePolicies, listAvailablePolicies, getRegistry
runtime.toolsRegistered tool catalog introspection
runtime.observerRouterLate-binding stream-observer registration; receipts on unregister
runtime.degradationModel degradation reads; getDegradedModelRecord returns a receipt
runtime.checkpointsrollback, list
runtime.timeTravelTime-travel API accessor
runtime.asyncAsync-task manager, subagent spawn, result lookup
runtime.interruptsInterrupt manager; resolve returns an InterruptResolveReceipt
runtime.adapterOrchestrator-adapter wiring + capability registry
runtime.diagnosticsBoot-time readiness probes for the host-extension bag
runtime.devDiagnostic sub-API — store, stream manager, repetition-guard wiring
runtime.graph.recoveryPer-turn recovery hooks
runtime.graph.heuristicsTunable graph heuristics
runtime.graph.configGraph-build configuration
runtime._internalINTERNAL graph-consumption surface — substrate-only, do not call

Receipt-shaped returns

Mutating accessors across sessions, sync, observerRouter, degradation, and interrupts return typed receipts rather than booleans or void. The pattern is deliberate: every call site that mutates runtime state echoes a structured outcome callers can branch on, audit, or log without re-derivation.

Examples:

  • runtime.sessions.delete(sessionId) → SessionDeleteReceipt (deleted, sessionId, removedFromCache).
  • runtime.sync.flushOutbox() → OutboxFlushReceipt with a four-verdict outcome ("all-flushed" | "partial" | "all-stuck" | "none-pending") plus failedEntries, flushedAt, and the pre-widening counts.
  • runtime.sync.resolveConflicts(...) includes a conflictedPaths array describing every diverged field (path, localValue, remoteValue, chosenSide).
  • runtime.sync.resolveConflict(...) → SyncConflictResolveReceipt (resolved, sessionId, conflictId, resolution, resolvedAt).
  • runtime.observerRouter.unregister(reg) → { removed, wasRegistered }.
  • runtime.degradation.getDegradedModelRecord(modelId) → receipt with the active routing entry or null when none is held.
  • runtime.interrupts.resolve(id, decision) → InterruptResolveReceipt.

Deprecation contract

Existing flat methods stay around with @deprecated JSDoc that points to the facet. For example, runtime.getTenantId() redirects readers to runtime.tenant.getTenantId(), and runtime.getSafetyRegistry() redirects to runtime.safety.getRegistry(). The minor release that adds the facet also marks the flat method @deprecated; the next major removes the flat method. Flat methods are never removed in a minor bump.

For the full facet inventory, the TurnOrchestrator.* facet set, and the audit gates that enforce coverage, see Facets.

Creating a session

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

createSession accepts a Partial<SessionConfig>. Defaults:

Config fieldDefault
provider{ type: "anthropic" }
model{ id: "claude-sonnet-4-20250514" }
tools{ enabled: [] }
projectId / campaignIdinherited from runtime if set

Returns a Session instance bound to a generated UUIDv7 id — session.id is what executeMessage takes. The session is written to storage before this resolves.

Executing a message

executeMessage is an async generator. Iterating it streams the turn; every yielded value is a StreamEvent.

for await (const event of runtime.executeMessage(session.id, "Hello")) {
  switch (event.type) {
    case "message.delta":
      process.stdout.write(event.delta);
      break;
    case "tool.completed":
      console.log("tool finished:", event.toolCall.name);
      break;
    case "error":
      console.error(event.code, event.error);
      break;
  }
}

Signature

runtime.executeMessage(
  sessionId: string,
  content: string,
  options?: {
    fileReferences?: unknown[];
    abortSignal?: AbortSignal;
    toolChoice?: TurnToolChoice;          // "auto" | "none" | "required" | { name }
    activeTools?: readonly string[];      // narrow this turn's tool offer
  },
): AsyncGenerator<StreamEvent>;

Per-turn tool directives

toolChoice and activeTools scope one turn. A turn that omits them clears the previous turn's values — the next turn sees the full tool set and the provider-default tool choice again. Neither knob is sticky.

toolChoice (TurnToolChoice, exported from the root barrel):

ValueEffect
"auto"Provider default — the wire field is omitted; identical to passing nothing
"none"The model may not call any tool this turn
"required"The model must call some tool. Suppressed when the request carries no tools
{ name: "search_corpus" }Force the named tool. Suppressed when the request carries no tools

The tool-forcing arms are suppressed on a tool-free request because forcing a call on absent tools throws on strict providers; "none" with no tools is safe and passes through.

activeTools intersects the turn's resolved tool set with the listed names. A name that matches no resolved tool logs a warning and is skipped — a typo narrows the offer, it never kills the turn. An empty array is a real value: it narrows the offer to zero tools.

The plumbing behind the options is exported for hosts that build their own dispatch layer: coerceTurnToolChoice validates an untyped route-body value into a TurnToolChoice, mapTurnToolChoiceToWire maps it onto the provider wire shape, and narrowToolsForTurn performs the intersection and returns a NarrowedToolsResult (tools + unknownNames). All four names come from the root barrel.

Bounding a turn: maxGraphSteps

SessionRuntimeConfig.maxGraphSteps caps the graph supersteps a turn may run — the step-cap knob for hosts migrating from SDKs with a maxSteps option. It is session-scoped configuration, not a per-turn option, and it is lowering-only: the effective bound is resolveGraphStepBound(maxGraphSteps, isGuest) with a floor of 1 and a ceiling at the hard safety bound (22, or 16 for guest sessions). A value above the ceiling is refused and clamped down with a warning — the hard bound is the audited runaway-loop limit, and raising it from config would bypass that audit. Unset means the hard bound, which is the prior behavior exactly.

Aborting a turn

Pass an AbortSignal. The runtime cancels in-flight provider calls, unwinds the tool loop cleanly, and flushes a final ledger row with outcome.status: "user-aborted". The user-aborted row carries the same (sessionId, turnId, stageId, seqWithinTurn) identity tuple as a successful row, which is what lets a per-turn cost rollup count an abort against the same turnId as the calls that ran before it — the aborted call still consumed input tokens through the provider's prefill, and the row exists so that spend isn't invisible.

This is the in-process route — a timer, a job runner, a test. For a user pressing Stop in a browser, see Stopping a streamed turn from the client.

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

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

Aborting programmatically

An AbortSignal is the external route; runtime.abort(reason) is the internal one. SessionRuntime owns one AbortCoordinator per turn, and runtime.abort(reason) fires it — cancelling the active provider call and unwinding the tool loop over the same composed signal, so both routes converge on one teardown path.

import { TURN_ABORT_REASON } from "@pleach/core";

runtime.abort(TURN_ABORT_REASON.Timeout);

reason is a TurnAbortReason — "user" (the default), "timeout", "provider-exhausted", "shutdown", or "error" — and it's recorded on the abort, so a cost rollup can tell a user Stop apart from a system teardown like "provider-exhausted" (the cascade ran out of in-family rungs — see family-strict cascade). Between turns there's no active coordinator and abort() is inert.

Only "user" is a Stop. runtime.wasLastTurnAbortedByUser() reads the reason the runtime recorded first, and a reason-less AbortController.abort() on the abortSignal you passed is classified "error", not "user".

Stopping a streamed turn from the client

Stop has no route of its own. A streamed turn's request is half-duplex: once the response is streaming, the client's only in-band signal is closing the connection, and a serverless host can't route a separate "stop" request to the instance running the turn. A shared flag (Redis key, DB row) would be a second source of truth for something the stream handler already observes. So stop is a client disconnect with a declared intent, and the stream handler classifies it.

The client declares, when it opens the stream, what its disconnect will mean:

Declared intentHow the client declares itA disconnect aborts the turn as
stopHeader x-pleach-on-disconnect: stop, or query ?on_disconnect=stop (for EventSource, which can't set headers)"user" — the Stop verdict
error (default)Nothing"error" — an ordinary transport loss

Code against the constants, not the literals

The intent values (stop, error) may be renamed before this ships in a published @pleach/core. Import DISCONNECT_INTENT, PLEACH_ON_DISCONNECT_HEADER, and PLEACH_ON_DISCONNECT_QUERY_PARAM from @pleach/core instead of spelling the strings, and a rename reaches your code with the upgrade. The table above is the only place these docs spell the values.

import { DISCONNECT_INTENT, PLEACH_ON_DISCONNECT_HEADER } from "@pleach/core";

const ctrl = new AbortController();
const res = await fetch("/api/chat", {
  method: "POST",
  headers: { [PLEACH_ON_DISCONNECT_HEADER]: DISCONNECT_INTENT.Stop },
  body: JSON.stringify({ sessionId, message }),
  signal: ctrl.signal,
});
// The user presses Stop:
ctrl.abort();

How the stream handler classifies an ending:

  • The client disconnects — "user" when it declared the stop intent, otherwise "error". A client that declares nothing keeps the disconnect-is-an-error label it had before.
  • The server closes the stream (max duration, idle backstop) — the handler aborts the turn with "timeout" before it closes. The runtime's abort latch is first-wins, so the client-side close that follows can't relabel the turn as user-stopped.
  • A write or socket error — "error".

With the stop intent declared, a network drop also reads as a Stop. That's the trade-off: the client opted in to that meaning.

createPleachRoute, and so the execute leg of createPleachRoutes, does this for you. A hand-written route binds it with three helpers from @pleach/core (also on @pleach/core/engine):

HelperSignatureDoes
parseDisconnectIntent(source: { headers?, url?, query? }) => DisconnectIntentReads the declaration from fetch Headers or a Node header record, the URL query string, or a parsed query record. Never throws
bindDisconnectToTurnAbort({ signal?, onClose?, onDisconnect, abort }) => DisconnectBindingCalls abort once on the first disconnect — signal aborting, onClose firing, or binding.disconnect() — until binding.dispose()
createTurnAborter({ runtime?, controller }) => (reason) => voidThe standard abort: "error" → reason-less controller.abort(); any other reason → runtime.abort(reason), falling back to controller.abort(reason) when no turn is active

DisconnectBinding carries disconnect(), dispose(), serverAbort(reason), and a disconnected flag. serverAbort detaches the binding and then aborts, which is the server-closes-first rule in one call. All three methods are idempotent.

import {
  bindDisconnectToTurnAbort,
  createTurnAborter,
  parseDisconnectIntent,
} from "@pleach/core";

const controller = new AbortController();
const binding = bindDisconnectToTurnAbort({
  signal: req.signal,
  onDisconnect: parseDisconnectIntent({ headers: req.headers, url: req.url }),
  abort: createTurnAborter({ runtime, controller }),
});

try {
  for await (const event of runtime.executeMessage(sessionId, message, {
    abortSignal: controller.signal,
  })) {
    // write the frame; on a max-duration hit:
    // binding.serverAbort("timeout");
  }
} finally {
  binding.dispose(); // a disconnect after the turn ends is not a stop
}

const stopped =
  runtime.wasLastTurnAbortedByUser() || controller.signal.reason === "user";

The controller.signal.reason check covers one window: a disconnect after the binding is armed but before executeMessage starts its turn finds no active turn, so the stop lands on the controller and the runtime's latch is never written.

Resuming after a stop needs no stop state. Persist the stopped turn's partial (marked interrupted) through your normal persistence path; any instance then resumes the session from the session store and checkpointer with runtime.resumeSession(sessionId).

Running a turn without streaming: run()

runtime.run(sessionId, content, options?) is the non-streaming sibling of executeMessage — the generateText / invoke analog. It drives the same turn (same queueing, same graph path, same options object — the options type is derived from executeMessage, so the two cannot drift) and folds the stream into one awaited result instead of handing you an iterator.

const { finalContent, usage, toolCalls } = await runtime.run(
  session.id,
  "Summarize the corpus results",
);

It returns a TurnResult:

FieldTypeNotes
finalContentstringFinal assistant text — the materialized message.complete body, falling back to accumulated deltas
messageIdstring?Id of the final assistant message; omitted on delta-only turns
toolCallsReadonlyArray<ToolCall>Every tool call observed during the turn
usageTokenUsage?Folded token usage. Omitted when no event reported any — a missing key means "not reported", never "zero tokens"
aborted / abortedByUserboolean?Present only when the stream carried abort evidence; absent on a clean turn. abortedByUser is never derived from aborted
eventsnumberCount of stream events drained

An error stream event rejects the promise with StreamCollectionError — the same error semantics as the stream consumers.

The underlying fold is exported as foldTurnStream from the root barrel, for callers already holding an executeMessage iterator:

import { foldTurnStream } from "@pleach/core";

const result = await foldTurnStream(runtime.executeMessage(session.id, "hello"));

Two types named TurnResult

@pleach/core/testing exports a different TurnResult from its runScriptedTurn helper — a test-oriented shape carrying the raw event log and wall-clock timing (text, events, eventCount, ttfbMs, totalMs). Same name, different contract. A file that imports both must alias one: import type { TurnResult as ScriptedTurnResult } from "@pleach/core/testing".

Deleting a session

await runtime.deleteSession(session.id);

Cascades into the checkpointer when one is wired. Audit-ledger rows are not deleted — the ledger is append-only by contract, which is why ProviderDecisionLedger has no update or delete primitive in any conforming language. The GDPRSoftDelete plug-point on the ledger interface (no-op default; production wiring lands in @pleach/compliance@0.1.0 alongside its scrubber cohort and attestation surface) is the path for subject-key-derived redaction: 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. Wire your own implementation today via HarnessPlugin.

Flat method surface (legacy)

The tables below mirror the facets above as flat methods on the runtime; the methods are @deprecated and forward to their facet equivalents — prefer Facets for new code.

Session lifecycle methods

MethodSignatureUse
createSession(config?: Partial<SessionConfig>) => Promise<Session>Mint a new session, persist to storage
resumeSession(sessionId: string) => Promise<Session>Load an existing session from storage
saveSession(state: SessionState) => Promise<void>Flush an explicit state mutation to storage
deleteSession(sessionId: string) => Promise<SessionDeleteReceipt>Remove the session; cascades to checkpointer. Returns a receipt with deleted, sessionId, and removedFromCache.
getActiveSession() => Session | nullLast session loaded into the runtime
rollbackToCheckpoint(sessionId, checkpointId) => Promise<Session>Restore a session from a named checkpoint
resolveConflict(sessionId, conflictId, "local" | "remote") => Promise<SyncConflictResolveReceipt>Resolve a sync conflict surfaced via sync.conflict. Returns a per-path receipt.
destroy() => Promise<void>Tear down stream + interrupt managers, unsubscribe listeners

Stream subscription

Two ways to consume events. executeMessage is the per-turn async generator above. For cross-cutting subscribers, the runtime also exposes a mode-filtered subscription via its embedded StreamManager.

const unsubscribe = runtime.subscribeToStream("messages", (event) => {
  // event is StreamEvent — already filtered to the mode
});
ModeIncludes
"values"session.created, session.resumed, checkpoint.created
"updates"message.added, message.complete, tool lifecycle, job lifecycle, artifact.created
"messages"message.delta, message.complete, thinking.delta, thinking.complete
"custom"Any event whose type starts with custom.
"debug"All events — unfiltered
"all"All events — unfiltered

getStreamManager() returns the underlying manager when you need to set a throttle or an event-type allowlist directly: manager.setThrottleInterval(ms), manager.setEventFilter(types).

Accessor methods

The runtime exposes its internal coordinators behind named getters. Returns are undefined when the coordinator wasn't wired at construction.

MethodReturns
getInterruptManager()InterruptManager | undefined — present when interrupt.enabled: true
resolveInterrupt(id, decision)InterruptResolveReceipt — submit an ApprovalDecision and receive a receipt echoing the outcome
getTimeTravelApi()Checkpoint list / restore API (see Checkpointing)
getAsyncTaskManager()Background job manager (see Stream events)
getPluginManager()Registered plugin set
getStreamManager()StreamManager — throttle + filter the live stream
getStore()The storage adapter passed at construction
getSubagentManager()SubagentManager | undefined when concurrency is enabled
getCapabilityRegistry()Registered capability strategies
getSafetyRegistry()SafetyPolicyRegistry
listActiveSafetyPolicies()readonly SafetyPolicySummary[]
getDegradedModels()Models the degradation tracker is routing around
isModelDegraded(modelId)Boolean check for a single model
getPlanningContext()Current planning context (advanced)

The published .d.ts is the canonical signature reference; your IDE autocompletes from it.

Lifecycle events

SessionRuntime extends EventEmitter. The same shapes that come out of executeMessage also fire as events, so a long-lived runtime can attach cross-cutting subscribers without owning the async iterator:

runtime.on("checkpoint.created", (event) => {
  metrics.increment("checkpoints", { sessionId: event.checkpoint.sessionId });
});

The full event union is the StreamEvent type — see Stream events for the catalog.

Plugin contributions

runtime.plugins is the canonical read surface for everything a plugin contributed. Beyond PluginManager access, the facet exposes a collect* cohort that mirrors PluginManager.collect* one-for-one — roughly two dozen accessors covering retry policy, continuation policy, fabrication detectors, finalization passes, intent classifiers, tool-coupling hints, intent-tool maps, sandbox bridge, interrupt UI handlers, citation rules, manifest providers, fabrication guard, guest-denied tools, stream observers, hallucinated-tool detectors, stream-chunk handlers, middleware, and runtime-aware middleware. The full hook list lives on Plugin contract.

const retryPolicies = runtime.plugins.collectRetryPolicy();
const continuationPolicies = runtime.plugins.collectContinuationPolicy();
const detectors = runtime.plugins.collectFabricationDetectors(sessionId);

Two convenience accessors round out the facet:

  • runtime.plugins.listAvailablePromptContributions() — every prompt contribution every plugin registered, as a snapshot array.
  • runtime.plugins.getSafetyRegistry() — the active SafetyPolicyRegistry. Equivalent to runtime.safety.getRegistry().

Collection-shaped accessors return [] when no plugin contributed; capability-shaped accessors return null. Empty results never throw.

Event-log projections

runtime.events.iterate(...) returns a paginated async-iterable over harness_event_log rows ordered by (chat_id, sequence_number, created_at, id). The accessor reads the durable Supabase log server-side; PA-1 Phase A.2 promoted this from a stub to a real projection surface.

for await (const row of runtime.events.iterate({ chatId })) {
  // EventLogRow — id, session_id, event_type, payload, sequence_number, …
}

const result = await runtime.events.fold(myProjection, { chatId });
// result.state, result.rowsProcessed, result.projection

runtime.events.fold(projection, options?) composes a GraphProjection<T> over the iteration. options.fromSequenceNumber skips at the SQL layer — rows with sequence_number IS NULL (pre-PA-1 legacy) are always included for back-compat.

See Event-log projections for the projection contract and the canonical bundled projections.

The live event bus (runtime.events.on, once, off) is co-located on the same facet but is unrelated to the persisted log — ordering between the two is not guaranteed; treat the bus as a hot stream.

Span introspection

runtime.spans exposes the OTel-shaped exporter the runtime threads through every turn:

runtime.spans.start({ name: "host.custom", kind: "host.custom" });
await runtime.spans.flush();    // call from a waitUntil tail
runtime.spans.inFlightCount();  // 0 when the exporter doesn't track
runtime.spans.isShutdown();     // false when the exporter doesn't track
runtime.spans.snapshot();       // { inFlightCount, isShutdown } in one read

inFlightCount, isShutdown, and snapshot are introspection methods backed by optional getInFlightCount?() / isShutdown?() hooks on the exporter contract. When a host-supplied exporter doesn't implement them, the facet returns 0 / false — documented as "unknown — assume zero / false". The default NoopOtelExporter and the reference CapturingOtelExporter both track accurately.

cacheBackend default-policy promotion

memoryCacheBackend is the substrate default for cacheBackend. The runtime constructor default-constructs one when config.cacheBackend is undefined; the seam factories receive the backend automatically via createSeam.ts. Previously the field had to be configured explicitly; today it is always-on with a default cap of 1000 entries and 64 MB.

The change is observable in runtime.cache?.metricsSnapshot() on day zero. No consumer action is required.

To override, pass a custom cacheBackend field on SessionRuntimeConfig. Set it to null to disable caching entirely.

For the contract, the two read modes, and the 4-gap fingerprint thread, see Cache.

Event-log hash chain

The persistence layer can stamp a per-chat hash chain onto every harness_event_log row. The wire-in lives on EventLogWriter and is gated by the c9PhaseBEnabled flag; when on, the writer maintains a prevHashCache keyed by chat_id, links each new row to the previous row_hash, and surfaces verifyChainForChat(...) and generateProof(...) from @pleach/core/eventLog for downstream audit. Hosts that don't care about the chain leave the flag off and the writer behaves exactly as before.

Where to go next