ADR-0024: Event Store Read Position Contract
Status: Accepted
Date: July 2026
Context
BaseEventStore.read(stream_name, position, no_of_messages) is the single read
primitive behind every consumer that pages through the store: the
EventStoreSubscription engine, projections and projectors, the outbox
reconciliation sweep, the observatory timeline, and the protean events CLI. All
of these track their progress by global_position — the store-wide,
monotonically increasing ordinal assigned to every message — and read the next
page from last_global_position + 1.
The position argument, however, meant different things depending on the stream
name and the adapter:
- A specific stream (
category-id, e.g.order-123) is naturally paged by its per-stream ordinal — the version within that one stream — which is what aggregate loading and snapshotting need. - A category (
order) or$allread spans many streams, so the only ordering that is meaningful across them isglobal_position.
The two adapters disagreed. MessageDB keyed category reads on global_position
(get_category_messages filters global_position >= position), but read $all
with a strict, unordered global_position > position LIMIT n — skipping the first
position and returning an arbitrary page. The memory adapter keyed both
category and $all reads on the per-stream ordinal and sorted by it. A
consumer paging by global_position then filtered the per-stream ordinal by a
global value and read the wrong page — most often nothing at all, at zero
apparent lag. This was masked because most reads are full reads from
position = 0, where >= 0 matches everything regardless of the key; it surfaced
on any paginated read (position > 0) — always for a multi-stream category, and
even for a single stream once a subscription advanced its cursor and re-read from
current + 1 (the per-stream ordinal is 0-indexed while global_position is
1-indexed, so the two never line up on a re-read).
Because the common test setup does a single full read, the divergence survived
undetected and made it impossible to reason about — or deterministically test —
any global_position-ordered behavior on the memory adapter.
Decision
We define one read-position contract that every event store adapter must honor:
- A specific-stream read (
stream_namecontains an id, i.e.category-id) is paged by the per-stream position, inclusive ofposition, ordered ascending by that position. - A category read (
stream_nameis a bare category) and an$allread are paged byglobal_position, inclusive ofposition(global_position >= position), ordered ascending byglobal_position.
"Inclusive" is uniform so that consumers page from last_global_position + 1
without per-adapter off-by-one handling. The memory adapter now filters and orders
category/$all reads by global_position; the MessageDB adapter reads $all
with an inclusive, global_position-ordered statement (its category reads already
satisfied the contract).
Consequences
- Subscriptions, projections, and the outbox reconciliation sweep behave the same
on the memory and MessageDB adapters, so the memory adapter is a faithful,
Docker-free test double for
global_position-ordered behavior. This is a prerequisite for gap-safe$allcheckpointing (see #1088). - A subscriber over a multi-stream category — or over
$all— now reads the correct, contiguous, globally-ordered page instead of silently reading nothing. - The outbox reconciliation tail scan (
read("$all", position=tail - limit + 1)) now includes the boundary position it intends to inspect, rather than skipping it. - The memory adapter carries
global_positionas a distinct, monotonic field (it already did); the read path now depends on it, so an adapter that fails to assign a meaningfulglobal_positioncannot satisfy the category/$allcontract. This is the correct constraint: a global order is exactly what these reads require. - No deprecation or compatibility flag is warranted: this corrects a bug (the
memory adapter returned wrong or missing pages; the MessageDB
$allread was exclusive and unordered) rather than changing a supported contract. Theglobal_position-ordered, inclusive semantics were already the de facto behaviour of MessageDB category reads, which every consumer relies on.
Alternatives Considered
- Make
$allexclusive (> position) everywhere and have consumers page fromlast_global_position. Rejected — category reads were already inclusive, and aligning on inclusive keeps one rule for every stream kind and matches how consumers already computelast + 1. - Leave the memory adapter keyed on the per-stream ordinal and forbid
multi-stream categories in tests. Rejected — it entrenches a silent
divergence from the production adapter and blocks deterministic testing of the
behavior that matters most (ordered, gapless category/
$allconsumption).