Stratara.Testing.EntityFrameworkCore 4.4.0

Prefix Reserved
dotnet add package Stratara.Testing.EntityFrameworkCore --version 4.4.0
                    
NuGet\Install-Package Stratara.Testing.EntityFrameworkCore -Version 4.4.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Stratara.Testing.EntityFrameworkCore" Version="4.4.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Stratara.Testing.EntityFrameworkCore" Version="4.4.0" />
                    
Directory.Packages.props
<PackageReference Include="Stratara.Testing.EntityFrameworkCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Stratara.Testing.EntityFrameworkCore --version 4.4.0
                    
#r "nuget: Stratara.Testing.EntityFrameworkCore, 4.4.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Stratara.Testing.EntityFrameworkCore@4.4.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Stratara.Testing.EntityFrameworkCore&version=4.4.0
                    
Install as a Cake Addin
#tool nuget:?package=Stratara.Testing.EntityFrameworkCore&version=4.4.0
                    
Install as a Cake Tool

Stratara.Testing.EntityFrameworkCore

Derived. The behaviour described here is specified under openspec/specs/. Those specifications are the source; this page explains and illustrates them.

Spin up the real Stratara event-sourcing write stack — IEventSource, IAggregationService, snapshots, and the EF Core write store — against a shared in-memory SQLite database, in one call. You exercise production code paths (real serialization, real version tracking, real unique constraints) without Postgres or Docker.

Builds on Stratara.Testing: the cross-cutting dependencies are wired with its in-memory doubles (InMemoryKeyStore, TestSessionContextProvider).

Why not a hand-rolled in-memory IEventSource?

Because a bespoke fake would drift from production (subject resolution, concurrency detection, outbox dispatch, snapshots). This package runs the genuine EventSource on SQLite instead, so your tests verify the real behavior.

Example

await using var host = EventStoreTestHost.Create(s =>
    s.AddAggregatesFromAssemblyContaining<Account>());

await host.ExecuteAsync(async events =>
{
    await events.CreateAsync<Account>(id, new AccountOpened(id, tenantId, "Ada", 100m));
    await events.AppendAsync<Account>(id, new AmountWithdrawn(30m));
    await events.SaveChangesAsync();
});

var account = await host.AggregateAsync<Account>(id);
Assert.Equal(70m, account!.Balance);
Assert.Single(host.Outbox.Bundles);   // the SaveChanges emitted one bundle

Contents

  • EventStoreTestHost — owns a shared open SQLite connection + a configured service provider; exposes ExecuteAsync(IEventSource), AggregateAsync<T>(streamId), the preset Session, and the recording Outbox. IAsyncDisposable.
  • AddStrataraTestingEventStore<TWriteDbContext>(connection, tenantId) — the lower-level DI extension if you compose the provider yourself. The overload that takes a connection string opens a connection per context to a shared-cache in-memory database — for a store used from several threads, such as a silo's — and applies an Action<DbContextOptionsBuilder> after the provider, for an interceptor.
  • StrataraTestWriteDbContext — a ready-made concrete write context (no subclass boilerplate).
  • RecordingEventBundleOutboxDispatcher — captures emitted bundles for assertions.

Notes

  • The SQLite connection is :memory: and shared across every DbContext the unit of work mints — it must stay open for the host's lifetime (the host manages this; dispose it when done).
  • Register your aggregates (AddAggregatesFromAssemblyContaining<T>()) so event payload types deserialize on rehydration.

Dependencies

  • Stratara.Testing, Stratara.Infrastructure, Stratara.EventSourcing.EntityFrameworkCore, Stratara.Shared, Stratara.Abstractions, Stratara.Contracts
  • Microsoft.EntityFrameworkCore.Sqlite

Reference it from test projects only.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on Stratara.Testing.EntityFrameworkCore:

Package Downloads
Stratara.Testing.Orleans

