# Event log (/docs/event-log)



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](/docs/audit-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](/docs/stream-events) for the shapes that become
event-log rows.

The event log is the persistence layer for the
[runtime-lifecycle cluster](/docs/session-lifecycle#the-runtime-lifecycle-cluster).
[Session lifecycle](/docs/session-lifecycle) mints and resumes
against it; [Turn lifecycle](/docs/turn-lifecycle) writes one row
per observable event during `executeMessage`. The other two cluster
members are the producers; this page is the substrate.

```typescript
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";
```

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

## Three layers [#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` [#eventlogwriter]

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

```typescript
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) [#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 [#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) [#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 `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 via `prevHashInitPromises` (mirrors the
   `sequenceInitPromises` precedent).
3. Call `chainStep` from `@pleach/core`'s
   [`hashChain`](https://github.com/pleachhq/core/blob/main/src/eventLog/hashChain.ts)
   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.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](/docs/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) [#manifest_hash-config-reference-rolling-out]

Each row also carries a `manifest_hash` — a foreign reference to
the [config manifest](/docs/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](/docs/config-manifest)
for the join shapes that this column unlocks.

### `HarnessEvent` shape [#harnessevent-shape]

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

```typescript
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 [#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`:

```typescript
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` [#assetconsumed]

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`.

```typescript
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` [#assetoffloadedpayloadmime_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` [#durableflush]

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

```typescript
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 [#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` [#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`.

```typescript
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` [#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.

```typescript
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 [#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                                  |

```typescript
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 [#reading-events--the-runtimeevents-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.

```typescript
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) [#eventlogrow-read-shape]

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

```typescript
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 [#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 [#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):

```typescript
// 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](/docs/event-log-projections).

## `content.delta` streaming chunks [#contentdelta-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](/docs/stream-events) for the wire-level shape
and [Event log projections](/docs/event-log-projections) for the
fold.

## `InterruptManager` terminal writes and host-plugin job events [#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](/docs/interrupts) and the
[Plugin contract](/docs/plugin-contract).

## Authoring a custom projection [#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.

```typescript
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`](https://github.com/pleachhq/core/tree/main/src/eventLog/projections)
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 [#where-to-go-next]

<Cards>
  <Card title="Schema" href="/docs/schema" description="What `harness_event_log` looks like — columns, indexes, RLS." />

  <Card title="Stream events" href="/docs/stream-events" description="The shapes the stream emits become event log entries." />

  <Card title="AuditableCall row" href="/docs/auditable-call-row" description="The other write stream — load-bearing decisions instead of observable events." />
</Cards>
