pleach
Architecture

Event log

The broader stream of observable events — distinct from the audit ledger — with durable-flush retries, hydration from events, and named projections.

The event log is the runtime's record of observable events — messages sent, tools dispatched, interrupts raised, subagents spawned, exports queued, domain events from plugins. It's distinct from the AuditableCall ledger: the ledger records load-bearing decisions (which model fired and why); the event log records what happened.

Both write streams matter. The ledger is what you query for audit and cost; the event log is what you hydrate a session from. See Stream events for the shapes that become event-log rows.

The event log is the persistence layer for the runtime-lifecycle cluster. Session lifecycle mints and resumes against it; Turn lifecycle writes one row per observable event during executeMessage. The other two cluster members are the producers; this page is the substrate.

import {
  EventLogWriter,
} from "@pleach/core";
import {
  ContentLedger,
  replayContentEvents,
  resolveDomainEventType,
} from "@pleach/core/eventLog";
import type {
  HarnessEvent,
  EventSeverity,
  ActorType,
} from "@pleach/core";
import type { DomainEventTypeConfig } from "@pleach/core/eventLog";

Three layers

The event log has three concerns, separated cleanly.

LayerModuleConcern
WriteEventLogWriterEnqueue events; fire-and-forget at the call site
Durable flushdurableFlushSurvive teardown via waitUntil; retry on transient failure
HydrationhydrateFromEventsWalk events and rebuild session state

EventLogWriter

Per-runtime writer. Calls return synchronously; the actual write is enqueued and drained by the durable-flush pipeline.

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

const writer = new EventLogWriter(supabase);

writer.write({
  type: "message.added",
  severity: "info",
  actor: { type: "user", id: userId },
  payload: { messageId, content: "Hello" },
});

sequence_number stamping (always-on)

Every chatId-bearing row is stamped synchronously with a per-chat monotonic sequence_number column. The writer issues a cold-start SELECT MAX(sequence_number) FROM harness_event_log WHERE chat_id = ? the first time it sees a given chat, then increments an in-process counter for every subsequent write. Concurrent first-writes for the same chat coalesce on a per-chat sequenceInitPromises: Map<chatId, Promise<void>> so the MAX query fires at most once per chat per writer lifetime.

The column lands via migration 20260530170000_harness_event_log_sequence_number.sql. The previous HARNESS_C1_DUAL_WRITE flag-gated mode is retired — dual-write is always-on for chatId-bearing writes; there is no opt-out.

The column carries the canonical sequence ordering that hydrateFromEvents, runtime.events.iterate({ fromSequenceNumber }), and the fold-vs-snapshot equivalence audit (PA-1 Phase A.2) all depend on.

chat_id is required for stamping

eventToRow always emits chat_id when present on the input event. A write without event.chatId skips sequence stamping — the column stays NULL and the row is excluded from per-chat folds.

If you want a row to participate in projection folding, populate chatId on the event. Substrate writes always do; legacy / out-of-band producers may not, and those rows are intentionally invisible to runtime.events.iterate({ chatId }).

prev_hash + row_hash (C9 hash chain)

When c9PhaseBEnabled is on (default true), the writer also stamps prev_hash + row_hash BYTEA columns on every row. The flow:

  1. Read the per-(tenant_id, chat_id) in-process prevHashCache.
  2. On miss, cold-start via resolvePrevHashColdStartSELECT row_hash FROM harness_event_log WHERE chat_id = $1 AND row_hash IS NOT NULL ORDER BY sequence_number DESC LIMIT 1, coalesced via prevHashInitPromises (mirrors the sequenceInitPromises precedent).
  3. Call chainStep from @pleach/core's hashChain module with the resolved prevHash + the canonical row fields.
  4. Stamp prev_hash + row_hash on the row.
  5. Update prevHashCache for the next write on the same chat.

The cold-start runs inside the same chat_id-keyed PG advisory lock scope that protects sequence_number allocation, so the SELECT-then-INSERT race is mitigated.

The chain verifier lives in @pleach/replay@0.1.0verifyChainForChat walks rows in sequence order and re-derives each row_hash from its predecessor; a mismatch surfaces as a tamper-detection signal. See Hash chain for the verifier surface and proof shape.

The CI gate audit:c9-hash-chain-integrity guards the wire-in surface against silent rollback (10 canonical anchors covering the writer-side stamping path).

manifest_hash (config reference, rolling out)

Each row also carries a manifest_hash — a foreign reference to the config manifest row for the substrate active when the event fired. The writer stamps it from the session's snapshot, computed once at construction. The column is nullable during rollout: rows written before the manifest substrate stay valid, and the reference is what makes a session offline-replayable (event stream + manifest = the full tuple).

