Skip to content

Architecture Decision Records

This section holds the Architecture Decision Records (ADRs) for eventsource — short documents that each capture one significant design choice, the situation that forced it, and what the project has agreed to live with as a result.

An ADR is not a tutorial and not a reference page. It will not teach you how to append an event or list the arguments to EventStore.append_events; the guides and API reference do that. An ADR answers the other kind of question — the one that starts with "why is it like this?" Why is every store and bus interface a coroutine function rather than offering a synchronous twin. Why DomainEvent is a frozen pydantic model instead of a dataclass. Why protocols.py mixes Python Protocol definitions with abstract base classes rather than picking one. These are the decisions that are cheap to make once and expensive to reverse, so they are written down with their reasoning intact.

The records are deliberately backward-looking. Each one describes a decision that has already been made and is already reflected in the code — ADR-0001: Async-First Design, for instance, documents a commitment you can verify by reading any interface in src/eventsource/stores/ or src/eventsource/bus/. When a decision changes, the old ADR is not edited into agreement with the new world; it is marked Superseded and a new record takes its place, so the history of the design stays legible.

Read them when you want the reasoning behind a constraint you have run into, when you are weighing a change that would cut across the library's shape, or when you are new to the codebase and want the design in narrative form rather than as a pile of modules. The rest of this page explains how to read an individual record, what the status values mean, indexes the decisions currently on file, notes some cross-cutting choices that have not yet been written up as ADRs, and describes how to add one of your own.

Why this library keeps ADRs

eventsource is a library, not an application, and that changes what a design decision costs. An application can quietly rewrite an internal interface between releases; nobody outside the repository notices. Here, the shape of EventStore, the frozen-ness of DomainEvent, and the expected_version argument on append are part of the contract other people's code is written against. CLAUDE.md states the practical consequence directly — the migrations/ SQL files are append-only by design, and the public exports in the top-level __init__.py are not to be changed without weighing backward compatibility. Decisions with that blast radius deserve a written record of why they were made, not just a diff that made them.

The second reason is that the library's most consequential choices are the ones least visible in the code. You can read src/eventsource/stores/interface.py and see that every method is async def; you cannot read it and learn that a synchronous twin hierarchy was considered and rejected, or that SyncEventStoreAdapter exists as a deliberate edge adapter rather than as the first half of a second API. Nothing in protocols.py explains why EventHandler is a Protocol while EventSubscriber is an ABC — the file shows the outcome, and the outcome alone reads like inconsistency. An ADR carries the part the source cannot: the alternatives that were on the table, and the reason one won.

Third, these decisions interlock, and a change to one propagates. Async-first is what makes the subscription runners' cancellation and timeout story coherent; frozen pydantic events are what make EventSourceJSONEncoder's wire format safe to rely on; the optimistic-locking contract is what the @handles-driven aggregates assume when they replay. Someone proposing to relax one of these needs to see what else is leaning on it. Reading six ADRs is a faster way to find that out than reading forty modules and inferring it.

Finally, ADRs are what keeps the reasoning from decaying into folklore as the project changes hands — between contributors, and between sessions. A rejected alternative that is not written down gets re-proposed, re-argued, and sometimes re-litigated to a different answer for no better reason than that the original argument was forgotten. Recording the decision once, with its context and its consequences, is cheaper than having the discussion twice.

This is deliberately a small set of records. Not every choice in the codebase is an ADR — most are ordinary implementation decisions that a code review settles and a future maintainer can safely reverse. A decision earns a record when reversing it would ripple across module boundaries or break downstream users: the kind of choice you make once, live with for a long time, and want to be able to explain years later.

How to read an ADR here

Every record in docs/adrs/ opens with a numbered title — # ADR-0001: Async-First Design, # 0007. Snapshot Strategy Pattern, # 7. Optional Dependency Extras and the Core/Backend Split. The numbering has drifted in its formatting over time and the prefix style varies; treat the number as an identifier, not as a date or a priority. What is consistent is that the title names one decision, not one subsystem, and the first few paragraphs under it state that decision in plain terms before any structure begins. If you read nothing else in a record, read the opening — most ADRs here lead with a summary that tells you what the code does today and what the rest of the document is going to justify.

After the opening comes Status, either as a bold field directly under the title (**Status:** Accepted) or as its own ## Status section. In this project that section does more work than the single word suggests. It usually names the source files the decision lives in, so you can go verify it: ADR-0023 on advisory locks points at PostgreSQLLockManager, LockInfo, migration_lock_key, and the exceptions exported from src/eventsource/locks/__init__.py; the event-bus record points at the EventBus ABC in src/eventsource/bus/interface.py and the four adapters beside it. It often names the tests that pin the behaviour too — tests/unit/bus/test_eventbus_tracing_patterns.py is cited as the compliance check for the bus contract. And it states the supersession relationships explicitly (**Supersedes:** nothing. **Superseded by:** nothing.). When you want to know whether a record still describes reality, the Status section is the fastest place to find the file you can open to check.

Most records are also, by their own admission, retroactive. ADR-0023 says so outright: "Accepted — and retroactive. This record describes a decision that is already in the code and shipped… Nothing here is a proposal; the ADR exists to explain choices that were made incrementally and never written down." That framing matters for how you read the Context section. It is not the minutes of a meeting held before the code was written; it is a reconstruction of the forces that were actually in play, assembled afterwards by someone reading the result. The reasoning is honest but the chronology is not a record of deliberation, and you should not expect a decision date on every file.

