Changelog and watch
Every committed write appends one row to the changelog, in the same transaction as the write itself. It is append-only, per tenant, and there is only one of it: the change feed, the audit trail, and the input stream for anything that learns are the same log. Sequence numbers are monotonic, so any consumer can resume from a cursor, and identical re-writes append nothing, so a re-sync leaves no wake.
One row, as the wire carries it, for the task created on the API page:
{"seq": 4190,
"ts": "2026-08-04T10:00:00.183742Z",
"actor": "owner",
"op": "put",
"entityId": "kq3v9x2m41pf",
"type": "task.tasks.teild.dev",
"payload": {"created": true, "properties": ["title", "dueAt"]}}
actor names who wrote (the owner, an integration's actor, the engine
itself), op is the mutation that made the row (put, patch, delete,
link, unlink, merge, split, plus the engine's own housekeeping), and
payload names what changed rather than repeating the values: created on
first write, the list of accepted properties, and the resulting state values
when a transition moved one. The entity itself is one read away.
Two guarantees consumers may lean on:
- Sequence order is commit-visibility order: no reader ever sees row N with N-1 still uncommitted, so resuming from a remembered
seqmisses nothing. Gapless resume is a guarantee, not a convention. - Causality terminates. Every delivery-authored row records the seq that caused it, always a strictly smaller number, so a causal chain is finite by construction and the engine parks a chain that runs deeper than its cap rather than spinning. The link is stored, not published: a change on the wire carries
seq,ts,actor,op,entityId,type, andpayload, and nothing else.
Watching
Any collection, and the changelog itself, streams with ?watch=1:
newline-delimited JSON (application/x-ndjson), opened with a bookmark you can
resume from, so a consumer that disconnects misses nothing:
GET /api/v1/core.teild.dev/changes?from=0&watch=1
{"bookmark": 4189}
{"seq": 4190, "op": "put", "type": "task.tasks.teild.dev",
"entityId": "kq3v9x2m41pf", "actor": "owner"}
The same endpoint pages backward through history with before=, and filters the
same way in watch and history modes alike. Every filter parameter is plural and
takes a repeated parameter or a comma-separated list: types, ops, actors,
and their negations excludeTypes, excludeOps, excludeActors. q matches
free text. Scoping the feed to one entity takes both entityId and
entityType, because an id alone names no entity; either one without the other
is a bad_request.
History returns one JSON body ({"changes": […], "cursor": <seq>})
newest-first; cursor is the seq the walk resumes below, the oldest seq the
page consumed, which you pass as the next before (omitted when the walk is
exhausted). It sits at or below the oldest row shown, because a scoped token's
unreadable rows are consumed and dropped rather than skipped over. The
changelog watch resumes from a transparent from={seq}. (Note
the continuation rule from the API: the changelog uses
transparent from and before seqs, because a seq is a real ordinal; opaque
after cursors are for list pagination.)
This is the other half of the list→watch handoff: a list response carries the
changelog head seq at its snapshot, so paging a collection and then opening
watch?from={head} misses nothing and double-sees nothing.
Frames and the horizon
The ndjson framing is pinned so a client can parse a stream unambiguously:
- A line with a
seqis a change row. - A line without a
seqis a control frame, keyed by its single key. The opening{"bookmark": N}is one such frame, and the idle heartbeat{}is another. The stream may also end with a terminal error frame carrying the one problem object, so a mid-stream failure is legible rather than a dropped connection:
{"error": {"code": "internal", "message": "…"}}
The changelog has a horizon: the oldest seq still resumable. Requesting
from= a seq below the horizon is a compacted error (HTTP 410), so a
consumer that has fallen too far behind is told plainly instead of silently
missing rows, and its handler is one it MUST have: re-list, then resume the
watch from the fresh head. The horizon is reported in
API discovery, and it is 0 today,
meaning full history is available and no compaction has run. Retention is a
deployment policy, not a wire guarantee: the wire promises gapless resume
from any seq at or above the horizon, and the horizon is where policy lives.
Who consumes it
- Triggers ride it: every entity-sourced trigger owns a cursor, and each row is checked against every enabled trigger. The changes feed reports each trigger's stance on each row (Functions).
- Watchers: the stream above, and
ssctl watchis that stream in a terminal (ssctl). Integrations reconcile from it. - The console's stream page is the same feed, paged backward through history and filtered (web console).
The to-do list, its GitHub feed, and anything you build next are all consumers of one log.
Next: functions, the callables and triggers that react to this feed.