ADR 0019: Clean-Architecture Store Ports and Opaque Positions¶
Status: Accepted and implemented — ports, memory/sqlite/postgresql adapters,
conformance suites, and the LegacyStoreAdapter compatibility wrapper have
landed on this branch. The legacy EventStore ABC remained the default shipped
surface, behind that compatibility wrapper, until the application layer was
retyped onto the new ports (sub-project 2). That condition has now ended.
Amended by ADR 0025. The retyping is complete: LegacyStoreAdapter and the
legacy EventStore ABC are deleted, and the ports surface described below is
the only surface. See Consequences.
Date: 2026-07-29
Deciders: Library maintainers (architecture owner)
Context¶
The EventStore ABC requires global-ordering methods that wide-column,
document, and log-structured backends cannot honestly provide. The interface
already admits this inconsistently: read_all has a non-abstract default that
raises NotImplementedError, get_global_position is abstract, and the
read_stream default fabricates global_position=0. The library has no
external users, making a full store-contract redesign uniquely cheap now. The
target backend families are SQL (PostgreSQL, SQLite, MySQL, CockroachDB),
wide-column (Cassandra, ScyllaDB, DynamoDB), document (MongoDB), and
purpose-built ES/log systems (EventStoreDB, single-log Kafka).
Full design: docs/superpowers/specs/2026-07-29-core-rings-design.md.
Decision¶
- Clean Architecture rings with the Dependency Rule. Entities
(
domain/), boundary ports (ports/), use cases (application/), interface adapters (adapters/) with framework/driver imports confined to the outermost ring. Enforced by import-linter contracts as the layout lands. - The store contract is five segregated output ports, structural
Protocolclasses:EventAppender,StreamReader,EventLookup,GlobalEventFeed,CategoryQuery. A backend implements exactly the ports it can honor; unsupported capability = unimplemented port, neverNotImplementedError. - Global feed positions are opaque ordered tokens (
Positionvalue object): totally ordered within one store, serializable, no arithmetic, store-identity-guarded (ordering comparisons across stores raise; equality returns False). Produced only byGlobalEventFeedimplementers. Consequence, accepted deliberately: position-delta lag metrics are abolished (amends ADR 0014); lag is re-expressed in wall-clock or count-behind terms in the application-layer redesign. - Per-stream versions remain 1-based integer event counts (absent = 0) —
unchanged from the current contract and schema. Optimistic concurrency via
an
ExpectedVersionVO withany/no_stream/stream_exists/exact. - The feed contract guarantees exclusive resumption and no-skip delivery: resuming strictly after a feed-produced position must never permanently skip a committed event. The PostgreSQL adapter bounds feed reads to a transaction-safe horizon to honor this.
- Duplicate
event_idappends raiseDuplicateEventError, backed by the existing unique constraint — a race-free idempotency primitive for migration tooling.
Alternatives Considered¶
- ABC inheritance ladder (
EventStore→GloballyOrderedEventStore): rejected — capabilities are orthogonal, ladders force false hierarchies, and default implementations invite theNotImplementedErrorpattern this ADR bans. - Int positions everywhere, NoSQL backends synthesize them: rejected — pushes an unbounded synthetic-ordering problem into every future adapter.
- Runtime capability flags (
supports_global_ordering): rejected — moves wiring errors from mypy to production. - Partially-ordered positions (Kafka offset vectors): rejected — exclusive
resumption is only well-defined over a total order; multi-partition Kafka
is out of
GlobalEventFeedscope rather than weakening the law.
Consequences¶
- Catch-up subscriptions and live migration type-require
GlobalEventFeed; stores without it are statically excluded from those roles. - Existing backends port behind legacy wrapper classes until the application layer is retyped; persisted int checkpoints stay decodable via the SQL position codec.
- ADR 0014 is amended as described in Decision 3.
- ADR 0018's tenant model stands: tenancy remains a read-option filter, not a stream-identity component.
- As of ADR 0025, the compatibility wrapper (
LegacyStoreAdapter) and the legacyEventStoreABC are deleted. All of this ADR's Decisions stand and are now the only store surface; nothing here is superseded.