bd list for that means diffing whole snapshots
and still missing anything that changed twice between two reads.
The events journal answers it. Every committed issue mutation writes one
ordered record, in the same transaction as the mutation itself, and a consumer
reads those records with a cursor it can resume from. The spirit is a binlog,
not a notification: a record states a change that has already committed, it
carries the resulting state, and it stays readable until you prune it.
The journal is off by default, local to one clone, and bounded by default
once it is on: beads keeps the retention floors below and prunes past them
without being asked. Everything below assumes you turned it on for a consumer
that is actually reading it.
Which of the three event systems do you want?
Beads has three things called events, and they answer different questions.Turning it on
The floors bound both prunes: the automatic one and
bd events prune. See
Retention and pruning.
All four are startup settings kept in config.yaml rather than database config,
and each has an environment equivalent: BD_EVENTS_JOURNAL,
BD_EVENTS_JOURNAL_RETAIN_DAYS, BD_EVENTS_JOURNAL_RETAIN_ROWS,
BD_EVENTS_JOURNAL_AUTO_PRUNE.
Reading it
seq it has durably
processed, and passes that as the next --since.
Over HTTP
A consumer that already talks to a workspace overbd serve reads the same
journal at GET /v0/beads/events instead of shelling out:
records are the records above — the same fields, the same encoding, the
same record contract — so an HTTP mirror and a
bd events export on the same workspace can be reconciled directly.
sinceis required, and it is the same checkpoint--sincetakes. A missing or negative value is a400, never a read from the beginning.limitruns from 1 to 10000 and defaults to 1000. There is no unlimited read here:limit=0is refused rather than meaning “everything” as it does onGET /v0/beads/issues.headis the highest sequence number ever assigned, so a consumer knows whether to keep reading or back off. When the last record’sseqequalsheadyou are caught up — a full page proves nothing on its own.- The journal is read-only over HTTP. Pruning stays a workspace decision made
with
bd events pruneand the retention floors.
410 Gone carrying the same
events_journal_truncated code and the same since / floor / head window
the CLI reports — with the same ways
forward. And a workspace whose journal is off answers 409 with
events_journal_disabled rather than an empty page, because a disabled journal
and an empty one look identical in the data and a consumer given the empty page
would poll a workspace that will never produce a record. That 409 is workspace
state, not a missing feature: events.list appears in /v0/beads/context’s
capabilities on every build, so treat the capability as “this server speaks it”
and the 409 as “not on this workspace”. (A workspace that has enabled the
journal on a storage backend with no journal support never gets this far —
bd serve refuses to start, the same refusal opening that workspace already
gives.)
The journal is per replica, which matters more over HTTP than on the command
line: a checkpoint is meaningful only against the server URL that issued it.
Track one per server, and re-baseline rather than carry one across.
Streaming instead of polling
GET /v0/beads/events:watch is the same journal, pushed. It answers
text/event-stream and holds the connection open, emitting each mutation as it
commits — the HTTP form of bd events tail --follow:
data is one line carrying exactly the record the paged read
returns, and id is that record’s seq — the same number since takes. The
comment lines are heartbeats, sent every 20 seconds of silence so idle
connections survive intermediaries that drop them; clients ignore comments
automatically.
Watch or poll? Poll unless the delay is the point. A poller holds nothing
between requests and can never be refused for capacity; a stream costs a
connection for as long as it is open, and this server holds at most 48 of
them before answering 503 with events_watch_saturated and pointing you back
at GET /v0/beads/events. That cap sits deliberately below the server’s
64-connection limit, so a workspace saturated with streams still has room to
answer polls, mutations and health checks — and room to deliver the 503
itself. Stream when something is waiting on the mutation — a live mirror, an
agent watching a gate — and poll for anything that can afford an interval.
Neither is faster at draining a backlog.
Delivery is at-least-once against your own checkpoint. Within a single
stream each record is sent once, in seq order, with no gaps. Duplicates are
possible only across a reconnect — if you resume from an id whose records you
had already applied — so make your consumer idempotent on seq and advance
your checkpoint only after your own write lands, exactly as when polling.
Reconnecting is the normal case, and it is free. When a stream drops,
reconnect with the standard Last-Event-ID header carrying the last seq you
processed; it overrides since. That is what makes a browser’s
EventSource correct with no extra code — it re-sends the original URL, whose
since is as old as your process:
since is still required on every connect — the header is absent on the first
one — and a Last-Event-ID that is not a sequence number is a 400 rather than
a silent fall back to since, which would start the stream somewhere you did
not ask for.
Every refusal happens before the stream opens. The 409 and the 410 above
are the same responses on this route as on the paged read, decided before the
first byte, so a client that got its 200 knows its checkpoint was servable.
The exposure warning above applies identically — a stream is the same history
over the same address and under the same shared credential, held open — and so
does the capability
rule: events.watch appears in /v0/beads/context’s capabilities on every
build, and says nothing about whether this workspace has a journal.
The record contract
Six operations are the public vocabulary — the only kinds a downstream event
feed built on the journal may carry:
An id rename has no operation of its own. It replays as the operations a
consumer can apply without understanding identity changes: the old edges
removed, the old id deleted, the new id created, the edges re-added under it.
A seventh operation,
comment, is journaled too. Its comment member is the
replayable payload, and comment.source is a closed two-value set:
structured for a comment someone wrote, audit for a comment recorded as an
audit-trail entry.
comment record rather than faulting on it — the write is already visible in
the issue snapshot beside it.
The issue snapshot
issue is the bead’s own row plus its labels plus is_blocked — the persisted
readiness projection, included so a dependency change replays without
recomputing the graph. It never inlines dependencies or comments; those arrive
as their own dep_* and comment records.
It is null on a delete, and on a dependency record whose source bead was
itself removed by the same cascade.
Dependency records are not symmetric
Adep member names the edge that changed:
dep_add is recorded for every accepted add, including an
idempotent same-type re-add that only refreshes the edge’s metadata. The audit
history deduplicates that case and writes nothing; the journal does not. Treat
dep_add as an upsert of the edge, never as proof the edge is new. A
dep_remove naming an edge that is already gone records nothing at all.
In payload. dep.metadata differs in provenance between the two. On a
dep_add it is the value being written, exactly as the caller supplied it; on
a dep_remove it is the stored column read back just before the delete. The
two can differ byte for byte while meaning the same thing, so compare parsed
values rather than strings.
Resuming, and the truncation error
If--since falls below the oldest retained record — the prefix you asked for
was pruned — the read fails. It does not skip ahead to the surviving suffix,
and it does not return an empty success, because both are silent record loss
and indistinguishable from “nothing new” at the cursor.
schema_version envelope. floor is the oldest
sequence number still retained, head the highest ever assigned. Two ways
forward, and the engine takes neither on your behalf:
- Accept the gap — resume from
floor - 1(bd events tail --since 40here) and carry on knowing records 13..40 are lost. - Re-baseline — rebuild your copy from the workspace’s current state, then
follow again from
head. Re-reading a few records is harmless: every record carries the full post-mutation snapshot, so applying one twice is the same as applying it once.
bd events export refuses the same way rather than present a pruned journal’s
surviving suffix as a complete history.
Mid---follow, the same failure arrives as one line of JSON on the stream it
interrupts — same code, same since / floor / head, same exit status,
compact and without the envelope, because the consumer on the other end is a
line reader:
since reporting the last sequence number the read could serve contiguously
from your checkpoint. Nothing bd does produces such a hole — pruning only ever
removes a prefix — but a restored, hand-edited, or half-copied journal table
can, and a consumer must never be handed one silently.
That case has a third way forward, and it is worth taking before the other
two: everything between your checkpoint and the reported since is intact and
servable, and the refusal did not hand it over. Drain it explicitly by asking
for exactly that span — the same --since you already passed, with --limit
set to response.since - your since — which stops the batch at the hole and
succeeds:
floor - 1 loses records you could have had.
Retention and pruning
The journal is bounded automatically. After a mutating command commits — and on a timer insidebd serve — beads deletes the prefix the two retention floors do
not protect, so an enabled journal settles at “the last 7 days, or the newest
100 000 records, whichever is larger” instead of growing forever.
--before and can only ever reduce what a prune
removes. Every prune — automatic or asked for — resolves --before and both
floors into a single bound and deletes the prefix below it, so a prune can never
leave a hole above a protected record. Automatic pruning is that same
computation with --before set past the head: delete everything the floors do
not protect, and nothing else.
bd events prune therefore cuts earlier, never deeper: it removes a
consumed span before the floors would have, and shrinking the retained window
itself means lowering the floors.
Maintenance never fails a command. A pass that cannot run is logged and skipped,
it is capped at a few batches per invocation so a long backlog drains over
several commands rather than stalling one, and a throttle keeps it to about one
pass an hour per workspace — or sooner after a large burst of writes.
A pass maintains the workspace whose command triggered it. A routed write
(
bd create --repo ../other) records into the target’s journal, but the
retention pass runs against the workspace you ran the command in. A workspace
that is only ever written remotely relies on commands run in it — or on its own
bd serve — to stay bounded; if nothing ever runs there, prune it on a schedule
of your own.
Pruned history cannot be recovered from the workspace; the journal is the only
local copy. Pruning frees rows, not disk: pair it with dolt gc to actually
reclaim the space, since these are working-set tables that ordinary Dolt commits
never collect.
What the journal does not cover
Each of these is a deliberate boundary, and each one matters to a consumer that assumes otherwise.-
One replica, one sequence space. Each clone counts from its own first
mutation. A checkpoint taken against one replica is meaningless against
another — the same
seqnames a different record, and aseqabove the other replica’s head reads as “caught up” and stalls forever. A fresh clone starts empty, withseqrestarting at 1. Track a checkpoint per replica and re-baseline rather than carry one across. The same reset happens in place if the journal tables are dropped and recreated, or ifbd_events_seqis lost or restored from a backup:seqbegins again at 1, so a parked consumer’s checkpoint now sits above the head and reads as “caught up” forever. No error is raised, because nothing about the new journal looks pruned. Re-baseline any consumer after either. - One branch. The journal records the mutations committed on the writer’s active branch. Records arrive by direct write, not by merge, so read on the same branch the writer commits to; a branch checkout or merge carries no records across.
-
Sync is not journaled.
bd dolt pull, and the changes a merge settles into this clone, arrived as data — nothing here wrote them through the mutation path. A consumer mirroring a synced workspace re-baselines after a sync. -
Raw SQL is not journaled. DML run through
bd sqlbypasses the write paths that record, and is a known non-coverage. - Store-open writes are not journaled. Schema migrations and the version reconciliation that runs before the workspace’s configuration reaches the store touch schema and clone-local metadata, never a bead, so a replaying consumer has nothing to apply them to.
-
Compaction and restore are not journaled.
bd admin compactrewrites a bead’s text outside the operation vocabulary, andbd restore --applyputs it back the same way. A consumer’s mirror of a compacted bead goes stale and stays stale until that bead’s next journaled mutation delivers a fresh snapshot.
How it sits in Dolt
The journal lives in two tables,bd_events_journal and bd_events_seq, both
registered in dolt_ignore. They are working-set state: never versioned, never
staged into a commit, never pushed, pulled, or federated — and, like untracked
files in git, they survive a dolt reset --hard.
That is what buys the journal its properties. Versioning these tables would put
a row per mutation into Dolt’s history, and a per-clone
sequence counter on a replicated table would conflict on every merge. The price
is locality: the journal describes what this clone mutated, which is exactly
what the boundaries above spell out.