Table of Contents

Release 2026-08-25

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-25.md, and llms.txt lists every page.

Part of the changelog.

A coordinated lockstep release advances the whole published package family to 9.3.0: every Orleans.Lattice and Orleans.Lattice.* package on the v9 line moves to 9.3.0 together. The minor bump is driven by two additive public primitives in the core library - a FIFO-fair distributed lock (ILatticeLockGrain, #1608) and a generic atomic-action / TCC saga coordinator (IAtomicActionGrain, #1609); the verified-WAL assurance work, the warm-authorization and batch / hot-path allocation optimisations, and a set of correctness and security fixes (two Entra Explorer credential-isolation fixes, an MCP telemetry allow-list-bypass fix, the OrSet duplicate-delivery convergence fix, the prefix-scoped backup bound fix, and the replication framing-overflow 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

  • A cluster-wide, FIFO-fair distributed lock / lease primitive. A new public grain ILatticeLockGrain, keyed by lock name, packages the single-threaded-grain FIFO mutual-exclusion pattern as a first-class primitive that also gets the failure modes right: AcquireAsync enqueues the caller FIFO and completes when the lock is granted (or faults with a TimeoutException when the caller's MaxWait elapses), without ever blocking the grain's activation turn; TryAcquireAsync is the non-blocking variant; RenewAsync and ReleaseAsync are honoured only for the current holder's fencing token; and GetStatusAsync returns a diagnostic snapshot. Every grant carries a strictly-increasing, persisted, never-reused fencing token (the standard Kleppmann fencing guarantee), so a superseded holder is detectable by the resource it guards, and a bounded lease is reclaimed and handed to the next FIFO waiter if the holder neither renews nor releases before expiry, so a crashed holder cannot wedge the lock forever. The lock's fencing and admission decisions are extracted into a pure, deterministic core (LockAdmissionCore) that both the production grain and a Coyote concurrency model execute, so its safety properties - monotonic fencing, stale-token rejection, mutual exclusion, and expired-lease reclamation - are machine-checked against every adversarial interleaving (with a non-vacuous guard test), not just asserted by integration tests. Documented in Distributed Lock and Verified Distributed Lock. (#1608) (Orleans.Lattice 9.3.0)
  • A public, generic atomic-action saga / TCC coordinator grain. A new public grain IAtomicActionGrain, keyed by a caller-supplied operation id (its idempotency key), runs an ordered plan of steps all-or-nothing: each step pairs a forward effect with a compensating effect, and if a later forward step faults, every already-committed step is compensated in strict reverse order so the action leaves no partial effect behind. It generalizes the key-only atomic write to arbitrary caller-defined effects. A step never carries a serialized delegate; a custom step names a pre-registered handler by a stable id (registered at silo start via AddLatticeAtomicAction, with a per-handler version tag) and carries a size-bounded, Orleans-serializable args payload, so a persisted plan is crash-recoverable and safe: resolving a handler id fails closed (an unknown or unregistered id never executes), and a crash-resume that finds a handler's version tag changed underneath it parks rather than replaying a changed effect. A fluent AtomicActionPlanBuilder mixes custom .Step(handlerId, args) steps with a built-in .TreeWrite(treeId, ...) step whose forward write delegates to the existing atomic-write machinery (IAtomicWriteGrain) - inheriting the verified single-tree-atomic / cross-tree-2PC guarantee - and whose compensation the library synthesizes by capturing and restoring key pre-images. The saga persists its per-step status vector after every transition and resumes reminder-driven to a single terminal outcome (Committed, Compensated, or terminal CompensationFailed, which surfaces CompensationFailedException for operator intervention); re-issuing the same plan under a terminal operation id returns the memoized outcome without re-running effects. The step-sequencing and crash-resume decisions are extracted into a pure, deterministic core (AtomicActionPlanCore) that both the production grain and a Coyote concurrency model execute, so its all-or-nothing-or-compensated, reverse-order-once, and resume-exactly-once safety is machine-checked against every adversarial crash interleaving (with a non-vacuous guard test), not just asserted by integration tests. Documented in Atomic Action and Verified Atomic Action. (#1609) (Orleans.Lattice 9.3.0)
  • The WAL's concurrency seams are now driven by verified cores and machine-checked for their durability-safety properties. Extending the proven-core pattern from the atomic-commit protocol to the write-ahead log, six WAL decision points are extracted into pure, deterministic cores that both the production grains and a Coyote verification layer execute, so each safety property is proven of the exact production logic rather than only exercised by integration tests: the shipping watermark (WalShippingWatermark) never advances past a gap in the acked set; the GC per-entry trim predicate (WalGcTrimCore) trims only past the minimum acked cursor across all consumers, never stranding a laggard; the WAL cursor registry (InMemoryWalCursorRegistry) max-merges each consumer's cursor so a stale re-delivery never regresses it; the shard-move fence (WalMoveFenceCore) keeps the fence check atomic with the offset assignment so a quiesce can never fence an already-numbered append; and the commit-log writer's shutdown drain (WalAdmissionGateCore) releases every parked admission caller by observing the drain token in the wait set; and the per-shard offset allocator (WalOffsetAllocationCore) reads and advances the log-offset counter in a single step so two concurrent appends never share an offset and the assigned sequence stays dense and strictly ascending. A Coyote concurrency tier model-checks each core under adversarial interleavings, and every model ships a non-vacuous guard test that removes the one fix the property depends on and proves Coyote then finds the violation. This is an assurance change with no public API or runtime-behaviour change (the extractions are behaviour-preserving delegations; the existing WAL, GC, and cursor-registry tests are unchanged and pass); the verified guarantees are documented in Verified WAL and demonstrated by the VerifiedWalDurability sample. (Orleans.Lattice 9.3.0)
  • The verified-WAL Coyote tier now also covers the buffer-pin blocked-floor lifecycle and the resumable placement-move tail copy. Two further WAL decision points are extracted into pure, deterministic cores that both the production path and a Coyote model execute: the blocked-floor meet (WalBlockedFloorCore) folds each consumer's live buffer pin into the minimum across consumers, so through every interleaving of pin-take, pin-raise, and pin-clear the GC's floor never rises above a live pin and never trims an entry a buffering receiver still needs; and the placement-move resume arithmetic (WalMoveResumeCore) resumes an interrupted tail copy just past what the target already holds, so a coordinator that crashes and re-drives at any offset boundary copies every retained offset exactly once, with no duplicate and no gap. Each ships a Coyote model plus a non-vacuous guard test (a maximum-pin join that strands a lagging buffer; a fixed-floor resume that re-copies a landed prefix). This is an assurance change with no public API or runtime-behaviour change (both extractions are behaviour-preserving delegations from InMemoryWalCursorRegistry and LatticeAdminGrain; the existing cursor-registry and WAL-move tests are unchanged and pass). Documented in Verified WAL. (Orleans.Lattice 9.3.0)

Changed

  • Three time optimisations on the warm authorization decision hot path, with no behaviour, ordering, or public API change. Three independent, benchmark-verified changes to the synchronous, in-memory decision path the enforcement gate pays on every gated operation once the compiled policy snapshot is warm, none of which touches the public API: (1) CompiledPolicy now indexes its governed trees in a FrozenDictionary<string, CompiledTree> rather than a Dictionary<string, CompiledTree> held behind an IReadOnlyDictionary field, so the once-per-decision TryGetTree lookup becomes a direct call on a read-optimised frozen map instead of an interface-dispatched Dictionary.TryGetValue; (2) CompiledTree likewise indexes its exact-key rule buckets in a FrozenDictionary<string, CompiledRule[]>, so the most-specific (exact-key) tier lookup in ResolvePoint is a direct frozen lookup; and (3) CompiledTree.TryBestInBucket walks each scope tier's CompiledRule[] by ref readonly over a ReadOnlySpan<CompiledRule> instead of a foreach that copied every ~40-byte CompiledRule struct into a loop variable per iteration, copying a rule only when a new best is found. Both compiled structures are built once at snapshot-compile time and read many times per decision, so freezing them is a pure read-side win; ordinal (case-sensitive) key comparison is preserved. A new cluster-free AuthDecisionBenchmarks microbench (opt-in suite BENCH_MICROBENCH_SUITE=authdecision) drives PolicyEvaluator.Evaluate directly against a representative ten-tree default-deny snapshot; at full fidelity the decision Mean falls on every scope tier - exact-key 34.13 ns to 28.70 ns (-15.9%), tree-wide 29.79 ns to 26.05 ns (-12.6%), prefix 37.73 ns to 35.04 ns (-7.1%) - and a mixed twelve-decision batch 442.25 ns to 338.07 ns (-23.6%); every path stays zero-allocation on the single-decision tiers. No public API change (all three types are internal in-process snapshot state that never crosses a grain boundary or serializes). (Orleans.Lattice.Auth 9.3.0)

  • Three allocation trims on the multi-key GetManyAsync / SetManyAsync batch fan-out hot paths, with no behaviour, ordering, or wire-format change. Three independent, MemoryDiagnoser-verified optimisations on the batch read and write fan-out, none of which touches the public API: (1) LatticeGrain.GetManyAsyncCore takes a single-shard fast path whenever every requested key routes to one physical shard - the only case for a single-shard tree, and the dominant case generally. It skips the shard-bucketing Dictionary<int, List<string>> and its per-shard bucket List (routing the caller's key list straight through), the per-call ConcurrentDictionary merge target, the per-shard task fan-out, and the final ConcurrentDictionary -> Dictionary copy, issuing one IShardRootGrain.GetManyAsync under the identical registry-snapshot scope + topology-version + snap2 stability checks and returning the shard's own result dictionary directly. This mirrors the shard grain's existing single-leaf fast path one layer down, so atomic visibility is unchanged. (2) ShardRootGrain.TraverseForBatchReadAsync presizes its multi-leaf merge dictionary to the requested key count (the returned count is bounded by it), eliminating the geometric grow/rehash chain on a batch that spans more than one leaf. (3) LatticeGrain.SetManyAsyncCore takes the symmetric single-shard fast path on the bulk-write side: when every entry routes to one physical shard it skips the shard-bucketing Dictionary<int, List<KeyValuePair<string, byte[]>>> and its whole-batch per-shard List copy (routing the caller's entry list straight through), and the per-call List<Task> + Task.WhenAll wrapper, issuing one write to the shard directly; the caller's list is only read (never mutated) after the fan-out, so reusing it is safe. Deterministic MemoryDiagnoser Allocated deltas on the microbenchmarks: single-shard Point get many (exercises (1)) 9,056 B to 7,864 B (-13.2%), the Point get many sweep batch-size lanes (exercise (1)) at batch 64 16,512 B to 9,656 B (-41.5%), at batch 32 12,544 B to 8,704 B (-30.6%), at batch 16 10,368 B to 8,144 B (-21.5%); the new multi-leaf Point get many deep tree (exercises (1) and (2)) 58,744 B to 53,992 B (-8.1%, of which (2) contributes 912 B); and Bulk load (exercises (3)) 102,840 B to 73,785 B (-28.3%) - every affected benchmark strictly lower, none regressed. No public API change (all three are internal implementation details; the ILattice / IShardRootGrain signatures and results are unchanged). (Orleans.Lattice 9.3.0)

  • Three steady-state allocation trims on the shard-root batch-write, batched CRDT receiver, and atomic-write saga hot paths, with no behaviour, ordering, or wire-format change. Three independent, MemoryDiagnoser-verified optimisations, none of which touches the public API: (1) ShardRootGrain's batch-write rejection guard gains a List<KeyValuePair<string, byte[]>> overload that iterates entry keys by index instead of the prior ThrowIfRejectedForAnyKey(entries.Select(e => e.Key)) projection, so each guarded SetManyAsync / SetManyWherePredicateAsync call (one per shard the write fans out to) no longer allocates a SelectListIterator that the steady-state (no split, no moved slots) guard early-returns without ever enumerating. (2) LatticeGrain.ApplyCrdtDeltaManyAsync's per-item receiver fold now enters a single readonly struct CrdtReceiverAmbientScope that saves and restores the three ambient origin / vector-clock / HLC-override RequestContext slots on the stack, replacing three nested LatticeOriginContext.With / LatticeVectorClockContext.With / LatticeHlcOverrideContext.With scopes that each allocated a heap IDisposable - removing 3N scope objects per batch of N. (3) AtomicWriteGrain's saga prepare derives its sorted touched-shard set directly from the shard-bucket dictionary keys it already builds, removing a separate HashSet<int> plus a full second routing.Map.Resolve pass over every key on every prepare. Deterministic MemoryDiagnoser Allocated deltas from the new cluster-free HotPathAllocationBenchmarks microbench (opt-in suite BENCH_MICROBENCH_SUITE=hotpath), which isolates each technique because the full end-to-end cluster benchmarks allocate on the order of megabytes per op and sit the trims below their run-to-run noise floor: guard projection (exercises (1)) 80 B to 0 B per guarded call, size-independent; receiver ambient scope (exercises (2)) 112 B per item - at N=16 18,816 B to 17,024 B, at N=64 75,264 B to 68,096 B, at N=256 301,056 B to 272,384 B (all -9.5%); atomic touched shards (exercises (3)) 568 B to 424 B at 1 key (-25.4%), 688 B to 544 B at 2 keys (-20.9%), 4,016 B to 3,840 B at 64 keys (-4.4%) - every affected benchmark strictly lower, none regressed. No public API change (all three are internal implementation details; the ILattice, IShardRootGrain, IReplicationApplyGrain, and IAtomicWriteGrain signatures and results are unchanged). (Orleans.Lattice 9.3.0)

Fixed

  • The Entra Explorer login provider now binds silent token renewal to the account that actually signed in, preventing cross-operator credential confusion. MsalEntraInteractiveTokenAcquirer.AcquireSilentAsync renewed with accounts.FirstOrDefault(), an arbitrary cached account never correlated to the operator who opened the connection. Whenever the MSAL token cache held more than one account, a connection could silently renew with a different operator's token and issue cluster calls under the wrong identity. EntraTokenRequest gains an optional Username (additive; existing constructions are unaffected) that the auth method sets from the interactive sign-in result and threads into renewal, and the acquirer now selects the matching cached account, failing closed to a re-challenge when it is absent rather than grabbing a different one. Covered by new acquirer and auth-method regressions. (Orleans.Lattice.Explorer.Entra 9.3.0)

  • The Entra Explorer login provider is now registered per Blazor circuit rather than as a process-global singleton, restoring per-operator credential isolation. AddExplorerEntraAuth registered both the MSAL-backed token acquirer (which owns an in-memory token cache) and the Entra auth method as singletons, so every circuit shared one operator's token cache - the exact process-global auth-session leak the Explorer credential-isolation invariant forbids, and which the sibling web-head provider already avoids. Both are now Scoped, matching the scoped IExplorerAuthSession that consumes them (no captive dependency). This is a DI-lifetime change only; the public types and IEntraInteractiveTokenAcquirer interface are unchanged. Covered by a new registration-lifetime regression. (Orleans.Lattice.Explorer.Entra 9.3.0)

  • The MCP telemetry deny-all metric-access gate now rejects an unconstrained label selector, closing an allow-list bypass that leaked denied metrics. In the DenyAllExceptAllowed posture the PromQL authorization gate extracts the metric names a query references and admits it only when every name is allow-listed, failing closed when no name can be extracted (for example a bare {job="api"} label selector, which matches series across every metric name). That empty-name guard was bypassable: padding the bare selector with any one admitted metric - up or {job="api"} - left the extracted name set non-empty (["up"]), so the gate admitted the whole expression and the backend returned every series carrying job="api", including denied metrics. The extractor now flags a top-level {...} selector that is neither anchored to a metric name in name position nor pinned by an exact __name__="..." matcher, and the gate fails closed on it, on both the instant-query and range-query paths. The anchored (up{job="api"}), exact-name ({__name__="up"}), and read-all paths are unchanged. Both the extractor type and its reference struct are internal, so there is no public API change. Covered by new PromQlMetricExtractor and TelemetryToolHandlers regressions. (Orleans.Lattice.Api.Mcp.Telemetry 9.3.0)

  • OrSet.MergeDelta is now idempotent under duplicate delivery. The observed-remove set's delta-merge add path appended each incoming add dot unconditionally (via Add) instead of unioning it, so replaying the same OrSetDelta twice - the norm under at-least-once delta replication and cross-tree stage replay - grew the per-element dot list without bound and broke convergence, contradicting the method's own documented idempotency contract. The add path now de-duplicates each (element, dot) pair exactly as the removes path already did, and a new OrSetMergeDeltaTests fixture closes the coverage gap (every sibling CRDT already had a duplicate-delivery idempotency test; OrSet was the sole exception). No public API change. (Orleans.Lattice 9.3.0)

  • Prefix-scoped backup and restore no longer silently skip keys whose prefix ends in U+FFFF. BackupConstants.PrefixUpperBound advanced the prefix's final code unit unconditionally, so a trailing U+FFFF wrapped to U+0000, producing an exclusive upper bound that sorts below the prefix and inverting the half-open scan range - a prefix-scoped capture or restore then matched nothing - while an empty prefix threw IndexOutOfRangeException. It now rolls over trailing maximum code units, incrementing the last unit below char.MaxValue and dropping the max tail, and returns an unbounded (null) upper bound when none exists, matching the canonical DataReader.PrefixUpperBound / RepoContextPortability.PrefixUpperBound contract. No public API change. (Orleans.Lattice.Backup 9.3.0)

  • The binary replication framing decoder rejects a forged field or entry length near int.MaxValue with its precise truncation error instead of a raw slice exception. Four bounds checks in OrleansBinaryReplicationBatchEncoder - the uncompressed and inflated-tail routing-string (treeName / originClusterId) readers and the per-entry body readers - summed the read cursor and an attacker-controllable declared length in 32-bit arithmetic, which overflows to a negative value and slips past the guard, the same overflow the compressed-body length check was already hardened against. All four now widen the sum to 64 bits, so a truncated or hostile payload fails closed with the descriptive framing-corruption ArgumentException rather than an opaque span.Slice / ArraySegment exception. No public API change. (Orleans.Lattice.Replication 9.3.0)

  • The azure-throughput benchmark rig no longer mis-grades the Orleans 10.2.2 startup manifest-convergence burst as a cohort regression (benchmark tooling only; no library or package change). After the family-wide Orleans 10.2.0 -> 10.2.2 bump (#1598), a fresh single silo converges its cluster-manifest / grain-directory view lazily per grain type: the first hot-path message addressed to a grain type the local silo has not yet resolved is rejected by the placement service with an OrleansException: No active nodes are compatible with grain <type> ... Known nodes with grain type: none, logged at fail: by the Orleans.Messaging category. Orleans re-addresses the waiting message once the manifest converges, so the caller's operation still succeeds (the cohort reaches a clean FINAL with failed=0) and the burst lands in the pre-measurement warm window that the t>=15s steady-state filter trims - steady-state throughput is unaffected. On the single-silo rig the affected types are the write-path grains the warm-up probe does not pre-activate (walmaterialiserpin, leafsnapshotstorage) plus late shardroot activations. The Layer 2 cohort-verdict grader counted these ~617 benign lines per cohort toward its DEGRADED exception tally, so every cohort graded DEGRADED and the report's HEALTHY-only aggregation excluded all of them (no HEALTHY cohorts ... row not updated), which looked like a total throughput collapse even though the measured steady-state means matched or slightly beat the prior baseline. The grader now subtracts current-cohort-attributable placement-convergence lines from the verdict-relevant exception count (mirroring the existing benign shutdown-race and warmup-retry exclusions), surfacing the excluded count as a diagnostic reason. The match is anchored on both the No active nodes are compatible with grain seam and the Known nodes with grain type: none cold-manifest phenotype, so a genuine placement fault (nodes known but version-incompatible, or an unsatisfiable placement filter) still counts, and a convergence retry that ever failed for real would surface failed>0 and still grade FAILED. Covered by new Test-CohortVerdict.ps1 regressions. (#1605)