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— derivingevent_typefrom the class name in two places, and warning rather than raising on mismatch. Pinned bytests/unit/test_event_type_auto.py. Complete.0013-handler-registry-composition.md—HandlerRegistryandHandlerAdapteras collaborators rather than base classes;require_asyncas 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 theEventBuscontract rather than a suggestion. Names one known gap:RedisEventBusdoes not propagate distributed trace context. Compliance pinned bytests/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=Truegiven one meaning ("do not wait for durability") across all four backends instead of being silently ignored by Redis, and a concreteBaseEventBuslayer between the pureEventBusABC 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 raisedHandlerDispatchErrorinstead 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 aSnapshotStrategyprotocol instead of insideAggregateRepository, 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 theSnapshotStrategyprotocol andAggregateSnapshotManagerwithSnapshotPolicy(when) andSnapshotScheduler(how) as two independently composable protocols, plustake_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 (NoSnapshotStrategyimplementing an unrunnable method) named but not fixed by ADR 0017. Complete.0022-command-objects-and-decider-style.md—DomainCommandas a frozen pydantic model,DeciderAggregatefor 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 (DomainCommandrelocates toeventsource.domain.command).0030-top-level-module-ring-consolidation.md— completing the ring migration for the last six pre-ring top-level modules:types.pyandexceptions.pyjoindomain/,protocols.pybecomesports/handlers.py,commands/'sDomainCommandbecomesdomain/command.py(also fixing a dependency-rule violationdomain/aggregate.pyanddomain/decider.pycarried against a module the ring map placed nowhere), andsync//serialization/joinadapters/.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 twoeventsource.locks/eventsource.readmodelsshims ADR 0029 introduced, ahead of the 0.8.0 removal it scheduled. Amends0022(command location only; the decider design stands) and0029(shim removal timeline accelerated). Complete.0031-bus-ring-split.md— the last multi-backend top-level package,bus/, joins the ring map:EventBusbecomesports/bus.py(besideEventPublisher),BaseEventBus/SubscriptionRegistrybecome adapters-internaladapters/_bus/, and the four backends (InMemoryEventBus,RedisEventBus,KafkaEventBus,RabbitMQEventBus) becomeadapters/memory/bus.py,adapters/redis/,adapters/kafka/,adapters/rabbitmq/— Redis's first per-technology adapter directory.bus/and its facade__init__.pyare 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. Amends0007,0010,0011, and0020(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 toapplication/subscriptions/;subscriber.pyandcoordination.pyeach split along their Protocol/implementation line intoports/subscribers.py/ports/coordination.pyplusadapters/memory/coordination.py(InMemoryLeaderElector,SharedLeaderState); a new two-methodSubscribableEventBusport (ports/bus.py) dissolves the application-ring-to-bus dependency instead of excepting it from the layering contract; the exception hierarchy merges intodomain/exceptions.py, withSubscriptionErrorrebased ontoEventSourceError(widening only). No shim, no transition window. Amends0009(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,EventRegistryand friends →domain/event_registry.py) andhandlers/split three ways by consumer ring (@handles→domain/decorators.py, sinceDeclarativeAggregateis 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,HandlerSignatureErrormerge intodomain/exceptions.py(widening-only rebase ontoEventSourceError); and the unranked_internal/background_tasks.py(BackgroundTaskManager, shared byapplication/aggregates/andadapters/_bus/) lands inapplication/background_tasks.pyper the rule that a utility shared across rings is owned by its innermost consumer. No shim, no transition window. Amends0013(module locations only) and0030(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/, andmodels.pyplus four repository Protocols cut out of the adapter modules → a newports/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 theimport-lintercontract actually covers — closed by the Protocol extraction, not by an exception.MigrationErrorrebases ontoEventSourceError(widening only); the four legacy*RepositoryProtocolaliases are deleted (no consumers). The two targeted forbidden contracts guarding this boundary are replaced by one fulltype = "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__replacingeventsource/__init__.py's module-level adapter imports;__all__byte-identical; aTYPE_CHECKINGblock carries the same imports verbatim so static type-checking is unaffected;aiosqliteprobed directly (not througheventsource.adapters.sqlite) to compute the conditionalSQLiteOutboxRepositoryexport without itself registeringeventsource.adaptersinsys.modules. Payoff: bareimport eventsourceno longer loads sqlalchemy, and runtime Tier-0 purity checks become possible for the first time — previously only staticast-based checks worked, a constrainttests/unit/ports/test_readmodels_port_surface.py's docstring had documented as a pre-existing condition. Complete.0036-snapshot-port-composed-protocols.md—SnapshotStoremoves from anABC(concretesnapshot_existsdefault,NotImplementedError-raisingdelete_snapshots_by_type) to aruntime_checkableProtocolwith no implementation code; bulk invalidation becomes its own optionalSnapshotTypeInvalidationcapability port. The three snapshot adapters go structural (no inheritance), and the conformance suite splits intoSnapshotConformance/SnapshotTypeInvalidationConformance/SnapshotStoreConformance, mirroring theProjectionCheckpoints/SubscriptionPositions/CheckpointRepositoryConformancemixin pattern ADR 0024 established. Zero call sites for the optional capability exist anywhere inapplication/— 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 optionalSupportsCloseport (named after the stdlibSupportsInt/SupportsFloatconvention, not the noun-per-capability pattern the rest ofports/uses, since no equally natural noun exists) replacesSyncStoreFacade'sgetattr(store, "close", None)duck-typing withisinstance.PostgreSQLEventStoregains a keyword-onlyowns_engine: bool = Falseconstructor flag;close()disposes the caller-injectedAsyncEngineonly whenowns_engine=True, closing theSyncStoreFacade(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 passowns_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 singulareventsource.migrationADR 0034 dissolved) relocates whole toadapters/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 themigration/migrationsname-confusion hazard flagged since ADR 0034. Packaging verified, not assumed:uv build+unzip -lon the wheel confirmed all 37.sql/.md/template files ship at the new path and none remain at the old one, with nopyproject.tomlbuild-config change needed since__init__.py's path resolution is entirelyPath(__file__)-relative. No shim, no transition window. Complete.0040-out-of-ring-settlement.md— recordsobservability/(cross-cutting telemetry, consumed byapplication/adapters) andtesting/(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 forbiddenimport-lintercontracts 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,SubscriptionErrorand its seven subclasses) out ofdomain/exceptions.pyinto a newports/exceptions.py, still rooted inEventSourceError. Roughly a third ofdomain/exceptions.pydescribed port-contract failures with no domain meaning; this closes that gap. No shim —from eventsource.domain.exceptions import LockAcquisitionErrornow raisesImportError. Amends ADR 0030 and ADR 0032 (location only, neither ADR's Decision changes). Complete.0058-eventsource-error-as-universal-base.md— makesEventSourceErrortrue rather than aspirational: every exception the library raises derives from it, soexcept EventSourceErrorat 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 fromExceptionwhile 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 aFieldInfo-mutation bug that corrupted a parent event's registry key on subclassing);extra="forbid"onDomainEvent;EventTypeNotFoundError/DuplicateEventTypeError/HandlerSignatureErrordrop theirKeyError/ValueErrorsecondary bases;clear_tenant_context()hard-clear semantics (raisesTenantContextResetErroron a stale reset instead of resurrecting a cleared tenant); duplicate-@handlesdetection plus class-definition-time handler signature validation onDeclarativeAggregate; 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_typebecomes a requiredClassVar[str]onAggregateRoot(AggregateTypeNotSetErrorat construction) andDomainEvent.aggregate_typeis validated againstCATEGORY_PATTERNvia amodel_validator(mode="after")(chosen overvalidate_default=Trueafter a ~15%-per-construction benchmark cost ruled it out);DeclarativeAggregate.unregistered_event_handlingdefaults to"error"(projections unaffected);domain/types.pyshedsVersion/StreamPosition/GlobalPosition(positions are opaque adapter tokens perports/positions.py) and threads its five identity aliases, now plainUUID, through realDomainEvent/DomainCommandsignatures;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 ruffUP04xPEP-695 modernization rules staged as documentedpyproject.tomlignores pending a dedicated follow-up. ADR 0022 and ADR 0042 stand; amends ADR 0030 (domain/types.pycontents). 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 betweenMigrationError's default classification andclassify_exception()'s need for the taxonomy by layering the vocabulary underneath rather than beside it.CircuitBreakerOpenErrorstays with the taxonomy (one taxonomy per area, per ADR 0041);classify_exceptionmoves toerror_handling.pyas a runtime bridge. Enforced by an AST-based layering test that does not exemptTYPE_CHECKINGimports. Non-breaking —__all__unchanged. Amends ADR 0034 (module list only); ADR 0041 stands. Complete.0045-pep695-type-parameter-syntax.md— every generic declaration insrc/,tests/, andbench/that ruff flags underUP046/UP047/UP040moves to native Python 3.13 type-parameter syntax (class AggregateRoot[TState: BaseModel](ABC)), and the three ruffUP046/UP047/UP040ignores ADR 0043 staged are deleted, leavingE501alone in the ignore list. Because a PEP 695 type parameter is scoped to its declaration, three module-levelTypeVarexports cease to exist:TState(breaking —from eventsource import TStatenow raisesImportError; declare an inline parameter instead),TAggregate, andTEvent. 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-levelTypeVar; a "What was not converted" section lists the seven module-levelTypeVars that deliberately survive (Protocol-bound,ParamSpec-paired, or simply unflagged — none sits beside a converted same-named parameter). An eighth, inadapters/_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 byfrom __future__ import annotations, so aTYPE_CHECKING-only bound must be quoted. The auto-variance risk that justified the deferral did not exist — noTypeVarhere ever declared variance. Amends ADR 0043; ADR 0022 stands. Complete.0046-aggregate-type-single-source.md—AggregateRepository.__init__drops itsaggregate_typeconstructor parameter; the type is now always inferred fromaggregate_factory.aggregate_type, the same requiredClassVar[str]ADR 0043 made mandatory onAggregateRoot. 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 aContextVarrather than a parameter, opt-inTenantDomainEventnarrowingtenant_idto required, enforcement in aTenantAwareRepositorycomposition wrapper rather than in the store, and write-only validation (validate_on_save=True, and the load-time flag off by default). Carries## Security Consequencesand## Adoption Guidancein 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— whyTenantAwareRepository's load-time flag is renamedrequire_tenant_contextand 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.multitenancydissolves:context.py→domain/tenant_context.py,events.py→domain/tenant_events.py, three exceptions (alreadyEventSourceError-rooted) merge intodomain/exceptions.py,repository.py→application/aggregates/tenant_repository.py. Theimportlib/getattrdynamic-importAggregateRoot._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;TenantAwareRepositorydeliberately 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 ofeventsource.migration: a five-state phase machine, source-first dual-write with best-effort target writes, an advisory-lock-guarded pause bounded by a hardcutover_timeout_ms(default 500ms), automatic rollback toDUAL_WRITEon timeout or failure, andPositionMapperrewriting 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_eventsdefault flips from 100 to 0).0028-strict-cutover-and-in-phase-resync.md— two paired fixes toeventsource.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_copierconstruction site shared with the automated bulk-copy path (which also fixes silently-deadposition_mapping_enabledwiring); andMigrationConfig.cutover_max_lag_eventsdefaulting to0instead of100, so cutover never switches routing over source eventssafe_lag_anchorhas proven absent from the target unless an operator explicitly opts into a bounded loss window. Amends0014(the old 100-event default). Complete.0023-postgresql-advisory-locks.md— whyeventsource.locks(noweventsource.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 dedicatedAsyncSessionper 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 (aports/locks.pyProtocol 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 intoports/locks.py(DistributedLock/LockRegistryProtocols, ISP-split along the two real consumer groups),adapters/postgresql/locks.py, and a newadapters/memory/locks.pytest double;readmodels/splits into aports/readmodels/subpackage (four distinct pure artifacts, not a flat module) and three backend adapters plusadapters/sql/{readmodel_schema,readmodel_projection}.py;engine.pyrelocates toadapters/_sql/engine.pywith no ring split (never a Protocol/implementation pair). The one semantic change:LockAcquisitionError/LockNotHeldErrorrebase ontoEventSourceError(widening only). Two recorded exceptions: the read-model exception trio stays out ofeventsource.exceptionspending the pre-existingOptimisticLockErrorname-collision fix; the lock exceptions' rebasing is the sanctioned one. Botheventsource.locksandeventsource.readmodelsbecome deprecated lazy shims, removed in 0.8.0. Amends0023without superseding it. Complete.0009-multi-instance-subscription-coordination.md— leader election as a protocol rather than an implementation, lease semantics split intoLeaderElectorWithLease, 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 onlyInMemoryLeaderElector. Complete, with Positive/Negative/Neutral consequences. Amended by ADR 0032 (the coordination surface relocates toports/coordination.py+adapters/memory/coordination.py+application/subscriptions/coordination.py; the Decision itself is unchanged).0047-live-runner-feed-driven-checkpointing.md— fixesLiveRunnernever checkpointing during the live phase: it read a_positionattribute off the bus-deliveredDomainEventthat 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.LiveRunnergains aGlobalEventFeeddependency (reusingTransitionCoordinator's existingevent_store) and drainsread_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-zeroevents_skipped_duplicate/buffer_events_skippedmetrics. Breaking:LiveRunner.__init__gains a requiredevent_feedargument. Amends0007, scoped to the live-subscription consumer only —EventBus's own delivery contract is unchanged for every other consumer.0019and0009stand. 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. Amends0047(states the checkpoint-lockstep dependency 0047 left implicit) and0054(Context prose only).0007,0009,0013,0017,0021,0032stand. Complete.0060-bounded-background-publishing.md— bounds in-flightpublish(background=True)tasks, which had no ceiling: a producer faster than its handlers grew the tracked task set until the process died, andshutdown()had to wait for or cancel all of it. The consequential half is what happens at the ceiling —_track_backgroundawaits 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 contradicting0007's at-least-once promise. The default is a real number, not an opt-in. Amends0010(call shape of_track_backgroundonly).0007,0017,0021stand. Complete.0061-leader-lease-protocol-deleted.md— deletesLeaderElectorWithLease, which extendedLeaderElectorwithlease_duration_seconds,lease_remaining_seconds, andwait_for_leadership(). Nothing implemented it, nothing consumed those members, and the criterion settles it: the library touches an elector at three points (readsis_leadertwice, callsrelease()once) and never callstry_acquire()orrenew()— 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. Amends0009(the lease split only; every other decision stands).0032stands. Complete.0062-single-declaration-sites-for-shutdown-timeout-and-retry-policy.md— collapses two facts that were each stored twice.SubscriptionConfig.shutdown_timeoutis deleted: it validated, defaulted, and read back correctly while having no reader anywhere, becauseSubscriptionManager.__init__(shutdown_timeout=...)is what reaches theShutdownCoordinator. And the projections retry Protocol is renamed toProjectionRetryPolicy, because it shared the bare nameRetryPolicywith the unrelated bus backoff dataclass thatfrom eventsource import RetryPolicyactually resolves to — the two are structurally incompatible (delay_forversusmax_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. Amends0022, extends0048. Complete.0063-live-batch-delivery-is-a-page-not-a-window.md— closes the live half of0059's batch-delivery sanction. The live runner now dispatches each bounded feed read to a batch-capable subscriber as onehandle_batch()call, andhandle_batch()takes precedence overhandle()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_timeoutbounds one call, now sometimes a batch, matching catch-up.EVERY_BATCHacquires 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. Amends0059(live half only).0047,0007,0060stand. Complete.0064-telemetry-attribute-catalogue-is-not-a-wishlist.md— deletes the span-attribute constants inobservability/attributes.pythat 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 realacquire()/release()paths. Rejected implementing every declared key, reserving them in docs, and emitting both lock spellings. Breaking removal, no shim, pre-1.0. Amends0016(§9's catalogue claim only; the naming rule stands).0040stands. Complete.0065-an-event-cannot-name-another-aggregate.md— an event'saggregate_idis its stream key, and it was the one auto-populated field a caller could override — with the override surviving to the store, unlike a divergentaggregate_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 raisesAggregateIdMismatchError, fromapply_event(is_new=True)as the backstop (hand-constructed events included) and fromcreate_event()/DeciderAggregate._stamp()ahead of it, where the message can name the command. Readingevent.aggregate_idrather 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. Extends0046and0048;0022stands. 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_schemaemitsCREATE TABLE IF NOT EXISTS, which does nothing to a table that already exists and does not say so, so a field added to aReadModelnever 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 theCREATEis always complete.generate_additive_migrationis pure — current columns in,ALTER TABLE ... ADD COLUMNout — andreconcile_read_model_schemaintrospects 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, sinceCREATE INDEX IF NOT EXISTSleaves a stale index under a matching name and reports success. Extends0039;0029stands. 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:SyncEventStoreAdapterraisesRuntimeErrorfrom a running loop instead of scheduling work onto that loop and blocking its thread (a guaranteed deadlock); SQLite and PostgreSQL wrap driver connection errors inEventStoreConnectionError, 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_retryreturn whether the event was retained and the offset is committed only then, and projection checkpointing moves out of the retrytryso a checkpoint outage no longer re-runs the handler and DLQs a successfully-projected event. Kafka also honors theretry_afterit was already writing, ending a divergence where the sameRetryPolicymade RabbitMQ back off and Kafka hot-loop. Breaking:shutdown_executor()/_get_executor()removed;EventStoreConnectionErrorreparented fromSubscriptionErrortoEventStoreError; a divergentaggregate_typeon an event class raisesAggregateTypeMismatchError. Amends0001and0041, extends0046, reaffirms0035;0007stands. Complete.0049-snapshot-boundary-crossing.md—EveryNEvents(n)changes fromversion % n == 0to 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 ofnis arithmetic, and for many strides the answer is never. Such a stream snapshotted exactly never, with no symptom beyond loads growing slowly slower. Amends0021;0017stands as a historical record.0050-read-model-version-conflict-error-name.md— the read-model conflict exception is renamedReadModelVersionConflictError. It had shared the nameOptimisticLockErrorwith 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. Amends0029;0041stands.0051-adapters-common-shared-port-semantics.md— addsadapters/_common/, a third adapters-internal package alongside_sql/(dialect-specific) and_bus/(transport-specific), for port semantics that belong to no backend.check_expectedhad 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.0025and0031stand.0052-feed-read-aggregate-type-filter.md—FeedReadOptionsgainsaggregate_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 handlesfrom_position/tenant_id/limit;limitstill bounds events returned after filtering, so positions resume the filtered sequence. Distinguishes the new filter fromread_category, which selects the same events but orders by storage time. PostgreSQL gains a composite(aggregate_type, global_position)index; SQLite needs none,global_positionbeing its rowid. Additive with a default.0025,0027, and0046stand.0053-sqlite-snapshot-store-owns-its-connection.md—SQLiteSnapshotStoreopens one connection lazily, applies the bundled sqlitesnapshotsDDL to it, and implementsSupportsClose, matchingSQLiteEventStore's discipline instead of opening a connection per operation. Makes":memory:"work (it never had), and gives consumers a way to release the non-daemonaiosqliteworker thread — the absence of which had pushed one downstream consumer into threading a single instance through every call site.0036,0037, and0039stand.0054-projection-replay-driver.md—replay(feed, projections, ...), the rebuild counterpart toProjectionCoordinator'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. Forwardstenant_id/aggregate_typeinto the adapter's query instead of filtering after delivery.0019,0024,0041, and0052stand; extends0048.-
0055-generic-store-projection-base.md— addsStoreProjection[TStore], aDeclarativeProjectionholding one store and exposing it to handlers asself._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 onlystoreand 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: Anywas rejected for trading a silent narrowing for a silent widening.0013,0024, and0045stand. -
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 wasdecidebuilding events — the one field in a fold-of-the-stream model that the stream could not reconstruct.decidenow 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 thematcharm.DeciderScenariolosesaggregate_id=for the same reason. Amends0022;0042and0046stand.
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 theEventStoreABC, opaque totally-orderedPositiontokens with a no-skip/exclusive-resumption feed guarantee, 1-based stream versions kept, duplicate-event_idappends promoted to a typed idempotency primitive. Amends0014(position-delta lag is abolished). The first genuinely forward-looking record in the set — written againstdocs/superpowers/specs/2026-07-29-core-rings-design.mdbefore 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:ProjectionCheckpointsandSubscriptionPositionsas segregated ports (withCheckpointRepositoryas their composed convenience protocol),ProjectionCheckpointManager/ProjectionDLQManagerdissolved into six module-level functions with span names deliberately kept,DatabaseProjectionclassified as an adapter, andcheckpoint_repo=None/dlq_repo=Nonechanged to mean "disabled" rather than "construct an in-memory default." Amends0015(connection-helper split). Sibling of0021. Complete. Amended by ADR 0025 (SubscriptionPositionsretypes fromintto the opaquePositionvalue object).0025-legacy-store-retirement.md— retiring the legacyEventStoreABC 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-positionAppendResultsemantics, duplicate-append raising, storage-time inclusive category reads,TypeConverterremoval,MemoryEventStorerenamedInMemoryEventStore, and outbox write support ported onto the PostgreSQL adapter. Amends0016(store spans removed),0019(compatibility wrapper deleted), and0024(SubscriptionPositionsretyped). Complete.0026-outbox-ring-migration.md— completing the Protocol/implementation split ADR 0024 began: the outbox Protocol,OutboxEntry/OutboxStats, andoutbox_event_data()move toports/outbox.py, the three backend implementations move to per-technologyadapters/{memory,postgresql,sqlite}/outbox.pymodules (per-backend rather than dialect-parameterized, since SQLite takes a rawaiosqlite.Connection),repositories/is deleted, andOutboxRepositoryProtocol/list_pending_eventsdie with no shims. Extends0024without 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'sid INTEGER PRIMARY KEY AUTOINCREMENTcorrected in place toTEXT PRIMARY KEY(a narrow, explicit exception tomigrations/'s append-only rule, since the shipped column could never hold a row its own writer produces), (b) anevents.txid xid8column plus a rewritten safe-horizon predicate replacing the wraparound-unsafexmin-cast comparison in the PostgreSQL global feed (NULL-txidrows are always safe by theACCESS EXCLUSIVEargument; bound per read rather than inlined per row), and (c) the shared integration test fixture'seventstable reconciled onto the canonicaltenant_id UUIDschema viaget_schema("events"), retiring the hand-rolledVARCHAR(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 ofredisfrom 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 ofrepositories/_connection.py).0016-optional-tracing-no-op-by-default.md— OpenTelemetry behind thetelemetryextra, a singleOTEL_AVAILABLEprobe inobservability/tracing.py, andNullTraceras a null object satisfying the sameTracerprotocol so call sites hold a plainwith 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 indocs/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.