Run the Stratara Orleans execution model in a test's own process — one silo with in-memory reminders and grain directory, the real write stack, commit-order reader and checkpoint store on in-memory SQLite, and every period shortened to seconds. Register handlers, projections, sagas and timers with the production calls; no cluster, broker or database server. Builds on Stratara.Testing.EntityFrameworkCore.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
4.4.0 0 9/27/2026
4.3.1 38 9/25/2026
4.3.0 90 9/23/2026
4.2.0 149 9/18/2026
4.1.1 82 9/16/2026
4.1.0 86 9/16/2026
4.0.4 138 9/14/2026
4.0.3 120 9/3/2026
4.0.2 100 9/3/2026
4.0.1 98 9/2/2026
4.0.0 103 8/31/2026
4.0.0-preview.1 68 8/31/2026
3.4.0 96 8/28/2026
3.3.0 108 8/25/2026
3.2.3 109 8/22/2026
3.2.2 111 8/14/2026
3.2.1 113 8/2/2026
3.2.0 117 7/18/2026
3.1.7 125 7/1/2026
3.1.6 114 6/22/2026
Loading failed

A release about events a reader has no use for. Rebuilding an aggregate, and every projection and
saga read path, used to resolve and decrypt each event before asking whether anything handled it, so
an event nobody in the host took — one retired from an aggregate, a framework event another host
reads — could stop a rebuild, dead-letter a bundle, fail every replay or stall an Orleans partition
merely because its type was not registered. Such events are now left unread, while an event that
might be handled still fails loudly. Beside that, a projection can declare that it forgets a deleted
tenant, so facts recorded for the tenant after its deletion no longer fail it; that needs a migration
of the read context.

It also closes gaps in who owns an event and what an erasure reaches. A tenant's erasure left the
default-level and confidential fields of the tenant's events readable; it now shreds every key naming
the tenant, and an erasure finds a key even when the subject it shares it with is no longer in the
directory. A stream keeps the user it was created for in every later save, a Subject stated for one
event stays with that event, and a user's erasure reaches the snapshots of that user's aggregates. A
save that fails no longer leaves its events staged for a retry to write a second time.

And it makes sure work that committed is not run twice. A save whose events are committed but whose
bundle could not be handed on now says so, with `CommittedEventsNotPublishedException`, and nothing the
framework runs — the transports, the retry pipelines, the Orleans execution model's recorded commands,
store readers, timers and sagas — runs it again. A commit once begun runs to its end, so a committed
save is never reported as cancelled. Subscriptions stop without handing back or dead-lettering what
their handlers finished, and the host waits for them within its shutdown timeout. A snapshot captures
only committed events, and an append now also runs on a write context whose execution strategy
retries on failure.

Finally, it makes a long history cheap to read and closes two gaps in how a host is put together. A
long-lived stream no longer makes a replay or a commit-order backfill re-read its past in every batch,
so a long history is read and prepared in time that grows with it. A write context that filters by
tenant no longer hides the store from the framework's own work, which now reads its events, snapshots
and hash-chain anchors past every filter the write context declares; a handler that must refuse another
tenant's aggregate checks the owner of the aggregate it loaded. A registration made after the host was
built is refused instead of changing the running host.

**Upgrading:**
- **Generate EF Core migrations for your read and write contexts.** The read context declares a new
 table, `projection_forgotten_tenant`, for projections that declare `IForgetsDeletedTenants`; a host
 without such a projection never touches it, and a projection that declares it knows deletions applied
 before the upgrade only after a replay — or, on the Orleans execution model, a rebuild of an
 `IRebuildableProjection`. The write context's `snapshot` table gains a nullable `user_id` column.
- **Remove the snapshots an earlier release may have written wrongly.** A snapshot of a user's aggregate
 was written under the tenant alone, and a snapshot could capture events that were never committed. The
 entries below give the statement that removes the first kind; to be sure of the second, delete the
 snapshots and let the next save the snapshot strategy approves write them again — a rebuild without
 one replays the events.
- **If an aggregate's stream holds an event the aggregate has no `Apply` for, upgrade.** Rebuilding
 it no longer requires that event's type to be registered. A workaround — a no-op `Apply` or an
 explicit `AddTrustedType<T>()` — keeps working. A no-op `Apply` can go; an `AddTrustedType<T>()`
 keeps the new warning quiet and is still needed wherever a projection, a saga or the bus reads the
 type.
- **Watch for the new warning `102_004`** (*Rebuilding … skipped events of type …*). It names an event
 whose type does not resolve in the host and that no `Apply` of the aggregate could take. Usually
 that is an event the aggregate ignores; if it is a handled type renamed without an upcaster, add the
 upcaster.
