---
title: "Release 2026-08-20 - Changelog"
url: "https://nsta1.github.io/Orleans.Lattice/changelog/2026-08-20.html"
source: "https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/CHANGELOG.md?plain=1#L1582-L1603"
documents: "Orleans.Lattice 9.9.0 (release line 9.9)"
built: "2026-10-04"
all-pages: "https://nsta1.github.io/Orleans.Lattice/llms.txt"
---
# Release 2026-08-20

Part of the [changelog](../CHANGELOG.md).

A coordinated lockstep release advances the whole published package family to `9.1.0`: every `Orleans.Lattice` and `Orleans.Lattice.*` package on the v9 line moves to `9.1.0` together. The minor bump is driven by the new per-entry TTL support for typed CRDT writes (an additive, backward-compatible feature); the CRDT allocation trims, the cold-start topology create-race fix, the WAL fall-off completion fix, and the auth/Explorer security hardening ride along. The not-yet-published `Orleans.Lattice.Api.Mcp.RepoContext`, `Orleans.Lattice.Api.Mcp.RepoContext.Replication`, and `Orleans.Lattice.Storage.File` packages remain unreleased and are unaffected.

## Added

- **Typed CRDT writes now support per-entry TTL, and TTL'd entries expire consistently across replicas.** Every typed CRDT accessor's primary write (for example `OrSet.AddAsync`, `PnCounter.IncrementAsync`, `OrMap.SetAsync`) gains a `TimeSpan ttl` overload, alongside a new `ILattice.ApplyCrdtDeltaAsync(key, mode, deltaBytes, ttl, ct)` seam, bringing the per-entry time-to-live already available on `SetAsync` to all thirteen CRDT merge modes. Expiry is resolved to an absolute UTC instant on the accepting silo and converges by an order-independent max-absolute-ticks join - a later or concurrent TTL'd write extends the entry's life, and a durable write leaves any existing expiry untouched - so unlike the last-writer-wins LWW path every replica agrees on the same expiry regardless of merge order. The resolved absolute expiry is shipped verbatim with each replicated delta (and each batched dispatch item) and re-applied without re-resolving it from a relative TTL, so inter-cluster clock skew cannot shift a replicated entry's lifetime, and a cold projection rebuild reconstructs the same expiry from the WAL independently of replay order. Read filtering, scans, counts, cursors, caching, and tombstone compaction treat a TTL'd CRDT entry exactly like a TTL'd `SetAsync` entry. See [TTL](../docs/lattice/ttl.md#per-entry-ttl-on-crdt-writes). (`Orleans.Lattice` 9.1.0, `Orleans.Lattice.Replication` 9.1.0, [#1549](https://github.com/NSTA1/Orleans.Lattice/issues/1549))

## Changed

- **The CRDT clone and merge hot paths shed avoidable defensive copies and empty-collection allocations, with no behaviour, ordering, or wire-format change.** Three independent, allocation-verified primitive optimisations on the paths the replication apply loop runs on every reconcile: (1) `BoundedRegister.Clone` no longer deep-copies its `Value` and `OrderKey` byte arrays. The payload is already treated as immutable everywhere it is stored (writes replace the reference, arrays are never mutated in place), so the clone now aliases the arrays like every other accessor - `MemoryDiagnoser` `Allocated` 120 B to 40 B (-67%) on `Crdt bounded register merge` / `mergeFrom`. (2) The deep-copy `Clone` of `GCounter`, `PnCounter`, `VersionVector`, `OrSet`, and `RwSet` now hands its fully-built backing dictionaries to a direct-assign private constructor instead of an object initializer, eliminating the discarded empty-collection "shell" the field initializer allocated only to be immediately overwritten (one collection per backing store: -80 B for the single-store counters/vectors, -160 B for two-store `OrSet`, -240 B for three-store `RwSet`). (3) `GCounter.Merge`, `PnCounter.Merge`, and `VersionVector.Merge` build the merged store directly (copy-construct from the left operand, then fold the right in through the existing single-probe pointwise-max) and return it through the same direct-assign constructor, dropping the merge's own shell while preserving the overlap-optimal clone-then-fold that keeps the result right-sized when the operands share replicas - the common replication case. Deterministic `MemoryDiagnoser` deltas on the existing microbenchmarks: `Crdt gcounter merge` 544 B to 464 B, `Crdt pncounter merge` 1,075 B to 912 B, `Crdt orset clone` 2,027 B to 1,874 B, `Crdt orset merge` 4,147 B to 3,984 B, `Crdt rwset merge` 15,882 B to 15,637 B, `VersionVector merge` 2,232 B to 2,150 B, `VersionVector clone` 848 B to 768 B - every affected benchmark strictly lower, none regressed across disjoint, partial, and fully-overlapping operand regimes. No public API change (the added constructors are private; the parameterless constructor is retained). (`Orleans.Lattice` 9.1.0, [#1558](https://github.com/NSTA1/Orleans.Lattice/pull/1558))

## Fixed

