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.
| Layer | Module | Concern |
|---|---|---|
| Write | EventLogWriter | Enqueue events; fire-and-forget at the call site |
| Durable flush | durableFlush | Survive teardown via waitUntil; retry on transient failure |
| Hydration | hydrateFromEvents | Walk 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:
- Read the per-
(tenant_id, chat_id)in-processprevHashCache. - On miss, cold-start via
resolvePrevHashColdStart—SELECT row_hash FROM harness_event_log WHERE chat_id = $1 AND row_hash IS NOT NULL ORDER BY sequence_number DESC LIMIT 1, coalesced viaprevHashInitPromises(mirrors thesequenceInitPromisesprecedent). - Call
chainStepfrom@pleach/core'shashChainmodule with the resolvedprevHash+ the canonical row fields. - Stamp
prev_hash+row_hashon the row. - Update
prevHashCachefor 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.0 —
verifyChainForChat 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 contentUseful 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:
| Projection | Returns | Use |
|---|---|---|
interruptProjection | InterruptAccumulator | Outstanding approvals queue |
subagentProjection | SubagentAccumulator | Subagent tree per turn |
exportProjection | Export queue accumulator | Action-rail exports awaiting completion |
userCardProjection | User-card accumulator | canvas.user_created rows folded into the canvas state |
configProjection | Per-chat config snapshot | Resolved session config at any point in the slice |
messageProjection | Final committed messages | Folds content.delta + correction + truncation rows |
toolCallProjection | Tool-call accumulator | Pending + completed tool calls per turn |
jobProjection | Job accumulator | Async job lifecycle state (uses createJobProjection factory for per-host resolvers) |
artifactProjection | Artifact accumulator | asset.offloaded + asset.consumed folded into the artifact state |
reconstructSessionState | Composite session state | Single-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;
}| Surface | Returns | Use |
|---|---|---|
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 / countEventsByType | rows / counts | Direct 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
hydrateFromEventsor the upcomingSessionRuntime.events.fold) is responsible for ordering by(chat_id, sequence_number)or(created_at, id). - Total. Unknown
event_typevalues 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
Turn lifecycle
What happens between runtime.executeMessage and the final SSE frame — the four-stage execution path, the events the client sees, and the rows the ledger writes.
Event-log projections
GraphProjection<T> — folding harness_event_log rows into typed runtime state. The contract is stable; the shipped projections are in soak.