- **A projection or saga host no longer needs every domain event type registered.** An event no
 handler in the host takes is left unread. `AddDomainEventTypesFromAssemblyContaining<T>()` calls that
 existed only for that can go. One trade: a handled type *renamed* without an upcaster used to fail the
 bundle or replay loudly and is now left unread — a type moved to another namespace or assembly still
 fails, because it keeps its name. Add the upcaster when you rename a handled event.
- **If you replaced a framework piece on the read path,** it keeps today's behaviour: a projection or saga
 manager of your own still receives every event of a bundle, and a mapper of your own — or a decorator
 that forwards only the older `MapToEventsAsync` overloads — still maps everything through the new
 overloads' default implementations. A test double of `IEventMapperFactory` must set up the new
 overloads (with Moq: or `CallBase = true`).
- **If you erased a tenant before upgrading, erase it again.** The earlier run left the keys of its
 default-level and confidential values that name no user; a second run shreds them and, where the key
 store can list its keys, the keys the tenant shared with its former members as well.
- **A key store of your own should implement `IKeyStore.ListScopesAsync`,** and a decorator should
 forward it. Without it an erasure shreds the keys the directory names and warns that it could not
 reach the rest.
- **Retrying a failed `SaveChangesAsync` without appending again now writes nothing.** A failed save
 discards what it staged. Append the events again, as after a `ConcurrencyException`. A save that
 fails with `CommittedEventsNotPublishedException` has recorded its events: do not append them again
 and do not retry it.
- **`ErasureReport.Planes` lists `KeyMaterial` before `Memberships`.** The erasure sweeps the
 memberships last now, so that a second run still finds them.
- **A write or read context your host registers itself** — the Orleans execution model on a store other
 than PostgreSQL — adds `CommitCompletionInterceptor.Instance`, last among its transaction interceptors,
 as the framework does on the contexts it registers. Without it the framework's unit of work saves on
 that context without the caller's token.
- **The Orleans execution model's registrations set `MessagingOptions.WaitForCancellationAcknowledgement`.**
 A grain call of the host that carries a token now waits for its grain's answer — at the latest until
 the response timeout — instead of ending the moment the token fires.
- **A RabbitMQ subscription holds at most `Messaging:PrefetchCount` messages** (default 16).
- **On a context whose execution strategy retries on failure,** the framework's saves run as one
 retriable unit: an override of `SaveChangesAsync(CancellationToken)` alone is no longer called there,
 and `SavedChanges` handlers run before the commit.
- **During a rolling upgrade of an Orleans cluster,** silos of the two versions cannot read each other's
 validation failures; such a call fails with a serialization failure until every silo is upgraded.
- **Register everything before the host is built.** A framework registration that adds to what is already
 registered — the catalogs, the membership options, the trusted types, the Orleans roles and singleton
 works — throws `InvalidOperationException` when called on `builder.Services` after `Build()`, instead of
 changing the running host.
- **If your write context declares the tenant query filter** (`ApplyGlobalTenantQueryFilters`), the framework
 now reads its store past it, so the write context behaves like one without the filter. A command handler
 given another tenant's aggregate id loads and appends to that aggregate; if yours must refuse that, check
 the loaded aggregate's owner against the session, as the tenant-isolation guide shows.
- New types and members are additive; no existing public signature changes.

### Added

- **`EventRelevance` and two `IEventMapperFactory` overloads that take it**, one for stored entries and one
 for bus messages. The framework's mapper resolves and decrypts only the events the relevance accepts;
 the overloads' default implementations map everything and filter afterwards, so a consumer's own
 mapper compiles and behaves as before. `EventRelevance.ForTypes`, `AnyResolvable` and `AnyResolvableWith`
 describe what a reader takes. Warning `102_005` names an event a stateful saga process's reader skipped
 because its type does not resolve, once per host and event type.
