Table of Contents

Release 2026-08-20

This page is part of the documentation for Orleans.Lattice 9.9.0 (release line 9.9), built 2026-10-04. It is also published as markdown, with every table and list, at 2026-08-20.md, and llms.txt lists every page.

Part of the changelog.

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. (Orleans.Lattice 9.1.0, Orleans.Lattice.Replication 9.1.0, #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)

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); 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; this entry also covers that change, which was released without a changelog note. (Orleans.Lattice 9.1.0, #1557, #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, #1537, #1539, #1542). (Orleans.Lattice 9.1.0, #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)
  • 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)