- **A brand-new tree no longer fails its very first write when a cold-start activation race duplicates a topology grain.** On the cold start of a not-yet-persisted tree, grain-directory warmup can briefly activate the same topology grain twice, and both activations would attempt the very first durable state write against a storage row that does not exist yet. The losing write threw `InconsistentStateException` and surfaced as a spurious failure of the caller's first operation, even though nothing was actually inconsistent - both writers were seeding the identical brand-new row. The three grains that seed a tree's durable topology - the leaf grain, the internal (branch) grain, and the shard-root grain - now route their first-create write through a shared seam that detects this benign race (a create-vs-create collision in which both the stored and current etags are empty) and adopts the winner's persisted row by re-reading it, so the operation converges instead of throwing. A genuine stale-state conflict (any non-empty etag) still throws exactly as before, preserving the fall-off-the-log fix family's fail-loud contract ([#1560](https://github.com/NSTA1/Orleans.Lattice/issues/1560)); and because the shard-root seeds its root leaf under a deterministic identity, the adopted row is the same leaf either writer would have created, so no orphan can result. The leaf grain's fix shipped first in [#1565](https://github.com/NSTA1/Orleans.Lattice/pull/1565); this entry also covers that change, which was released without a changelog note. (`Orleans.Lattice` 9.1.0, [#1557](https://github.com/NSTA1/Orleans.Lattice/issues/1557), [#1566](https://github.com/NSTA1/Orleans.Lattice/issues/1566))
- **A leaf can no longer fail to reactivate - throwing `LeafProjectionStaleException` on its next cold restart - because its durable snapshot coverage regressed below an already-trimmed WAL prefix.** `LeafSnapshotStorageGrain.SaveAsync`, the single writer of a leaf's durable recovery blob, overwrote it wholesale on every capture, and each capture recomputes per-partition coverage from the leaf's current checkpoints. When a partition's checkpoint had regressed - a cold rehydrate resetting it to a lower offset, or a projection-admin rebuild - the next capture could persist coverage for that partition below an earlier blob that had already licensed a coverage-gated WAL GC trim, while the in-memory monotonic-max trim pin could not regress; the trim then outlived the durable coverage, and the next cold restart replayed from a WAL whose prefix was gone and threw `LeafProjectionStaleException`. `SaveAsync` now merges each incoming blob into the stored one so per-partition coverage is monotonic by construction (element-wise max of the per-partition offsets plus a CRDT last-writer-wins union of the row sets, which cannot lose data), making the rehydrate path's long-standing "coverage is monotonic" assumption true and ensuring the GC trim pin can never authorise a trim past durable coverage. This completes the fall-off-the-log fix family ([#1535](https://github.com/NSTA1/Orleans.Lattice/issues/1535), [#1537](https://github.com/NSTA1/Orleans.Lattice/issues/1537), [#1539](https://github.com/NSTA1/Orleans.Lattice/issues/1539), [#1542](https://github.com/NSTA1/Orleans.Lattice/issues/1542)). (`Orleans.Lattice` 9.1.0, [#1560](https://github.com/NSTA1/Orleans.Lattice/pull/1560))

## Security

- **`AddMemberAsync` now validates the target group id against the identity directory, not just the member.** When `LatticeIdentityDirectoryOptions.ValidationRequired` is set and a real identity-directory provider is active, `ILatticeAuthAdmin.AddMemberAsync` previously validated only the member principal, so an administrator could create a membership edge whose target group did not resolve in the directory. It now also requires `groupId` to resolve to a `Group` principal before writing anything, closing the gap and matching the validation `UpsertGroupAsync` already applies. The check is fail-closed and surfaces the same `LatticeDirectoryValidationException` (an `InvalidArgument` over the gRPC/MCP/Explorer surfaces). Behaviour is unchanged when validation is not required or the no-op `NullIdentityDirectory` is active, so local-only group namespaces are unaffected. (`Orleans.Lattice.Api.Auth` 9.1.0, [#1519](https://github.com/NSTA1/Orleans.Lattice/issues/1519))
- **The Explorer web head now refuses to start rather than silently serving its admin console without the baseline security-response headers.** `MapLatticeExplorer` registers the anti-clickjacking / anti-sniffing header middleware (`Content-Security-Policy`, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`; CWE-1021) only when the endpoint route builder is also the application's `IApplicationBuilder` middleware pipeline. Previously, when it was not - for example when the explorer was mapped onto a nested route group that is not itself a pipeline - registration silently no-oped, so the admin console was served with none of those headers and no error was raised. It now throws `InvalidOperationException` at mapping time instead, failing closed so a misconfigured mount is visible and correctable rather than quietly unprotected. The documented, supported mount (`app.MapLatticeExplorer()` on a `WebApplication`, with the mount point configured via `LatticeExplorerWebOptions.BasePath`) is unaffected - a `WebApplication` is both interfaces, so the headers are emitted exactly as before. No public API change. (`Orleans.Lattice.Explorer` 9.1.0)

Newer release: [Release 2026-08-23](../changelog/2026-08-23.md). Older release: [Release 2026-08-19](../changelog/2026-08-19.md). Contents: [Changelog](../CHANGELOG.md).
