Skip to content

0055. StoreProjection Forwards the Projection Constructor by Name, Once

0.10.0 widened three projection constructors because their subclasses had been silently dropping retry_policy and tracer. That fixed the classes in this tree and left every subclass outside it re-deriving the same forwarding boilerplate by hand.

Status

Accepted.

ADR Impact

ADR Status
0013 Stands. StoreProjection is a DeclarativeProjection, so handler discovery and routing are inherited unchanged; this ADR adds nothing to and takes nothing from the registry.
0024 Stands, and supplies the ring test. It classified DatabaseProjection as an adapter because its constructor names an async_sessionmaker. StoreProjection takes a type parameter instead, which is what keeps it in the application ring.
0045 Stands. StoreProjection[TStore] uses the inline parameter syntax it decided, declared at the class rather than as a shared TypeVar.

Context

A projection that folds the log into exactly one store is the common shape, and writing one currently means either accepting DeclarativeProjection's constructor verbatim or restating it. Restating it is what the 0.10.0 defect was: several signatures spelling out one parameter list, with nothing failing when one of them fell behind.

The library fixed its own. A consumer that needs a store — or any constructor parameter of its own — is back in the same position, and worse placed to notice: the narrowing is invisible until someone passes an argument the class does not take, and the class is theirs, so no upstream test covers it. One downstream consumer carried a file whose entire content was the restatement, with a docstring explaining why. That file is the artifact of a gap here, not of a mistake there.

The restatement also leaks a version fact. A subclass that names retry_policy in its own signature has to know which release made that parameter addable; get the floor wrong and the code imports fine and fails at construction against an older library.

**kwargs: Any is the obvious way to stop restating, and it is why the problem persisted: it trades a silent narrowing for a silent widening. The type checker stops verifying that forwarded options exist, are spelled right, or have the right types, and a typo becomes a runtime TypeError at the super().__init__ call. The downstream file rejected it for exactly this, and that judgment was correct.

Decision

Add StoreProjection[TStore] to application/projections/: a DeclarativeProjection holding one store, exposed to handler methods as self._store.

Its constructor takes the store and forwards everything else through **options: Unpack[ProjectionOptions], where ProjectionOptions is a public TypedDict naming the parent's option set. This is PEP 692: the keyword arguments stay individually typed, checked, and completable, and no caller or subclass writes a parameter name. A subclass adding parameters of its own declares only those, and forwards the rest as one opaque **options.

The store is a type parameter, never a concrete adapter or a driver type. That is the whole of what keeps this class in the application ring, and the line ADR 0024 drew when it sent DatabaseProjection to adapters/sql/.

The store attribute is _store, not store. Every collaborator on these classes is protected — _tracer, _retry_policy, _tenant_filter, _handler_registry, _model_class, _session_factory — and the only intended reader is a @handles method on the subclass itself. A public store would be the sole exception, and would promise external callers a read-through the class does not otherwise offer.

Forwarding is Unpack[ProjectionOptions], not explicit parameters and not **kwargs: Any. Explicit parameters solve the problem only for a subclass that adds nothing; the moment a consumer needs one parameter of their own, they are restating the list again, which is the defect. **kwargs: Any solves it at the cost of type checking. Unpack is the option that gives up neither.

The constructor contract

The base class is half the answer. The other half is a promise, stated here because it is what a subclass author actually relies on:

Every projection base's constructor accepts at least what its parent's accepts. This holds permanently, in every release, and is enforced by test.

That is what 0.10.0 established by fixing the four constructors and adding the superset test; it had never been written down as a guarantee, only performed. Writing it down is what lets a subclass author stop tracking releases. Without it, **options forwarding is a mechanism whose safety depends on nobody narrowing a constructor later — and a narrowing would break forwarding subclasses exactly as silently as it broke restating ones.

The concrete consequence for a consumer: a parameter never disappears, so the question "which version added retry_policy?" has no successor question. A subclass forwarding **options names no parameter at all, so the only version floor it needs is the one for StoreProjection itself — one floor for the base class, not one per parameter, which is the failure mode that shipped an undetected too-low floor downstream.

The guarantee is about acceptance, not defaults or semantics. A parameter's default may change, and such a change is breaking in the ordinary way and announced in the ordinary way; what does not happen is a parameter silently ceasing to be accepted.

ProjectionOptions is a second declaration of the parent's parameter list, which is recurring defect shape #2 — so it is pinned: a test asserts the TypedDict's keys equal DeclarativeProjection.__init__'s named parameters, and fails if either side gains or loses one. The 0.10.0 superset test is extended rather than sidestepped: its parameter-name extraction now expands an Unpack-annotated **kwargs, so StoreProjection is checked against its parent by the same rule as every other pair in the chain.

Consequences

A consumer writing a store-backed projection subclasses StoreProjection, writes @handles methods, and never learns what DeclarativeProjection's constructor accepts. Options added to the parent in a future release reach existing subclasses without them changing anything, and without a version floor to get wrong.

The superset test becomes load-bearing in a way it was not before. It was added as a regression guard; it is now the enforcement of a public contract, and a future change that trips it is a contract breach to be reconsidered rather than a test to be updated.

ProjectionOptions is public API, because a subclass's own signature has to name it. It is therefore subject to the same compatibility expectations as the constructor it mirrors — which is appropriate, since it is that constructor's signature under another name.

Forwarded options become keyword-only. DeclarativeProjection accepts checkpoint_repo, dlq_repo, and enable_tracing positionally; StoreProjection does not, store being its only positional parameter. No existing call site is affected, the class being new, and keyword-only is the better default for a set of optional collaborators.

Unpack for **kwargs is a new idiom in this tree. It is deliberately scoped to constructor forwarding along an inheritance chain — the one place where restating a parameter list has demonstrably drifted. It is not a general license to replace explicit signatures.