> ## Documentation Index
> Fetch the complete documentation index at: https://ddd-cqrs-es.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# 0003 per aggregate global feed

# ADR-0003: Global replay feeds are scoped per aggregate type

* Status: Accepted; untyped feed implemented as `RawEventFeed` (0.3.0-rc.7)
* Date: 2026-08-23
* Scope: `src/event_store.rs`, `src/projection.rs`, `src/raw_feed.rs`, all
  store backends

## Context

`EventStore<A>` is generic over one aggregate type, and every backend's
`load_global_after` / `load_global_after_limited` filters the global sequence
by that aggregate type (`WHERE aggregate_type = $1` in the SQL stores; a
per-store key prefix in Redis). "Global" order is therefore global **within
one aggregate type**, not across the whole event log.

Consequences for read models:

* A projection consumes exactly one aggregate's events. A read model that
  spans aggregates (for example orders joined with payments) needs one runner
  and one checkpoint per aggregate type.
* There is no ordering guarantee **across** those feeds. Two runners each see
  their own stream in order, but the read model observes an interleaving that
  depends on polling cadence.
* The typed API is the reason: envelopes deserialize into `A::Event`, so a
  feed mixing aggregate types cannot exist behind `EventStore<A>`.

## Decision

Keep the per-aggregate-type feed as the only replay surface for now, and
document the limitation prominently instead of shipping a cross-aggregate
feed speculatively.

Rationale:

* Most read models in current deployments project one aggregate type; the
  limitation costs nothing there.
* Cross-aggregate read models are still buildable today: run one
  checkpointed projection per aggregate type into the same read-model store,
  and design the read model to be commutative across feeds (idempotent
  upserts keyed by aggregate id, no cross-stream ordering assumptions).
* A proper fix is additive and deserves its own design pass rather than a
  bolt-on: an untyped feed (for example
  `EventEnvelope<serde_json::Value, String>` without the type filter), a
  projection API over raw envelopes, checkpoint semantics for the combined
  sequence, and upcaster integration all need decisions.

## Revisit criteria

Design the untyped feed (working name: `RawEventFeed`) when any of these
appear:

* A read model that genuinely requires cross-aggregate ordering (not just
  co-location) — e.g. a ledger that must observe `OrderPlaced` before the
  matching `PaymentCaptured` even across streams.
* A subscriber that must consume *every* event in commit order (audit trail,
  outbox relay, external bus bridge).
* More than one deployment hand-rolling SQL against the events table to get
  around the filter.

The SQL schema already supports it: the global `sequence` column is assigned
across aggregate types, so an untyped feed is a query change plus new API,
not a migration.

## Addendum: RawEventFeed (implemented)

The untyped feed shipped in the same release cycle as `RawEventFeed` /
`AsyncRawEventFeed` in `src/raw_feed.rs`:

* `RawEventEnvelope = EventEnvelope<serde_json::Value, String>` — payloads
  and metadata as raw JSON, `aggregate_id` as the stored serialized string.
* `load_raw_global_after_limited(sequence, limit)` is the only method:
  bounded batches by design, no unbounded tail.
* Implemented for Postgres, MySQL, and SQLite (the global query without the
  aggregate-type filter), Redis (every event under one key prefix), the
  in-memory store, and the JSON file store. Upcasters apply per event type,
  matching typed loads.
* A raw projection is `Projection<serde_json::Value, String>`;
  `PersistedProjectionRunner::run_raw_batch` (and the async twin) drive it
  with the same once-per-batch checkpoint flush as typed runners.

The typed `load_global_after` keeps its per-aggregate-type scoping; the
sections above remain the rationale for that default.
