Stratara.ServiceDefaults.AspNetCore
4.0.0
dotnet add package Stratara.ServiceDefaults.AspNetCore --version 4.0.0
NuGet\Install-Package Stratara.ServiceDefaults.AspNetCore -Version 4.0.0
<PackageReference Include="Stratara.ServiceDefaults.AspNetCore" Version="4.0.0" />
<PackageVersion Include="Stratara.ServiceDefaults.AspNetCore" Version="4.0.0" />
<PackageReference Include="Stratara.ServiceDefaults.AspNetCore" />
paket add Stratara.ServiceDefaults.AspNetCore --version 4.0.0
#r "nuget: Stratara.ServiceDefaults.AspNetCore, 4.0.0"
#:package Stratara.ServiceDefaults.AspNetCore@4.0.0
#addin nuget:?package=Stratara.ServiceDefaults.AspNetCore&version=4.0.0
#tool nuget:?package=Stratara.ServiceDefaults.AspNetCore&version=4.0.0
Stratara.ServiceDefaults.AspNetCore
Derived. The behaviour described here is specified under
openspec/specs/. Those specifications are the source; this page explains and illustrates them.
License: MIT.
ASP.NET Core extensions on top of Stratara.ServiceDefaults. Reference from every API host.
What's in the box
| Extension | Purpose |
|---|---|
AddDefaultHealthChecks |
Registers a self-tagged health check that always reports Healthy + tags it as live so the /alive predicate can filter on it. |
MapDefaultEndpoints |
Maps /health (full health-check report) and /alive (only the live-tagged checks). |
ConfigureAspNetOpenTelemetry |
Wires ASP.NET Core request instrumentation on metrics + tracing; filters out the health-check + aliveness endpoints from traces so they don't pollute the request stream. |
Quick start
builder.ConfigureOpenTelemetry(); // from Stratara.ServiceDefaults
builder.ConfigureAspNetOpenTelemetry(); // from this package
builder.AddDefaultHealthChecks();
builder.ConfigureSerilog(); // from Stratara.ServiceDefaults
var app = builder.Build();
app.MapDefaultEndpoints();
Dependencies
Stratara.ServiceDefaults— baseConfigureOpenTelemetryand shared endpoint constants.OpenTelemetry.Extensions.Hosting+OpenTelemetry.Instrumentation.AspNetCore.
| Product | Versions 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. |
-
net10.0
- OpenTelemetry.Extensions.Hosting (>= 1.18.0)
- OpenTelemetry.Instrumentation.AspNetCore (>= 1.18.0)
- Stratara.ServiceDefaults (>= 4.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 4.0.0 | 0 | 8/31/2026 |
| 4.0.0-preview.1 | 0 | 8/31/2026 |
| 3.4.0 | 74 | 8/28/2026 |
| 3.3.0 | 82 | 8/25/2026 |
| 3.2.3 | 88 | 8/22/2026 |
| 3.2.2 | 97 | 8/14/2026 |
| 3.2.1 | 452 | 8/2/2026 |
| 3.2.0 | 118 | 7/18/2026 |
| 3.1.7 | 116 | 7/1/2026 |
| 3.1.6 | 524 | 6/22/2026 |
| 3.1.5 | 113 | 6/22/2026 |
| 3.1.4 | 126 | 6/15/2026 |
| 3.1.3 | 119 | 6/10/2026 |
| 3.1.2 | 129 | 6/5/2026 |
| 3.1.1 | 199 | 6/1/2026 |
| 3.1.0 | 119 | 5/30/2026 |
| 3.0.23 | 116 | 5/28/2026 |
### Changed
- **`AddBusEnvelopeIntegrity` gains a base64-string overload, and the start-up warning stops naming
one that did not exist.** When integrity is enabled with no signer registered, the probe told the
operator to call `AddBusEnvelopeIntegrity("<base64-key>")`. No such overload existed — the two that
did take an `Action<BusEnvelopeIntegrityOptions>` or an `IConfiguration`. The message sent whoever
was fixing the problem looking for an API that was not there.
The message now names the overloads that exist, and the one it promised has been added, because it
was the right shape: `SharedKey` is a `byte[]`, which configuration binding cannot produce from a
string, so the `IConfiguration` overload binds the mode and leaves the key to a second call.
```csharp
services.AddBusEnvelopeIntegrity(
builder.Configuration["BUS_ENVELOPE_SIGNING_KEY"]!,
BusEnvelopeIntegrityMode.Permissive);
```
It rejects a malformed base64 string and a key shorter than 32 bytes at registration, where a host
can still fix it, rather than at the first signed message. Read the key from a secret store or an
injected environment variable — not from a checked-in `appsettings.json`, which signs for anyone who
can read the repository.
- **BREAKING: encrypting at a level that claims isolation, with no identifying dimension at all, is
now refused outside development.** `TenantScoped` claims that a value is encrypted under
a key belonging to that tenant. On an aggregate that has none, that was never true: an event row's
tenant is not nullable, so a tenant-less aggregate supplied the empty identifier and every such
value across every subject resolved to one scope —
`TenantScoped:00000000-...-000000000000:00000000-...-000000000000` — and therefore one key. Erasing
one subject would have erased all of them, so crypto-shredding was unavailable at that level while
the annotation implied it was there, and nothing reported the difference.
A guard for the same mistake already existed and refused a *null* tenant. It could not fire on the
event path, which never produces null. It was one condition away from the case it was written for.
**A coarser scope than the level names is not refused.** A user-scoped value carrying only a tenant
resolves to a per-tenant key — weaker than the name suggests, but it separates tenants, and the
framework binds an event's payload to its stream's owner exactly that way. Only the collapse to a
single system-wide key is refused.
**Migration: use `DataSensitivityLevel.Confidential`** for data that genuinely has no tenant. It
claims one system-wide key and no isolation, which is what was happening anyway — the difference is
that it says so.
In development the same encryption warns and proceeds, so local work continues while the mistake
stays visible. **Decryption is untouched:** values already written into that scope decrypt exactly
as before, because a refusal on the read path would destroy access to data rather than protect it.
- **BREAKING: `IMessageBus` gains `EnsureSubscriptionAsync`, and a subscription can now be created
without consuming from it.** A broker delivers only to queues that already exist, and until now the
only way to create one was `SubscribeAsync` — which also starts reading, so a queue could not exist
before the worker that reads it was ready. On a topic carrying more than one subscription that is a
silent loss: `event-bundle` carries the projection and the saga subscription, and one bound queue is
enough for a publish to be confirmed. A worker that binds a few seconds later misses everything
published in between, no outbox row is written, nothing retries, and nothing logs. Two workers
starting twenty seconds apart on a fresh broker is enough.
```csharp
var ids = app.Services.GetRequiredService<IMessagingIdentifier>();
var bus = app.Services.GetRequiredService<IMessageBus>();
await bus.EnsureSubscriptionAsync(ids.EventBundleTopic, ids.EventBundleSubscription);
await bus.EnsureSubscriptionAsync(ids.EventBundleTopic, ids.EventBundleSagaSubscription);
```
Call it from whichever process publishes first, before its first publish. Worker queues are durable,
so it only matters on a broker that has never seen them — a new environment, a rebuilt host, a CI
run. **Nothing calls it for you:** the framework cannot know your start-up order or which processes
publish, and a protection that holds in one topology while silently failing in another is the defect
this fixes, not a fix for it.
*What it costs:* an established subscription retains until something consumes, where before the
message was dropped. A queue whose worker never starts will grow.
*If you implement `IMessageBus` yourself*, add the member. Azure Service Bus implements it as a
no-op, and that is the whole implementation for any transport whose subscriptions are provisioned
before the application runs. A transport that cannot establish a subscription ahead of its consumer
should throw rather than return successfully — RabbitMQ does exactly that for client subscriptions
(`default-*`), whose queues are exclusive and auto-deleting and would vanish before a handler
attached. `Stratara.Testing.InMemoryMessageBus` now retains for an established subscription and
replays on attach, so a test cannot pass on start-up ordering that production fails.
- **BREAKING: an event's owner now comes from its stream, not from the session that wrote it.**
Every event records a subject — the tenant whose key encrypts its payload and whose erasure reaches
it. Resolution consulted the owner already recorded on the stream *only* when the aggregate
implemented `ITenantAggregate`. For any other aggregate it fell through to the session, so a stream
written by two tenants ended up with entries owned by whoever happened to write them.
`ITenantAggregate` adds exactly one member — `Guid TenantId { get; set; }` — so that a rehydrated
aggregate carries its tenant as a property. It is a statement about the shape of the class, not
about whether the stream has an owner. Every entry has one either way, which is why the condition
was testing the wrong thing.
**What it cost.** Erasure became incomplete: erasing one tenant shredded its key while the other
tenant's entries on the same stream stayed readable, so neither erasure covered the aggregate. And
because each event is decrypted with the subject recorded on its own entry, once one key was
shredded the aggregate could no longer be rebuilt **at all** — a lawful erasure in one tenant broke
a shared aggregate for everyone.
After this change, once a stream has a recorded owner every later event carries the same owner,
whatever session appends it. The acting session is still recorded separately as the actor.
Consumers that genuinely want an event attributed to a different subject keep the explicit route,
`AppendOnBehalfOfAsync`, which outranks everything — the difference is that shared ownership is now
something a consumer states rather than something the absence of an interface produces.
**Nothing stops compiling.** Events simply start being attributed differently, so a consumer
relying on the old behaviour gets no compile-time warning — which is the whole reason for this
note. If you deliberately appended to one stream from several tenants, those appends now land on
the stream's first owner.
**Existing mixed-ownership streams are not repaired.** Their recorded entries keep the owners they
were written with; only new appends follow the stream's first owner. The store does not rewrite
recorded events, by design. A consumer with such a stream still has an erasure that does not cover
all of it, and this change does not fix that retroactively — you can find them by looking for one
stream whose entries carry differing tenant ids.
**The framework's own `Tenant` aggregate is affected too.** `Tenant` is a plain `IAggregate` — a
tenant does not belong to a tenant — so its streams previously took the session's tenant on
non-creation events and now take the owner recorded at creation. This is not only a consumer's
behaviour changing.
The cost is one extra read on the write path, bounded by the existing per-batch cache: at most one
transaction plus two queries per distinct existing stream per `SaveChanges`, regardless of how many
events that batch appends to it. A first append to a stream that does not exist yet costs one
query, and `ITenantAggregate` aggregates are unchanged.
- **BREAKING: a user with several active memberships and no valid selection now receives no tenant
claim.** Previously the framework picked one for them — the first of the active memberships sorted
by `TenantId`. `OrderBy` on a `Guid` compares byte groups in an order of its own, so the winner was
not the oldest membership, not the alphabetically first tenant and not the user's primary one. It
was an artefact of Guid comparison semantics, and whatever a user expected, it was not that.
A user with **exactly one** active membership is unaffected and keeps resolving to that tenant. A
user with a **valid stored selection** is unaffected. What is removed is only the guess.
**Nothing stops compiling.** A multi-tenant user simply stops receiving a claim they used to
receive, and the symptom is authorization failures downstream rather than an error naming the
cause — which is why this note exists. A host that supports multi-tenant users must obtain a
selection through `SetActiveTenantAsync` and offer a route reachable without a tenant claim to
obtain it on.
Distinguishing the two no-claim cases is a `GetMembershipsAsync` call: no active memberships means
no access at all, several means a tenant has not been chosen. They call for opposite responses, and
showing a multi-tenant user "you have no access" is the mistake worth guarding against. See
[Tenant Membership](https://docs.stratara.tech/guides/tenant-membership.html).
**The removed behaviour never matched the specification.** The `tenant-directory` requirement has
said "otherwise no claim at all" since it was written; the implementation emitted a claim anyway.
A consumer relying on it was relying on a defect — which does not make the break less real for
them, but does explain why it is being removed rather than specified. The requirement's scenario
hedged with "deterministic rather than arbitrary", which a Guid sort satisfies while defeating the
sentence above it; that scenario is replaced with one that states the outcome.
- **`StackExchange.Redis` moves to `3.x` in the published dependency graph.** `Stratara.Infrastructure`
and `Stratara.Outbox.RabbitMQ` now declare `StackExchange.Redis` `3.1.31`, up from `2.13.10`. Both
packages use it on a runtime path — the distributed cache registration, the outbox lock and the
projection replay state — so this reaches consumers: an application pinning `StackExchange.Redis`
`2.x` will have to move with it. The framework's own use of the client is unchanged and the
behaviour is covered by the Redis integration suite.
- **Test-support toolchain moved to xunit v3 `4.0.0`.** `xunit.v3`, `xunit.runner.visualstudio` and
`Microsoft.Testing.Extensions.CodeCoverage` moved together, because xunit v3 `4.0.0` is the first
release shipping a Microsoft.Testing.Platform v2 variant — which is what the CodeCoverage
extension has required since `18.1.0`. This affects how the framework tests itself and does not
change any published package's surface.
- **Dependency refresh across the package family.** 58 pinned versions moved to their current
releases: the .NET 10 stack (Entity Framework Core, `Microsoft.Extensions.*`, ASP.NET Core
authentication and identity) to `10.0.11`, OpenTelemetry to `1.18.0`, `Microsoft.Extensions.*`
resilience and service discovery to `10.9.0`, `Microsoft.IdentityModel.*` to `8.22.0`, plus
Serilog, Npgsql, RabbitMQ.Client and Azure.Messaging.ServiceBus. These are the versions the
published packages now declare, so a consumer resolving Stratara picks them up transitively.
### Removed
- **BREAKING: the six deprecated members held for the major are gone.** Every one shipped with a
named successor and at least one minor version of overlap; this is the second half of that promise.
A consumer still calling one stops compiling, at the call site, with the successor named in the
deprecation message it has been seeing since the member was deprecated.
| Removed | Package | Replace with |
|---|---|---|
| `ISnapshotRepository.GetAsync(Guid, long?, CancellationToken)` | `Stratara.Abstractions` | `GetAsync(streamId, aggregateTypeName, toVersion, ct)` |
| `ISnapshotRepository.GetLatestVersionOrDefaultAsync(Guid, CancellationToken)` | `Stratara.Abstractions` | `GetLatestVersionOrDefaultAsync(streamId, aggregateTypeName, ct)` |
| the two implementations of the above | `Stratara.EventSourcing.EntityFrameworkCore` | — |
| `AddNpsqlWriteDbContextFactory<TDbContext>()` | `Stratara.EventSourcing.EntityFrameworkCore` | `AddNpgsqlWriteDbContextFactory<TDbContext>()` |
| `UseAuthorizationExceptionTo403()` | `Stratara.Infrastructure` | `AddStrataraProblemDetails()` + `app.UseExceptionHandler()` |
The `aggregateTypeName` the snapshot overloads now require is the value the framework itself passes,
`aggregateType.GetQualifiedTypeName()` — not new information a caller has to invent. A type-less
lookup could return a snapshot written for a *different* aggregate type sharing the stream id, which
was then deserialized into the requested type and yielded corrupt or default state; that is why the
overloads were deprecated and why they are now gone.
`AddNpsqlWriteDbContextFactory` was the original misspelling — no `g` in `Npgsql`. It forwarded
verbatim to the correctly-spelled name from `3.2.0` onward. Adding the `g` is the whole migration.
- **BREAKING: `UseAuthorizationExceptionTo403()` and the middleware behind it are removed, and the
replacement answers in a different shape.** This is the one removal that is not a
signature-for-signature substitution, so it gets its own paragraph rather than a table row.
The removed middleware mapped an authorization refusal and a tenant-access denial to a bare `403`
with no body. `AddStrataraProblemDetails()` in `Stratara.ServiceDefaults.AspNetCore` maps the same
two refusals — plus validation failures — to one RFC 7807 problem shape, so a host that migrates
gains a response body it did not have. Both lines are needed: the registration alone does nothing
without `app.UseExceptionHandler()` in the pipeline.
A host that wants neither the middleware nor the problem shape can map the refusals itself.
`AuthorizationException` and `TenantAccessDeniedException` are both declared in
`Stratara.Abstractions`, so a host's own exception handler catches them without referencing the
mediator or infrastructure packages.
The two must never have been registered together — the middleware answered first and the handler
never saw the exception. That trap goes with the middleware.
- **If you implement `ISnapshotRepository` yourself, nothing breaks at compile time.** Removing a
member from an interface leaves an implementor with two methods that no longer implement anything;
the compiler does not complain about an extra method, so there is no error to hunt. Delete them at
leisure.
### Security
- **The SQLitePCLRaw advisory `GHSA-2m69-gcr7-jv3q` is resolved and no longer suppressed.**
The High-severity advisory against `SQLitePCLRaw.lib.e_sqlite3` 2.1.11 was reached transitively
through `Microsoft.EntityFrameworkCore.Sqlite` — used by the `Stratara.Testing.EntityFrameworkCore`
test-support package, never on a shipped runtime path — and had no patched version to move to, so
it was suppressed in the build audit. Entity Framework Core `10.0.11` resolves a patched
`lib.e_sqlite3`, and both suppression entries have been removed. The repository now carries no
audit suppressions at all.
### Documentation
- **The signature scope documented for bus-envelope integrity now matches what 3.4.0 signs.**
`BusEnvelopeIntegrityOptions`, `IBusEnvelopeSigner` and `SECURITY.md` still described the
pre-3.4.0 projection — identity only, `CommandTypeName + "|" + SessionContextJson` for a command
and `SessionContextJson` for an event bundle — and told adopters the payload body was not bound,
down to advising an extra integrity check at the application layer. Since 3.4.0 the canonical
projection covers every field of the message except the signature itself, with the command body
and the carried events covered as SHA-256 digests and every field length-prefixed. All three now
state that, the application-layer advice is gone, and the `[EncryptData]` note is kept for what it
does cover — decryption fails after tampering — rather than as a substitute for an unsigned
payload. No behaviour changes; the projection itself has been correct since 3.4.0.
- **The samples say what their CI coverage actually is.** `samples/README.md` promised that a
breaking API change fails CI. That holds for the four packages the samples reference; the other
twenty-one are written out by hand in the samples rather than consumed, so nothing there would
break. `Stratara.Sample.MoneyTransferSaga` and `Stratara.Sample.OutboxWorker` now say at the top
that their saga and outbox are hand-written illustrations, and point at the guides for the wiring a
host actually uses — reading them as a template produces a re-implementation of what the framework
already ships.
- **New `llms-full.txt` at the repository root** — a generated reference for tooling that has the
packages but not this source tree. Four tables, all derived from the assemblies and their
documentation: every bindable configuration key with its type and default, every registration with
what it does, every exception the framework throws, and every topic, subscription and cache key it
uses. `llms.txt` stays the orientation and now links to it. A build regenerates the file and fails
on a difference, so it cannot drift from the code it describes.
- **`llms.txt` stated the stable version as 3.2.0.** It has said "prefer the facts here over any
pre-trained knowledge" while being two minor versions behind since 3.3.0. Corrected, and pinned to
`Directory.Build.props` by a test.
- **Every registration states its contract in the XML documentation that ships with the package.** The
`.nupkg` carries the doc XML next to the dll, so what a registration says there is what a consumer
sees at the call site. All 96 registrations across the family now name the configuration key path
they bind, their prerequisites and ordering constraints, and carry a worked example that is
compiled in CI — among them
`AddRedisOutboxLock` (needs an `IConnectionMultiplexer`; required before a second worker replica),
`AddSecurity` (register a key store first, the fallback uses `TryAdd`), and both
`AddBusEnvelopeIntegrity` overloads (the key is a `byte[]`, and the configuration overload binds
only `Mode`).
- **`UseAuthorizationExceptionTo403` is no longer listed as a registration to reach for.** It is
`[Obsolete]` and superseded by `AddStrataraProblemDetails()`; registering both makes the middleware
answer first, so the problem-details handler never sees the exception. A cheatsheet that lists it
invites exactly that.
- **Three bindable configuration sections are documented for the first time.** `BusEnvelopeJson`
(`MaxDepth`, `MaxBodyBytes` — the size and depth guards on every inbound envelope), `ProjectionReplay`
(`LeaseSeconds`), and `Stratara:BlobEncryption` (`LegacyBlobsCarryPurpose`). All three shipped
bindable and named on no page, so a host could only reach them by reading the source.
- **The DI cheatsheet lists every registration a host can call.** 28 were missing — among them the
à-la-carte primitives the umbrella extensions compose, the workers without their composite, the
OpenTelemetry and Serilog configuration, `AddStrataraProblemDetails` and
`UseAuthorizationExceptionTo403`. A test now enumerates the registration surface by the type each
method extends and fails on one that no page lists.
- **Bus-envelope integrity guide: the signer interface and the configuration section were both
wrong.** The `IBusEnvelopeSigner` snippet declared `Sign(BusEnvelopeCanonical)` /
`Verify(BusEnvelopeCanonical, string)`; the real interface takes the canonical projection as a
`string`, and `BusEnvelopeCanonical` is a static helper that produces it, not a type you can pass.
The configuration example bound a `BusIntegrity` section — the bound section is
`BusEnvelopeIntegrity` — and assigned a configuration string straight to `SharedKey`, which is a
`byte[]`. All three are corrected, and the key is now read from a secret store rather than
`appsettings.json`.
- **Bus and topic names in the outbox guides are the ones the framework actually uses.** Both guides
described a routing model of `stratara.commands.{appName}` / `stratara.events.{appName}`. The
defaults are `command`, `heavy-command`, `event-bundle` and `notifications` with their matching
subscriptions, all overridable through the `Messaging:Topics` configuration array. An operator who
provisioned a broker from those pages created topics nothing publishes to. The RabbitMQ guide now
carries the full table and the override shape; the Azure Service Bus guide points at it and notes
that Service Bus entities must be provisioned up front.
- **`[EncryptData]` payload encryption is no longer described as conditional on integrity mode.** The
encryption guide claimed the bus carries ciphertext "when `BusEnvelopeIntegrityOptions.Mode != Off`".
Payloads are serialized through `ISecureJsonSerializer` on the way out regardless; integrity mode
decides whether envelopes are *signed*, not whether payloads are *encrypted*.
- **The log-event ID allocation table lists every bucket again.** The reference page and the
`LogEvents` XML doc both stopped short of the current allocation — the page at `113_999`, the XML
doc at `110_999` — while `TenantIsolation` (114_000s), `ExternalLoginProvisioning` (115_000s) and
`ApiKeys` (116_000s) already existed. That table is what a consumer reads to pick a
non-colliding range for its own events. `Stratara.Diagnostics`'s README carried the same stale list.
- **The DI cheatsheet covers the registrations it was missing.** New sections for the write-store
context factories, `AddWriteStore`, `AddCommandAuditing`, the health checks, and — the two that
cost the most when unknown — `AddRedisOutboxLock`, without which a second outbox-worker replica is
not safe, and `AddProjectionReplayState`, which is what leases a projection-replay marking.
- **`OutboxOptions.BatchSize` no longer describes the drain loop 3.4.0 removed.** The XML doc still
claimed the worker keeps fetching batches until the table is drained and that the value caps
per-query memory pressure rather than throughput. A cycle takes one batch of each kind and ends,
and entries the bus did not accept are retried on the next interval — so `BatchSize` together with
`PollingIntervalSeconds` is exactly what sets the drain rate (20 000 entries a minute with the
defaults). The doc now says so, and says which knob to raise for a large backlog.