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.
| 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 |
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:
| Family | Types | What it records |
|---|---|---|
| Session / turn | session.created/resumed/ended · turn.started/completed/failed · graph.completed | Lifecycle boundaries; turn conservation checks read these |
| Messages / content | message.user/assistant · content.delta/reset/correction/finalized | What the user sent and saw — the content ledger replayContentEvents folds |
| Tools | tool.started/completed/failed/schema_failure · tool_offer.snapshot | Tool lifecycle + the offered-set provenance snapshot |
| Interrupts | interrupt.requested/approved/rejected/edited/timeout | HITL decisions; interrupt.edited has a two-row shape (operator row + post-rewrite effective-args row) |
| Subagents | subagent.spawned/tool_used/completed/failed | Delegated-work attribution |
| Checkpoints | checkpoint.created/forked/bulk_updated/branched | Durable-state lineage |
| Decisions / outcomes | continuation.decision · guard.decision · intent.detected · synthesis.outcome · answer.sufficiency · hallucination.detected | Turn control flow + the fabrication-detection verdict |
| Errors / retries | error.propagated · error.code · retry.direct/orchestrator | Classified failure trail — incl. provider-exhausted (family exhaustion) |
| Cost / model | llm.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 / identity | skill.activated/used/created/promoted · identity.updated | What the agent was armed with, what it saw, what it authored, and self-model mutations |
| Assets / misc | asset.offloaded/consumed · event.redacted | Artifact 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, frozenThe 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:
- 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 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);
}| Field | Meaning |
|---|---|
ok | true iff failed === 0 — every event enqueued since the last flush reached the table. The field to branch on |
flushed | Rows durably inserted in this window. Exact |
failed | Events 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 |
errors | The 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 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.
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
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.