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:

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:

{"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

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.