---
title: "Release 2026-09-07 - Changelog"
url: "https://nsta1.github.io/Orleans.Lattice/changelog/2026-09-07.html"
source: "https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/CHANGELOG.md?plain=1#L1131-L1176"
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-09-07

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

Patch release on the `9.6` line. Nine packages advance to `9.6.1`: `Orleans.Lattice`, `Orleans.Lattice.Api.Mcp`, `Orleans.Lattice.Api.TreeAdmin`, `Orleans.Lattice.Auth`, `Orleans.Lattice.Backup`, `Orleans.Lattice.GrainIndex`, `Orleans.Lattice.Membership`, `Orleans.Lattice.Replication` and `Orleans.Lattice.Schema`, each tagged per [`docs/RELEASING.md`](../docs/RELEASING.md). Every other published package is untouched and stays on its `9.6.0` tag, and the Explorer sub-family remains deliberately held at `9.4.x` exactly as it was for the `9.6.0` wave. This is a security and runtime-correctness patch: it carries no new public API surface, no feature work and no performance changes, and an unbumped package continues to resolve its `9.6.0` dependency floor forward to `9.6.1` transitively.

The bulk of it is authorization, across nine entries. Four close a seam where enforcement was weaker than the contract the surface published: two where a caller-supplied *second* identifier was composed, namespace-checked and then acted upon without ever being put to the access gate (the tree-admin alias, snapshot and view-rebind verbs; and a cross-tree backup restore, which authorized its target but never the tree its manifest was captured from), and two where an existence probe out-reached the enforcement decision it is required to mirror. A fifth closes a direct-grain-call route around the access gate on three internal surfaces that deliberately bypass the `ILattice` facade. The remaining four derive a JWT signature-algorithm allow-list from the configured key families, stop a read-only MCP caller being advertised - and therefore handed - the mutating repository-context tools, make the repository-context workspace boundary enforce rather than merely advertise, and validate the distributed lock's two lease-duration options. Two of the nine change behaviour for a host that registers an authorization add-on, and both say so in their own entry: a genuine cross-tree restore now additionally requires the `Backup` capability over the manifest's captured source tree, and a non-positive distributed-lock lease option is now **rejected at startup** rather than silently disabling the lease cap. A host with no authorization add-on registered is unaffected by the first, because the no-op gate allows everything at zero cost; the second affects any host whose configuration was already broken in a way nothing previously reported.

