ADR-0025: Legacy Store Surface Retirement¶
Status: Accepted
Date: 2026-07-31
Deciders: Library maintainers (architecture owner)
Context¶
The EventStore ABC in src/eventsource/stores/interface.py is the legacy store surface. As of slice (b), a new port-based surface (EventAppender, StreamReader, EventLookup, GlobalEventFeed, CategoryQuery) has been introduced to replace it. This ADR documents the multi-slice effort to migrate off the legacy surface and retire it from the public API entirely.
The full design is recorded in docs/superpowers/specs/2026-07-31-legacy-store-retirement-design.md. The decision and consequences are filled in by slice (d).
Decision¶
The library is unreleased, so the standing rule applies without qualification: retirement introduces no deprecation shims and no back-compat aliases. stores/ is deleted entirely and eventsource ends with exactly one blessed set of store names.
- The
EventStoreABC surface is retired outright — no shim module, no re-exported alias, noDeprecationWarningbridge. Every consumer is retyped onto the five segregated ports (EventAppender,StreamReader,EventLookup,GlobalEventFeed,CategoryQuery) landed by ADR 0019, andsrc/eventsource/stores/no longer exists. expected_versionint-to-VO translation is by name, never by numeric coincidence.ANY(-1) maps to.any_(),NO_STREAM(0) maps to.no_stream(),STREAM_EXISTS(-2) maps to.stream_exists(). The sentinel integers happened to be negative and zero; nothing about the mapping may ever rely on that arithmetic, only on the named constant being matched.- Cross-type
get_events(aggregate_type=None)is dropped, not ported. No production caller exercises it — every real call site already carries a concreteaggregate_type— and the one test that omitted it asserted callability against a mock, not cross-type semantics. Rejected alternative, named and not built: a narrowStreamDiscovery.find_streams(aggregate_id) -> list[StreamId]port. If a genuine cross-type need appears later, that is the honest shape for it. AppendResult.positionis the position of the first appended event. Legacyglobal_positionwas the position of the last appended event. All three adapters return the first-event position; this was already true ofLegacyStoreAdapterwithout being documented, and is now the recorded contract.- Duplicate-
event_idappends raiseDuplicateEventError. The legacy in-memory and PostgreSQL stores silently skipped a duplicate append; every ports adapter raises instead, giving migration tooling a race-free idempotency primitive rather than a silent no-op. - Category reads filter and order on storage time, inclusive, with position as tie-break. Legacy
get_events_by_typefiltered and ordered on the event's ownoccurred_at, exclusive (>).CategoryQuery.read_categoryfilters and orders onEventEnvelope.stored_at, inclusive (>=), with position as the deterministic tie-break. Naive datetimes are rejected withValueErrorrather than silently compared against timezone-aware ones. TypeConverteris removed, not moved. Field-name guessing inside untypeddict[str, Any]fields is replaced by typed pydantic sub-models; a consumer that needs structure declares it.- Store-level tracing spans are removed (amends ADR 0016). The legacy stores carried per-operation spans (
inmemory_event_store.*,postgresql_event_store.*,sqlite_event_store.*); the ports adapters carry none. A ports-level tracing decorator is backlogged, not promised — spans survive at the repository, projection, subscription, and migration layers, which is where this loss is absorbed. SubscriptionPositionsis retyped fromposition: intto the opaquePositionvalue object (amends ADR 0024). The integer was the legacy store's global position leaking through a port that has no business knowing about store internals.- Position-delta lag becomes count-behind lag, completing the amendment ADR 0019 already made to ADR 0014. Subtracting opaque positions was never well-defined; lag is now expressed in count-behind or wall-clock terms wherever it is surfaced.
OptimisticLockErrorkeeps its int-typedexpected_versionfield, deliberately. The adapters already preserve the legacy sentinel ints (-1/0/-2) for message fidelity via private adapter-internal constants. Retyping a widely-caught exception to carry the VO is churn with no consumer demand, and the sentinel constants are an adapter-internal message-formatting detail, not part of the port contract.MemoryEventStoreis renamedInMemoryEventStore, for sibling-naming consistency with the other adapter classes.- Outbox write support is ported onto the PostgreSQL adapter.
outbox_enabledmoves to the adapter's constructor so the same-transaction outbox guarantee survives the deletion of its only previous writer (the legacyPostgreSQLEventStore).
Two further decisions were surfaced by slice (c)'s self-review and are not enumerated in the design spec's §9 list; they are recorded here as additions to that enumeration:
- Nearest-position lookup became a binary search.
find_nearest_source_positionwas previouslyORDER BY source_position DESC LIMIT 1over a BIGINT column. OpaquePositiontokens cannot be ordered in SQL —Position.to_str()is JSON, and its lexicographic order is not position order — so the lookup became a binary search over the surrogateidcolumn, with the ordering comparison performed in Python and resting on a documented monotonicity precondition. Rejected alternative: load all mappings and scan linearly, which is correct but unbounded in memory. - The legacy BIGINT position columns are frozen, not dropped.
projection_checkpoints.global_position,migration_position_mappings.source_position/.target_position, andtenant_migrations.last_source_position/.last_target_positionare neither written nor read by the library after slice (c). They remain in the schema: dropping a column is a destructive operation,schemas/checkpoints.sqlis under the Do Not Modify rule, and the additive-fragment migration mechanism exists to add columns, not remove them. They will die with their own schema revision, not this one.
ADR Impact¶
Per .claude/rules/definition-of-done.md, this record's own impact statement over the ADRs it touches:
| ADR | Disposition |
|---|---|
| 0001 async-first design | Stands. Every retyped signature stays async; the sync wrapper remains a wrapper. |
| 0014 live-migration cutover semantics | Stands, as amended by 0019. 0019 already abolished position-delta lag; this retirement performs that abolition in the migration sync-lag tracking (Decision 10). No further amendment of its own. |
| 0015 optional-dependency extras | Stands. Extras are unchanged. |
| 0016 optional tracing no-op by default | Amended. Store-level spans are removed with the legacy stores (Decision 8); status pointer added. |
| 0018 tenant isolation model | Stands. Tenancy remains a read-option filter, never a stream-identity component. |
| 0019 clean-architecture store ports | Amended. Its Status condition — the legacy ABC remaining the default surface behind the compatibility wrapper — has ended; status pointer and a Consequences line added. |
| 0021 snapshot policy/scheduler composition | Stands. Snapshot collaborators were already ports-typed. |
| 0024 projection persistence ports | Amended. SubscriptionPositions retypes from int to Position (Decision 9); status pointer added. |
Consequences¶
- The public surface loses
eventsource.storesentirely:import eventsource.storesraisesModuleNotFoundError, andMemoryEventStore,LegacyStoreAdapter,StoredEvent,EventStream,ReadOptions,TypeConverter, andEventStoreConformanceSuiteall die without a replacement name.InMemoryEventStore,PostgreSQLEventStore, andSQLiteEventStorecontinue to exist, sourced fromeventsource.adapters.*and re-exported from the ports adapters rather than fromstores/. - Consumers upgrading past this ADR must re-verify every call site touching
AppendResult.position(first-vs-last), duplicate-append handling (now raising), category-read time semantics (storage time, inclusive),stored_atassertions in tests written against the legacy in-memory store's fabricated timestamp, empty-batch appends (nowValueError),current_position()on an empty store (nowNone, not0), and any use of BACKWARD feed reads or feed-level timestamp filters, neither of which has a ports equivalent. docs/adrs/0016-optional-tracing-no-op-by-default.md,docs/adrs/0019-clean-architecture-store-ports.md, anddocs/adrs/0024-projection-persistence-ports.mdeach carry an "Amended by ADR 0025" Status pointer as part of this change, per the ADR Impact table above.- The interface-adapters ring's
PostgreSQLEventStoreoutbox write path (Decision 13) is the one piece of legacy-adjacent code that grows rather than shrinks in this retirement; everything else in the deletion list (§7 of the design spec) is net removal. - The rejected
StreamDiscoveryport (Decision 3) and the rejected linear-scan nearest-position lookup (Decision 14) are recorded here so a future implementer who reaches for either first checks why it was declined.
Supersedes¶
Nothing.
Superseded by¶
Nothing.