- **A projection can forget a deleted tenant.** A projection that removes a deleted tenant's rows met
 facts recorded for that tenant after its deletion — work queued before it ran to its end — with
 nothing to apply them to, and its `PrecedingFactMissingException` made the live bundle dead-letter and
 every replay fail, leaving the read models the replay had emptied empty. Declare
 `IForgetsDeletedTenants` on it — a promise that a deleted tenant's data is gone from its read model
 once either deletion fact is applied: the framework hands it `TenantDeleted` and `CustomerTenantsDeleted`
 whether or not it handles them, records per projection the tenants it deleted, and passes over a
 missing-prerequisite report for a fact owned by such a tenant, logged at Information (`104_014`).
 Nothing else changes. The record is kept by `IForgottenTenantStore`, which
 `AddNpgsqlReadDbContextFactory<TContext>()` registers over the new table, or
 `AddStrataraForgottenTenants<TReadContext>()` for a read context registered another way; a declaring
 projection without it fails on its first fact, naming both. A replay empties the record of each
 declaring projection it registers just before the read models — and no other deployment's — and a
 single-projection rebuild on the Orleans execution model empties that projection's just before its
 read model.
 `AddProjectionsFromAssemblyContaining<T>()` trusts the two deletion facts for a declaring projection.

### Changed

- **A write context that filters by tenant no longer changes what the framework does with its store.** The
 framework reads its events, snapshots and hash-chain anchors past every query filter the write context
 declares, so such a write context now behaves like one that declares none. A command handler given another
 tenant's aggregate id loads that aggregate and appends to it, recording the event for the aggregate's owner
 with the session's actor, as it always has on an unfiltered write context. Before, the filter made that load
 come back empty and the append fail with a version conflict. Neither the tenant-isolation guard nor the
 filter checks a stream's owner against the session. A handler that must refuse another tenant's aggregate
 checks the owner of the aggregate it loaded; the tenant-isolation guide shows how.
 `IEventStreamRepository`, `ISnapshotRepository` and `IEventChainRepository` return every tenant's rows to a
 caller that uses them directly.

### Fixed

- **A framework exception keeps its type between silos.** Orleans carries an exception from one silo
 to another with its type only for namespaces it supports, and a chain with one exception it refuses
 does not cross at all. A `ConcurrencyException` from a handler on another silo therefore did not reach
 the caller as one — its inner failure is a database exception — and a bus worker running the
 forwarding handler counted a conflict against the delivery bound instead of the conflict bound; a
 `CommittedEventsNotPublishedException` would have been delivered again. The execution model's
 registrations (`AddStrataraOrleans`, `AddStrataraOrleansCommandDispatcher`,
 `AddStrataraAggregateGrains`) now let every exception type cross, alongside a filter the host set
 itself. The type, message, stack trace and inner exceptions cross; the properties of the framework's
 exceptions — `ConcurrencyException`, `CommittedEventsNotPublishedException`,
 `PrecedingFactMissingException`, `ErasureIncompleteException`, `AuthorizationException`,
 `PermissionAuthorizationException` — read empty on the far side rather than null.

- **A stopping subscription lets its handlers finish, and the host waits for it.** A RabbitMQ
 subscription closed its channel the moment it was cancelled, while a handler could still be running,
 so the handler's acknowledgement failed and the message was delivered again — a handler that completed
 during a shutdown ran twice; deliveries the client had already fetched then ran on the closing channel
 too. A Service Bus subscription did not stop its processor at all. A stopping subscription now stops
 taking messages, hands fetched but unhandled ones back to the queue, lets the running handler settle
 — as long as the host's shutdown timeout allows from the moment the application starts stopping,
 twenty seconds otherwise — and then closes; the host waits for that when it stops, before anything is
 disposed. A handler that gives up because its subscription stops has its message put back rather than
 counted as a failure, which on its last allowed delivery would have dead-lettered it; the broker still
 counts the delivery. A RabbitMQ handler that never returned kept its channel from closing, and the
 bus's disposal — and with it the process — waited forever; the close is now bounded too. Both
 transports settle a handler's outcome whatever the subscription's own cancellation says.

- **A RabbitMQ subscription holds at most `Messaging:PrefetchCount` messages** (default 16, 1 to 65535,
 validated at start-up). It held every message the broker would push: on a stop they all went back to
 the queue, and the broker counted each as a delivery — enough restarts could dead-letter a message
 whose handler never ran. What a subscription holds beyond the running handler's message still goes
 back counted.