Integrity is enforced by the audit:event-log-manifest-hash-valid gate — every distinct manifest_hash in the log must resolve to a surviving manifest row — rather than a database foreign key, so the write path stays cheap. See Config manifest for the join shapes that this column unlocks.

HarnessEvent shape

Every event extends a common base. Optional id columns let projections join on whichever entity the event is about.

interface BaseEvent {
  chatId:       string;             // load-bearing — events are chat-scoped
  sessionId?:   string;
  actorId?:     string;
  actorType?:   ActorType;
  toolCallId?:  string;
  subagentId?:  string;
  jobId?:       string;
  checkpointId?: string;
  requestId?:   string;
  durationMs?:  number;
}

type HarnessEvent = BaseEvent & {
  type:     string;                 // dot-namespaced, e.g. "tool.completed"
  payload:  Record<string, unknown>;
};

type EventSeverity = "info" | "warning" | "error" | "audit";
type ActorType     = "user" | "system" | "subagent" | "guest";

The persisted row stamps severity per event type — interrupts, retries, safety, and action-rail events land at "audit"; tool + session lifecycle land at "info"; failures at "error".

Events carry a client-generated id (ULID); the durable-flush layer is idempotent on this id, so retries don't double-write.

Event type conventions

The runtime emits dot-namespaced types — session.created, message.added, tool.completed, interrupt.requested. Plugins emit domain.event stream events that the writer can resolve into typed event log entries via resolveDomainEventType:

const config: DomainEventTypeConfig = {
  prefix: "compliance",
  kinds:  ["redaction.applied", "tamper.evidence.written"],
};

const resolved = resolveDomainEventType(config, "redaction.applied");
// → "compliance.redaction.applied"

Use this so plugin-namespaced events don't collide with the substrate's reserved type space.

asset.consumed

Pairs with the existing asset.offloaded event to make artifact consumption a first-class event-log entry instead of a sidecar manifest mutation. Emitted at the consumption call site; the asset-projection arm folds the row in place by flipping the matching artifact's consumed field to true.

interface AssetConsumedEvent extends BaseEvent {
  type: "asset.consumed";
  payload: {
    s3_key:           string;   // join key against asset.offloaded
    consumed_at:      string;   // ISO-8601
    by_tool_call_id?: string;   // present when LLM-driven; absent for UI surfaces
    by_surface:
      | "tool-result-injection"
      | "ui-canvas-open"
      | "ui-export"
      | "manifest-recovery";
  };
}

The join key is s3_key (the canonical content address) so the projection arm walks asset.offloaded rows by content rather than cross-row identifiers. by_surface discriminates consumption provenance — LLM tool-call injection, user-driven canvas open or export, or server-driven manifest recovery. by_tool_call_id is the only PII-adjacent field and rides the compliance scrubber's allowlist.

asset.offloaded.payload.mime_type

AssetOffloadedEvent.payload.mime_type is a required string field. The producer is expected to populate it via a shared derivation helper that ladders known mimeType → extension-derived → application/octet-stream. Empty-string emission is treated as a regression-detection signal — the producer arm guarantees non-empty. Back-historic rows that predate the field land extension-derived at projection read time without a database backfill.

durableFlush

Wraps the write queue with retry + waitUntil so teardown on Fluid Compute / Lambda / Cloudflare Workers doesn't lose writes.

import { setWaitUntilImpl } from "@pleach/core/eventLog";

// In your edge / function handler:
setWaitUntilImpl(ctx.waitUntil.bind(ctx));

After registration, every write the runtime emits is wrapped in a promise routed through waitUntil — the platform keeps the function alive until the write lands.

Retry policy

  • 3 attempts
  • 100 ms / 300 ms / 800 ms backoff
  • Idempotent on event id — re-sends after a transient failure don't double-write

When all retries fail, the event surfaces as an error stream event with code 4001. The runtime keeps running — durable flush is observability, not control flow. The dropped event's client-generated id is included in the error payload, so a recovery job can re-derive the missing row from the audit ledger (if the event corresponds to a ProviderDecisionLedger write) or reconstruct it from hydrateFromEvents against the surrounding slice.

hydrateFromEvents

Rebuild session state from an event slice. Used for resuming a session after restart, recovering from a checkpoint that's older than the latest event, and replaying through @pleach/eval.

import { hydrateFromEvents } from "@pleach/core/eventLog";

const events = await client.listEvents({ sessionId, since: lastSeenId });
const state  = hydrateFromEvents(events);

// state.messages, state.pendingToolCalls, state.pendingJobs,
// state.completedJobs, state.interrupts — all rebuilt from events.

The hydration walks events in order and applies each to a working state, mirroring how the runtime mutates state during a live turn. Same event sequence = same final state — that's the contract.

ContentLedger + replayContentEvents

