Reads and ordering
You read one aggregate's events in order, from a version, optionally filtered. That is the whole read surface on the write side. No query language, because Celeriant is not the read database. See when not to use it.
Order is the version, not the clock
Every write lands as a batch, and the server stamps the batch with the aggregate's next aggregate_version: 1, 2, 3, gap-free, assigned in the order the server appends. Each event inside a batch also gets a server-assigned event_seq. A read returns batches in version order, always. Version 5 is version 5 forever, between 4 and 6, which is what lets a projection fold the stream deterministically.
Timestamps play no part in that order. The event timestamp is client-supplied and untrusted; the server timestamp is metadata on the batch, not a tiebreaker. Two writers with drifting clocks still get one unambiguous order: the order the server appended them.
That cuts both ways. An event stamped earlier in wall-clock time but written later sits later in the stream, and it wins any last-write-wins fold. Do not resolve conflicts with "latest timestamp wins": a client with a fast clock silently shadows everyone else. Resolve by stream position.
The offline exception
Stream order assumes every event has a position when you fold it. An offline-first client breaks that: it applies its own events locally before the server has placed them, while peer events arrive with positions. Two clients that were both offline can sync the same events and fold them in different effective orders.
If the projection must converge through that window, a client-timestamp last-write-wins tiebreak in your fold gives every client the same answer. It is your code, not a server behaviour, and it has two hard conditions:
- Trusted writers only. On an open-write aggregate anyone can stamp a far-future timestamp and shadow every later event on that entity. One forged event grief-locks it for everyone.
- Clamp the future, not the past. Cap incoming timestamps at your own clock plus a few minutes of skew before comparing. Leave old timestamps alone: a client back from weeks offline carries honestly old actions, and they should lose to anything done since. The cap turns a forged far-future value from a permanent lock into a brief shadow.
If the trust boundary does not hold, stay on stream position: keep unsent local events to one side and re-apply them on top of incoming peer events until the server confirms their position. You may see a moment of divergence. You will not see a grief-lock.
Either way the stream is untouched. A replay returns version order.
Filters
Filters run on batch metadata first, so a batch that cannot match is skipped without reading its data. Surviving batches are then trimmed to the matching events.
- Version range.
from_aggregate_version(inclusive) is the only required field; an optionalto_aggregate_versioncaps it. Afrompast the tip returns an empty page, not an error. - Event types. An include list, so a projection that cares about three of twenty types does not pay for the rest.
- Writer. Include or exclude one client id, or one user id. Batches are single-writer, so this is a whole-batch filter.
- Client sequence range.
min_client_seq/max_client_seq. The point read that settles an idempotency 2002 uses this with an include-client filter; see the idempotency guide. - Event sequence range.
min_event_seq/max_event_seq. - Time. A server timestamp range or an event timestamp range, both inclusive. The event timestamp is whatever the writer's clock said; the server timestamp is the one to trust.
Paging
A read stops adding batches once the page would pass the server's --max-response-size (default 64 MiB), measured on uncompressed batch size. When it stops early, the response carries next_aggregate_version; pass it back as the next from. When it is absent you have everything. There is no fixed batch count per page (--list-page-size bounds the list APIs, not reads), so follow the cursor until it disappears instead of guessing.
Trimmed history
After a trim, versions below the floor are gone. A read whose from is below the floor fails with error 1000 (UnavailableBatchIndex), and the error carries minimum_available: BatchIndexUnavailableException.MinimumAvailableVersion in .NET and ReadError::UnavailableBatchIndex { minimum_available_version, .. } in Rust. Restart from that version. from = 1 is not "the whole stream" on a trimmed aggregate.
Catching up and following
A projection subscribes first, then reads from its last processed version to catch up, then follows the live tail. Reading first and subscribing second leaves a window nothing covers. See Building a read model.
Which node answers
Reads go to the leader by default. The leader fsyncs a batch but holds it back from readers until replication is acknowledged, by the follower or, in fallback, by S3. A standalone node makes it readable at fsync.
Routing reads to followers (RouteReadsToFollowers in .NET, route_reads_to_followers in Rust) takes load off the leader, at a price: a follower applies a replicated batch but keeps it invisible until the leader's next replication message confirms it committed. Follower reads therefore trail the leader: slightly in steady state, by much more while a follower is catching up. A write you just made can read back as "aggregate does not exist". Opt in only where stale reads are fine. There is no two-phase commit anywhere in this path.
See Reading and replaying a stream for client code.