Skip to content
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 the router's per-call decisions (which model fired and why — engineering forensics); the event log records what the session experienced, and is the compliance surface.

Both write streams matter, but they are not peers for review: the event log is the single compliance-review and attestation boundary — the one ledger an auditor walks, with one hash chain and one verification call — while the AuditableCall ledger is engineering telemetry (routing, cost, and quality forensics), non-authoritative for compliance by design. Application developers hydrate sessions from the event log; replay and client-side /events folds consume it; any router-side fact a review needs is projected into it as a typed row. See Audit ledger → Two ledgers, one compliance surface for the doctrine and the side-by-side, and 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

The durable type union

The writer accepts the registered core event types plus plugin-namespaced domain.<plugin>.<kind> rows. The core union (53 types as of 2026-08-29) is the durable contract — a shape not listed here (and not domain.*-namespaced) is rejected at the storage constraint, and many high-frequency stream events (deltas, progress ticks) are deliberately stream-only. Grouped:

FamilyTypesWhat it records
Session / turnsession.created/resumed/ended · turn.started/completed/failed · graph.completedLifecycle boundaries; turn conservation checks read these
Messages / contentmessage.user/assistant · content.delta/reset/correction/finalizedWhat the user sent and saw — the content ledger replayContentEvents folds
Toolstool.started/completed/failed/schema_failure · tool_offer.snapshotTool lifecycle + the offered-set provenance snapshot
Interruptsinterrupt.requested/approved/rejected/edited/timeoutHITL decisions; interrupt.edited has a two-row shape (operator row + post-rewrite effective-args row)
Subagentssubagent.spawned/tool_used/completed/failedDelegated-work attribution
Checkpointscheckpoint.created/forked/bulk_updated/branchedDurable-state lineage
Decisions / outcomescontinuation.decision · guard.decision · intent.detected · synthesis.outcome · answer.sufficiency · hallucination.detectedTurn control flow + the fabrication-detection verdict
Errors / retrieserror.propagated · error.code · retry.direct/orchestratorClassified failure trail — incl. provider-exhausted (family exhaustion)
Cost / modelllm.turn · cost.rollup · cache.stream_hit · synthesize.tool_strip · model.reasoning_complete · model.upgraded (deprecated, historical rows only)Per-call and per-turn cost/model facts
Skills / identityskill.activated/used/created/promoted · identity.updatedWhat the agent was armed with, what it saw, what it authored, and self-model mutations
Assets / miscasset.offloaded/consumed · event.redactedArtifact lifecycle + scrubber meta-events

session.ended — a close you can query for

session.ended records a session closing, so "this session ended normally" is a positive fact rather than something inferred from the absence of later rows. It is latched: at most one per runtime.

Absence is not evidence of a crash

A session whose process dies never writes session.ended. So the row's presence proves a clean close; its absence proves nothing on its own — it could be a crash, a still-open session, or a host that never disposed the runtime. Grade on the row you have, never on the row you don't.

HARNESS_EVENT_TYPES — the union as a runtime value

HarnessEvent is a TypeScript union, and a union is erased at runtime. Guards that needed the producer list used to regex-scrape the writer's source text for the type: "…" idiom — a source-text lock that empties silently the moment anyone refactors the idiom.

@pleach/core/eventLog now exports the list as a real value:

import { HARNESS_EVENT_TYPES } from "@pleach/core/eventLog"
import type { HarnessEventType } from "@pleach/core/eventLog"

HARNESS_EVENT_TYPES.includes(candidate) // sorted, frozen

The tuple is derived from an exhaustive Record<HarnessEventType, true> that lives in production source, so tsc rejects a union member with no key and a key that is not a union member. The tuple and the union are structurally incapable of drifting.

Membership means emittable, not persistable

A type in HARNESS_EVENT_TYPES can still be rejected at the storage constraint. A row survives only if its type is in the constraint's core IN (…) list, or is rewritten to domain.<ns>.<kind> and accepted by the constraint's regex arm. A type in neither reaches the database bare, fails the CHECK with Postgres 23514, and is swallowed by the writer's fire-and-forget error path — silently.

answer.sufficiency

answer.sufficiency is a member of HARNESS_EVENT_TYPES. It records the post-turn verdict on whether the delivered answer carried a substantive body — one row per judged message, with the verdict, the judge's reason, the content length, and the pending plan-step count. Write it through the exported producer rather than a hand-rolled writer.write:

import {
  emitAnswerSufficiency,
  ANSWER_SUFFICIENCY_REASON_MAX_CHARS,
} from "@pleach/core/eventLog";
import type { AnswerSufficiencyLedger } from "@pleach/core/eventLog";

emitAnswerSufficiency(ledger, verdict) takes an AnswerSufficiencyLedger ({ writer, chatId }) and is a silent no-op when the ledger is unwired — diagnostics never break the turn. The judge-sourced reason string is truncated at the producer to ANSWER_SUFFICIENCY_REASON_MAX_CHARS (160), so no caller can widen it past the scrub contract.

EventLogWriter

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

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

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 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 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 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 doesn't lose writes.

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

// In your 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.

flush() reports durability

On the provider-agnostic Postgres writer (createPgEventLogWriter), write() is fire-and-forget by contract — it never throws, and one row's failure never poisons the serialized write tail. That leaves flush() as the only place a caller can learn whether the events it handed over actually reached the table, so flush() resolves to an EventLogFlushResult and never rejects. Check .ok:

import type { EventLogFlushResult } from "@pleach/core/eventLog";

const result = await writer.flush();
if (!result.ok) {
  report(result.failed, result.errors);
}
FieldMeaning
oktrue iff failed === 0 — every event enqueued since the last flush reached the table. The field to branch on
flushedRows durably inserted in this window. Exact
failedEvents that did not land. Exact — counts both a thrown INSERT and an event dropped by the fail-closed scrubber policy, which is otherwise observable nowhere else
errorsThe errors behind failed, capped at 100 retained — a sample, never a census

The counts cover the window since the previous flush() call, so ok reports the current window rather than latching false forever after one historical failure.

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.

message.assistant payload.cost_rollup

On the quickstart path, the message.assistant row also carries payload.cost_rollup: a snapshot of the turn's cost rollup across sub-agents, taken when the row is written.

payload.cost_rollup = {
  root_turn_key: "turn:<userMsgId>", // the rollup's join key
  snapshot:      true,
  as_of:         "2026-10-06T12:00:00.000Z", // ISO time the snapshot was taken
  self:          TurnCostBucket,   // the root runtime's own calls
  descendants:   TurnCostBucket,   // every sub-agent call under the turn
  total:         TurnCostBucket,   // self + descendants
  partial:       boolean,          // any unpriced call, or truncated
  truncated:     boolean,          // rows before a release or eviction are missing
};

root_turn_key is not the row's turn_id. The route stamps its own turn id on the row, so the rollup key is carried separately for joins to a sub-agent's parentTurnId and to runtime.getTurnCostRollup(root_turn_key).

The snapshot is a lower bound, valid as of as_of. A late spawn() child or a post-turn writer can record rows after it is taken, so partial: false means only that nothing was unpriced or truncated at that moment. Re-read getTurnCostRollup(root_turn_key) for a later figure. The field is omitted when no cost row was attributed to the turn. It holds counts and ids only, no message content.

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