For message content specifically — streaming deltas, corrections, truncations — there's a content-shaped accumulator. It honors content.correction events (post-stream fabrication guards) and stream.truncated events, producing the same final message shape the user saw.

import { ContentLedger, replayContentEvents } from "@pleach/core/eventLog";

// One-shot: rebuild the final user-visible content from a stored event slice.
const content = replayContentEvents(events);

// Or drive a ledger directly (feed deltas/resets/corrections as they arrive),
// then materialize the committed view.
const ledger = new ContentLedger();
const finalContent = ledger.materialize().content; // → final message content

Useful when a UI cache is out of sync with the underlying event stream — re-derive instead of refetch.

Projections

A projection is a deterministic fold over an event slice that produces a derived view. The package ships nine built-in projections plus a composite session-state reconstructor:

ProjectionReturnsUse
interruptProjectionInterruptAccumulatorOutstanding approvals queue
subagentProjectionSubagentAccumulatorSubagent tree per turn
exportProjectionExport queue accumulatorAction-rail exports awaiting completion
userCardProjectionUser-card accumulatorcanvas.user_created rows folded into the canvas state
configProjectionPer-chat config snapshotResolved session config at any point in the slice
messageProjectionFinal committed messagesFolds content.delta + correction + truncation rows
toolCallProjectionTool-call accumulatorPending + completed tool calls per turn
jobProjectionJob accumulatorAsync job lifecycle state (uses createJobProjection factory for per-host resolvers)
artifactProjectionArtifact accumulatorasset.offloaded + asset.consumed folded into the artifact state
reconstructSessionStateComposite session stateSingle-call hydrator that runs all projections above
import {
  interruptProjection,
  toInterruptArray,
  subagentProjection,
  toSubagentArray,
} from "@pleach/core/eventLog";

const interruptAcc = events.reduce(interruptProjection.reduce, interruptProjection.empty);
const interrupts   = toInterruptArray(interruptAcc);

const subagentAcc = events.reduce(subagentProjection.reduce, subagentProjection.empty);
const subagents   = toSubagentArray(subagentAcc);

Projections are pure; same input slice = same output. Build your own for domain-specific views — the contract is { empty, reduce, toArray? }.

The interruptProjection reduce shape is the canonical example. empty is { byId: {}, order: [] }; reduce(acc, event) walks the event types — interrupt.requested inserts a pending entry keyed on interruptId, interrupt.resolved flips the entry's decision field and leaves it in place, and interrupt.timeout flips it to a timeout-shaped record. toInterruptArray(acc) materializes acc.order.map((id) => acc.byId[id]) so the consumer iterates in arrival order. Same event slice in, same array out — that's what makes the approval queue safe to re-derive on every render instead of caching it.

Reading events — the runtime.events facet

Reads go through the runtime.events facet, backed by a HarnessEventLogReader the host supplies via SessionRuntimeConfig.eventReader. @pleach/core ships no concrete reader — cast your store (Supabase, Postgres, an HTTP API) into the iterate shape.

import type { HarnessEventLogReader, EventLogRow } from "@pleach/core/eventLog";

// Walk rows for a chat, optionally from a sequence cursor.
async function recentRows(
  reader: HarnessEventLogReader,
  chatId: string,
): Promise<EventLogRow[]> {
  const rows: EventLogRow[] = [];
  for await (const row of reader.iterate({ chatId, fromSequenceNumber: 0 })) {
    rows.push(row);
  }
  return rows;
}
SurfaceReturnsUse
runtime.events.iterate(filter)AsyncIterable<EventLogRow>Walk a chat's rows (server-side, service-role)
runtime.events.fold(projection, filter)ProjectionResult<T>Fold rows into typed state via a GraphProjection<T>
queryHarnessEvents / getAllChatEvents / countEventsByTyperows / countsDirect read helpers exported from @pleach/core/eventLog

EventLogRow (read shape)

What hydrateFromEvents walks. Snake-cased — these are the column names you query against directly.

interface EventLogRow {
  id:             string;                       // ULID, primary key
  session_id:     string | null;
  event_type:     string;
  actor_id:       string | null;
  actor_type:     string;
  tool_call_id:   string | null;
  subagent_id:    string | null;
  job_id:         string | null;
  checkpoint_id:  string | null;
  request_id:     string | null;
  payload:        Record<string, unknown>;
  duration_ms:    number | null;
  severity:       string;
  created_at:     string;
  domain?:        string | null;                // plugin namespace, when domain.* row
  kind?:          string | null;                // plugin event kind, when domain.* row
  manifest_hash?: string | null;                // config-manifest reference; NULL on pre-rollout rows
}

Plugin-namespaced rows populate domain + kind; core rows leave both null. hydrateFromEvents flattens both shapes — same input slice produces the same hydrated state regardless of whether the slice predates the namespacing migration.

Schema

