Skip to content

0057. Tenant Load Enforcement Is a Precondition, and Says So

TenantAwareRepository's enforce_on_load flag enforced nothing about what was loaded. It is renamed require_tenant_context, and the limitation now appears at the point of use rather than only in an ADR section.

Status

Accepted.

ADR Impact

ADR Status
0018 Amended — the flag it describes is renamed, and the reasoning in its §6 for why filtering was not implemented argues from an EventStore interface that no longer exists. Its conclusion is unchanged and restated here on current grounds; the isolation model itself stands.
0030 Stands. Applied: the rename lands without an alias.
0038 Stands. The wrapper stays where that relocation put it.

Context

Downstream consumers have repeatedly read enforce_on_load=True as read isolation, discovered it is not, and reported it as a bug. It is not a bug — ADR 0018 §6 records the incompleteness deliberately — but a setting whose name promises enforcement of loads, and which enforces only that the caller is inside some tenant scope, is a name that has to be corrected by documentation the reader only consults after being surprised. This project has paid for that shape before (ADR 0050).

ADR 0018 §6 justified the absence of filtering by pointing at EventStore.get_events(aggregate_id, aggregate_type=..., from_version=...) and at ReadOptions.tenant_id existing only on the newer streaming paths. Neither API survives. The aggregate-load path today runs through AggregateStore.read_stream(StreamId, StreamReadOptions), and the options objects have since been split by read shape: FeedReadOptions and CategoryReadOptions both carry tenant_id; StreamReadOptions does not. So the surface facts the old argument rested on are stale, while its conclusion — no filtering on the aggregate-load path — still describes the tree.

Restating the case on current ground gives a stronger reason than the original one. A stream is a single aggregate, and an aggregate belongs to one tenant. Tenancy is therefore a property of the stream, not of the individual events within it: a per-event tenant predicate on a stream read is not a filter, it is an ownership check wearing a filter's clothes. Adding tenant_id to StreamReadOptions would express the check in the one shape that cannot mean what it should — in the mismatch case it returns a subset, and the repository replays that subset into a partially reconstituted aggregate. That is the outcome ADR 0018 rejected as worse than nothing, and pushing the predicate down into the query does not change it; it only removes the consumer's chance to notice.

Two further facts close off the cheap alternatives. Nothing carries an aggregate's tenant outside its events: AggregateRoot has no tenant_id, and neither does Snapshot. So the wrapper cannot check ownership after the fact, because by the time load() returns, the events are gone and only state remains. And a snapshot restores state derived from events without reading them at all, so any events-only filter is bypassed exactly when an aggregate is large enough for someone to care.

Decision

No filtering, and no tenant parameter on the stream-read path. Read isolation continues to be delegated to the storage layer, as ADR 0018 §7 decided. An all-or-nothing stream-ownership check is the shape that would be correct, and it requires a notion of stream ownership the library does not have and a tenant on the snapshot record; neither is in scope here, and a half-measure that returns partial aggregates is worse than the documented gap.

Rename enforce_on_load to require_tenant_context. The new name states the precondition it actually imposes — a tenant scope must be active — and promises nothing about the result of the read. Per ADR 0030 there is no alias: the old keyword raises TypeError, which is the point, because silent acceptance would preserve the misreading the rename exists to end.

State the limitation where it is read. The class docstring, the load(), exists(), and load_or_create() docstrings, the multi-tenancy tutorial, and the multi-tenancy API guide each say that reads are not isolated and that isolation must come from the database. A reader who never opens an ADR now learns this before relying on the flag.

Pin the non-guarantee in tests. Unit tests assert, against a real store, that a load inside one tenant's scope returns another tenant's aggregate fully replayed. Asserting an absence of protection is unusual, and deliberate: it makes any future change that begins filtering reads announce itself here.

Consequences

Callers passing enforce_on_load= get a TypeError and a one-word fix. The runtime behavior of the wrapper is unchanged — nothing that was prevented before is now allowed, and nothing that was allowed is now prevented.

The security posture is unchanged and now honestly labelled. What the library guarantees on reads is: this read happened inside a tenant scope. What it does not guarantee is: this read returned only that tenant's data. That second property must come from PostgreSQL row-level security or physical separation, exactly as before.

The correct primitive is now recorded rather than merely absent. If read enforcement is picked up later, this ADR names its shape — an ownership check on the stream, all-or-nothing, covering the snapshot path — so that the next attempt does not rediscover the partial-replay trap by building it.