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
| Part | Owner |
|---|---|
| Envelope, kind registry, payload schemas, cursor | packages/protocol (the only source of truth) |
Journal, append, retention, read, federation | one Node module, services/node/src/events/; retention in events/retention/ |
events.read / events.watch SDK methods, CLI, MCP | generated 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.
| Field | Type | Rule |
|---|---|---|
id | ULID, event_ prefix | Unique per event; the idempotency key. Monotonic per Node (below). |
seq | integer ≥ 1, decimal string on the wire | Per-Node position. Strictly increasing in commit order, never reused, may skip after retention. A string keeps 64-bit values lossless. |
node | Node ID | The Node whose journal holds the event. Constant per journal, not stored per row. |
source | registered string | The producing component; always the first segment of kind. The registry lists the sources; it is not a closed enum. |
kind | string | <source>.<noun>[.<qualifier>…], 2–5 segments of [a-z][a-z0-9_-]*, e.g. session.activity.changed, node.cpu.high. |
ts | UTC, millisecond ISO | The Node's wall clock at append. Display and global merge only; never used for per-Node order. |
v | integer ≥ 1 | Schema version of this kind's payload. |
subject | { type, id, seq }, seq a decimal string | The exact aggregate the event is about (Session, terminal, Node, hook) and its gap-free seq (today's aggregate_sequence). |
command | receipt command ID, optional | Present when a command's transaction wrote the event; links the receipt to its events. |
payload | JSON object | Validated 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):
kindis at most 128 characters;vis an integer from 1 to 65,535;tsis UTC with exactly three fractional digits;payloadis 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 (publickinds stream to consumers;internalkinds 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, addsv + 1, and the producer writes only the newest version. A consumer decodes eachvit knows strictly and skips, but advances past, an event whosevit 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;classificationbecomes a per-kind registry attribute. - Existing events: every current
domain_eventstype 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.
| Source | Kinds |
|---|---|
session | created, 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 |
turn | queued, dispatching, started, completed, failed, cancelled, interrupted (plus the interrupt phases the interrupt module writes) |
approval | requested, resolved |
remote_node | remote_node.<state>: one kind per registered state, written by the remote registry |
question | opened, answered, cancelled |
interaction | created, answered, cancelled |
connection | created, state.changed, capacity.changed, limit.recorded, login.started |
execution | placement.created, command.prepared/running, file.read.prepared/running, relocation.prepared/reconciled, worktree.checkpoint.prepared/running, worktree.reconciled, worktree.remove.prepared |
hold, port | hold.placed/released; port.lease.acquired/renewed/released |
project | created, registered, renamed, guard.changed, context.recorded |
run, dispatch | run.created, run.ledger.created, run.ledger.task.dispatched/settled, dispatch.settled |
routine | created, paused, resumed, disabled, fire.recorded |
integration | authorization.applied/revoking, authorization.update-policy.set |
team-workspace, workspace | team-workspace.bootstrapped; workspace.selected/execution.configured/membership.attached/repository.attached |
terminal, voice, agent | terminal.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.
| Status | When (first match wins) |
|---|---|
ended | the Session is stopped or stopping |
error | the Session failed, or it is idle and its newest attempt failed, including a start the runtime refused; accepted work supersedes it |
starting | the Session is starting or recovering; this hides every runtime fact below |
question | the earliest live pending prompt is a question |
approval | the earliest live pending prompt is a command, file change or tool request |
unknown | the stored value is not a known status, a waiting Session's approval record cannot be read, or a harness signal this version cannot read |
compacting | the running turn's harness reports it is compacting its context |
rate_limited | the running turn's harness reports a rate limit and retries |
working | a turn is queued, dispatching or running |
done | idle, and the newest attempt completed (a failed, interrupted or outcome-unknown turn is never done) |
idle | none 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.
| Column | Meaning |
|---|---|
global_sequence | seq; INTEGER PRIMARY KEY AUTOINCREMENT, so it is never reused |
event_id | id; TEXT NOT NULL UNIQUE (the index also resolves --since <id>) |
source, event_type | source, kind (event_type keeps its name) |
schema_version | v |
ts_ms | ts as integer milliseconds (recorded_at stays as its text form) |
aggregate_type, aggregate_id, aggregate_sequence | subject, unchanged, unique together |
command_id | now nullable: events with no command (resources, delivery status) have none |
payload_json | payload, 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
| Need | SQLite (domain_events) | Segment files |
|---|---|---|
| Event atomic with the state change and the receipt | Same transaction, nothing to add | Two durable stores: a crash between them loses or invents events |
| Extra fsync per append | None: the event rides the transaction's one WAL commit | One more per append, or batching that breaks atomicity |
| Durability knob | events.fsync = always / interval / never, mapped to PRAGMA synchronous; node-wide, as the DB is shared | Own knob of the same names |
| Crash recovery | WAL recovery on open; torn frames are discarded by checksum | Own scanner, torn-tail truncation, index rebuild |
| Retention | Batched DELETE in seq order, WAL checkpointed after each batch | Drop whole segments (cheaper, coarser) |
| Reads by seq, ID, subject, kind | Indexes | Sparse index to maintain |
| New code and dependencies | A migration and a module | A 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:
| Policy | SQLite | A process crash | A power loss or OS crash |
|---|---|---|---|
always | synchronous=FULL | No committed event lost | No committed event lost; no corruption |
interval | synchronous=NORMAL | No committed event lost | The newest commits since the last checkpoint (WAL sync) may be lost; no corruption, no reorder |
never | synchronous=OFF | No committed event lost | Recent 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.
| Setting | Default | Notes |
|---|---|---|
events.retention.maxAge | 14 days | Rows older than this are pruned. |
events.retention.maxBytes | 64 MiB | Logical 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:
| Reader | Reads | EV0d moves it to |
|---|---|---|
Receipt lookup (findReceipt, the interrupt receipt select) | revision at command_receipts.event_cursor | a 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 revision | a durable turn_interrupts row: phase and revision (done, EV0d-2, migration 73) |
Team Workspace bootstrap ownership (team-workspace/store.ts) | count of bootstrap events | the receipt count and the aggregate revision alone (done, EV0d-1) |
Session attention (attention-sql.ts) | a session.stopped event after the turn | the Session row's stop_revision (done, EV0d-2, migration 73) |
| Session activity (EV1) | last session.activity.changed event | the 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.
| Call | Behavior |
|---|---|
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).
| Frame | Terminal | Data |
|---|---|---|
snapshot | no | head, oldest: sent once, before the first events. |
event | no | 1 to 100 envelopes and next, the cursor after the last one. |
heartbeat | no | head, oldest, next: sent after 5 s without a frame; next is the current cursor, and the watcher has caught up when next reaches head. |
gap | yes | reason: pruned: retention removed events after the cursor mid-stream; next is before the oldest kept. |
reset | yes | The 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
seqorder, with no reordering across a reconnect. Within asubject,subject.seqhas no holes until retention. - Reset. A cursor whose
seq + 1is belowoldest(retention ran), whose epoch or Node differs (restored journal), whoseseqis abovehead, or that has expired, returnsstatus: "cursor_reset_required"with areason(position_pruned,epoch_changed,position_ahead,cursor_expired),oldest,headand a server-issued cursor positioned just beforeoldest(see Empty journal), so resuming from it skips nothing that remains. Aseqof exactlyoldest - 1orheadis valid. The consumer chooses: resume (accepting the loss) or re-read state. A reset andinvalid_cursorare 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.stoppingandadmission.refusedare thepublickinds (IDs, class codes and time only); every other kind isinternal. A hookable kind is public, so a webhook consumer sees only events anevents.readreader 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.fsyncpolicy, the size and age bounds and the pruner. Until then nothing prunes the journal, soposition_prunedcannot occur on a Node that runs only this code. - Authorization:
events.readis 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 opaquenextcursor that remote issued (with its own epoch); the control Node never builds a remote cursor from aseq. 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,
seqorder is exact and is never changed by the merge. Across Nodes the merge emits the smallest(ts, node, seq)among the Nodes' next events, wheretsis first made monotone per Node (the merge key is the largesttsthat 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 carrieslate: trueon its frame and is otherwise unchanged. Caveat:tsis each Node's wall clock; skew between Nodes makes global order approximate. Causality between Nodes is expressed with thecommandand subject IDs, never inferred fromts(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 asstream_unsupported, and closes with the Node's registration. EV2 addsevents.watchto 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.unreachableafter 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
- 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. - 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. - Events state facts that happened. Derived state is computed from rows,
never from screen scraping (D-175);
terminal.output.matchedneeds the screen engine (TM2b) as its producer. - No other path writes the journal. Workers call Node operations; those operations append.
Consumer contracts
| Consumer | Contract |
|---|---|
maia watch --json | One envelope per line; --since <cursor|id>; --kind, --subject filters; prints gap as a line, then exits non-zero unless --resume-at-oldest. |
| MCP, remote clients | The same operations through the SDK; no separate feed. |
| Outbound webhooks (EV3) | The webhooks contract below. |
| Dashboard | events.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 event | Source | Data (all optional fields are omitted when absent) |
|---|---|---|
session.started | session.runtime.started, session.runtime.resumed | resumed |
session.working | session.activity.changed to working | |
session.idle | session.activity.changed to idle | |
session.blocked | session.activity.changed to waiting_approval | approvalId |
session.turn.completed | turn.completed | turnId |
session.turn.failed | turn.failed | turnId, failureClass (a code, never the message) |
session.runtime.failed | session.runtime.failed | failureClass |
session.ended | session.stopped, session.archived | reason: stopped or archived |
session.adopted | session.created carrying adoptedFrom | adoptedFrom |
admission.refused | a command refused by an admission limit (own transaction, after rollback) | scope, reasonClass, droppedSinceLast (refusals the one-per-minute limit merged), Session in context |
node.started | the Node's start | previousShutdown: clean or unclean |
node.stopping | a graceful stop | |
update.done | the updater's success path | updateId, to |
- Node down. A crashed Node cannot announce itself. A crash is reported
afterwards by
node.startedwithpreviousShutdown: unclean. Seeing a Node go away while it is still down needs an observer on another Node; that is out of scope here (EV4node.unreachablecovers a control Node). - Blocked. A second approval can be queued without an activity change, so
approval.requestedalso producessession.blocked. A hook gets one event perapprovalId, whichever of the two reaches it first. - Ended. A Session delivers one
session.endedper 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.idis the journal event ID and the idempotency identity.sampleistrueonly forwebhooks test, whose kind iswebhook.test.contextis 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/protocoland decodes strictly. A change adds a newschemaVersion.
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), andMaia-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 onIdempotency-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-privateon 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
attemptsandnextAttemptAt. 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'sdroppedParkedcounter rises; the count appears inwebhooks 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,
gapOldestandgapResets, anddroppedParked), never silent. A cursor cannot make delivery lossless against retention; the journal pruner (EV0e) is still disabled by default.
Operations and CLI
| Operation | Risk | Notes |
|---|---|---|
webhooks.add | consequential | preview, confirmation, compare-and-set, receipt. Records URL, events, filters, secret name. |
webhooks.remove | consequential | same; names the hook revision |
webhooks.list | read | hooks with state, droppedParked, last error class |
webhooks.deliveries | read | paged; --failed lists parked and denied |
webhooks.test | consequential, external-uncertain | sends one signed sample; same mutation flow, bound to the hook revision and destination |
webhooks.redeliver | consequential, external-uncertain | re-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)
| Kinds | Reading | Default trigger |
|---|---|---|
node.cpu.high / node.cpu.recovered | load average per core, and CPU % | load above the core count |
node.memory.low / node.memory.recovered | MemAvailable share, swap in use | available below 20% |
node.disk.low / node.disk.recovered, node.inode.low / node.inode.recovered | used share per mount | above 85% |
node.unreachable / node.reachable | the control Node's bridge to a remote Node | see Federation |
session.memory.high | per-Session RSS and CPU from process/cgroup counters | per-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 isunknownuntil 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.*.highandsession.memory.highlist up to five Session IDs or process IDs by share, only where counters already exist. - Snapshot:
maia node stats [--node X] --jsonreturns the current readings and states.
Budget and measurement
No new process, thread or port: the core runs in the Node.
| Resource | Bound |
|---|---|
| Memory | ID generator state, 256-event/1 MiB merge window per remote Node, one page (≤ 100 events, ≤ 192 KiB) per reader. Nothing else is buffered. |
| Disk | Pruning target maxBytes (logical); ceilings: the 512 MiB database cap and the WAL (see Retention). |
| CPU | One 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 consumer | One cursor row; no buffer beyond the page. |
| Admission | 8 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.
| Slice | Deliverable | Waits for |
|---|---|---|
| EV0a | Registry, envelope and cursor schemas in packages/protocol; every existing kind registered; the kind-parity test | main |
| EV0b | Journal migration and event_journal_meta; events.append; existing producers write through it (source candidate) | EV0a |
| EV0c | EV0c-1: cursor, key, epoch, reset, events.read (source candidate). EV0c-2: events.watch frames (source candidate) | EV0b |
| EV0d | Revision and state dependencies moved off event rows (Retention table); only the events module reads the journal | EV0b |
| EV0e | Retention, the pruner, events.fsync, row-overhead calibration, B0 metrics | EV0c, EV0d |
| EV1 | Session activity as session.activity.changed through EV0b's append (EV1a contract; EV1b write path and hooks, source candidate; EV1c public) | EV0b, EV0d |
| EV1d | Turn completed, turn failed, runtime failed and inbox acknowledged as public events with the Session ID and class codes only, never text | EV1c |
| EV7 | Context-window threshold, per-turn usage and provider-quota events (D-173); a quota adapter reads an external snapshot; IDs and numbers only | EV1c |
| EV2 | maia watch --json --since, MCP and remote feeds; the federated read, the bridge stream, the ten-Node measurement | EV0c; bridge: TM3 |
| EV3 | Outbound webhooks: durable cursors, signing, retry, delivery status (D-187, D-203; planned) | EV0e |
| EV4 | Node resource events with hysteresis, and reachability events | EV0b; reachability: EV2 |
| EV5 | Question events for the supervisor role (D-186, planned) | EV0c |
| EV6 | Terminal lifecycle and gap rows move from terminal_live_events into the core | EV0b; with TM2a |
Output-match events (terminal.output.matched) join when TM2b lands.
Imported from Maia OS docs at 3dc3e07