The event log persists to harness_event_log (file 003 in the schema bundle). The table is append-only, ULID-keyed, indexed by (session_id, id) for cursor pagination. RLS templates ship in the file; production deployments using a service-role client bypass them. The append-only contract is what makes hydrateFromEvents safe to call from any point in time — a row never mutates, so the same since cursor against the same table produces the same hydrated state next year.

Durability is the storage adapter's job, not the event log's. On the quickstart path (createPleachRoute) the runtime writes the message.user / message.assistant / tool.* rows that hydrateFromEvents and the tool/manifest projection fold from on turn finalization — so message and tool restoration work out-of-the-box, provided a durable storage adapter is configured. The default in-memory adapter keeps these rows in the process heap only: they survive in-process resume but are lost on restart and absent on a fresh serverless instance. Wire a durable adapter (Supabase / Postgres / Redis) for the event log to outlive the process.

Projections — folding rows into state

GraphProjection<T> is the substrate piece for folding event-log rows into typed runtime state. Consumers depend on the contract instead of rolling their own iterators.

The runtime exposes the canonical read surface as the runtime.events facet (landed PA-1 Phase A.2):

// Walk raw rows for a chat, optionally starting at a sequence number.
for await (const row of runtime.events.iterate({
  chatId,
  fromSequenceNumber: lastSeen,
})) {
  // ...
}

// Fold rows into typed state via a GraphProjection<T>.
const result = await runtime.events.fold(interruptProjection, { chatId });
// result.state, result.rowsProcessed, result.projection (name)

iterate is a paginated async iterable (1000 rows per page, ordered by chat_id, then sequence_number, then created_at, then id). fold walks iterate under the hood and applies the projection's reduce → optional finalize. Both are server-side only (require a Supabase service-role client).

For the deep write-up and a custom-projection example, see Event log projections.

content.delta streaming chunks

content.delta is the canonical streaming-chunk event type. The producer emits one row per token-or-chunk during model output.

A producer-side capture helper exists for hosts that want to record the chunk stream into the event log without wiring it by hand.

messageProjection folds the content.delta rows for a turn into the final committed message — the same shape the user saw at end-of-turn.

See Stream events for the wire-level shape and Event log projections for the fold.

InterruptManager terminal writes and host-plugin job events

When the interrupt manager resolves an interrupt — approve, deny, or edit — it writes a terminal event-log row. Replay tools rely on that row as the deterministic boundary for the interrupt.

Host plugins contribute job-lifecycle event types under their own domain.<plugin>.* namespace. Two common shapes: domain.<plugin>.job.timeout and domain.<plugin>.job.completed, the latter typically carrying a duration_ms payload field.

The domain.<plugin>.* convention keeps host-contributed events isolated from substrate-emitted events. Substrate code never writes under a host plugin's namespace.

See Interrupts and the Plugin contract.

Authoring a custom projection

The bundled projections (messageProjection, toolCallProjection, jobProjection) cover the common reads — committed message text, tool-call pairs, job lifecycle. Domain reads — a per-tenant retry-rate dashboard, a citation-source histogram, a refund-event audit — author against the GraphProjection<T> interface.

import type { GraphProjection, EventLogRow } from "@pleach/core/eventLog"

interface RetryCount {
  total: number
  byTool: Record<string, number>
}

export const retryCountProjection: GraphProjection<RetryCount> = {
  name: "retry-count",
  initial: () => ({ total: 0, byTool: {} }),
  reduce: (acc, row: EventLogRow) => {
    if (row.event_type !== "tool.retried") return acc
    const toolName = row.payload?.toolName as string | undefined
    if (!toolName) return acc
    return {
      total: acc.total + 1,
      byTool: { ...acc.byTool, [toolName]: (acc.byTool[toolName] ?? 0) + 1 },
    }
  },
  // Optional finalize — runs once after all rows are consumed.
  // Use it for cross-row resolution; return the input unchanged for a no-op.
}

Three contract rules every projection must satisfy:

  • Pure. Same input rows produce the same output state. No wall-clock reads, no RNG, no network calls. The same projection folded over the same row stream twice produces byte-identical results — that's what makes projections replay-safe.
  • Order-preserving. The reducer processes rows in the order supplied by the caller. The caller (typically hydrateFromEvents or the upcoming SessionRuntime.events.fold) is responsible for ordering by (chat_id, sequence_number) or (created_at, id).
  • Total. Unknown event_type values are no-ops, not throws. The event-log surface accumulates new event types over time; projections must not break on rows from a newer schema version.

The reducer returns the same acc reference when no change applies — the conventional no-op signal. See the bundled messageProjection, toolCallProjection, jobProjection sources for full exemplars; the runtime accessor that folds a projection over a session's events ships in a later phase.

Where to go next

On this page