- **A validation failure keeps its fields between silos.** A `StrataraValidationException` thrown by a
 handler on another silo reaches the caller with its `Failures` — each field, message and code, never
 the attempted value — so the problem-details handler still answers with the fields to correct. Its
 message stays generic: a failure's message may quote the input, and exception messages are logged.
 During a rolling upgrade, silos of the two versions cannot read each other's validation failures; such
 a call fails with a serialization failure until every silo is upgraded.

- **An append runs under a retrying execution strategy.** A host whose write context retries on failure
 (`EnableRetryOnFailure`, on by default in Aspire's EF integrations) and that keeps the partition
 positions for the portable commit-order reader could not append at all: `PartitionCounterInterceptor`
 began a transaction outside the strategy, which EF refuses. The framework's unit of work now runs a save
 on such a context as one retriable unit through the strategy — a transaction of its own, the changes
 accepted only after the commit — so the interceptor finds the transaction and a transient failure runs
 the append again whole. This applies to every framework save on a context whose strategy retries —
 write, read or projection: an override of `SaveChangesAsync(CancellationToken)` alone is no longer
 called there (override the `(bool, CancellationToken)` overload), `SavedChanges` handlers run before the
 commit, and a single-statement save runs in a transaction. A host's own save on such a context with the
 partition counter uses the same unit; the migration guide shows it. A context whose strategy does not
 retry saves as before.

- **A commit once begun runs to its end.** A database driver told to cancel while it waits for a commit to
 be acknowledged may report the cancellation after the database committed, so a stop that landed
 there had a committed save look cancelled — and a transport, a store reader or a resumed command then
 ran it again and recorded the same facts twice. The new `CommitCompletionInterceptor` lets a commit
 run to its end whatever the cancellation says, while a cancellation during the writes still leaves none
 of them committed, and gives a single-statement save a transaction so it has a commit to protect. The
 framework adds it to every context it registers. On a context a host registered itself without it —
 write or read — the framework's unit of work saves without the caller's token, so the save runs to its
 end whole, bounded by the connection's pool wait and command timeout rather than the caller. Such a
 host adds the interceptor — the Orleans execution model on a store other than PostgreSQL, to its write
 and read contexts, beside `PartitionCounterInterceptor`, last among the transaction interceptors. The
 recorded-command store runs every statement whose outcome it acts on to its end, so a recorded command is never recorded twice and
 a claim never spends an attempt on a hand-over that did not happen. The Orleans commit-order interceptor
 commits without the token and releases its transaction when a save is cancelled (a cancelled append left
 it open, and the context's next save ran inside it); a durable timer whose handler finished as its silo
 stopped is unregistered rather than fired again, and a store reader whose batch finished as it stopped
 records its checkpoint rather than applying the whole batch again; the execution model's registrations
 set `MessagingOptions.WaitForCancellationAcknowledgement`, so a grain call cancelled while its callee
 committed — a saga step called by a stopping timer or reader — reports the commit instead of a
 cancellation that ran the step again. The setting applies to every grain call of the host that carries
 a token: such a call now waits for its grain's answer — at the latest until the response timeout — instead
 of ending the moment its token fires; a saga step no longer fails after its save committed — a failure to reread its state or
 cancel its timers is logged (`LogEvents.Orleans.SagaStepAftermathFailed`, `117_130`); and the
 event-bundle dispatcher records a bundle whose publication a cancellation cut short instead of losing
 it.

- **A save that committed but could not publish says so, and nothing runs it again.** On a host
 without durable bundles, `SaveChangesAsync` hands the committed events' bundle to the outbox after
 the commit. When both the bus and the outbox's own table failed, the save threw the outbox's
 exception although the events were recorded. Everything that runs work again on a failure then ran
 it again and recorded the same facts a second time: the transports delivered the message again, the
 Orleans execution model resumed a recorded command, a store reader retried the entry, a durable timer
 fired again, and a pipeline that retries on any exception retried. The save now throws the new
 `CommittedEventsNotPublishedException`, naming the committed streams, with the handover's failure as
 the inner exception, a cancellation after the commit included. Each of those places treats it as
 done and logs an error:
 - the RabbitMQ and Azure Service Bus transports acknowledge the message, whatever their own
   cancellation says (`LogEvents.Messaging.CommittedEventsNotPublished`, `108_113`);
 - on the Orleans execution model a recorded command is completed
   (`LogEvents.Orleans.IntentCommittedNotPublished`, `117_127`), a store reader counts the entry as
   applied (`EntryCommittedNotPublished`, `117_128`), and a durable timer counts as fired
   (`TimerCommittedNotPublished`, `117_129`);
 - `ResilienceNames.CommandDispatcher`, `EventBundleDispatcher`, `MessageBus` and
   `ProjectionReplayBatch` do not retry it.

 The bundle itself is lost: replay the projections that consume it; a saga that reacts to bundles has
 missed it. A host that cannot lose a bundle stores bundles with the commit, and such a host no longer
 fails a save whose handover fails after the commit, since its bundle is recorded. A caller that caught
 the outbox's own exception type, or `OperationCanceledException`, after a save now finds it as the
 inner exception.

- **A save with nothing staged no longer publishes an empty bundle.** It still requires a session, and
 it stores and publishes nothing.

- **A snapshot captures only committed events.** A snapshot was written in a transaction of its own,
 before the events it captured were committed. A save that then lost a concurrency race left a
 snapshot of events that were never recorded, and every later rebuild started from it: the aggregate
 silently carried state from a write that did not happen. A clash on the snapshot's own version also
 surfaced as the provider's raw exception instead of a concurrency conflict. The event source now
 writes the snapshot after the events are committed and their bundle handed on, from the committed
 stream up to the save's highest version. A failure to write it, a cancellation included, is logged
 (`LogEvents.EventStore.SnapshotFailed`, `102_006`) and no longer fails a save whose events are
 recorded. A snapshot that an earlier release wrote this way stays in the store and keeps serving
 rebuilds. The framework has no API to find it, so to be sure none is left, delete the snapshots
 (`DELETE FROM snapshot;` on the write store) and let the strategy write them again. Until each stream
 is snapshotted again, its rebuilds replay the whole stream.

- **An erasure finds every key that names the subject, not only the ones the directory names.** A
 key is named by level, tenant and user together, and the eraser computed those names from the
 current memberships. A key shared with someone outside the directory was found by neither erasure
 and stayed readable. That is a user who had left the tenant, or an operator acting in a tenant from
 outside it: commands and intents are encrypted under the acting user. A second run after an
 earlier erasure had already removed the memberships missed these keys too. `IKeyStore` gains
 `ListScopesAsync`, and the file-backed and in-memory key stores implement it. An erasure now also
 shreds every listed key it covers: a key of any level naming the tenant, or a user-level key naming
 the user. A key store of your own keeps compiling: the default implementation throws
 `NotSupportedException`, and the erasure then falls back to the directory's keys as before,
 logging a warning (`LogEvents.KeyManagement.KeyScopesNotListable`, `112_008`).
 Implement it to close the gap; a decorator around a key store has to forward it. Both erasures now
 also refuse an empty id: with the listing, erasing it would have shredded what the system actor and
 data without a tenant are keyed by, in every tenant.

- **A user's erasure reaches the snapshots of that user's aggregates.** A snapshot holds an aggregate's
 whole state, protected fields included. It was encrypted under the stream's tenant alone and
 recorded no user. After a user's erasure, rebuilding that user's aggregate therefore started from a
 snapshot that still read their data, although every event behind it had become unreadable. A
 snapshot is now protected under the stream's recorded owner, tenant and user, and records the user
 in a new nullable `Snapshot.UserId`. It also took its tenant from the first event of the batch that
 triggered it. When that event was appended on behalf of another subject, the snapshot landed under
 that subject. It now takes the owner from the stream's first event. A snapshot written before this
 change records no user and is still read under its tenant alone.

 **The write context's snapshot table gains a column: generate an EF Core migration for it and apply
 it before the new version runs.** Every read of a snapshot selects the column, so without it
 rebuilding an aggregate fails.

 Snapshots written before the upgrade keep serving rebuilds, including rebuilds bounded to an earlier
 version, because snapshots are never pruned. To let a user's erasure reach them, delete the snapshots
 whose owner differs from their stream's. They are a cache: a rebuild without one replays the events,
 and the next threshold writes a new one. With the Npgsql registration's snake_case names:

 ```sql
 DELETE FROM snapshot s
 USING event_stream_entry e
 WHERE e.stream_id = s.stream_id AND e.version = 1
   AND (s.tenant_id <> e.tenant_id OR s.user_id IS DISTINCT FROM e.user_id);
 ```

- **A tenant's erasure shreds every key naming the tenant.** A key is named by level, tenant and user
 together, and the serializer binds every value to a tenant. `ISubjectEraser.EraseTenantAsync`
 shredded only the tenant-level key with no user and its members' user-level keys. It left three
 kinds of value readable after the tenant was erased:
 - a value at the default `[EncryptData]` level, which is user-level, written for the tenant with no
   user. That is every such field of an event appended from an ordinary request, where the session's
   data-owner user is not set;
 - a tenant-level value written for a member;
 - a `Confidential` value written for the tenant, whose key names the tenant like any other.

 A tenant's erasure now shreds every key naming the tenant, at every level, alone or with each of
 its members. A user's erasure is unchanged: it shreds the user's user-level keys, and tenant-level
 and confidential values written for the user stay the tenant's.

 Memberships are now swept after the key material, so an erasure run again after its key sweep
 failed still finds the tenants and members whose keys it has to shred. Before, they were already
 gone and the second run shredded less. `ErasureReport.Planes` now reads `ApiKeys`, `Settings`,
 `KeyMaterial`, `Memberships`.

 `ISubjectEraser` and the membership guide state what remains out of reach:
 - a key shared with a user who is not a member when the erasure runs, unless the key store lists
   its keys (see the entry on listing keys above);
 - on a user's erasure, the snapshot of an aggregate that user owns.

- **A stream keeps the user it was created for.** A later event takes its owner from the stream's
 first event rather than from the session: since 4.0.0 for every aggregate, and before that for
 aggregates implementing `ITenantAggregate`. But only the tenant was taken over. A stream whose
 first event was recorded for a user, from the session's data-owner user or from a stated Subject,
 recorded every event of a later save with no user. Their protected fields were encrypted under
 keys without that user, so erasing the user did not reach them. The save that created the stream
 had kept the user, so the owner depended on how events were batched.

 A later event now carries the whole owner recorded on the stream's first event, tenant and user. A
 stream whose first event names no user is still given none, whatever user the session names. The
 user now also appears where the event's owner is passed on: in the bundle published for the save,
 and in the session a projection handles the event under.

 Events already recorded are not rewritten. An event a later save recorded without the stream's
 user stays encrypted under its tenant's keys: a tenant's erasure reaches it, the user's does not.

- **A save that fails discards what it staged.** A failed save is specified to discard the whole
 staged batch, but only a successful save and a concurrency conflict did so. A save that failed for
 any other reason kept its events staged, whether the store was briefly unavailable or the save was
 refused for a missing causation id. If the handler ran again in the same scope, it staged its
 events a second time on top of them. A pipeline that retries on any exception does exactly that,
 such as `ResilienceNames.CommandDispatcher`, and so does a consumer's own retry. The next
 successful save then wrote both, putting the same facts in the stream twice. After a refused save,
 every later save in that scope was refused again. **Behaviour change:** calling `SaveChangesAsync`
 again after a failure, without appending again, used to retry the staged events and now writes
 nothing. Append the events again, as after a `ConcurrencyException`.

- **A failed `AppendOnBehalfOfAsync` no longer leaves its Subject behind.** When the append failed
 before its event was staged, for example with no session or a failing serializer, the stated owner
 stayed attached to the event object. Appending the same instance again without a stated owner then
 recorded it under that owner anyway.

- **A Subject stated with `AppendOnBehalfOfAsync` no longer carries over to the next append in the
 same batch.** Within one `SaveChangesAsync` batch the store remembers the owner it resolved for
 each stream, and it remembered a stated one too. So after `AppendOnBehalfOfAsync`, an ordinary
 `AppendAsync` to the same stream in the same batch recorded its event under the stated owner
 instead of the stream's. That event was then encrypted under the wrong tenant's keys, was missed
 by the stream owner's erasure, and was erased with a tenant it did not belong to. A stated Subject
 now applies to its own event only, as specified. A later batch was never affected. On a stream's
 first event the stated Subject is the owner the stream records, so the rest of that batch keeps
 it, as every later batch does.

…the complete notes for this release are in the changelog: https://github.com/yesbert/Stratara/blob/v4.4.0/CHANGELOG.md