Skip to content

Event core: envelope, journal, cursors and federation

Status: Planned (EV0, D-188). Implemented so far: the registry and schemas (EV0a), and the journal migration, events.append and the producers that write through it (EV0b, Source candidate), and the cursor, its signing key, the epoch, the reset and events.read (EV0c-1, Source candidate), and the events.watch frames (EV0c-2, Source candidate). Nothing else below is implemented. Today's Node already has the append-only domain_events journal, per-aggregate cursors, session.events.list and the observe.events stream; EV0 evolves them into this core and adds no second journal.

One event core serves every Node. Every producer appends to it and every consumer reads from it: no per-feature emitter, side file or private queue.

 producers (in the writer's transaction)          consumers (cursor each)
 Session state, questions, output match,          watch --json, MCP, remote,
 node resources, hook delivery status             webhooks, dashboard
          |                                                ^
          v                                                |
   events.append(tx, event) --> journal (SQLite, one per Node) --> events.read
                                          ^                         (also over
                                          |                          the SSH bridge)
                          control Node pulls each remote Node's journal
                          with one cursor per Node (federation)

Placement

PartOwner
Envelope, kind registry, payload schemas, cursorpackages/protocol (the only source of truth)
Journal, append, retention, read, federationone Node module, services/node/src/events/; retention in events/retention/
events.read / events.watch SDK methods, CLI, MCPgenerated from the registry
Workers (Pi, vendor executors)never own a journal; they emit through Node operations

A new package waits for a second real consumer of the journal code (AGENTS.md). This is Maia's form of one shared event core: a generic package may be extracted later, once a second project adopts the same envelope.

Envelope

One envelope, schemaVersion: "maia.event.v2". It supersedes the unemitted EventV1 target in contracts; there is one discriminator. Producers supply source, kind, subject, v and payload; the core fills the rest in the append.

FieldTypeRule
idULID, event_ prefixUnique per event; the idempotency key. Monotonic per Node (below).
seqinteger ≥ 1, decimal string on the wirePer-Node position. Strictly increasing in commit order, never reused, may skip after retention. A string keeps 64-bit values lossless.
nodeNode IDThe Node whose journal holds the event. Constant per journal, not stored per row.
sourceregistered stringThe producing component; always the first segment of kind. The registry lists the sources; it is not a closed enum.
kindstring<source>.<noun>[.<qualifier>…], 2–5 segments of [a-z][a-z0-9_-]*, e.g. session.activity.changed, node.cpu.high.
tsUTC, millisecond ISOThe Node's wall clock at append. Display and global merge only; never used for per-Node order.
vinteger ≥ 1Schema version of this kind's payload.
subject{ type, id, seq }, seq a decimal stringThe exact aggregate the event is about (Session, terminal, Node, hook) and its gap-free seq (today's aggregate_sequence).
commandreceipt command ID, optionalPresent when a command's transaction wrote the event; links the receipt to its events.
payloadJSON objectValidated against the registry schema for (kind, v).

Rules:

  • Metadata and digests only (D-168). No message bodies, transcripts, terminal text, secrets or file contents. A consumer that needs text (a question's wording, a matched line) reads it from the owner through an authorized operation at delivery time.
  • Payload limit: a per-kind maximum in the registry, 4 KiB of UTF-8 JSON by default for new public kinds. Existing kinds register the limit they have today (the journal's 64 KiB cap bounds all of them). An append over its kind's limit fails the producer's transaction (a code defect). Historical rows are never rewritten; a row larger than a read frame allows is returned as a stub (id, seq, kind, oversize: true, no payload).
  • ID: 48-bit millisecond timestamp plus 80 random bits, and the generator is monotonic: within one millisecond, or after the clock steps back, it increments the previous ID instead of drawing a new one. It reseeds from the journal's last ID at start, so a restart or clock regression never yields an ID below the previous one.
  • Wire bounds (the protocol schemas enforce them and reject out-of-range input with a decode failure): kind is at most 128 characters; v is an integer from 1 to 65,535; ts is UTC with exactly three fractional digits; payload is a plain JSON object nested at most 32 levels; a cursor token is at most 2,048 base64url characters, and a federated token at most 16,384.
  • Kinds are registered. The registry names each kind's source, versions, payload schemas, subject type, exposure (public kinds stream to consumers; internal kinds stay readable only by Node code) and required authorization scope. An unregistered kind fails the append.
  • Evolution: decoding is strict everywhere (packages/protocol/AGENTS.md); forward compatibility comes from versions. Any payload change, including a new optional field, adds v + 1, and the producer writes only the newest version. A consumer decodes each v it knows strictly and skips, but advances past, an event whose v it does not know. A kind is never reused for another meaning. The envelope changes only by a new decision.
  • Migration from EventV1: eventId→id, eventType→kind, ownerType/ownerId/ownerSequence→subject, occurredAt→ts. Project, Session, activation, attempt, actor, causation and correlation IDs move into the kinds whose payload needs them; classification becomes a per-kind registry attribute.
  • Existing events: every current domain_events type keeps its name and is registered once (inventory below); none is renamed.

Existing kinds (inventory)

Types written by services/node/src today, by source. EV0a registers all of them as internal; the slice that first streams a kind marks it public. A registry-parity test in EV0a fails on any event_type the source can write that the registry lacks, so this list cannot drift. Registering a kind internal changes no existing read: session.events.list, the terminal event reads and the aggregate cursors keep their shapes and rows.

SourceKinds
sessioncreated, spawned, renamed, stopped, archived, start.requested, resume.requested, runtime.started, runtime.resumed, message.sent, inbox.acknowledged, runtime.failed, runtime.recovery.requested, worktree.removed, agent-permission.granted
turnqueued, dispatching, started, completed, failed, cancelled, interrupted (plus the interrupt phases the interrupt module writes)
approvalrequested, resolved
remote_noderemote_node.<state>: one kind per registered state, written by the remote registry
questionopened, answered, cancelled
interactioncreated, answered, cancelled
connectioncreated, state.changed, capacity.changed, limit.recorded, login.started
executionplacement.created, command.prepared/running, file.read.prepared/running, relocation.prepared/reconciled, worktree.checkpoint.prepared/running, worktree.reconciled, worktree.remove.prepared
hold, porthold.placed/released; port.lease.acquired/renewed/released
projectcreated, registered, renamed, guard.changed, context.recorded
run, dispatchrun.created, run.ledger.created, run.ledger.task.dispatched/settled, dispatch.settled
routinecreated, paused, resumed, disabled, fire.recorded
integrationauthorization.applied/revoking, authorization.update-policy.set
team-workspace, workspaceteam-workspace.bootstrapped; workspace.selected/execution.configured/membership.attached/repository.attached
terminal, voice, agentterminal.create.rejected and the terminal lifecycle kinds; voice.pi.relay.acknowledged; agent.created/revised/disabled

Terminal lifecycle today lives in a second table, terminal_live_events (exit, cleanup, controller-revoked, lifecycle-transition and gap rows; output rows were removed by migration v067). EV6 (with TM2a) moves the lifecycle rows into the core as terminal.* kinds and drops them from that table. gap rows (restart and retention gaps) are durable and replayable today, so they migrate too (terminal.output.gap), and terminal cursors keep their meaning and survive a restart. Output text stays outside the journal, in the terminal read lane (TM2a's bounded memory buffer). Session item streams (session.attach, session_stream_records) and terminal byte transports carry content, keep their own lanes (D-077) and never enter the core.

New kinds

Producer (slice)Kinds
Session state (EV1)session.activity.changed (from, to, approval ID), registered in EV1a and still internal
Session status (E13)session.status.changed (from, to, and the approval ID while the Session waits on one); public, subject type session-status; identifiers only
Updates (ZU2a)update.started (update ID, from, to), update.done (update ID, to), update.rolled_back (update ID, from, to, reason class); IDs, versions and class codes only, registered ahead of the producer, internal
Questions (EV5)the existing question.opened, question.answered, question.cancelled (IDs and state only); no parallel kinds
Output match (needs TM2b)terminal.output.matched (terminal ID, rule ID, position; never the text)
Node resources (EV4)node.cpu.high/recovered, node.memory.low/recovered, node.disk.low/recovered, node.inode.low/recovered, node.unreachable, node.reachable, session.memory.high
Hook delivery status (EV3)hook.delivery.failed, hook.delivery.recovered (not in the first webhooks slice)

session.activity.changed is the one kind whose subject is not the entity it describes: its subject type is session-activity and its ID is the Session ID, so the Session's own history, revisions and session.events.list never see it. The subject's seq counts activity events only; never compare it with Session.revision. A consumer treats the event as an invalidation and re-reads the Session, including after a reset.

The Node announces activity from the write path (EV1b). Every write that decides activity (a Session state, a turn state or an approval status) marks the Session; one hook per enclosing transaction then settles it after the transaction's last write and before it commits, so a rollback leaves no event and a replayed command appends none. The Session row keeps a checkpoint (sessions.activity and activity_approval_id): the last announced value. Settle compares it with the live value and appends one event when they differ. The event carries the causing command ID when its receipt is in the journal. A new Session announces null to its first value. At start the Node settles every Session whose checkpoint is stale, with no command ID, so a crash between a write and its settle loses nothing. An approval that expires by the clock announces only when the Node closes it, so a late close delays that event; The kind is public: events.read returns it (see Exposure below), the first public kind. Reading the live activity on session.show and session.list arrives with EV1c. See D-195.

session.status.changed follows the same path (E13): the same mark, the same settle hook, and a checkpoint on the Session row (sessions.status and status_approval_id), so one transaction announces both. Its subject type is session-status and its ID is the Session ID. A change of the prompt a Session waits on announces too (question to question with another approval ID), so a consumer never misses question B behind question A. The payload carries identifiers alone: from, to and, while the Session waits, the approvalId. The journal never holds the prompt's text or options (D-168): session show and approval show return them to a reader who may read the approval, and a webhook delivery carries metadata alone. The status derives from rows alone. A stored value outside the status list reads as unknown. A harness's own signal on the running turn adds compacting or rate_limited (see the table); Maia only observes it.

StatusWhen (first match wins)
endedthe Session is stopped or stopping
errorthe Session failed, or it is idle and its newest attempt failed, including a start the runtime refused; accepted work supersedes it
startingthe Session is starting or recovering; this hides every runtime fact below
questionthe earliest live pending prompt is a question
approvalthe earliest live pending prompt is a command, file change or tool request
unknownthe stored value is not a known status, a waiting Session's approval record cannot be read, or a harness signal this version cannot read
compactingthe running turn's harness reports it is compacting its context
rate_limitedthe running turn's harness reports a rate limit and retries
workinga turn is queued, dispatching or running
doneidle, and the newest attempt completed (a failed, interrupted or outcome-unknown turn is never done)
idlenone of the above

hook.* events are never delivered to hooks, so a failing hook cannot feed itself. node.unreachable / node.reachable are written by the control Node about a remote Node in its own journal (subject: the remote Node); the remote cannot report its own loss.

Journal

One authoritative journal per Node: the existing SQLite database's domain_events table, evolved by one migration. It stays a single table with an ever-growing seq.

ColumnMeaning
global_sequenceseq; INTEGER PRIMARY KEY AUTOINCREMENT, so it is never reused
event_idid; TEXT NOT NULL UNIQUE (the index also resolves --since <id>)
source, event_typesource, kind (event_type keeps its name)
schema_versionv
ts_msts as integer milliseconds (recorded_at stays as its text form)
aggregate_type, aggregate_id, aggregate_sequencesubject, unchanged, unique together
command_idnow nullable: events with no command (resources, delivery status) have none
payload_jsonpayload, json_valid, at most the kind's registered limit

Indexes: the primary key (read by seq), event_id (unique), and the existing subject index. A (event_type, global_sequence) index for kind filters joins with events.read (EV0c), the first reader that filters by kind.

A one-row event_journal_meta holds the journal epoch (random 128-bit ID, set at creation, rotated when the journal is restored or rebuilt) and last_event_id (written by the pruner, so the ID generator reseeds correctly even when retention emptied the table).

The migration (v069) rebuilds the table once (SQLite cannot drop NOT NULL) and keeps every existing seq and aggregate_sequence, so stored cursors stay valid. It back-fills each event_id as a ULID whose 80-bit random part is the seq itself (big-endian, zero-padded) and whose 48-bit time part is the running maximum of ts_ms in seq order. Back-filled IDs therefore sort strictly in seq order even where a past clock stepped back, while ts_ms keeps the recorded time. source is the first segment of event_type. A row whose recorded_at is not a UTC timestamp fails the whole migration, which rolls back and names the seq. New IDs come from a monotonic generator reseeded from the larger of the newest row and last_event_id, and again whenever SQLite's data_version shows another connection committed, so IDs sort after the back-fill and stay distinct across connections. Per-kind payload schemas do not exist yet; they are the next step, and until then the append checks the envelope and the payload shape only.

events.append (in services/node/src/events/) is the journal's only writer. It rejects an unregistered kind or version, a recorded_at that is not a protocol timestamp, a payload that is not a plain JSON object, and a payload over the kind's cap; it stores the decoded copy of the payload, takes the time of the ID from recorded_at, returns the subject's new revision, and updates the journal counters through a trigger, so an append costs one insert and one update of a single metadata row. A test fails when any other production source writes domain_events (migrations, which are frozen history, and the observation trigger excepted). A guard on the Node's connection backs the test at run time: it aborts any insert into domain_events that does not come from events.append. It covers inserts only, on that connection, and costs about 9 µs per append. The migration creates the epoch, and events.read compares every cursor against it.

Why SQLite and not segment files

NeedSQLite (domain_events)Segment files
Event atomic with the state change and the receiptSame transaction, nothing to addTwo durable stores: a crash between them loses or invents events
Extra fsync per appendNone: the event rides the transaction's one WAL commitOne more per append, or batching that breaks atomicity
Durability knobevents.fsync = always / interval / never, mapped to PRAGMA synchronous; node-wide, as the DB is sharedOwn knob of the same names
Crash recoveryWAL recovery on open; torn frames are discarded by checksumOwn scanner, torn-tail truncation, index rebuild
RetentionBatched DELETE in seq order, WAL checkpointed after each batchDrop whole segments (cheaper, coarser)
Reads by seq, ID, subject, kindIndexesSparse index to maintain
New code and dependenciesA migration and a moduleA storage engine

Segment files win only on delete cost at very high event rates; Maia's rates (a handful per Session change) do not need it. The decisive point is the first row: a separate store cannot be atomic with Session state, which is the producer contract. Segments stay a rejected alternative until a measured bottleneck (B0, below) names the journal.

Fsync policy. events.fsync (default always) maps to SQLite's synchronous for the journal's database, so it also covers the state and receipts it commits with. Guarantees, with the WAL journal mode in use:

PolicySQLiteA process crashA power loss or OS crash
alwayssynchronous=FULLNo committed event lostNo committed event lost; no corruption
intervalsynchronous=NORMALNo committed event lostThe newest commits since the last checkpoint (WAL sync) may be lost; no corruption, no reorder
neversynchronous=OFFNo committed event lostRecent commits may be lost and the database file may be corrupt; for tests and disposable Nodes

interval is bounded by wal_autocheckpoint (1,000 pages, as today), not by a timer. The setting never lets an event outlive its state change, because both share one transaction. Today always is the only setting: synchronous=FULL is fixed for every Node database. events.fsync becomes a per-Node setting with S13, the Node configurator; no loader or environment variable reads it before then.

Torn writes. Every WAL frame carries a checksum and the WAL's salt values. On open, recovery replays frames up to the first one whose checksum or salt does not match and discards everything after it, so a half-written transaction is never visible. Readers see only committed transactions. Damage to the main database file is caught by the quick_check the Node runs at every open (storage_corrupt, SQLite contract); a page that rots while the Node runs is not detected until the next open.

Crash safety. Recovery is SQLite's. On start the core reseeds the ID generator from the last row (or last_event_id when the table is empty); seq continues from sqlite_sequence. A reader sees only committed events, in seq order, so a reader never sees a hole that later fills.

Retention

Bounded by size and age; whichever limit is hit first prunes the oldest rows, and age applies to every row, the newest included. A prune takes the oldest rows while they are expired (or the size limit demands it) and stops at the first row that is neither, so after a backward clock step an expired row behind a fresher row stays until that row expires. The defaults are set per Node in configuration (owner decision, D-188). This replaces the 90-day event default of D-038; receipts keep their 90 days. Until S13 ships the Node configurator, the defaults below are constants in services/node/src/events/, not settings.

SettingDefaultNotes
events.retention.maxAge14 daysRows older than this are pruned.
events.retention.maxBytes64 MiBLogical size: payload_json bytes plus events.rowOverheadBytes per row, kept as counters by triggers.

events.rowOverheadBytes ships as the constant rowOverheadBytes (264) in services/node/src/events/. It is measured, not configured: after 2,000 Session writes on a file database, the page bytes of domain_events and its three indexes (SELECT sum(pgsize) FROM dbstat WHERE name IN (...)), minus payload_bytes, divided by row_count, gave 263.9 on every run. The events.row_overhead.bytes metric re-measures it, so a schema change that moves it shows in pnpm metrics; it counts whole pages, free space inside them included. The counters row_count and payload_bytes live in event_journal_meta and equal a recount of the journal: two triggers (v074) update them on every insert and delete, in or out of a transaction. The delete trigger also raises last_event_id, so an emptied journal never reissues an ID.

Prerequisite. Pruning ships only after EV0d, because Node logic reads event rows as authoritative state today:

ReaderReadsEV0d moves it to
Receipt lookup (findReceipt, the interrupt receipt select)revision at command_receipts.event_cursora durable aggregate_revision column on the receipt (done, EV0d-1, migration 72)
Interrupt phase and recovery listing (turn/storage/interrupt.ts)last event type, event joined to the revisiona durable turn_interrupts row: phase and revision (done, EV0d-2, migration 73)
Team Workspace bootstrap ownership (team-workspace/store.ts)count of bootstrap eventsthe receipt count and the aggregate revision alone (done, EV0d-1)
Session attention (attention-sql.ts)a session.stopped event after the turnthe Session row's stop_revision (done, EV0d-2, migration 73)
Session activity (EV1)last session.activity.changed eventthe Session row

After EV0d only the events module reads the journal, and the maia-effect/event-journal-reads rule enforces it (done, EV0d-3; empty baseline, and an allow-list of the events module's files and the observation-commit trigger, see Effect). A receipt's event_cursor may then point at a pruned row; receipt show reports it as pruned, never as missing: eventsPruned: true when 0 < event_cursor < the journal's oldest retained sequence (a live-terminal receipt stores 0, no event, and is never marked).

Pruned-history marker (D-191). session events list and terminal events list add oldestRetainedCursor only when after + 1 < oldest: the aggregate's lowest retained sequence, or its revision plus one when all of it is pruned. A page without a hole has no marker, so the result is unchanged while the pruner is off. The page and the marker are read on the one synchronous connection. The terminal marker is on the unavailable-terminal path only: a live terminal has its own 50,000-event retention and reports an evicted range as an in-band gap event.

Pruner. An Effect fiber in the Node's scope (no new process). Status: Source candidate, dormant. An internal constant keeps it off: with the gate off, the Node starts no fiber and no timer, and runs no prune or checkpoint. The first run comes 60 s after start. A run loops: delete at most 1,000 oldest rows in one transaction and run wal_checkpoint(PASSIVE) while holding the Node's database writer, release the writer, yield 50 ms, and repeat until both limits hold or the 5 s loop budget is spent. The budget uses the monotonic clock and counts batches, checkpoints and yields; the age limit uses wall time. The loop lasts at most 5 s plus one batch. After a run that deleted rows, or while a checkpoint is blocked, the pruner makes one non-waiting wal_checkpoint(TRUNCATE) attempt (busy timeout 0 for that call), which can extend a run past the budget by the time it takes to truncate the log. Cadence: the next run follows after 10 s while the journal is over a limit or the last run failed, and after 60 s otherwise. A blocked checkpoint gets one 10 s retry after the limits hold, then the 60 s cadence resumes. A failure is logged by code and retried after the same 10 s: no hot loop, no backoff. A run that outlasts the budget, its checkpoint included, logs a warning; it is not counted. A checkpoint failure after a committed delete is reported and does not undo the delete. Closing the Node's scope interrupts the fiber and waits for it before the database closes. A SQLite call is synchronous and cannot be interrupted, so shutdown waits for the batch in progress. The yield does not bound CPU by itself: the bound is the loop budget plus the batch and checkpoint time, which stay unmeasured until the activation benchmark lands. The pruner never deletes the row at the largest sequence (2^63 − 1): the counter cannot issue past it, so the journal is never empty at that head. The logical retention limit never refuses or drops an append. While a prune batch or the end-of-run checkpoint holds the write lock, an append can wait (the connection's busy timeout applies, and a lock failure is reported as it is today).

Activation evidence. An opt-in benchmark test in the retention scope (MAIA_NODE_RUN_PRUNE_BENCHMARK=1, or MAIA_NODE_RUN_PRUNE_METRICS=1 pnpm metrics --suite node for the warn-only events.prune.* metrics) prunes a backlog while a separate process appends every 5 ms against the real 1,000 ms busy timeout. It prints one JSON line with, per scenario (a contested journal and a pinned reader): each append's latency from dispatch to commit (the base, because the single-threaded issuer queues behind its own blocked append, which the line reports as dispatch lateness), how late the issuer dispatched against its schedule, the appends that overlapped a batch (and sufficientOverlap, false below 20, which the collector reads as unavailable), failures, the time of each prune batch with its PASSIVE checkpoint, the final TRUNCATE while pinned and after the release timed separately, CPU per batch and per process (the pruner is the test worker while pruning after the issuer's warm-up; the issuer is its whole lifetime; user plus system), event-loop delay, the log backlog and release attempts, the revision and the environment. After the reader ends, the harness probes every 100 ms, up to 5 attempts; the pruner's single 10 s retry is covered by the TestClock tests and the benchmark does not measure it, so the release timing is indicative only. The collector reports every metric but the failure count as unavailable when an append failed, the pinned reader did not hold the log, or the release never cleared. The gate stays off until the printed samples show p99 ≤ 100 ms and max ≤ 250 ms for every window that holds the writer; a passing exit does not count.

Status fields. node.status reports storage.events (optional): excessBytes (logical bytes over maxBytes, 0 within the limit), oldestAgeMs (now minus the timestamp of the lowest retained event; absent for an empty journal, 0 when the clock is behind the row), and checkpointBlocked (true when the last attempt found a reader holding the log, false after one that completed; absent until an attempt ran, so absent while the gate is off). After the storage connection closes, or on a schema without the counters, the block is absent and storage.integrity is unavailable; a status never reports invented zeros.

Empty journal. seq stays unique after a full prune because sqlite_sequence keeps the last value. One rule covers every empty journal: oldest = head + 1. A journal that never held an event reports head = 0, oldest = 1; a fully pruned journal reports head = N (the last seq issued) and oldest = N + 1. A reset cursor is then positioned at N, and the next event is N + 1. events.read, the observation page and snapshot bounds, and the observation validator all follow it; the validator accepts 1 ≤ oldest ≤ head + 1.

Compatibility note (alpha). The observation bounds change for two cases: a journal that never held an event reports (head, oldest) = (0, 1) instead of (0, 0), and a fully pruned journal keeps its head ((N, N + 1)) instead of reporting (0, 0). A client that resumed with a cursor from before a full prune now sees the cursor as expired (gap) when it is below the head, and as valid when it equals the head. Nothing in the first-party clients depends on the old values.

Read transactions. A page read holds its snapshot only for that page (under 100 ms by construction: at most 100 rows). A watch stream holds no transaction between pages. A reader that does hold one blocks the TRUNCATE checkpoint: the pruner then retries once after 10 s and node status reports storage.events.checkpointBlocked.

Disk. maxBytes is a pruning target on logical size, not a bound on the file. auto_vacuum is NONE, so deleted pages are reused by later events and the file stays at its peak: about maxBytes × the measured overhead factor plus state. The hard ceilings are the 512 MiB database cap and the WAL, which stays small only while reads are short and the checkpoint above succeeds; journal_size_limit trims the WAL after a checkpoint and does not cap an active one. Returning space to the filesystem needs a VACUUM, run by an operator command, not by the pruner. An auto_vacuum change would force a one-time VACUUM (exclusive, free space about the database size) and is not part of EV0.

Cursors and replay

A cursor names a position in one Node's journal. On the wire it is ev1. + base64url of { node, epoch, seq (decimal string), filter digest, expiry } + an HMAC-SHA256. The signing key is 32 random bytes in event-cursor.key in the Node's state directory. It is created once, when the file is absent, with O_EXCL|O_NOFOLLOW and mode 0600, so cursors survive a restart. A present file that is unreadable, not 32 bytes, a link, shared or owned by another user fails closed and is never regenerated, because a new key would invalidate every cursor. An explicit rotation (delete the file) invalidates outstanding cursors: they read as invalid_cursor, and the consumer starts again without a cursor. A cursor expires 30 days after it is issued. The node in a cursor and in envelopes is the journal's node_id, a random ID in event_journal_meta that migration 71 creates once and a trigger keeps fixed. It does not depend on the signing key, so a rotation leaves the Node's identity alone; a restored journal keeps it and gets a new epoch. The epoch is the journal's: a restored or rebuilt journal has a new epoch, so a cursor from before is detected even when the new journal has caught up to its seq.

since accepts a cursor, an event id or a seq; the last two are resolved (id through the unique index) and the page returns a cursor for the filter in use. Without since the read starts at the oldest event kept. A cursor made for another filter, or under another key, is invalid_cursor, and so is an id the journal does not hold. A cursor holds a position and a filter, never authority: each read takes the kinds the caller may see from the current request, so a cursor issued to a more privileged caller shows a less privileged one no more than it could read itself.

CallBehavior
events.read { since?, kinds?, subject?, limit? }Events after since, in order: at most 100 per page and 192 KiB serialized, counting the cursor and metadata (under the 256 KiB frame cap; a page always holds one event, and append caps a payload at 64 KiB); one read examines at most 5,000 sequence numbers, so a page can come back empty with next moved forward and the reader keeps reading while pages return events; returns status: "ok" with next, head and oldest (decimal strings).
events.watch { since?, kinds?, subject?, limit? } (the live lane)The same stream after read catches up, on the /api/v1/observations socket: snapshot, event, heartbeat, gap, reset frames (see below). Source candidate.

The CLI reads events

maia events read [--kind <kind>]... [--since <cursor>] [--subject <type>:<id>] [--limit <1..100>] is the events.read operation as a command, locally and with --node; each flag is the parameter of the same name. With --json the result is the operation's response unchanged (status, events, next, head, oldest, or the reset and invalid-cursor results), nested as every Node-backed command nests it: { ok, result: { command: "events.read", result: <response> } }. Every response status exits 0, an empty page and cursor_reset_required included, so a script reads status; a usage error (an unknown --kind, a --subject that is not session-activity:<id>, a bad --limit) exits 64 before any request and an unavailable Node exits 69. Read, handle, then store next only from an ok page. Back off after an empty page, and treat cursor_reset_required and invalid_cursor (which has no next) by dropping the cursor and reading again. Never compare two next values: every read renews the cursor's expiry, so next can change at the same position.

Watch frames. The first message names events.watch and the read filter; limit sizes each page. Each frame carries the stream fields of the other lanes (correlationId, streamId, terminal).

FrameTerminalData
snapshotnohead, oldest: sent once, before the first events.
eventno1 to 100 envelopes and next, the cursor after the last one.
heartbeatnohead, oldest, next: sent after 5 s without a frame; next is the current cursor, and the watcher has caught up when next reaches head.
gapyesreason: pruned: retention removed events after the cursor mid-stream; next is before the oldest kept.
resetyesThe events.read reset (not pruning under a running watcher): reason and next, before the oldest event.

A watcher reads one page, sends it, and reads the next from the cursor it holds: nothing else is buffered, and a commit wakes it instead of a timer. A watcher that sends no frame for 5 s, idle or still scanning a long run of excluded events, sends a heartbeat; the deadline moves only when a frame goes out. A write that fails (a peer that stops reading hits the 5 s deadline) closes the socket without a frame; the client reconnects with the last next it held and loses nothing. gap and reset close with code 4409. An invalid cursor ends with an error frame. Streams count against the observation admission of the socket (8 per Node), which is below the per-principal and per-Node limits in the Limits table.

  • At-least-once. A consumer commits its cursor after it has handled an event; a crash between handling and commit redelivers it. Handlers dedupe on id. Exactly-once is not offered.
  • Order. Per Node, events arrive in seq order, with no reordering across a reconnect. Within a subject, subject.seq has no holes until retention.
  • Reset. A cursor whose seq + 1 is below oldest (retention ran), whose epoch or Node differs (restored journal), whose seq is above head, or that has expired, returns status: "cursor_reset_required" with a reason (position_pruned, epoch_changed, position_ahead, cursor_expired), oldest, head and a server-issued cursor positioned just before oldest (see Empty journal), so resuming from it skips nothing that remains. A seq of exactly oldest - 1 or head is valid. The consumer chooses: resume (accepting the loss) or re-read state. A reset and invalid_cursor are results of the read, not request failures.
  • Filters (kinds, subject) narrow what is returned, never what is counted: the cursor is still the journal position, so a filtered consumer does not skip.
  • Exposure. A read returns only kinds the registry marks public. session.activity.changed, session.status.changed, node.started, node.stopping and admission.refused are the public kinds (IDs, class codes and time only); every other kind is internal. A hookable kind is public, so a webhook consumer sees only events an events.read reader can also see. The read takes its visible kinds as an input, which lets the tests use a public kind.
  • Durability and retention are EV0e: the events.fsync policy, the size and age bounds and the pruner. Until then nothing prunes the journal, so position_pruned cannot occur on a Node that runs only this code.
  • Authorization: events.read is scoped per kind and subject by the registry; an event a caller may not see is skipped, not reported.

Federation

A control Node reads each remote Node's journal over the SSH bridge it already holds for that Node (TM3), with the same events.read/events.watch operations. Remote Nodes know nothing about being observed.

  • Per-Node cursors. A federated cursor is evf1. + a map { node: remote cursor } + an HMAC by the control Node. Each entry is the opaque next cursor that remote issued (with its own epoch); the control Node never builds a remote cursor from a seq. Each subscriber (or webhook, or dashboard) keeps one entry per Node; a Node that is unreachable keeps its entry and resumes from it, or gets that Node's gap/reset when retention outran the outage.
  • No durable mirror (decided, D-188). Remote events are not copied into the control Node's journal; the remote journal stays the single copy. The control Node holds a merge window in memory: at most 256 events and 1 MiB per remote Node, dropped as soon as they are delivered. Replay after a restart fetches from the remote again.
  • Order. Per Node, seq order is exact and is never changed by the merge. Across Nodes the merge emits the smallest (ts, node, seq) among the Nodes' next events, where ts is first made monotone per Node (the merge key is the largest ts that Node has produced so far), so a clock regression never reorders a Node's own events. A silent Node does not hold the merge back beyond a 2-second window (events.federation.skewWindow). An event that arrives after the merge has emitted one with a later key carries late: true on its frame and is otherwise unchanged. Caveat: ts is each Node's wall clock; skew between Nodes makes global order approximate. Causality between Nodes is expressed with the command and subject IDs, never inferred from ts (contracts).
  • Reachability is itself an event, node.unreachable / node.reachable, written by the control Node, so a subscriber learns of an outage in-band.
  • Bridge (EV2 requirement). Today the SSH bridge streams only session.attach, refuses any other operation as stream_unsupported, and closes with the Node's registration. EV2 adds events.watch to the bridge with: stream ownership by the subscription (the bridge stays open while any subscription holds it and closes with the last); a reconnect policy (capped exponential backoff, 1 s to 60 s with jitter, resuming from the per-Node cursor; node.unreachable after 30 s without a connection); and accounting in the bridge's stream cap and the Node's process and memory budget.
  • Each Node pulls only its own remotes. A remote that is also a control Node is not re-exported: no transitive fan-in.

Producer contract

  1. A producer appends with events.append(tx, event) in the same database transaction as the state change it describes (and the command receipt, if any). The event exists exactly when the change does.
  2. The core fills id, seq, ts, validates the payload and returns the subject's new revision. A command's result reports the final aggregate revision after every event it wrote in its transaction, so the next compare-and-set from a printed revision succeeds.
  3. Events state facts that happened. Derived state is computed from rows, never from screen scraping (D-175); terminal.output.matched needs the screen engine (TM2b) as its producer.
  4. No other path writes the journal. Workers call Node operations; those operations append.

Consumer contracts

ConsumerContract
maia watch --jsonOne envelope per line; --since <cursor|id>; --kind, --subject filters; prints gap as a line, then exits non-zero unless --resume-at-oldest.
MCP, remote clientsThe same operations through the SDK; no separate feed.
Outbound webhooks (EV3)The webhooks contract below.
Dashboardevents.read for catch-up, then events.watch; on cursor_reset_required it re-reads state snapshots.

Outbound webhooks (EV3, source candidate)

A webhook is a consumer like any other, with a persisted cursor; it adds no journal. No webhook exists until a person adds one. The decision text is in the pull request that adds webhooks. Code exists and is release-disabled (releaseQualified: false); delivery, retry and status labels stay Source candidate until qualification.

A Node polls the journal about once a second and delivers from its own queue, so a hook event arrives about a second after the journal records it, plus network time. The count of refusals merged by the one-per-minute limit (droppedSinceLast) is kept in memory and restarts at zero with the Node.

Vocabulary

A webhook delivers hook events, a vocabulary derived from authoritative Node facts. Maia relays what it observed and never infers what a harness meant. Each hook event is built by the Node from stored rows into a strict, metadata-only projection; a stored payload is never forwarded.

Hook eventSourceData (all optional fields are omitted when absent)
session.startedsession.runtime.started, session.runtime.resumedresumed
session.workingsession.activity.changed to working
session.idlesession.activity.changed to idle
session.blockedsession.activity.changed to waiting_approvalapprovalId
session.turn.completedturn.completedturnId
session.turn.failedturn.failedturnId, failureClass (a code, never the message)
session.runtime.failedsession.runtime.failedfailureClass
session.endedsession.stopped, session.archivedreason: stopped or archived
session.adoptedsession.created carrying adoptedFromadoptedFrom
admission.refuseda command refused by an admission limit (own transaction, after rollback)scope, reasonClass, droppedSinceLast (refusals the one-per-minute limit merged), Session in context
node.startedthe Node's startpreviousShutdown: clean or unclean
node.stoppinga graceful stop
update.donethe updater's success pathupdateId, to
  • Node down. A crashed Node cannot announce itself. A crash is reported afterwards by node.started with previousShutdown: unclean. Seeing a Node go away while it is still down needs an observer on another Node; that is out of scope here (EV4 node.unreachable covers a control Node).
  • Blocked. A second approval can be queued without an activity change, so approval.requested also produces session.blocked. A hook gets one event per approvalId, whichever of the two reaches it first.
  • Ended. A Session delivers one session.ended per episode: the first stop or archive after it ran. Archiving a stopped Session delivers nothing. Stop, resume, stop delivers two.
  • Not delivered: every other kind, and hook.* events (a failing hook cannot feed itself).

Body (maia.hook.v1)

{
  "schemaVersion": "maia.hook.v1",
  "deliveryId": "delivery_...",
  "sample": false,
  "event": {
    "id": "...",
    "kind": "session.turn.failed",
    "ts": "2026-11-03T10:00:00.000Z",
    "data": { "turnId": "turn_...", "failureClass": "runtime_exit" }
  },
  "context": {
    "node": "...",
    "sessionId": "session_...",
    "projectId": "project_...",
    "labels": { "owner": "...", "goal": "..." }
  }
}
  • event.id is the journal event ID and the idempotency identity. sample is true only for webhooks test, whose kind is webhook.test.
  • context is frozen when the Node ingests the event, not read again at send time (only authority is re-checked then). It holds IDs and the label values the user set. A Session title is never sent: it can come from the first prompt line. A field that is absent is omitted. Every string is control-character-stripped and bounded; the whole body is at most 8 KiB.
  • The schema lives in packages/protocol and decodes strictly. A change adds a new schemaVersion.

Requests and signature

  • Target: HTTPS; plain HTTP only when the connection really is to a loopback address. No credentials in the URL. Redirects are never followed. The response body is discarded; only the status class is recorded.
  • Headers: Content-Type: application/json, Idempotency-Key: <event id>, Maia-Delivery-Id, Maia-Timestamp (unix seconds, ASCII), and Maia-Signature: v1=<lowercase hex>.
  • Signature: HMAC-SHA256 over the ASCII Maia-Timestamp, a ., and the exact UTF-8 body bytes sent. Every attempt signs a fresh timestamp. A receiver verifies the raw bytes (never a re-serialized body), compares in constant time, rejects a timestamp more than 300 seconds in the past or the future, and dedupes on Idempotency-Key. The window does not stop a replay inside it; dedupe does.
  • Secret: the hook names an environment variable of the Node; the Node reads it at send time. The secret is never stored, logged, traced, echoed or put in a receipt, and HTTP tracing is off for these requests. A missing or empty variable fails closed: the attempt is a retry with error class secret_unavailable.
  • Network: the Node resolves the host, validates every answer, and connects only to the approved address (hostname verification and SNI stay on the host name), again on every attempt. Public addresses only by default. Loopback is allowed for plain HTTP. Private and tailnet ranges need --allow-private on the hook, shown in its confirmation. Link-local ranges (169.254.0.0/16, fe80::/10) and cloud metadata ranges are always refused. No proxy settings are read. A deadline (10 s) and cancellation bound each request.

Authorization

All six operations (add, list, test, remove, deliveries, redeliver) need the operator credential policy: a transport-authenticated operator with no Invocation attachment, as executeApprovalRequest enforces for approvals. A hook records the actor that added it. Before every send the Node checks that the profile's current authenticated client is that actor, is not an attached caller and is a client principal. That is the whole check: there is no deeper role lookup and no per-event scope check, because a hook's events are already limited to what its filters name. A failed check sends nothing: the delivery is denied with error class authority_lost, visible in webhooks deliveries, and the queue moves on.

Delivery state machine

 journal event ──filter──▶ skipped
        │ enqueue and cursor advance, one transaction
        ▼
     pending ──authorize──▶ denied (terminal)
        │
        ▼
    in-flight ──2xx──▶ delivered (terminal)
        │
        └─error/timeout─▶ retry(attempts, nextAttemptAt) ──▶ in-flight
                               │ attempts exhausted (10)
                               ▼
                          dead (parked)
 redeliver: dead|delivered|denied ─▶ pending (cursor untouched; the retained
            body is sent again, even after its journal row expired)
  • Cursor. One durable row per hook (per source Node when federated): cursor, and per delivery attempts and nextAttemptAt. Ingestion reads a page of events, enqueues the matching ones and moves the cursor in one transaction, so the cursor never passes an event that is not queued. Attempts and schedule are persisted, so a restart keeps the retry budget.
  • Crash boundaries. A crash after the receiver answered but before the commit delivers that event again (a duplicate, deduped by key). A crash during parking leaves the event retrying. After a restart, persisted in-flight work resumes with the same event ID and the preserved retry budget; duplicates are allowed. No database transaction is held across a request. The send permit is held through the active request and its socket cleanup and released before any backoff.
  • Ordering. A hook has one send in flight at a time, oldest event first. A newer event never passes an older one that waits for a retry. A parked event does not block later ones, so a redelivered older event can arrive after newer ones: redelivery is an explicit exception to order. Ingestion enqueues a page of events and advances the hook's cursor in one transaction.
  • Retry. Exponential backoff with full jitter (base 1 s, cap 5 min, 10 attempts).
  • Bounds. 8 requests in flight in total (tests and redeliveries count), 32 hooks (typed refusal saturated), at most 1,000 waiting rows per hook (a hook at the cap stops reading; its cursor stays and a journal that expires past it leaves a gap marker), a parked list of 100 per hook, and the newest 100 settled (delivered or denied) entries of a hook, none older than 7 days, trimmed on the engine tick so an idle hook ages out too. When the oldest parked entry is dropped for a new one, the hook's droppedParked counter rises; the count appears in webhooks list.
  • Removal interrupts and awaits the hook's running sends before it returns. A send checks that its hook is still active inside the same fence, so nothing starts after the removal commits; a request already transmitted is uncertain and may still reach the receiver. Removing a hook deletes its delivery rows, and the confirmation says so.
  • What is guaranteed. At-least-once delivery with receiver dedupe, and no loss across a restart within the retained journal history. A journal prefix pruned before delivery and a dropped parked entry are reported (a gap marker on the hook, gapOldest and gapResets, and droppedParked), never silent. A cursor cannot make delivery lossless against retention; the journal pruner (EV0e) is still disabled by default.

Operations and CLI

OperationRiskNotes
webhooks.addconsequentialpreview, confirmation, compare-and-set, receipt. Records URL, events, filters, secret name.
webhooks.removeconsequentialsame; names the hook revision
webhooks.listreadhooks with state, droppedParked, last error class
webhooks.deliveriesreadpaged; --failed lists parked and denied
webhooks.testconsequential, external-uncertainsends one signed sample; same mutation flow, bound to the hook revision and destination
webhooks.redeliverconsequential, external-uncertainre-queues one delivery; same mutation flow, bound to the delivery

maia webhooks add (--url <url> --secret-env <NAME> --event <hook-event>... [--label <key=value>...] [--project <project-id>] [--node-id <node-id>] [--allow-private] | --confirm-stdin), webhooks list, webhooks remove <webhook-id> --expected-revision <revision>, webhooks test <webhook-id> --expected-revision <revision>, webhooks deliveries [--webhook <webhook-id>] [--failed] and webhooks redeliver <delivery-id> --expected-revision <revision>. Every change takes --dry-run, --confirm-stdin and --command-id; see the CLI reference. Filters: label.<key> (--label), project and node; the event list is the kind filter. A filter on a field an event does not carry never matches. Debounce and the hook.delivery.* status events are not part of this slice.

Node resource events (EV4, spec only)

KindsReadingDefault trigger
node.cpu.high / node.cpu.recoveredload average per core, and CPU %load above the core count
node.memory.low / node.memory.recoveredMemAvailable share, swap in useavailable below 20%
node.disk.low / node.disk.recovered, node.inode.low / node.inode.recoveredused share per mountabove 85%
node.unreachable / node.reachablethe control Node's bridge to a remote Nodesee Federation
session.memory.highper-Session RSS and CPU from process/cgroup countersper-Session limit from configuration
  • Sampling: one timer in the Node, every 30 s by default (configurable).
  • Hysteresis: each resource has a trigger and a recover threshold, both configurable per Node, the recover threshold on the healthy side of the trigger. A level change emits one event; staying beyond a threshold emits nothing, and a reading must hold for two consecutive samples.
  • Unknown readings: an unavailable reading is unknown; it is never healthy and never emits a recovery. At start every resource is unknown until its first two samples; a start inside a breach emits the high event once, and a start with a healthy reading emits nothing.
  • Attribution: node.*.high and session.memory.high list up to five Session IDs or process IDs by share, only where counters already exist.
  • Snapshot: maia node stats [--node X] --json returns the current readings and states.

Budget and measurement

No new process, thread or port: the core runs in the Node.

ResourceBound
MemoryID generator state, 256-event/1 MiB merge window per remote Node, one page (≤ 100 events, ≤ 192 KiB) per reader. Nothing else is buffered.
DiskPruning target maxBytes (logical); ceilings: the 512 MiB database cap and the WAL (see Retention).
CPUOne indexed insert per event; a pruning run with a 5 s loop budget plus the final checkpoint, every 60 s (10 s while over a limit), in 1,000-row batches.
Debounce≤ 1,024 keys and 256 KiB per hook, so 8 MiB for 32 hooks.
Per consumerOne cursor row; no buffer beyond the page.
Admission8 events.watch streams per principal, 64 per Node; 32 hooks; 32 remote Nodes. Beyond a limit: typed refusal saturated.

pnpm metrics (benchmarks) adds, with EV0: events.append latency and bytes per event in a Session-write transaction (both collected since EV0b, as events.append.p95.ms and events.append.bytes_per_event), events.read page latency from a fresh cursor (collected since EV0c-1, as events.read.p95.ms), the measured bytes per row, and the Node idle resident memory with the journal and pruner running. EV2 adds the same measurement with ten federated Nodes attached. They are counted in the Node's base budget (D-177). Timing metrics warn on a regression and byte metrics fail it, as the benchmark policy sets for each noise class.

Delivery slices

EV0a–EV7 sit in the terminal multiplexer track, before its views. Enterprise keeps E1–E5; these numbers are separate.

SliceDeliverableWaits for
EV0aRegistry, envelope and cursor schemas in packages/protocol; every existing kind registered; the kind-parity testmain
EV0bJournal migration and event_journal_meta; events.append; existing producers write through it (source candidate)EV0a
EV0cEV0c-1: cursor, key, epoch, reset, events.read (source candidate). EV0c-2: events.watch frames (source candidate)EV0b
EV0dRevision and state dependencies moved off event rows (Retention table); only the events module reads the journalEV0b
EV0eRetention, the pruner, events.fsync, row-overhead calibration, B0 metricsEV0c, EV0d
EV1Session activity as session.activity.changed through EV0b's append (EV1a contract; EV1b write path and hooks, source candidate; EV1c public)EV0b, EV0d
EV1dTurn completed, turn failed, runtime failed and inbox acknowledged as public events with the Session ID and class codes only, never textEV1c
EV7Context-window threshold, per-turn usage and provider-quota events (D-173); a quota adapter reads an external snapshot; IDs and numbers onlyEV1c
EV2maia watch --json --since, MCP and remote feeds; the federated read, the bridge stream, the ten-Node measurementEV0c; bridge: TM3
EV3Outbound webhooks: durable cursors, signing, retry, delivery status (D-187, D-203; planned)EV0e
EV4Node resource events with hysteresis, and reachability eventsEV0b; reachability: EV2
EV5Question events for the supervisor role (D-186, planned)EV0c
EV6Terminal lifecycle and gap rows move from terminal_live_events into the coreEV0b; with TM2a

Output-match events (terminal.output.matched) join when TM2b lands.

Imported from Maia OS docs at 3dc3e07