Four of the fixes below were separated out of the repository-context replay investigation, and it is worth being explicit that **the root cause of that investigation is not fixed here**: an interrupted leaf replay banks no progress when a partition meets an unresolved saga prepare during replay pass 1, which is tracked as [#2098](https://github.com/NSTA1/Orleans.Lattice/issues/2098) and remains open with its repair still under design, because the obvious repair would authorise the collector to trim the very entries a resume needs. What ships here are four independent defects found alongside it, none of which depends on that work. Two are runtime faults. The other two repair the over-budget replay warning itself, which is the primary evidence an operator has for recognising that still-open root cause when it strikes, and which in its `9.6.0` form was both ambiguous about which leaf it described and voluminous enough to roll that evidence out of a container log.

## Fixed

- **A replication receiver's blocked floor now reaches the producer on every ack, so write-ahead-log garbage collection can no longer trim entries a partially-buffered atomic batch still needs to recover.** `ReplicationAck.BlockedAtHlc` was transmitted by the receiver on every ack and then discarded on arrival: `PublishPeerBlockedFloorAsync` had no caller at all, so the producer's `IWalCursorRegistry` never learned a peer receiver's blocked floor, and `LatticeWalGc` was free to trim exactly the entries that receiver still needed. The helper is now wired into all three ack paths - the serial ship, the pipelined completion, and the idle liveness probe - immediately after the ack is in hand and ahead of the cursor advance, so the collector can never observe an advanced cursor without the pin that constrains it. The rejected path publishes too, because a receiver that defers a batch behind its inbound receive fence still stamps its pin, and that deferral is precisely the window the mechanism exists to cover. The probe path matters for the symmetric reason: on an idle link the probe ack is the only channel that can re-arm a new pin or clear a drained one. The affected types are `internal`, so no public surface changes. ([#2050](https://github.com/NSTA1/Orleans.Lattice/issues/2050)) (`Orleans.Lattice.Replication` 9.6.1)

- **A faulting write-ahead-log prefix-trim probe is now confined to its own partition, instead of being read as "nothing was trimmed" for the whole leaf.** `AnyPartitionWalPrefixTrimmedAsync` gates the snapshot rehydrate by asking each WAL partition whether its oldest still-readable offset has advanced past zero, since a non-zero tail on any partition is positive evidence that the collector trimmed a prefix and therefore that a snapshot at or behind the persisted checkpoint is the sole durable copy of it. The `try` wrapped the entire partition loop, so the first faulting coordinator aborted the probe for every later partition and the method reported "not trimmed" for the whole tree - on an eight-partition tree, one slow coordinator masked the other seven. The consequence is both expensive and self-reinforcing: declining the rehydrate forces a full replay from the oldest readable offset, a large window may not finish that replay inside the activation budget so the tree stays saturated, and because the probe is itself a grain call *into* the tree being replayed, a saturated tree is exactly where the tail-offset call is most likely to time out and decline again. The failure fires most readily where its cost is highest. Each partition is now probed under its own `try`: a fault increments an unprobed counter, captures the first exception and continues, and a positive is returned on the first non-zero tail *without* being gated on a fault-free sweep, since gating it would reintroduce the same masking one level up. Reading the partition count moves into its own `try`, because having no count at all is a different condition from one coordinator timing out and must not be reported as "probed everything, all zero", and a cancellation still returns immediately so teardown semantics are unchanged. Durability semantics are identical: both "could not probe" and "probed everything, all genuinely zero" still decline, because a probe that could not prove a prefix was trimmed must never be read as proof that it was - the distinction is now surfaced in a summarising warning rather than in the return value, so only the diagnosis improves. The affected type is `internal`, so no public surface changes. ([#2082](https://github.com/NSTA1/Orleans.Lattice/issues/2082), [#2084](https://github.com/NSTA1/Orleans.Lattice/pull/2084)) (`Orleans.Lattice` 9.6.1)

- **The write-ahead-log materialiser pin grain now evicts a bucket's cached state holder when its write fails, so a stale ETag can no longer wedge a shard into a retry loop that never terminates.** `WalMaterialiserPinGrain` caches one grain-state holder per bucket so each slot's ETag carries into later writes, and the write path re-read the slot only when that holder was *absent* - so a write that failed left the holder cached, still carrying the ETag that had just conflicted. Retrying the bucket is deliberate (the durable-write path re-arms the batch and documents that the bucket stays dirty for the next flush to retry), but every retry reused the stale ETag and conflicted identically, so the designed retry could never succeed. The grain then persisted no pin at all for the life of the activation, and because the retry cadence is far shorter than the idleness threshold Orleans collects on, the loop's own traffic kept the activation hot and suppressed the collection that would otherwise have cleared the cache. The holder is now evicted on any write failure, so the next attempt reads a fresh ETag: a conflict means the slot moved underneath the activation, and any other failure leaves the ETag describing a write whose outcome is unknown, so both are unusable and re-reading is cheap next to a wedged pin shard. Durability is unaffected - a bucket that did not land leaves its pins staler than memory, which retains *more* WAL, and the in-memory pin map is what the trim floor consults. The regression tests bring their own ETag-enforcing storage double, because the existing bucket fixture models no ETags and a concurrency assertion against it would pass whether or not the defect were present. The affected type is `internal`, so no public surface changes. ([#2096](https://github.com/NSTA1/Orleans.Lattice/issues/2096), [#2097](https://github.com/NSTA1/Orleans.Lattice/pull/2097)) (`Orleans.Lattice` 9.6.1)

- **A work-pump coordinator started inside Orleans' reminder-service startup window no longer fails the caller's operation.** `CoordinatorGrain<TSelf>.StartCoordinatorAsync` - the shared base for the tree snapshot, resize, shard-split and reshard coordinators - registered its crash-recovery keepalive reminder with a bare `RegisterOrUpdateReminder` call. Orleans initialises its reminder service asynchronously after the silo reaches `Active`, so a coordinator that started inside that window saw the transient "Reminder Service is still initializing" fault propagate straight out and fail the operation that launched it, even though the same registration moments later would have succeeded. The registration is now wrapped in the same bounded `ReminderServiceReadiness.RetryWhileInitializingAsync` wait-out that the atomic-write saga's essential keepalive already uses, so the transient is retried across the startup window rather than surfaced; any other fault, and a transient that never clears within the retry budget, still surface with their original shape so a genuinely stuck reminder service is not mistaken for a healthy start. A `protected virtual` backoff seam lets the regression tests drive the retry budget without real delays. The affected type is `internal`, so no public surface changes. ([#2086](https://github.com/NSTA1/Orleans.Lattice/issues/2086)) (`Orleans.Lattice` 9.6.1)

- **The over-budget leaf-replay warning now names the leaf grain it describes, and its throttle is keyed per leaf, so consecutive lines can no longer be misread as one leaf failing to make progress.** The warning states its own fault criterion - a persisted checkpoint that does not advance across repeats is a replay livelock and IS a fault, rather than a slow but converging replay - but it named only the tree and the WAL partition ordinal, and the partition ordinal is iterated over the full `WalPartitions` range inside every leaf's activation, so it does not identify a leaf. The throttle compounded this: keyed on (tree, partition) at one line a minute, the first leaf to trip suppressed every other leaf in that tree and partition, so consecutive lines were one-per-minute samples of arbitrary different leaves. Checkpoints that appeared to repeat or move backwards across those lines were an artifact of that sampling, which is precisely the evidence the criterion asks an operator to weigh, so the diagnostic actively misled the diagnosis it exists to support. The message now names the leaf grain id, the fault criterion is qualified to repeats naming the same leaf and partition, and the throttle is re-keyed to (tree, leaf, partition) with a bounded opportunistic sweep so the key set cannot grow without limit. The `orleans.lattice.leaf.activation_replays_over_budget` counter gains a `partition` tag and is deliberately not tagged by leaf, because leaf count is unbounded; the counter therefore measures the rate and the log makes the per-leaf call. The affected type is `internal`, so no public surface changes. ([#2023](https://github.com/NSTA1/Orleans.Lattice/issues/2023), [#2043](https://github.com/NSTA1/Orleans.Lattice/pull/2043)) (`Orleans.Lattice` 9.6.1)

- **The over-budget leaf-replay warning is now bounded in aggregate volume, so it can no longer roll the evidence needed to diagnose it out of the log.** Per-leaf keying is what makes the warning diagnostically useful, but it also multiplies volume: a tree with L leaves and P WAL partitions has L x P throttle keys and so L x P times the per-key rate. This is how this single warning reached 46 percent of one deployment's container log and rolled away the older entries that were the evidence needed to diagnose the condition producing it. A per-tree, per-window cap now bounds how many *repeat* detail lines one tree may emit, on top of the existing per-key backoff. What is deliberately not traded away: a leaf partition reporting over budget for the first time is exempt from the cap, so a newly appearing condition still surfaces promptly; everything the cap withholds is reported as a summary line rather than dropped silently; and the counter remains the exact census. The gate's own state is kept honestly - the per-key backoff stamp is committed only when a line is really emitted, so a repeat the cap withheld does not pay backoff for a line it never printed and still rotates into the budget to yield the two comparable lines the fault criterion needs; the stamp sweep retires rather than deletes, so housekeeping cannot manufacture the first-time exemption on a large estate; a clean in-budget activation retires the backoff, so a leaf that misbehaves, recovers and regresses hours later reports at the base interval instead of inheriting an accumulated ceiling; and both capacity sweeps are rate-limited and bounded. One reporting limit is documented rather than removed: the withheld-repeat summary is carried by a later occurrence on the same tree, so a tree's final withheld tally goes unreported once the condition resolves, and the counter is the exact census for that case. The affected type is `internal`, so no public surface changes. ([#2100](https://github.com/NSTA1/Orleans.Lattice/issues/2100), [#2109](https://github.com/NSTA1/Orleans.Lattice/pull/2109)) (`Orleans.Lattice` 9.6.1)

- **A fractional literal compared against an integral indexed property is now rejected instead of being silently rounded to a wrong exact bound.** `GrainIndexValueBinder` brings a literal captured from a lambda back to the indexed property's declared type with `Convert.ChangeType` before encoding it, but `Convert.ChangeType` rounds a fractional value to an integral target (1.5 becomes 2) rather than throwing. The binder then encoded an exact bound the caller never wrote, and because the planner drops the residual predicate for an exact range, a query such as `x.Count > 1.5` against an `int` property returned wrong or empty results with no error. The binder now round-trips every converted value back to its original type and rejects any conversion that does not compare equal, so a lossy literal fails to encode (the planner keeps the residual filter) while a lossless widening (2.0 to an `int`) still encodes exactly as the property would. The affected type is `internal`, so no public surface changes. (`Orleans.Lattice.GrainIndex` 9.6.1)

- **A value-transform member read now matches input property names case-insensitively, as its documented contract and its sibling predicate evaluator already do.** `LatticeValueTransform.Member` is documented to resolve members ordinally and case-insensitively, and the predicate projection path (`SchemaValueChecks.TryProjectStringMember`) already does so through a case-insensitive lookup. The transform evaluator, however, parsed the input document with the default `JsonNode` options, so a `Member("Name")` read against an input carrying `"name"` resolved to nothing and produced a JSON null, contradicting the contract and diverging from the predicate path. The evaluator now parses with `PropertyNameCaseInsensitive`, which every downstream member operation (read, set, drop, rename) inherits, so a member read resolves regardless of case. The affected type is `internal`, so no public surface changes. (`Orleans.Lattice.Schema` 9.6.1)

## Security

- **Three internal grain surfaces that deliberately bypass the `ILattice` facade now assert internal origin, closing a direct-grain-call route around the access gate.** All access-gate enforcement lives on the `ILattice` facade, so the physical grains it delegates to have carried a defense-in-depth internal-origin assertion since the shard and leaf grains were hardened for [#1103](https://github.com/NSTA1/Orleans.Lattice/issues/1103). Three surfaces reachable by the same route did not, and each was declared `internal` on the understanding that accessibility was itself the boundary. It is not: Orleans binds a grain interface by its stable `[Alias]` string rather than by CLR identity, so an external client can declare a structurally matching aliased interface and bind to the same activation. That is precisely why the capability-stripping call filter re-derives trust from the call's source at every hop instead of trusting the caller, and why the shard and leaf grains guard at all. The three gaps differ in impact, so all three are closed. (1) `ISystemLattice` is the internal read, write, delete and enumerate surface over the reserved system-tree namespace that the public facade refuses to address, which holds the auth policy tree and the replication write-ahead log, so reaching it externally was a full policy-store bypass rather than access to one tree; all six members now assert, and the two streaming members assert on the call itself rather than inside the deferred iterator body, so a caller that never enumerates is refused just the same. (2) `IReplicationApplyGrain` installs a remote mutation while preserving the authoring cluster's hybrid logical clock and origin cluster id verbatim, deliberately bypassing both the access gate and local conflict resolution so a receiver reproduces the producer's view exactly; reaching it externally let a client forge replicated writes attributed to another cluster and stamped with an attacker-chosen clock, which conflict resolution would then honour. All ten apply entry points now assert. (3) `ILatticeCrossTreeReceiverGrain.NotifyTerminalAsync` carries the commit-or-abort verdict for a whole cross-tree batch on a receiver cluster and, on the first terminal, freezes the wait set, origin cluster and operation id; it validated the shape of a terminal but authorized nothing, so an external caller could force a premature global verdict on a batch in flight and equally poison a barrier that had not started, breaking the all-or-nothing cross-tree visibility the coordinator exists to enforce. It is the receiver-side twin of the single-tree saga's `IAtomicWriteGrain.FinalizeAsync`, which was already guarded, and it is now guarded identically and ahead of the redelivery short-circuit so a settled verdict is not disclosed either. The sibling `ILatticeCrossTreeTxGrain.CommitAsync` was audited in the same sweep and left unchanged, because it already re-derives the access gate per leg against the original caller's identity before any staging. Every assertion uses the enforcement-gated overload, which resolves the `LatticeInternalOriginEnforcementMarker` sentinel from the activation's services and returns immediately when it is absent, so a cluster that has not opted into `AddLatticeAuth` pays one singleton lookup on a low-frequency coordinator path and behaves exactly as before. No public surface changes: all three interfaces are `internal`. Regression tests drive each surface by direct external grain call against a cluster with enforcement registered and assert the refusal, alongside the existing coverage proving the facade path still succeeds and that a forged internal-origin or system-origin marker is stripped from a client call. (`Orleans.Lattice` 9.6.1)

- **The three tree-admin verbs that act on a second caller-supplied identifier now authorize that identifier too, closing three paths by which a caller could reach a tree it holds no grant on.** Each of `SetTreeAliasAsync`, `SnapshotTreeAsync` and `CreateViewAsync` accepts more than one caller-named resource, composes and reserved-namespace-checks every one of them - and then authorized only the first, leaving the far side of a cross-tree operation ungoverned. That is the same shape the core already defends against at its sibling seams (`LatticeGrain.MergeAsync` authorizes its caller-supplied source through `EnforceSourceTreeReadAsync` precisely so "authorizing only the destination would let a caller holding Admin on a tree it owns siphon any other tree in the cluster", and the backup engine asserts physical-tree provenance at its own alias-swap seam), so all three are now brought into line. (1) `SetTreeAliasAsync` authorized the **logical** id but not the alias **target**. Every data-plane gate - `LatticeGrain`'s access-gate enforcement, and the request this facade's own verbs build - is evaluated against the logical id *before* the registry resolves the alias, and the physical shard and leaf grains deliberately enforce no policy of their own because they are documented as reachable only through an already-authorized logical identity; a caller holding `Admin` on a single tree it legitimately owned could therefore alias that tree onto one it held nothing on and read and rewrite the target through the ordinary `ILattice` surface, under its own tree's policy. The registry's `ThrowIfAliasEscalatesNamespace` guard is namespace-shaped (it refuses a `_lattice_` target, an ordinary-to-`sys-` crossing, and a foreign-tenant target), so two ordinary same-namespace ids passed it unconditionally - which is exactly the case now closed. (2) `SnapshotTreeAsync` authorized the **source** but not the **destination**, and a snapshot *creates* its destination in the registry (at caller-chosen structural sizing) and drains the source's whole contents into it. The core's `EnforceWholeTreeAsync(Admin)` is hard-bound to the source tree's own id and its `ThrowIfReservedSnapshotDestination` is likewise namespace-only, so an ordinary destination was ungoverned end to end: a caller could stand up a fully-populated tree at an id it had no authority over, bypassing both `CreateTreeAsync` (which authorizes the exact id being created) and `BeginBulkLoadAsync` (which authorizes the `BulkLoad` capability on it), and pre-plant forged content at an id a victim's policy grants them but which they have not created yet. (3) `CreateViewAsync` authorized the caller's chosen **source** but never the **incumbent** registration behind the caller-supplied view name, and view registration is last-write-wins at both layers (`ViewRegistryGrain.RegisterAsync` overwrites the durable record, `ViewCatalog.Register` the entry). A caller holding `Admin` on any tree it owned could therefore re-point an established view at its own source: the maintainer then projected attacker-controlled data into the existing `view-` tree, which the caller could not write to directly because the maintainer runs under a system-origin bypass, and the rightful owner was locked out of `RebuildViewAsync` / `ReconcileViewAsync` / `DropViewAsync`, all of which authorize against the *resolved* source. A first-time create and an idempotent re-create over the same source are unaffected; only a genuine rebind now requires `Admin` on the source the view currently tails as well as on the one it is moving to. All three fixes are purely additive authorization calls at the facade seam - no public API signature changed, and no previously-authorized call is now refused. (`Orleans.Lattice.Api.TreeAdmin` 9.6.1)

- **A restore now authorizes the tree its manifest was captured from, closing a cross-tree read primitive gated only on knowledge of a backup id.** `LatticeBackupRestoreService` read the target manifest before any authorization and then retargeted the manifest's own captured scope onto the caller-supplied target tree - so by the time the fail-closed check ran, the captured `Scope.TreeId` had already been discarded and was never put to the gate on any restore path. Only the target was governed, which meant a caller holding `Restore` over a tree it owns could have the entire contents of a tree it holds nothing on replayed into a tree it fully controls, and then read it through the ordinary `ILattice` surface under its own tree's policy. That is the same "authorize one identifier, act on another" shape closed on the tree-admin facade above, and the restore path was the outlier within its own package: every other manifest-consuming verb on `LatticeBackupControl` - describe, delete, export artifact, and the three health verbs - already gates on `manifest.Scope`. The captured source is now authorized with the `Backup` capability, matching those verbs and matching the authority the caller would have needed to capture the manifest itself, at every entry point that replays a manifest: `RestoreAsync` (and therefore `RestoreSetAsync` and the catalog-free `ColdRestoreAsync`, which both delegate to it) and the coordinated engine's `BuildShadowAsync`. Two properties keep it tight. The check is skipped when source and target are the same tree, so the ordinary same-tree point-in-time rollback needs no `Backup` grant it did not need before, and only a genuine cross-tree retarget - clone, disaster recovery into a fresh id, environment seeding - is affected. And the scope put to the gate is the effective restore scope retargeted back onto the source tree, so a sub-scoped restore authorizes only the range actually replayed rather than the whole captured scope. Every distinct source tree in the manifest chain is gated, not just the tip's: an increment inherits its base's scope on the capture path, but the sink is a trust boundary, so the chain is checked rather than inferred. `RevertRestoreAsync` and `CommitShadowAsync` are unchanged because neither consults a manifest - they move a registry alias between physical trees whose provenance is already asserted against the target. **Potentially breaking for a host with an authorization add-on:** a cross-tree restore that previously succeeded on a `Restore` grant over the target alone is now refused unless the caller also holds `Backup` over the captured source. A host with no authorization add-on registered is unaffected, because the no-op gate allows everything at zero cost. No public API signature changed. (`Orleans.Lattice.Backup` 9.6.1)

- **An existence probe under `DefaultEffect = Allow` no longer reports a grant for a tree the subject is wholly denied, closing a listing-visibility disclosure.** `PolicyEvaluator.HasAnyGrant` - the structural "any grant" signal that existence-hiding relies on - returned `true` unconditionally whenever the default effect was allow, ignoring a matched whole-tree deny. Enforcement resolves that tree-wide deny for every key, so a subject denied the whole tree can read nothing, yet the probe still reported the tree as reachable and kept it visible in listings: the exact out-reach the gate's documented invariant forbids ("an existence probe must never out-reach the enforcement decision"). The probe now mirrors enforcement: under default-allow a tree whose tree-wide decision is deny with no exact or prefix allow carve-out reports no grant and is hidden, while a whole-tree deny paired with a prefix or exact allow keeps the tree visible because some keys remain readable. This fails closed and aligns the probe with the enforcement path. The affected type is `internal`, so no public surface changes. (`Orleans.Lattice.Auth` 9.6.1)

- **That same existence probe now mirrors the two remaining enforcement tiers as well, closing three further listing-visibility disclosures wherever the all-trees tier decides the outcome.** The fix above aligned `PolicyEvaluator.HasAnyGrant` with enforcement only for a specific tree's own whole-tree deny under default-allow; the probe still ignored the all-trees (`Tree:*`) tier entirely, so it kept disagreeing with `ResolveTiered` in three further configurations, each of which advertised a tree in listings that the gate denies on every single key. (1) An all-trees **deny** paired with a specific whole-tree **allow**: tier 1 gives the all-trees deny precedence over every specific-tree rule, so the gate denies each key, but the probe took the specific allow as proof of a grant and reported the tree visible. This one was not merely unguarded but positively asserted, by a test whose own comment conceded "the specific allow keeps the tree visible even though a real read would be denied by the wildcard" - that test now asserts the corrected behaviour. (2) The same all-trees deny under `DefaultEffect = Allow` against a tree carrying no rules of its own, where the probe returned `true` before consulting the wildcard bucket at all. (3) An all-trees **allow** paired with a specific whole-tree **deny** and no allow carve-out, under `DefaultEffect = Deny`: tier 2 gives the specific tree's own verdict precedence over an all-trees allow, so the gate again denies every key, yet the probe fell through to the wildcard allow and reported a grant. The probe now applies the tiers in enforcement's own order: a matched all-trees deny hides the tree outright, because that verdict is resolved tree-wide and is therefore uniform across every key; the "denies every key" test that closed the default-allow case is now applied on the default-deny path too, ahead of the all-trees allow it must outrank. A tree that keeps an exact or prefix allow carve-out, or one reachable only through a wildcard grant, stays visible exactly as before, so nothing legitimately listable is hidden. The stale doc comment claiming a wildcard deny was "deliberately not consulted" by the probe is corrected along with it. The affected type is `internal`, so no public surface changes. (`Orleans.Lattice.Auth` 9.6.1)

- **The distributed lock's two lease-duration options are now validated, so a non-positive ceiling can no longer silently disable the lease cap.** `LatticeOptions.MaxLockLeaseDuration` documents itself as "positive and at least `DefaultLockLeaseDuration`", and four further places - the three `ILatticeLockGrain` acquire/renew overloads, `LockTypes`, and the distributed-lock guide - promise that any requested duration "is capped at `MaxLockLeaseDuration`". Nothing enforced any of it: `LatticeOptionsValidator` checks around thirty other durations, several with a far smaller blast radius, but neither lock-lease option at all. The clamp is written `if (max > TimeSpan.Zero && lease > max)`, so a zero or negative ceiling does not disable *leasing*, it disables the *cap*: every caller-supplied duration is then honoured verbatim, and a large one overflows the absolute expiry tick (`nowTicks + leaseTicks`) and wraps into the past, which the reclaim path reads as an already-expired lease and hands the lock to a waiter while the original holder still believes it holds it. A non-positive `DefaultLockLeaseDuration` is worse and needs no overflow at all: it is the fallback taken whenever a caller names no duration, and it is granted verbatim, so `LeaseExpiresAtTicks` equals the grant instant and mutual exclusion is lost for every default-duration acquire. The validator now rejects a non-positive value for either option, and a ceiling below the default, while explicitly still admitting the boundary case of a ceiling exactly equal to the default. This is validation only: no public API signature changed, and every already-valid configuration still validates. (`Orleans.Lattice` 9.6.1)

- **`JwtCredentialAuthenticator` no longer leaves the signature-algorithm allow-list unrestricted by default.** `JwtAuthenticatorOptions.Algorithms` is empty by default, and the authenticator pinned `TokenValidationParameters.ValidAlgorithms` only when it was non-empty. An empty `ValidAlgorithms` is treated by the token validator as "no restriction", so a deployment that configured an issuer and signing keys and never touched `Algorithms` - the shape in the package's own documentation - accepted a token whose header advertised *any* algorithm the trusted key set could satisfy, leaving the algorithm-confusion gap (CWE-347) open: a host that pinned both asymmetric and symmetric key material could be induced to verify an attacker-minted HMAC token against a symmetric key configured for an unrelated purpose. Porting the sibling `OidcCredentialAuthenticator`'s deny-all posture was not viable, because there the empty case is a discovery document that named no algorithms whereas here empty is the documented default, so deny-all would take every existing host from "authenticates" to "rejects every token" on upgrade. Instead the allow-list is now *derived* from the families of the configured signing keys: an RSA-only or EC-only key set accepts only that family's algorithms, and a symmetric-only key set accepts only the HMAC algorithms, so a deployment can never be talked into verifying against a key of the wrong family. The list is left unrestricted only where no family can be established - an empty key set, a set mixing symmetric and asymmetric material, or one containing a key type the derivation does not recognise - so a legitimately mixed host keeps working and can pin explicitly. An explicit `Algorithms` pin remains authoritative and is never widened. No public surface changes; the existing `Algorithms` and `ValidationParameters` knobs already express every override a host needs. (`Orleans.Lattice.Membership` 9.6.1)

- **A read-only MCP caller is no longer advertised the mutating repository-context tools.** `LatticeApiMcpGroupCapabilityMap.RequiredOperations` returns one coarse operation mask per facade group, and `AuthAdminMcpPermissionResolver` admits a group when *any* single `Allow` rule intersects that mask. The repository-context group's mask spans the whole data plane - `Read`, `Write`, `Delete`, `RangeRead`, `RangeDelete`, `CrdtApply`, `AtomicWrite`, `BulkLoad` - so a principal granted nothing but `Read` was admitted to the entire group, and the caller's actual operation mask was then discarded rather than consulted again. The session's advertised tool list therefore included `repocontext_remember`, `repocontext_update`, `repocontext_forget`, `repocontext_add_repo`, `repocontext_remove_repo` and `repocontext_bootstrap`, and because that same collection serves `tools/call`, they were invocable, not merely visible - a least-privilege violation that let a read grant write to and delete from the store. `LatticeApiMcpAccessSet` now also carries the union of the caller's `Allow`-granted operations, and the discovery core applies a per-tool minimum inside a group it has already admitted, via a new `ILatticeApiMcpToolGroup.RequiredOperationsFor` whose default implementation returns the group's own mask so no existing group changes behaviour. The repository-context group overrides it to demand a mutating operation for those six tools. The group masks themselves are unchanged, so the `lattice_capabilities` wire response is byte-identical and a read-only caller still legitimately sees the group for its read tools. A resolver that supplies no operation detail keeps the previous group-level-only filtering, so the refinement cannot silently narrow an existing deployment. All the affected types are `internal`; no public surface changes. (`Orleans.Lattice.Api.Mcp` 9.6.1)

- **The repository-context workspace boundary is now enforced, not merely advertised.** `AddRepoContextTools(enableWrites: true, workspaceMode: true)` with no `workspaceRoot` - a legal call, because the parameter is optional - registered a `RepoContextWorkspaceGuard` with no roots. That guard reports itself as not enforcing and its resolver short-circuits to admit *every* path, yet workspace mode still contributed `repocontext_add_repo`, whose own published contract states that the path is "resolved against the workspace boundary; a path outside it is rejected". A caller could therefore name any directory on the host - `/`, a home directory, a secrets mount - with `respectGitignore: false` and `excludeBinary: false`, have the ingest project every file body into the content tree, and read it straight back out through `repocontext_context`, `repocontext_search` and `repocontext_outline`: an unauthenticated-at-the-tool-layer arbitrary local file read dressed as repository onboarding. The fix is fail-closed at two seams. The registration extension now computes the workspace roots *before* building the tool group and withholds `repocontext_add_repo` entirely when there are none, so advertisement matches enforcement; it deliberately does **not** substitute `repocontext_bootstrap` in its place, because bootstrap accepts an equally unbounded caller-supplied path and the fallback would reopen the same hole under a different name. `repocontext_remove_repo` and `repocontext_list_repos` are unaffected - neither takes a path. The handler then re-checks the *effective* dependency-injected guard when `repocontext_add_repo` is invoked and refuses with a clear `McpException`, which is what covers a host that registered its own non-enforcing guard despite supplying a root. Argument validation still runs first, so a malformed call reports the parameter problem rather than the boundary refusal. The single-repository `repocontext_bootstrap` surface is untouched and still accepts an arbitrary path by design: there the path is host configuration rather than caller input, and that deliberate opt-out is now pinned by test. No public surface changes. (`Orleans.Lattice.Api.Mcp.RepoContext`, not published to NuGet)

Newer release: [Release 2026-09-09: Fixed](../changelog/2026-09-09-2.md). Older release: [Release 2026-09-05](../changelog/2026-09-05.md). Contents: [Changelog](../CHANGELOG.md).
