Stratara.Security 4.0.0

dotnet add package Stratara.Security --version 4.0.0
                    
NuGet\Install-Package Stratara.Security -Version 4.0.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.Security" Version="4.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Stratara.Security" Version="4.0.0" />
                    
Directory.Packages.props
<PackageReference Include="Stratara.Security" />
                    
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.Security --version 4.0.0
                    
#r "nuget: Stratara.Security, 4.0.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.Security@4.0.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.Security&version=4.0.0
                    
Install as a Cake Addin
#tool nuget:?package=Stratara.Security&version=4.0.0
                    
Install as a Cake Tool

Stratara.Security

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

License: MIT.

Dependency-light key store and envelope encryption for Stratara. Provides a production IKeyStore with KEK-wrapped, versioned per-scope data-encryption keys (rotation, revoke, and crypto-shred), a file-backed master-key provider, and an AES-GCM blob encryptor — referencing only Stratara.Abstractions + BCL crypto. No EF Core, RabbitMQ, Redis, or cloud SDKs in the graph.

Quick start

// appsettings / secrets:
// "Stratara": { "KeyStore": { "MasterKeyBase64": "<openssl rand -base64 32>", "StorePath": "/var/run/secrets/keystore.json" } }

builder.Services.AddStrataraFileKeyStore(builder.Configuration);

// Encrypt a blob bound to a tenant scope + purpose:
var scope = new KeyScope(DataSensitivityLevel.TenantScoped, TenantId: "acme-corp");
await using var encrypted = await encryptor.EncryptAsync(plainStream, scope, purpose: "attachment");
await using var plain = await encryptor.DecryptAsync(encrypted, scope);

What's inside

  • EnvelopeFileKeyStore (IKeyStore) — random 32-byte DEK per scope/version, KEK-wrapped with AES-256-GCM (wrap AAD bound to the key id, so a wrapped DEK can't be moved to another scope). The store file holds only wrapped DEKs + metadata, never plaintext. RotateAsync adds a version; RevokeAsync makes one version undecryptable; EraseScopeAsync deletes all versions for a scope (GDPR Art. 17 crypto-shred). DEKs are zeroed after use; the store file is written 0600 on Unix.
  • FileMasterKeyProvider (IMasterKeyProvider) — KEK from MasterKeyBase64, validated to decode to exactly 32 bytes (AES-256) at startup. The custody seam: swap for an HSM / KMS / vault provider later without touching the stored data.
  • AesGcmSecureBlobEncryptor (ISecureBlobEncryptor) — AES-GCM stream encryption with a purpose-bound AAD ({tenant}||{purpose}) and a versioned, self-describing format (v2 leading byte). Reads legacy streams without the version byte; set Stratara:BlobEncryption:LegacyBlobsCarryPurpose to match the legacy layout.
  • DummyKeyStore — Development-only deterministic fallback (throws outside Development).

Key id schema

{level}:{tenant}:{user}:v{N} — e.g. TenantScoped:acme-corp::v1. GetOrCreateCurrentKeyAsync returns the highest non-revoked version (creating v1 if none); RotateAsync creates v{N+1}.

Dependencies

  • Stratara.Abstractions
  • Stratara.Diagnostics
  • Microsoft.Extensions.{Configuration,DependencyInjection,Hosting,Logging}.Abstractions
  • Microsoft.Extensions.Options (+ Options.ConfigurationExtensions)
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 (2)

Showing the top 2 NuGet packages that depend on Stratara.Security:

Package Downloads
Stratara.Infrastructure

Infrastructure glue for the Stratara framework — authorization decorators, configuration providers, and DI composition helpers that wire Mediator, Outbox, Identity, and EF Core into a hosted app.

Stratara.Testing

Test doubles and assertion helpers for applications built on the Stratara framework — an in-memory IKeyStore, an in-memory IMessageBus, in-memory membership/setting/API-key stores, a preset ISessionContextProvider, deterministic tenant ids, and a given/when/then aggregate rehydration harness. Drop the Postgres/RabbitMQ testcontainers for unit tests.

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 133 8/28/2026
3.3.0 334 8/25/2026
3.2.3 153 8/22/2026
3.2.2 756 8/14/2026
3.2.1 397 8/2/2026
3.2.0 146 7/18/2026
3.1.7 160 7/1/2026
3.1.6 581 6/22/2026
3.1.5 157 6/22/2026
3.1.4 1,620 6/15/2026
3.1.3 163 6/10/2026
3.1.2 162 6/5/2026
3.1.1 879 6/1/2026
3.1.0 134 5/30/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.