The body then works through a familiar sequence: Context (the situation and the forces — often broken into ### subsections like "Forces at Play" or a bulleted list of hard requirements), Decision (what was chosen, usually one ### per moving part), sometimes Rationale as a separate section where the argument is long enough to warrant it, Consequences (what the project now has to live with, positive and negative, frequently labelled as such), Alternatives Considered (the options that lost, and why), and References. Not every record carries every heading — the tenant-isolation ADR splits out Security Consequences and Adoption Guidance instead of a generic Consequences section, and several records add a section for how the decision interacts with a neighbouring one. Treat the template as a shape rather than a checklist.

Two habits will get you the most out of these documents. First, read Consequences before Decision if what you actually want to know is whether a constraint you have hit is intentional. The consequences sections here are unusually candid — the snapshot-strategy record lists "silent failure means snapshot loss is only visible in logs/metrics" and "background mode has no backpressure bound on _pending_tasks" as negatives it accepted, and the optional-dependency record documents that asyncpg has no *_AVAILABLE guard as a known inconsistency rather than quietly fixing it. If the behaviour that surprised you is listed there, it is a trade-off, not a bug. Second, read Alternatives Considered before proposing a change. It is where the argument you are about to make has most likely already been had, and where you will find what the current design was chosen against.

Be aware that the set is uneven. Several records are complete — the event-bus, snapshot-strategy, tenant-isolation, live-migration, event-type-derivation, and multi-instance-coordination ADRs all run through Decision, Consequences, and Alternatives. Others currently stop partway: 0001-async-first-design.md, 0013-handler-registry-composition.md, 0016-optional-tracing-no-op-by-default.md, and 0023-postgresql-advisory-locks.md have their opening summary and Context written but not yet their full Decision and Consequences sections. Their summaries are accurate about what the code does — they are simply not finished as arguments. Length is not a quality signal either way: the optional-dependency-extras record makes its whole case in a handful of dense paragraphs, while the live-migration record needs several hundred lines because the cutover protocol genuinely has that many moving parts.

Finally, read a record as a snapshot of a decision, not as documentation of the current API. The ADR tells you why DomainEvent is frozen; src/eventsource/events/ and the API reference tell you what its fields are today. Where the two disagree, the code is right and the ADR is either stale or superseded — and if you find such a gap, the fix is a new record or a status change, not a quiet edit that erases what was originally decided.

ADR status values (Proposed / Accepted / Superseded / Deprecated)

A record's status answers one question: how much should you trust it as a description of the library you are working in right now? The four values below are the vocabulary this project uses. They are not a workflow — there is no review board moving records through stages — they are labels for four different relationships between a document and the code.

Proposed means the decision is written down but not yet settled. The record exists so the argument can be had against a concrete text rather than in a thread, and the code does not necessarily reflect it. If you find a Proposed record, do not treat it as a constraint on your work; treat it as an open question you are entitled to weigh in on. No record in docs/adrs/ currently carries this status. That is a consequence of how the ADRs here came to be: they were written to explain code that already existed, so they were Accepted from the moment they were committed. A genuinely forward-looking proposal — a redesign being argued before it is built — is exactly the case Proposed is reserved for.

Accepted means the decision is in force and the code implements it. Every record on file today is Accepted, and several say so with unusual precision. 0015-optional-dependency-extras.md reads "Accepted, and in force as of 0.5.0" and then names the exact pyproject.toml contents the claim rests on. 0014-live-migration-cutover-semantics.md says "Accepted — implemented and shipped in eventsource 0.5.0." 0023-postgresql-advisory-locks.md goes further and qualifies itself as "Accepted — and retroactive," spelling out that the exports it describes are already in the released 0.5.0 line and that "nothing here is a proposal." Read Accepted as a claim you can check, because these records generally hand you the file to check it in.

Note that Accepted says nothing about whether the record is finished. Four of the files — 0001-async-first-design.md, 0013-handler-registry-composition.md, 0016-optional-tracing-no-op-by-default.md, and 0023-postgresql-advisory-locks.md — are Accepted but stop after their Context section. The decision is real and the code is as described; the argument for it just has not been written out yet. Status tracks the decision, not the prose.

Superseded means a later record replaced this one. The old document is left intact — not rewritten, not deleted — and gains a pointer to its replacement, so that anyone reading old code or an old discussion can still find the reasoning that was current at the time. The replacement points back. 0023-postgresql-advisory-locks.md models the mechanism in advance while declining to invoke it, sketching the case that would trigger it — a future record that "supersedes this one and introduces a LockManager protocol — not an edit to this file" — and then stating plainly: "Supersedes: nothing. Superseded by: nothing." 0014-live-migration-cutover-semantics.md says the same in prose. One record is Superseded today: 0017-snapshot-strategy-pattern.md's Status section now prepends a pointer to 0021-snapshot-policy-scheduler-composition.md, which replaced its SnapshotStrategy design, while 0017's Decision and Rationale sections stay exactly as originally written — the pattern the status vocabulary was defined for.

Deprecated means the decision is no longer recommended but nothing has replaced it. This is the status for a choice the project has backed away from without settling on a successor — a pattern you should stop reaching for, where the alternative is still an open question. It is the rarest of the four and, like Proposed and Superseded, is currently unused here.

Two habits make these values useful rather than decorative. First, when a decision changes, change the status; do not edit an Accepted record into agreement with new code. Silently rewriting a record destroys the thing ADRs exist to preserve — the fact that the project once believed something different, and why. Second, when you are reading, check the status before the body, and check the Status section rather than just the word: in this project it usually carries the source files and tests that pin the decision, and occasionally an explicit admission of what is not implemented. 0015-optional-dependency-extras.md uses its Status section to record that asyncpg has no *_AVAILABLE guard — "one part of the decision is knowingly unimplemented" — rather than letting the Accepted label imply more coverage than the code delivers. A status value tells you how to weigh the record; the section around it tells you how to verify it.

Index of decisions

Two things are worth knowing before you use this index. The first is that the six numbered headings below are the planned core set — the decisions the project considers foundational enough to deserve a record — and only one of them has a file today. The second is that the files actually in docs/adrs/ do not line up with that numbering: they were written to explain subsystems as those subsystems got documented rather than in the order of the plan. (Two files briefly shared the 0009 prefix — the advisory-locks record and the multi-instance-subscription-coordination record — until the advisory-locks record was renumbered to 0019 in the 0.7.0 cycle, when this decomposition's own record was added as 0020.) Both lists appear here. Where a planned record exists as a file, the entry links to it; where it does not, the entry says so and points at the code and the neighbouring records that currently carry the reasoning.

The gap used to be visible in the site build too: mkdocs.yml carried nav entries for adrs/0002-pydantic-event-models.md through adrs/0006-event-registry-serialization.md when none of those files existed, so the sidebar advertised five records that were never written. Those entries have since been removed, and scripts/check_adr_index.py now fails CI if the nav or this page names a file that is not in docs/adrs/, or omits one that is. The sidebar is therefore an accurate list of the records on file; the six planned entries below are not.

ADR-0001: Async-First Design — all EventStore/EventBus/Projection interfaces are async; SyncEventStoreAdapter wraps for sync callers

0001-async-first-design.md — Accepted. The one record from the planned core set that exists as a file, and the most load-bearing decision in the library: every I/O-bound abstraction is a coroutine interface, and there is no parallel synchronous hierarchy behind it. EventStore and EventBus in src/eventsource/stores/interface.py and src/eventsource/bus/interface.py are ABCs whose abstract I/O methods are all async def, and the PostgreSQL, SQLite, Redis, RabbitMQ, Kafka, and in-memory backends implement those signatures directly against asyncpg, aiosqlite, redis-py's asyncio client, aio-pika, and aiokafka rather than delegating to blocking calls in a thread pool. There is no hidden run_in_executor layer to reason about.

The record's argument runs through the subscription machinery, which is where the choice pays for itself. application/subscriptions/lifecycle.py starts consumers with asyncio.create_task and stops them with asyncio.gather(..., return_exceptions=True); flow_control.py counts in-flight work behind an asyncio.Lock so shutdown can await the drain; retry.py backs off with asyncio.sleep; shutdown.py bounds every drain, checkpoint, and close step with asyncio.wait_for and treats asyncio.CancelledError as the ordinary way a running consumer exits. That is one coherent set of primitives covering concurrency, timeout, and cancellation. A thread-based design would need four mechanisms that compose less cleanly, and the record is blunt about why: a blocked thread cannot be cancelled.

Two boundaries in the decision are worth knowing before you read anything else in the codebase, because both look like inconsistencies until you see them named. First, only I/O is coloured async. EventBus.publish is a coroutine, but subscribe, unsubscribe, subscribe_all, subscribe_to_all_events, and unsubscribe_from_all_events are ordinary methods, because wiring a handler into a registry is in-process bookkeeping and should not force an event loop on module-level wiring code. The same line is drawn on the store side. Second, EventStore.read_all is an async iterator whose base implementation raises NotImplementedError, so a backend opts into global ordering rather than inheriting a silently wrong default.

SyncEventStoreAdapter in eventsource.sync is the single deliberate exception, and the record is careful to frame it as an edge adapter for Celery tasks, RQ workers, and Django management commands — "not a second API." It wraps a store and nothing else: there is no sync repository, projection, or bus facade. It exposes seven *_sync counterparts, validates in __init__ that what it was handed is really an EventStore, bounds every call with a constructor-level timeout (default 30.0s) plus a per-call override, and keeps a wrapped_store property so a caller that later grows an event loop can drop straight to the async object it already holds. _run_sync probes for a running loop first: the ordinary case falls through to asyncio.run(asyncio.wait_for(...)), while a running loop logs a warning and hands the coroutine to asyncio.run_coroutine_threadsafe against a shared four-worker ThreadPoolExecutor. One genuine behavioural divergence is documented rather than hidden — read_all_sync drives the async iterator to exhaustion and returns a list[StoredEvent], because a synchronous caller cannot consume an async generator.

Read this record if you are wondering why there is no synchronous store, or before proposing one; the "Forces at Play" subsection has the case for and against a second hierarchy already laid out, including the observation that async cannot be retrofitted from below — a sync interface can only be driven from async code by pushing it to a thread, whereas an async interface can be driven from sync code by an adapter at the edge. Note also that the record cross-references ADR-0007 for the removal of the SyncEventStore abstract class in 0.2.0.

Completeness: the file runs through its opening summary, Context (with "Python Async Ecosystem Maturity" and "Forces at Play"), and a full Decision section broken into "Core Choices" and "Implementation Patterns". It stops there — Consequences and Alternatives Considered are not written, and the addendum on _run_sync's three event-loop cases that the summary and Decision both promise does not exist in the file yet. Until it does, src/eventsource/sync/adapter.py is the source for that detail; its class docstring enumerates the three scenarios.

ADR-0002: Pydantic Event Models — DomainEvent as frozen pydantic v2 BaseModel

Not yet written as a file. docs/adrs/0002-pydantic-event-models.md does not exist, and there is no nav entry for it. The decision itself is settled and load-bearing, so what follows is a sketch of what the record needs to cover, drawn from the code rather than from a document.

The decision in force: DomainEvent in src/eventsource/events/base.py is a pydantic v2 BaseModel carrying model_config = ConfigDict(frozen=True), and pydantic is one of only two packages a plain pip install eventsource pulls in (pydantic>=2.0,<3.0 and sqlalchemy — see 0015-optional-dependency-extras.md). The base class supplies twelve fields covering identity (event_id, event_type, event_version, occurred_at), aggregate placement (aggregate_id, aggregate_type, aggregate_version), tenancy (tenant_id, optional at this level), provenance (actor_id, correlation_id, causation_id), and an open metadata dict. Subclasses add payload fields and nothing else structural. Only aggregate_id and aggregate_type are required; the rest carry defaults or default_factory callables, so an event class is usually four or five lines of field declarations.

What pydantic buys that a @dataclass(frozen=True) would not is the reason the choice is not arbitrary. Events cross a serialization boundary in both directions — out to a store as JSON, back in as a dict read from a row — and pydantic makes that round trip a validated operation rather than a hopeful cls(**row). to_dict() is model_dump(mode="json"), which coerces UUIDs to strings and datetimes to ISO 8601 without the caller doing anything; from_dict() is model_validate, which raises ValidationError on a malformed row instead of constructing a half-typed object that fails later in a projection. Constraints ride along in the field definitions — event_version and aggregate_version are both ge=1 — so a corrupt version number is caught at the boundary. And the model metadata that pydantic exposes at runtime, cls.model_fields, is what makes the auto-derivation machinery possible at all: __init_subclass__ reaches into cls.model_fields["event_type"] and mutates its FieldInfo.default to cls.__name__. A dataclass has no equivalent introspectable, mutable field registry, and the _ensure_event_type mode="before" validator that covers the dict-construction path has no dataclass analogue either. That whole design, documented in 0012-event-type-auto-derivation.md, is downstream of the pydantic choice.

Freezing is the other half, and it follows from what an event is: a record of something that already happened. A stored event is immutable by definition, so the in-memory representation should be too, and pydantic enforces it — tests/unit/test_domain_event.py::TestDomainEventImmutability pins that assigning to a payload field, to aggregate_id, or to metadata raises ValidationError. The library then makes derivation explicit instead of mutation: with_causation, with_metadata, and with_aggregate_version each return a new instance via model_copy(update=...). with_aggregate_version in particular is why frozen events do not fight the aggregate lifecycle — the aggregate does not stamp a version onto an event it holds, it replaces the event with a copy carrying the right version.

Two consequences of frozen=True are worth a record's Consequences section because both surprise people. First, frozen blocks rebinding, not deep mutation: event.metadata = {...} raises, but event.metadata["k"] = v succeeds and silently mutates the event in place. The test suite is precise about this — its assertion is that the attribute "cannot be reassigned" — and with_metadata exists so callers have a correct path that does not depend on noticing the distinction. Second, frozen=True in pydantic v2 normally makes a model hashable, but DomainEvent is not: the metadata: dict[str, Any] field makes hash(event) raise TypeError: unhashable type: 'dict'. Events cannot go in a set or serve as dict keys; use event_id for that.

Whoever writes this record should also settle two things the code leaves implicit: the relationship to TenantDomainEvent in src/eventsource/multitenancy/events.py, which subclasses DomainEvent purely to narrow tenant_id from optional to required (an inheritance pattern only viable because the base is a validating model), and the boundary with EventSourceJSONEncoder in src/eventsource/serialization/ — pydantic's mode="json" dump handles UUIDs and datetimes for events, while the stdlib-only encoder handles the same two types for everything else, and the two overlapping mechanisms deserve an explicit division of labour. That encoder is nominally ADR-0006 territory, which is also unwritten.

ADR-0003: Optimistic Locking — expected_version on append, OptimisticLockError contract

Not yet written. No file, and no nav entry. 0023-postgresql-advisory-locks.md refers to this record by number and carries a section titled "Why ADR-0003 Does Not Cover This," drawing the boundary between per-aggregate optimistic concurrency (expected_version on append, OptimisticLockError on conflict) and the cross-instance mutual exclusion that advisory locks provide. That section is the only written treatment of the distinction, and it assumes a record that does not exist. Anyone writing ADR-0003 should read it first — the boundary is already drawn, from the other side.

ADR-0004: Projection Error Handling — checkpoints, DLQ, retry and flow control in subscriptions/

Partially written. The persistence half — checkpoint and DLQ storage, their ports and implementations, and the disabled-by-default semantics of checkpoint_repo=None/dlq_repo=None — is now recorded in 0024-projection-persistence-ports.md. Retry, flow control, health, and shutdown remain unwritten: that machinery lives in src/eventsource/application/subscriptions/ (retry.py, flow_control.py, health.py, shutdown.py) and src/eventsource/application/projections/retry.py. Two existing records touch the edges of the unwritten remainder without covering it: 0007-event-bus-delivery-semantics.md establishes that handler errors are caught, logged, and swallowed so one failing handler cannot abort a publish, and names the DLQ as the recovery path; 0001-async-first-design.md describes the subscription primitives from the concurrency side rather than the failure side.

ADR-0005: API Design Patterns — mixed Protocols + ABCs in protocols.py, declarative decorators, single top-level re-export surface

Not yet written. No file, and no nav entry. This is the record the "Why this library keeps ADRs" section above points at as the clearest case of a decision that reads like inconsistency without its rationale: protocols.py defines EventHandler, SyncEventHandler, and FlexibleEventHandler as Python Protocols while EventSubscriber and AsyncEventHandler are ABCs. Part of the argument does exist elsewhere — 0013-handler-registry-composition.md explains why HandlerRegistry and HandlerAdapter are collaborators rather than base classes, why DeclarativeProjection has a registry instead of inheriting one, and why aggregates kept their own class-level handler map in AggregateRoot.__init_subclass__. That covers the composition-over-inheritance half. The Protocol-versus-ABC choice and the single top-level re-export surface are still unrecorded.

ADR-0006: Event Registry Serialization — init_subclass auto-registration and EventSourceJSONEncoder wire format

Not yet written as a file, but substantially covered by one that exists. 0012-event-type-auto-derivation.md is Accepted and complete, and it documents the half of this decision that concerns event type names: why DomainEvent derives event_type from cls.__name__ rather than asking authors to declare it, why the derivation happens in two places (a FieldInfo default mutated in __init_subclass__ at class-definition time, plus a mode="before" _ensure_event_type validator for dict-shaped input through model_validate/from_dict), and why a mismatch between class name and declared event_type warns rather than raises — backward compatibility with streams already on disk, and versioned names like OrderCreated.v2. It cites ADR-0006 by number for the registry side, noting that EventRegistry._resolve_event_type reads the event_type field default. The EventSourceJSONEncoder wire format in src/eventsource/serialization/ remains undocumented by any record.


The records actually on file, grouped by what they are about. All are Accepted; four are unfinished past Context and are marked below.

Events and handlers

  • 0012-event-type-auto-derivation.md — deriving event_type from the class name in two places, and warning rather than raising on mismatch. Pinned by tests/unit/test_event_type_auto.py. Complete.
  • 0013-handler-registry-composition.md — HandlerRegistry and HandlerAdapter as collaborators rather than base classes; require_async as a constructor parameter rather than a fixed policy. Context only.

Buses and delivery

  • 0007-event-bus-delivery-semantics.md — at-least-once and never exactly-once, no cross-handler ordering on distributed buses, handler-error isolation, thread-safety as a required invariant, and a fixed span-name and attribute convention that is part of the EventBus contract rather than a suggestion. Names one known gap: RedisEventBus does not propagate distributed trace context. Compliance pinned by tests/unit/bus/test_eventbus_tracing_patterns.py. Amended by 0010 (D4) and 0011 (D3, handler-error isolation and ack behavior).
  • 0010-uniform-event-bus-contract.md — background=True given one meaning ("do not wait for durability") across all four backends instead of being silently ignored by Redis, and a concrete BaseEventBus layer between the pure EventBus ABC and the four backends holding shared subscription management, event-class resolution, and background-task tracking. Fixes a Kafka name-keyed dispatch bug found while unifying dispatch through the shared registry. Complete.
  • 0011-handler-error-isolation-with-no-ack.md — every backend still runs all handlers per delivery, but failures are now aggregated into a raised HandlerDispatchError instead of being swallowed, and on Redis/RabbitMQ/Kafka consume paths an aggregate failure withholds the ack/commit so the broker's own redelivery mechanism takes over. Records an explicit user ruling against documenting per-backend divergence. Complete.
  • 0020-broker-backend-collaborator-decomposition.md — RabbitMQ and Kafka each split into a package of internal, state-owning collaborators (connection, topology/config, publisher, consumer, DLQ admin, serialization) composed by a facade that keeps every public signature. Complete.

Aggregates and snapshots

  • 0017-snapshot-strategy-pattern.md — the when and how of snapshot creation behind a SnapshotStrategy protocol instead of inside AggregateRepository, three concrete strategies (threshold/sync, background, no-op/manual), and why nearly every snapshot failure path degrades silently: events are authoritative, snapshots are regenerable cache entries. Complete, with candid negatives. Superseded by ADR 0021.
  • 0021-snapshot-policy-scheduler-composition.md — replaces the SnapshotStrategy protocol and AggregateSnapshotManager with SnapshotPolicy (when) and SnapshotScheduler (how) as two independently composable protocols, plus take_snapshot()/read_valid_snapshot() as the single construction and load-validation paths. Fixes an LSP violation (isinstance-sniffing a concrete strategy) and an ISP violation (NoSnapshotStrategy implementing an unrunnable method) named but not fixed by ADR 0017. Complete.
  • 0022-command-objects-and-decider-style.md — DomainCommand as a frozen pydantic model, DeciderAggregate for eager state initialization and provenance-aware event stamping, structural typing for opt-in causation, and the decider as the primary showcased aggregate style. Complete. Amended by ADR 0030 (DomainCommand relocates to eventsource.domain.command).
  • 0030-top-level-module-ring-consolidation.md — completing the ring migration for the last six pre-ring top-level modules: types.py and exceptions.py join domain/, protocols.py becomes ports/handlers.py, commands/'s DomainCommand becomes domain/command.py (also fixing a dependency-rule violation domain/aggregate.py and domain/decider.py carried against a module the ring map placed nowhere), and sync//serialization/ join adapters/. config.py, an empty seven-line placeholder with zero importers, is deleted outright. All six old import paths are deleted outright, no deprecation shim, no transition window — the "no shim, the library is unreleased" standing rule ADR 0025 and ADR 0026 established, extended here project-wide. Also deletes the two eventsource.locks / eventsource.readmodels shims ADR 0029 introduced, ahead of the 0.8.0 removal it scheduled. Amends 0022 (command location only; the decider design stands) and 0029 (shim removal timeline accelerated). Complete.
  • 0031-bus-ring-split.md — the last multi-backend top-level package, bus/, joins the ring map: EventBus becomes ports/bus.py (beside EventPublisher), BaseEventBus/SubscriptionRegistry become adapters-internal adapters/_bus/, and the four backends (InMemoryEventBus, RedisEventBus, KafkaEventBus, RabbitMQEventBus) become adapters/memory/bus.py, adapters/redis/, adapters/kafka/, adapters/rabbitmq/ — Redis's first per-technology adapter directory. bus/ and its facade __init__.py are deleted outright, no shim, completing both the "Migrate bus/ interface and backends to ports/adapters" and "Remove bus facade compat shims" backlog entries in one move. Amends 0007, 0010, 0011, and 0020 (module locations only; all four Decisions stand). Complete.
  • 0032-subscriptions-ring-migration.md — completing the ring migration for the last large pre-ring package: subscriptions/'s seventeen orchestration modules move verbatim to application/subscriptions/; subscriber.py and coordination.py each split along their Protocol/implementation line into ports/subscribers.py / ports/coordination.py plus adapters/memory/coordination.py (InMemoryLeaderElector, SharedLeaderState); a new two-method SubscribableEventBus port (ports/bus.py) dissolves the application-ring-to-bus dependency instead of excepting it from the layering contract; the exception hierarchy merges into domain/exceptions.py, with SubscriptionError rebased onto EventSourceError (widening only). No shim, no transition window. Amends 0009 (coordination surface relocated across three modules; the Decision itself stands). Complete.
  • 0033-events-handlers-internal-ring-migration.md — dissolving the last transitional top-level packages: events/ (DomainEvent → domain/event.py, EventRegistry and friends → domain/event_registry.py) and handlers/ split three ways by consumer ring (@handles → domain/decorators.py, since DeclarativeAggregate is its only consumer; HandlerRegistry → application/projections/handlers.py, its ADR-0013 extraction site; HandlerAdapter → adapters/_bus/handler_adapter.py, since every importer is a bus adapter); EventTypeNotFoundError, DuplicateEventTypeError, HandlerSignatureError merge into domain/exceptions.py (widening-only rebase onto EventSourceError); and the unranked _internal/background_tasks.py (BackgroundTaskManager, shared by application/aggregates/ and adapters/_bus/) lands in application/background_tasks.py per the rule that a utility shared across rings is owned by its innermost consumer. No shim, no transition window. Amends 0013 (module locations only) and 0030 (floor-path citations only). Complete.
  • 0034-migration-ring-and-layers-contract.md — the last top-level package, eventsource.migration, dissolves: 14 modules → application/migration/, four SQL repositories → adapters/sql/migration/, and models.py plus four repository Protocols cut out of the adapter modules → a new ports/migration/ subpackage (the models had to travel with the Protocols since their signatures reference the model types, and ports must not import application). The move itself surfaced a latent application-imports-adapters violation in five orchestration modules — invisible until the package joined a ring the import-linter contract actually covers — closed by the Protocol extraction, not by an exception. MigrationError rebases onto EventSourceError (widening only); the four legacy *RepositoryProtocol aliases are deleted (no consumers). The two targeted forbidden contracts guarding this boundary are replaced by one full type = "layers" contract (adapters > application > ports > domain), adding domain-ring coverage neither predecessor had. No shim, no transition window. No prior ADR is amended — neither the ABC-vs-Protocol shape nor the two forbidden contracts this ADR replaces were ever the subject of an earlier ADR's Decision. Complete.
  • 0035-lazy-front-door.md — PEP 562 __getattr__/__dir__ replacing eventsource/__init__.py's module-level adapter imports; __all__ byte-identical; a TYPE_CHECKING block carries the same imports verbatim so static type-checking is unaffected; aiosqlite probed directly (not through eventsource.adapters.sqlite) to compute the conditional SQLiteOutboxRepository export without itself registering eventsource.adapters in sys.modules. Payoff: bare import eventsource no longer loads sqlalchemy, and runtime Tier-0 purity checks become possible for the first time — previously only static ast-based checks worked, a constraint tests/unit/ports/test_readmodels_port_surface.py's docstring had documented as a pre-existing condition. Complete.
  • 0036-snapshot-port-composed-protocols.md — SnapshotStore moves from an ABC (concrete snapshot_exists default, NotImplementedError-raising delete_snapshots_by_type) to a runtime_checkable Protocol with no implementation code; bulk invalidation becomes its own optional SnapshotTypeInvalidation capability port. The three snapshot adapters go structural (no inheritance), and the conformance suite splits into SnapshotConformance/SnapshotTypeInvalidationConformance/SnapshotStoreConformance, mirroring the ProjectionCheckpoints/SubscriptionPositions/CheckpointRepositoryConformance mixin pattern ADR 0024 established. Zero call sites for the optional capability exist anywhere in application/ — the split lives entirely at the port/adapter/conformance layer. No prior ADR decided the port's ABC shape, so none is amended. Complete.
  • 0037-store-lifecycle-port.md — a new optional SupportsClose port (named after the stdlib SupportsInt/SupportsFloat convention, not the noun-per-capability pattern the rest of ports/ uses, since no equally natural noun exists) replaces SyncStoreFacade's getattr(store, "close", None) duck-typing with isinstance. PostgreSQLEventStore gains a keyword-only owns_engine: bool = False constructor flag; close() disposes the caller-injected AsyncEngine only when owns_engine=True, closing the SyncStoreFacade(PostgreSQLEventStore(shared_engine))-silently-disposes-the-caller's-pool hazard. Breaking: close() no longer disposes by default. Three integration-test fixtures constructing private per-test engines were updated to pass owns_engine=True, preserving their existing teardown behavior explicitly. Complete.
  • 0039-schema-ddl-to-adapters.md — eventsource.migrations (plural, the schema-DDL package, unrelated to the singular eventsource.migration ADR 0034 dissolved) relocates whole to adapters/sql/schemas/: it's the storage format itself, zero code deps, exactly two consumers (adapters/postgresql/store.py, adapters/sqlite/store.py), adapters-ring by definition. Ends the migration/migrations name-confusion hazard flagged since ADR 0034. Packaging verified, not assumed: uv build + unzip -l on the wheel confirmed all 37 .sql/.md/template files ship at the new path and none remain at the old one, with no pyproject.toml build-config change needed since __init__.py's path resolution is entirely Path(__file__)-relative. No shim, no transition window. Complete.
  • 0040-out-of-ring-settlement.md — records observability/ (cross-cutting telemetry, consumed by application/adapters) and testing/ (public test toolkit, imports adapters by design) as settled out-of-ring, not transitional — the last two top-level packages after ADRs 0038/0039 complete the ring dissolution. Two new forbidden import-linter contracts enforce both boundaries going forward: domain and ports must not import observability; no ring may import testing. Closes the campaign-completion claim (ls src/eventsource/ = exactly the four rings plus these two). Also generalizes the Kafka-logger-name / migration-meter-name precedent (ADRs 0031, 0034) into an explicit rule: telemetry identifiers are a stable public schema, deliberately decoupled from import paths. Does not amend ADR 0031's Status line — that ADR's body never recorded the naming question as an open one (it lived in the PR description, not the ADR text), so there is no Decision of 0031's to amend; this ADR's naming-principle Decision is new, not a revision of 0031's. Complete.
  • 0041-infrastructure-exceptions-to-ports.md — moves thirteen infrastructure-meaning exceptions (CheckpointError, CheckpointNotFoundError, EventBusConnectionError, EventStoreConnectionError, LockAcquisitionError, LockNotHeldError, PositionDecodeError, PositionForeignError, SubscriptionError and its seven subclasses) out of domain/exceptions.py into a new ports/exceptions.py, still rooted in EventSourceError. Roughly a third of domain/exceptions.py described port-contract failures with no domain meaning; this closes that gap. No shim — from eventsource.domain.exceptions import LockAcquisitionError now raises ImportError. Amends ADR 0030 and ADR 0032 (location only, neither ADR's Decision changes). Complete.
  • 0058-eventsource-error-as-universal-base.md — makes EventSourceError true rather than aspirational: every exception the library raises derives from it, so except EventSourceError at a service boundary really is the one handler that catches everything. The snapshot, read-model, subscription retry and circuit-breaker, migration write-pause and store-routing, and RabbitMQ shutdown and batch-publish families had each derived straight from Exception while the API reference and the error-handling guide promised otherwise. Generalises the one-family-at-a-time rebases of ADR 0029 and ADR 0032 into a rule rather than repeating them a third time. Complete. Amends ADR 0029 and ADR 0032.
  • 0042-domain-event-strictness.md — six entities-ring hardening changes from the domain-ring hardening wave: DomainEvent.event_type_name() as the single wire-name derivation source (fixing a FieldInfo-mutation bug that corrupted a parent event's registry key on subclassing); extra="forbid" on DomainEvent; EventTypeNotFoundError/DuplicateEventTypeError/HandlerSignatureError drop their KeyError/ValueError secondary bases; clear_tenant_context() hard-clear semantics (raises TenantContextResetError on a stale reset instead of resurrecting a cleared tenant); duplicate-@handles detection plus class-definition-time handler signature validation on DeclarativeAggregate; unified provenance stamping with an unconditional ambient-tenant fallback. ADR 0033 stands (locations unchanged); amends ADR 0038 (tenant-context semantics) and ADR 0022 (decider stamping semantics). Complete.
  • 0043-domain-model-guards-and-vocabulary.md — the DDD teaching-layer wave: aggregate_type becomes a required ClassVar[str] on AggregateRoot (AggregateTypeNotSetError at construction) and DomainEvent.aggregate_type is validated against CATEGORY_PATTERN via a model_validator(mode="after") (chosen over validate_default=True after a ~15%-per-construction benchmark cost ruled it out); DeclarativeAggregate.unregistered_event_handling defaults to "error" (projections unaffected); domain/types.py sheds Version/StreamPosition/GlobalPosition (positions are opaque adapter tokens per ports/positions.py) and threads its five identity aliases, now plain UUID, through real DomainEvent/DomainCommand signatures; DeciderAggregate[TState, TCommand] gains a native PEP-696-defaulted second type parameter for typed command dispatch; the teaching layer (getting-started, index, aggregate-styles.md, tutorial 08) is rewritten decider-first per ADR 0022 §5; and the Python floor rises to >=3.13, activating three ruff UP04x PEP-695 modernization rules staged as documented pyproject.toml ignores pending a dedicated follow-up. ADR 0022 and ADR 0042 stand; amends ADR 0030 (domain/types.py contents). Complete.
  • 0044-migration-error-module-decomposition.md — application/migration/exceptions.py (1533 lines, mixing a taxonomy, a circuit breaker, an error handler, and a classification vocabulary) splits into four modules forming a one-way DAG (error_classification.py → exceptions.py → circuit_breaker.py → error_handling.py), resolving the mutual dependency between MigrationError's default classification and classify_exception()'s need for the taxonomy by layering the vocabulary underneath rather than beside it. CircuitBreakerOpenError stays with the taxonomy (one taxonomy per area, per ADR 0041); classify_exception moves to error_handling.py as a runtime bridge. Enforced by an AST-based layering test that does not exempt TYPE_CHECKING imports. Non-breaking — __all__ unchanged. Amends ADR 0034 (module list only); ADR 0041 stands. Complete.
  • 0045-pep695-type-parameter-syntax.md — every generic declaration in src/, tests/, and bench/ that ruff flags under UP046/UP047/UP040 moves to native Python 3.13 type-parameter syntax (class AggregateRoot[TState: BaseModel](ABC)), and the three ruff UP046/UP047/UP040 ignores ADR 0043 staged are deleted, leaving E501 alone in the ignore list. Because a PEP 695 type parameter is scoped to its declaration, three module-level TypeVar exports cease to exist: TState (breaking — from eventsource import TState now raises ImportError; declare an inline parameter instead), TAggregate, and TEvent. No shim, per the pre-1.0 NO-SHIMS policy. Six sites beyond the flagged inventory were converted so no module ships a function-scoped parameter shadowing a same-named module-level TypeVar; a "What was not converted" section lists the seven module-level TypeVars that deliberately survive (Protocol-bound, ParamSpec-paired, or simply unflagged — none sits beside a converted same-named parameter). An eighth, in adapters/_bus/handler_adapter.py, turned out to be dead and was deleted. Records the eager-bound trap: PEP 695 bound expressions evaluate at declaration time and are not covered by from __future__ import annotations, so a TYPE_CHECKING-only bound must be quoted. The auto-variance risk that justified the deferral did not exist — no TypeVar here ever declared variance. Amends ADR 0043; ADR 0022 stands. Complete.
  • 0046-aggregate-type-single-source.md — AggregateRepository.__init__ drops its aggregate_type constructor parameter; the type is now always inferred from aggregate_factory.aggregate_type, the same required ClassVar[str] ADR 0043 made mandatory on AggregateRoot. Motivated by a reproduced silent-miscategorization bug: with the class attribute, the repository parameter, and an event's own field default set to three different strings, the repository parameter silently won for stream category and event stamping while the event's own declaration was discarded entirely, invisible on a save/load round-trip through the same misconfigured repository. An audit found 83 of 86 call sites already restated the class attribute as ceremony; the remaining 3 existed only inside the test module built to exercise the override itself. Breaking change, no deprecation window, per the pre-1.0 NO-SHIMS policy. The event-class field default (the third declaration site) is explicitly out of scope. Complete.

Multi-tenancy

  • 0018-tenant-isolation-model.md — ambient tenancy via a ContextVar rather than a parameter, opt-in TenantDomainEvent narrowing tenant_id to required, enforcement in a TenantAwareRepository composition wrapper rather than in the store, and write-only validation (validate_on_save=True, and the load-time flag off by default). Carries ## Security Consequences and ## Adoption Guidance in place of a generic Consequences section, and is explicit that parts of the model are deliberately incomplete. Complete. Amended by ADR 0038 (module relocation only; the Decision itself is unaffected) and by ADR 0057 (the load flag renamed; §6's supporting argument restated on current APIs, its conclusion unchanged).
  • 0057-tenant-load-enforcement.md — why TenantAwareRepository's load-time flag is renamed require_tenant_context and why the aggregate-load path still carries no tenant filter. Records the argument ADR 0018 §6 could no longer make on current APIs: a stream is one aggregate, so tenancy is a property of the stream and a per-event predicate on a stream read yields a partially replayed aggregate rather than isolation. Names the shape a future read guard would need — an all-or-nothing ownership check covering the snapshot path. Amends ADR 0018. Complete.
  • 0038-multitenancy-dissolution.md — eventsource.multitenancy dissolves: context.py → domain/tenant_context.py, events.py → domain/tenant_events.py, three exceptions (already EventSourceError-rooted) merge into domain/exceptions.py, repository.py → application/aggregates/tenant_repository.py. The importlib/getattr dynamic-import AggregateRoot._get_tenant_from_context() used to reach the module lazily (guarding against it not existing, back when it sat outside the ring map) is replaced by a direct module-level import now that the target is a same-ring sibling shipped unconditionally. Root __all__ byte-identical; TenantAwareRepository deliberately still not re-exported from the front door (never was). No shim, no transition window. Complete.

Coordination and migration

  • 0014-live-migration-cutover-semantics.md — the consistency model of eventsource.migration: a five-state phase machine, source-first dual-write with best-effort target writes, an advisory-lock-guarded pause bounded by a hard cutover_timeout_ms (default 500ms), automatic rollback to DUAL_WRITE on timeout or failure, and PositionMapper rewriting subscription checkpoints instead of replaying them. The longest record in the set, and explicit about what the system does not guarantee. Complete. Amended by ADR 0028 (cutover_max_lag_events default flips from 100 to 0).
  • 0028-strict-cutover-and-in-phase-resync.md — two paired fixes to eventsource.migration: MigrationCoordinator.run_resync_pass(migration_id) -> int, an operator-triggered bounded catch-up copy pass that recovers a lag anchor clamped by a dual-write mirror failure without aborting the migration, built through a new single _build_copier construction site shared with the automated bulk-copy path (which also fixes silently-dead position_mapping_enabled wiring); and MigrationConfig.cutover_max_lag_events defaulting to 0 instead of 100, so cutover never switches routing over source events safe_lag_anchor has proven absent from the target unless an operator explicitly opts into a bounded loss window. Amends 0014 (the old 100-event default). Complete.
  • 0023-postgresql-advisory-locks.md — why eventsource.locks (now eventsource.adapters.postgresql.locks) ships exactly one mutual-exclusion primitive rather than a backend-agnostic protocol: string keys hashed to a 63-bit lock ID, one dedicated AsyncSession per held lock, crash release delegated to the database session lifecycle, and a client-side poll loop standing in for a server-side lock timeout. Context only, though its Status section is unusually detailed about exports and supersession. Read alongside the live-migration record, which is its motivating consumer. Amended by ADR 0029 (a ports/locks.py Protocol pair added, describing the shape of the dependency only -- the single-primitive argument and PostgreSQL-only scope stand).
  • 0029-locks-readmodels-and-engine-rings.md — completing the ring migration for the last three pre-ring modules: locks/ splits into ports/locks.py (DistributedLock/LockRegistry Protocols, ISP-split along the two real consumer groups), adapters/postgresql/locks.py, and a new adapters/memory/locks.py test double; readmodels/ splits into a ports/readmodels/ subpackage (four distinct pure artifacts, not a flat module) and three backend adapters plus adapters/sql/{readmodel_schema,readmodel_projection}.py; engine.py relocates to adapters/_sql/engine.py with no ring split (never a Protocol/implementation pair). The one semantic change: LockAcquisitionError/LockNotHeldError rebase onto EventSourceError (widening only). Two recorded exceptions: the read-model exception trio stays out of eventsource.exceptions pending the pre-existing OptimisticLockError name-collision fix; the lock exceptions' rebasing is the sanctioned one. Both eventsource.locks and eventsource.readmodels become deprecated lazy shims, removed in 0.8.0. Amends 0023 without superseding it. Complete.
  • 0009-multi-instance-subscription-coordination.md — leader election as a protocol rather than an implementation, lease semantics split into LeaderElectorWithLease, coordination carried over the event bus on reserved topics, peer liveness by heartbeat staleness rather than a membership service, redistribution as advisory orphan reporting rather than automatic reassignment, and shipping only InMemoryLeaderElector. Complete, with Positive/Negative/Neutral consequences. Amended by ADR 0032 (the coordination surface relocates to ports/coordination.py + adapters/memory/coordination.py + application/subscriptions/coordination.py; the Decision itself is unchanged).
  • 0047-live-runner-feed-driven-checkpointing.md — fixes LiveRunner never checkpointing during the live phase: it read a _position attribute off the bus-delivered DomainEvent that nothing in the tree ever set, so live events were always recorded at the unchanged catch-up position and a subscription live for any length of time replayed its entire live period on restart. The store now owns ordering; the bus is a wake-up signal only. LiveRunner gains a GlobalEventFeed dependency (reusing TransitionCoordinator's existing event_store) and drains read_all(from_position=...) forward from the checkpoint on every notification, checkpointing per envelope. Because the feed never re-reads a position once checkpointed, the catch-up→live duplicate-suppression check becomes unreachable by construction and is deleted along with its permanently-zero events_skipped_duplicate/buffer_events_skipped metrics. Breaking: LiveRunner.__init__ gains a required event_feed argument. Amends 0007, scoped to the live-subscription consumer only — EventBus's own delivery contract is unchanged for every other consumer. 0019 and 0009 stand. Complete.
  • 0059-ordered-subscription-delivery.md — records that a subscription delivers one event at a time, in feed order, and does not begin the next until the current one is handled and its position recorded. Nothing in the tree said so: the runners were built around a flow controller that could bound concurrent delivery, but no caller ever delivered concurrently, so the bound never engaged — it reported a constant and the config that tuned it changed nothing, making an invariant that checkpoint correctness depends on look like an unfinished performance feature. Names the sanctioned ways to raise throughput without lifting the invariant (batch handling, more subscriptions, partitioned instances) and what lifting it would actually cost. Amends 0047 (states the checkpoint-lockstep dependency 0047 left implicit) and 0054 (Context prose only). 0007, 0009, 0013, 0017, 0021, 0032 stand. Complete.
  • 0060-bounded-background-publishing.md — bounds in-flight publish(background=True) tasks, which had no ceiling: a producer faster than its handlers grew the tracked task set until the process died, and shutdown() had to wait for or cancel all of it. The consequential half is what happens at the ceiling — _track_background awaits the coroutine inline rather than blocking on a slot, because a handler that publishes from inside a background publish task would otherwise wait for a slot held by the task it runs in, and no re-entrancy guard is needed if the inner call simply completes. Dropping was rejected as contradicting 0007's at-least-once promise. The default is a real number, not an opt-in. Amends 0010 (call shape of _track_background only). 0007, 0017, 0021 stand. Complete.
  • 0061-leader-lease-protocol-deleted.md — deletes LeaderElectorWithLease, which extended LeaderElector with lease_duration_seconds, lease_remaining_seconds, and wait_for_leadership(). Nothing implemented it, nothing consumed those members, and the criterion settles it: the library touches an elector at three points (reads is_leader twice, calls release() once) and never calls try_acquire() or renew() — acquisition is the user's, and renewal travels with acquisition. The split was made expecting Kubernetes and Redis electors that never arrived; a backend with leases now defines its own lease API, which is better than conforming to a shape guessed at and never called. Rejected building the renewal loop (the library would own a lifecycle it does not start) and adding conformance (a suite pins agreement between implementations; there are none). Breaking removal, no shim, pre-1.0. Amends 0009 (the lease split only; every other decision stands). 0032 stands. Complete.
  • 0062-single-declaration-sites-for-shutdown-timeout-and-retry-policy.md — collapses two facts that were each stored twice. SubscriptionConfig.shutdown_timeout is deleted: it validated, defaulted, and read back correctly while having no reader anywhere, because SubscriptionManager.__init__(shutdown_timeout=...) is what reaches the ShutdownCoordinator. And the projections retry Protocol is renamed to ProjectionRetryPolicy, because it shared the bare name RetryPolicy with the unrelated bus backoff dataclass that from eventsource import RetryPolicy actually resolves to — the two are structurally incompatible (delay_for versus max_retries/get_backoff/should_retry), so following the export gave users a type the projection loop cannot call. Renaming rather than un-exporting keeps both surfaces and puts the distinction at every annotation. Both breaking, no shims, pre-1.0. Amends 0022, extends 0048. Complete.
  • 0063-live-batch-delivery-is-a-page-not-a-window.md — closes the live half of 0059's batch-delivery sanction. The live runner now dispatches each bounded feed read to a batch-capable subscriber as one handle_batch() call, and handle_batch() takes precedence over handle() there as it already did on catch-up. The constraint that makes it safe: the batch is a page the feed already returned, never an accumulator — nothing is held back waiting for more events, so a lone event is still dispatched immediately and no timer or minimum batch size exists. Stop and pause stay per-envelope (checked during the pre-dispatch scan); processing_timeout bounds one call, now sometimes a batch, matching catch-up. EVERY_BATCH acquires a meaning on the live path. Rejected a time-window accumulator: it buys throughput with the live path's defining property, and a subscription that cannot keep up is a catch-up problem. Amends 0059 (live half only). 0047, 0007, 0060 stand. Complete.
  • 0064-telemetry-attribute-catalogue-is-not-a-wishlist.md — deletes the span-attribute constants in observability/attributes.py that nothing in the tree ever set. A span attribute is the cleanest case of the declared-but-never-dispatched shape, because a user never sets one: if the library does not emit the key, nobody does, and the constant reads as a contract whose query returns nothing forever. Deleting is the default — inventing an emission site to justify a key nobody asked for adds unowned surface to satisfy a declaration that was itself the mistake. The lock family inverted on verification: the PostgreSQL lock manager was emitting those attributes, under hand-written literals ("lock.key") that disagreed with the declared constants ("eventsource.lock.key") — two spellings of one fact, invisible to a grep for the constant name. Those literals are replaced by the constants and the family survives, with a test driving the real acquire()/release() paths. Rejected implementing every declared key, reserving them in docs, and emitting both lock spellings. Breaking removal, no shim, pre-1.0. Amends 0016 (§9's catalogue claim only; the naming rule stands). 0040 stands. Complete.
  • 0065-an-event-cannot-name-another-aggregate.md — an event's aggregate_id is its stream key, and it was the one auto-populated field a caller could override — with the override surviving to the store, unlike a divergent aggregate_type, which gets restamped. An event emitted from one aggregate while naming another was appended to a stream that disowns it: the emitter never reads that stream, the named aggregate never receives the event, and a save/load round-trip of either is internally consistent, so nothing can see the disagreement. Emitting one now raises AggregateIdMismatchError, from apply_event(is_new=True) as the backstop (hand-constructed events included) and from create_event()/DeciderAggregate._stamp() ahead of it, where the message can name the command. Reading event.aggregate_id rather than a per-aggregate declaration of permitted targets is what makes it work with no opt-in — a guard that must be declared protects the codebases that did not need protecting, which is why the mixin form was rejected. Replay is not checked: a historical mistake should not become an unloadable aggregate. Breaking, no shim, pre-1.0. Extends 0046 and 0048; 0022 stands. Complete.
  • 0066-read-model-schema-reconciliation-is-additive-and-opt-in.md — closes the additive half of a gap that only opens in the environments that are expensive to test. generate_schema emits CREATE TABLE IF NOT EXISTS, which does nothing to a table that already exists and does not say so, so a field added to a ReadModel never becomes a column in a database created before it; the projection then writes a column that is not there, and no test catches it because tests build their tables from nothing, where the CREATE is always complete. generate_additive_migration is pure — current columns in, ALTER TABLE ... ADD COLUMN out — and reconcile_read_model_schema introspects a live connection and executes them, taking no dialect argument because the connection already carries that fact. Additive only, and opt-in: everything excluded has a data question attached, and a library answering those unattended at startup is one that loses data; a required column with no default is refused before anything executes rather than attempted. Rejected reconciling automatically when a projection starts (DDL at a moment the consumer did not choose, against a schema something else may own) and full reconciliation (that is a migration tool, and getting it wrong destroys data rather than quietly doing nothing). Indexes on an existing table are accepted as out of scope: an index must be compared by definition rather than name, since CREATE INDEX IF NOT EXISTS leaves a stale index under a matching name and reports success. Extends 0039; 0029 stands. Complete.
  • 0048-failure-paths-report-and-retain.md — records the two rules behind a sweep of the open audit findings. A failure path that cannot do its job says so at the point of failure: SyncEventStoreAdapter raises RuntimeError from a running loop instead of scheduling work onto that loop and blocking its thread (a guaranteed deadlock); SQLite and PostgreSQL wrap driver connection errors in EventStoreConnectionError, which the library defined and exported but never raised. Never claim work is done that is not: the Kafka consumer's _send_to_dlq/_republish_for_retry return whether the event was retained and the offset is committed only then, and projection checkpointing moves out of the retry try so a checkpoint outage no longer re-runs the handler and DLQs a successfully-projected event. Kafka also honors the retry_after it was already writing, ending a divergence where the same RetryPolicy made RabbitMQ back off and Kafka hot-loop. Breaking: shutdown_executor()/_get_executor() removed; EventStoreConnectionError reparented from SubscriptionError to EventStoreError; a divergent aggregate_type on an event class raises AggregateTypeMismatchError. Amends 0001 and 0041, extends 0046, reaffirms 0035; 0007 stands. Complete.
  • 0049-snapshot-boundary-crossing.md — EveryNEvents(n) changes from version % n == 0 to a crossing test. A save carries every event a command produced, so versions advance in strides; whether a strided sequence ever lands on a multiple of n is arithmetic, and for many strides the answer is never. Such a stream snapshotted exactly never, with no symptom beyond loads growing slowly slower. Amends 0021; 0017 stands as a historical record.
  • 0050-read-model-version-conflict-error-name.md — the read-model conflict exception is renamed ReadModelVersionConflictError. It had shared the name OptimisticLockError with the unrelated domain exception raised on event append: different base class, different constructor, neither catching the other, distinguished only by import path. Breaking, with no deprecation alias per the pre-1.0 no-shim policy. Amends 0029; 0041 stands.
  • 0051-adapters-common-shared-port-semantics.md — adds adapters/_common/, a third adapters-internal package alongside _sql/ (dialect-specific) and _bus/ (transport-specific), for port semantics that belong to no backend. check_expected had existed verbatim in three store adapters and a testing double. Records the standing rule: behavior a conformance suite asserts is implemented once — the suite proves the adapters agree, it does not stop each from deriving the agreement separately. 0025 and 0031 stand.
  • 0052-feed-read-aggregate-type-filter.md — FeedReadOptions gains aggregate_type, so a consumer scoped to one aggregate type stops reading the whole global feed and discarding the rest in Python. The adapters push it into the same query that already handles from_position/tenant_id/limit; limit still bounds events returned after filtering, so positions resume the filtered sequence. Distinguishes the new filter from read_category, which selects the same events but orders by storage time. PostgreSQL gains a composite (aggregate_type, global_position) index; SQLite needs none, global_position being its rowid. Additive with a default. 0025, 0027, and 0046 stand.
  • 0053-sqlite-snapshot-store-owns-its-connection.md — SQLiteSnapshotStore opens one connection lazily, applies the bundled sqlite snapshots DDL to it, and implements SupportsClose, matching SQLiteEventStore's discipline instead of opening a connection per operation. Makes ":memory:" work (it never had), and gives consumers a way to release the non-daemon aiosqlite worker thread — the absence of which had pushed one downstream consumer into threading a single instance through every call site. 0036, 0037, and 0039 stand.
  • 0054-projection-replay-driver.md — replay(feed, projections, ...), the rebuild counterpart to ProjectionCoordinator's live catch-up: a foreground fold of the global feed that records a rejection and carries on, where a live subscription must stop. Separates "how much of the log did not reach the read models" (per event, derived) from "which projection refused what" (per rejection, retained with the exception itself), bounds the retained list because each entry pins a traceback, and reports what the bound dropped rather than truncating quietly. Forwards tenant_id/aggregate_type into the adapter's query instead of filtering after delivery. 0019, 0024, 0041, and 0052 stand; extends 0048.
  • 0055-generic-store-projection-base.md — adds StoreProjection[TStore], a DeclarativeProjection holding one store and exposing it to handlers as self._store. The store is a type parameter, never an adapter or a driver type, which is what keeps the class in the application ring. Its constructor declares only store and forwards the rest through **options: Unpack[ProjectionOptions] (PEP 692), so a subclass adding parameters of its own never restates — and never silently drops — a parent parameter, the defect 0.10.0 fixed inside this tree and left every downstream subclass to re-derive. **kwargs: Any was rejected for trading a silent narrowing for a silent widening. 0013, 0024, and 0045 stand.

  • 0056-decider-initial-state-is-nullary.md — DeciderAggregate.initial_state() takes no arguments. The state before any event has occurred is one value for the aggregate type, not one per id; declaring it as a function of the aggregate id forced a required, defaultless id field into every decider state model, whose only reader was decide building events — the one field in a fold-of-the-stream model that the stream could not reconstruct. decide now takes the aggregate id from the command, which names the aggregate it targets and mostly carried the id already. Breaking, no shim: subclasses delete the parameter, drop the id field from state, and capture the id in the match arm. DeciderScenario loses aggregate_id= for the same reason. Amends 0022; 0042 and 0046 stand.

Store contract and layering

  • 0019-clean-architecture-store-ports.md — the Clean Architecture redesign of the store contract: five segregated output ports (EventAppender, StreamReader, EventLookup, GlobalEventFeed, CategoryQuery) replacing the EventStore ABC, opaque totally-ordered Position tokens with a no-skip/exclusive-resumption feed guarantee, 1-based stream versions kept, duplicate-event_id appends promoted to a typed idempotency primitive. Amends 0014 (position-delta lag is abolished). The first genuinely forward-looking record in the set — written against docs/superpowers/specs/2026-07-29-core-rings-design.md before the code exists. Complete. Amended by ADR 0025 (the legacy ABC and its compatibility wrapper are deleted; this ADR's ports are now the only store surface).
  • 0024-projection-persistence-ports.md — the same ISP-split-plus-manager-dissolution move ADR 0021 made for snapshots, applied to projection checkpoints and the dead letter queue: ProjectionCheckpoints and SubscriptionPositions as segregated ports (with CheckpointRepository as their composed convenience protocol), ProjectionCheckpointManager/ProjectionDLQManager dissolved into six module-level functions with span names deliberately kept, DatabaseProjection classified as an adapter, and checkpoint_repo=None/dlq_repo=None changed to mean "disabled" rather than "construct an in-memory default." Amends 0015 (connection-helper split). Sibling of 0021. Complete. Amended by ADR 0025 (SubscriptionPositions retypes from int to the opaque Position value object).
  • 0025-legacy-store-retirement.md — retiring the legacy EventStore ABC surface with no shims and no back-compat aliases in favor of the port-based surface (EventAppender, StreamReader, EventLookup, GlobalEventFeed, CategoryQuery), authored across store-retirement slices (a, b, c) and completed in slice (d): first-position AppendResult semantics, duplicate-append raising, storage-time inclusive category reads, TypeConverter removal, MemoryEventStore renamed InMemoryEventStore, and outbox write support ported onto the PostgreSQL adapter. Amends 0016 (store spans removed), 0019 (compatibility wrapper deleted), and 0024 (SubscriptionPositions retyped). Complete.
  • 0026-outbox-ring-migration.md — completing the Protocol/implementation split ADR 0024 began: the outbox Protocol, OutboxEntry/OutboxStats, and outbox_event_data() move to ports/outbox.py, the three backend implementations move to per-technology adapters/{memory,postgresql,sqlite}/outbox.py modules (per-backend rather than dialect-parameterized, since SQLite takes a raw aiosqlite.Connection), repositories/ is deleted, and OutboxRepositoryProtocol/list_pending_events die with no shims. Extends 0024 without amending it; ADR 0016's tracing decision stands for the outbox unlike the store adapters ADR 0025 amended it for. Complete.
  • 0027-schema-correctness-fixes.md — three independent schema-correctness fixes bundled under one record: (a) the SQLite outbox's id INTEGER PRIMARY KEY AUTOINCREMENT corrected in place to TEXT PRIMARY KEY (a narrow, explicit exception to migrations/'s append-only rule, since the shipped column could never hold a row its own writer produces), (b) an events.txid xid8 column plus a rewritten safe-horizon predicate replacing the wraparound-unsafe xmin-cast comparison in the PostgreSQL global feed (NULL-txid rows are always safe by the ACCESS EXCLUSIVE argument; bound per read rather than inlined per row), and (c) the shared integration test fixture's events table reconciled onto the canonical tenant_id UUID schema via get_schema("events"), retiring the hand-rolled VARCHAR(255) drift with zero test failures on the flip. Extends ADR 0025's store-ports mechanism without amending its decision. Complete.

Dependencies and observability

  • 0015-optional-dependency-extras.md — why a plain install pulls in only pydantic and sqlalchemy, and every driver sits behind a named extra. Documents the three different meanings the codebase gives to "missing" (raise at construction, omit from __all__, degrade to a no-op) and records the demotion of redis from core to extra in the 0.5.0 cycle as a breaking change. Short but self-contained. Amended by ADR 0024 (the checkpoint/DLQ connection helper moved out of repositories/_connection.py).
  • 0016-optional-tracing-no-op-by-default.md — OpenTelemetry behind the telemetry extra, a single OTEL_AVAILABLE probe in observability/tracing.py, and NullTracer as a null object satisfying the same Tracer protocol so call sites hold a plain with self._tracer.span(...) and no conditional. Context only. Amended by ADR 0025 (per-operation store spans removed with the legacy stores; the ports adapters carry none).
  • 0008-mutation-testing-tool-selection.md — why mutation testing here runs two tools rather than one: mutmut 3.x as the default, plus cosmic-ray scoped to the modules whose behaviour sits inside decorator-registered callbacks, which mutmut's trampoline excludes from mutation by construction rather than by config. Records why the answer is not a version pin, and what was ruled out on the way. The baselines and per-mutant triage live in docs/development/mutation-testing.md, not in the record. Complete.

Three practical notes on using this list. Numbers are unique today — .github/workflows/adr-check.yml fails a duplicate prefix — but they have not always been: the 0009 prefix was briefly shared by two files until the advisory-locks record was renumbered to 0019, so a citation from an old commit message may not resolve to the record you expect. The grouping above is editorial, not a property of the files; nothing in the repo declares these categories. And several records cross-reference each other by ADR number, including numbers that have no file (ADR-0002 through ADR-0006), so a reference you follow may lead nowhere until the planned set is filled in.

Adding a record. Name the file NNNN-slug.md, taking the next free number from docs/adrs/ on current main, and give it an H1 that leads with that number. Then add it here — one bullet in the group it belongs to — and one nav entry in mkdocs.yml. Both of those are second copies of the directory listing, which is why they used to drift; scripts/check_adr_index.py (run by make adr locally and by the ADR Check workflow in CI) now fails when either one omits a file, names a file that does not exist, or carries a number that disagrees with the file it points at. A strict mkdocs build does not catch any of that, which is how five separate fix: commits came to exist for this page.