---
title: "Release 2026-09-02: Fixed to Security - Changelog"
url: "https://nsta1.github.io/Orleans.Lattice/changelog/2026-09-02-2.html"
source: "https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/CHANGELOG.md?plain=1#L1355-L1414"
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-02: Fixed to Security

Part of [Release 2026-09-02](../changelog/2026-09-02.md), in [Changelog](../CHANGELOG.md).

## Fixed

- **`LatticeValueTransform` now compares by structure, so a schema value-transform tree equals a structurally identical one instead of only its own reference.** As a `public readonly record struct` it inherited the compiler-generated record equality, which compares its `LatticeValueTransform[]? Children` array by reference, so two structurally identical transform trees (for example one that round-trips through serialization) never compared equal - the same defect already fixed on its documented sibling `LatticePredicateNode`. `LatticeValueTransform` now implements `Equals`/`GetHashCode` explicitly, recursing over `Children` with sequence semantics and comparing every scalar field, the embedded `Condition` predicate, and the `Constant` literal. No public API signature changed, and the wire format is unchanged. (`Orleans.Lattice.Schema` 9.5.0)

- **A pushed-down predicate comparing against a `ulong` literal greater than `long.MaxValue` no longer silently corrupts the constant to a negative value.** `LatticePredicateTranslator` captured every `ulong` literal with `Integer(unchecked((long)ul))`, so a value above `long.MaxValue` (for example `ulong.MaxValue`) wrapped to `-1` and the translated predicate compared against `-1` rather than the intended magnitude, never matching the documents the caller meant. Such a literal is now captured as a `Double` - the representation the evaluator already reads a large `ulong` JSON member as, since a JSON integer above `long.MaxValue` fails `TryGetInt64` and decodes as `double` - so a translate-then-evaluate round trip agrees with the compiled lambda; a `ulong` within `long.MaxValue` is still captured as an exact `Int64`. No public API signature changed. (`Orleans.Lattice` 9.5.0)

- **Re-registering a runtime view carrying a provider payload no longer forces a redundant durable write.** `RuntimeViewRegistration` holds a `byte[]? ProjectionProviderPayload` that its compiler-generated record equality compared by reference, and `LatticeRuntimeViewProjectionDescriptor.Payload` returns a fresh array on every access, so re-issuing the same `CreateAsync` produced a content-identical but distinct payload array. `ViewRegistryGrain.RegisterAsync` guards its idempotent re-registration path with `existing.Equals(registration)`, so the reference mismatch defeated the guard and re-wrote grain state on every repeat, violating the documented idempotency contract. The record now compares the payload by content (with a matching content-based `GetHashCode`), so an identical re-registration is correctly deduplicated. The type is internal, so no public API changed. (`Orleans.Lattice` 9.5.0)

- **Three public model/builder surfaces now defensively copy the caller-owned array or collection they are handed, so a mutation of the caller's buffer after the call can no longer reach committed or stored data.** `LatticeAtomicWriteBuilder` staged the caller's `byte[]` value (and, on the internal delta and `Set(LatticeStagedCrdtWrite)` paths, the delta) by reference and forwarded those same arrays into the `LatticeTreeBatch` list at `CommitAsync`, so mutating the buffer after staging but before commit corrupted the committed payload. In the schema package, `LatticeSchemaPolicy` stored the caller's `IReadOnlyList<LatticeSchemaRule>` directly and `LatticeSchemaDeadLetterEntry` both stored and exposed the caller's preview `byte[]` directly, so either model - despite being `[Immutable]` and documented as holding a copy - changed when the original list or array was mutated, and the preview could additionally be mutated through its getter. All three now copy on ingress (the dead-letter preview also returns a defensive copy from a serialized backing field), so the staged, policy, and dead-letter payloads are immune to later caller mutation. No public API signature changed, and the wire format is unchanged. (`Orleans.Lattice` 9.5.0, `Orleans.Lattice.Schema` 9.5.0)

- **`LatticePredicateNode` now compares by structure, so a restated identical guard predicate is accepted instead of always throwing.** As a `public readonly record struct` it inherited the compiler-generated record equality, which compares its `LatticePredicateNode[]? Children` array by reference - so two structurally identical predicate IRs (for example the same lambda compiled twice) never compared equal. That made the deliberate restatement branch in `LatticeAtomicWriteBuilder.SetWhere` unreachable: guarding a second key in a cross-tree atomic write by restating the same predicate threw `InvalidOperationException` ("a cross-tree slice supports at most one") even though the predicate was identical, and a node that round-tripped through serialization never equalled its pre-serialization self. `LatticePredicateNode` now implements `Equals`/`GetHashCode` explicitly, recursing over `Children` with sequence semantics and comparing every scalar field ordinally; the one-predicate-per-tree guard still rejects a genuinely different predicate. `LatticeConstant` was checked and is all-scalar, so its record equality was already correct. No public API signature changed. ([#1827](https://github.com/NSTA1/Orleans.Lattice/issues/1827)) (`Orleans.Lattice` 9.5.0)

- **`ILatticeTreeAdmin.BeginBulkLoadAsync` now rejects a tree emptied by deleting every key, instead of grafting the load onto tombstoned leaves.** The emptiness precondition probed the tree with a shallow diagnostic and tested `TotalLiveKeys > 0 || TotalTombstones > 0`, but the shallow diagnostic path never populates `TotalTombstones` (only the deep path walks leaves and reads per-leaf stats), so the tombstone half of the guard was dead code. A tree emptied by deleting every key reported zero live keys and zero tombstones, passed the probe, and the append grafted onto leaves that still held tombstoned rows rather than building a fresh tree - exactly what the guard's comment said it prevented. The probe is now deep, which observes the tombstones; it is not asymptotically dearer here, because both diagnostic modes already walk the whole leaf chain and deep only substitutes a per-leaf stats read for a live-only count. The existing unit test stubbed the diagnostic regardless of the `deep` argument, so it passed over a guard dead in production; a real-cluster regression test now drives a genuinely emptied tree, and the migration guide is corrected. ([#1823](https://github.com/NSTA1/Orleans.Lattice/issues/1823)) (`Orleans.Lattice.Api.TreeAdmin` 9.5.0)

- **The Commit Path and Replication Grafana dashboards no longer ship panels whose `gridPos` rectangles overlap.** Grafana silently repacks overlapping panels when it loads a dashboard, so the checked-in JSON stopped describing the rendered layout and the next author computed a "next free y" from coordinates that no longer held. The Commit Path dashboard had two overlapping panels and the Replication dashboard eleven, each the result of two branches independently appending a row at the same bottom `y` and a textual merge keeping both. The affected panels are re-spaced into a contiguous, non-overlapping layout (panel content and ids unchanged), and a new `DashboardJsonTests` drift guard fails the build on any pair of intersecting panel rectangles - the coordinate twin of the existing duplicate-panel-id guard. ([#1829](https://github.com/NSTA1/Orleans.Lattice/issues/1829)) (`Orleans.Lattice.Dashboards` 9.5.0)

- **A `ChangeFeedCursor` can no longer be mutated through its exposed offset map.** `ChangeFeedCursor.PartitionOffsets` documents a defensive snapshot owned by the cursor, but the getter returned the mutable backing `Dictionary` boxed as `IReadOnlyDictionary`, so a caller that downcast to `IDictionary<int, long>` could rewrite a cursor's per-partition offsets after construction and silently resume a subscription from a different position. The empty map returned for `Initial` and empty cursors was a single shared mutable static, so one such downcast-and-mutate poisoned every future `Initial` cursor process-wide. The backing map is now wrapped in a `ReadOnlyDictionary<int, long>` and the shared empty is `ReadOnlyDictionary<int, long>.Empty`, so every exposed snapshot rejects mutation and no cross-cursor leak is possible. (`Orleans.Lattice.Replication` 9.5.0)

- **A WAL read from an exclusive lower bound of `long.MaxValue` no longer replays the whole log from the head.** Three WAL read paths advanced the exclusive cursor with `fromOffsetExclusive + 1` before comparing it against stored offsets; at `long.MaxValue` that overflows to `long.MinValue`, which selects the entire log instead of the empty suffix a maximal lower bound denotes. `WalCommitLogReader.ReadAsync` (whose dead `if (nextSequence < 0)` guard turned the overflow into a read-from-zero and polled the shard grain), `InMemoryWalStorageProvider.ReadAsync`, and `FileWalShard.SnapshotAsync` now each short-circuit to an empty result at that boundary. (`Orleans.Lattice` 9.5.0; `Orleans.Lattice.Storage.File` is not yet published to NuGet)

- **`ComputePressureMath.Clamp01` now maps positive infinity to the safe floor, as its contract documents.** The guard rejected `NaN` but not the infinities, so a non-finite positive infinity fell through and clamped to `1.0` - the maximum compute-pressure signal - rather than the documented `0.0`, injecting a spurious saturation spike that could drive a bogus scale-out decision. The check is now `!double.IsFinite(value)`, so every non-finite input clamps to `0.0`. (`Orleans.Lattice.Scaling` 9.5.0)

- **A cluster-wide `Telemetry` grant authored the documented way is now honoured.** `LatticeScope.ClusterWide()` produces a scope over the all-trees sentinel `"*"` and its own documentation names `LatticeOperation.Telemetry` as its intended use, but both telemetry authorizers asked the access gate about the reserved auth-policy tree instead, and the evaluator deliberately refuses to fold an all-trees grant into a reserved tree. The two never met, so the grant was silently inert: cluster telemetry was reachable only by a bootstrap administrator, and the Explorer's Access area offered an authoring path that authorized nothing. `TelemetryAccessAuthorizer.AuthorizeClusterTelemetryAsync` and `TreeAdminAccessAuthorizer.AuthorizeClusterTelemetryAsync` now both request `LatticeScope.ClusterWideTreeId`, matching the documented contract and each other. To keep the fail-closed posture that scoping to the reserved namespace previously supplied, `PolicyAccessGate` now governs a request whose **target** is the sentinel with control-plane isolation: a data-plane read or write always names a real tree, so a request on `"*"` can only be a scopeless capability request, and an unmatched one is denied regardless of `LatticeAuthOptions.DefaultEffect`. The existence probe mirrors it. `PolicyEvaluator` also resolves a request on the sentinel whole-scope rather than per-key, so an unrelated key- or prefix-scoped rule sitting in the `"*"` bucket - inert for the all-trees tier, and authorable through the ordinary admin surface - can no longer divert the capability check into a per-key filtered decision whose winning match is by definition unmatched, which would have denied cluster telemetry for every caller. Platform-operator validation still authorizes over the reserved policy tree, data-plane requests on named trees are untouched, and the all-trees tier still never reaches the reserved, tenant-admin, or tenant-registry namespaces. **Breaking, with a migration:** a Telemetry grant authored against `sys-auth-policy` to satisfy the previous behaviour no longer confers cluster telemetry - re-author it with `LatticeScope.ClusterWide()` (in the Explorer, the "all trees (cluster-wide)" affordance with Telemetry ticked). ([#1795](https://github.com/NSTA1/Orleans.Lattice/issues/1795)) (`Orleans.Lattice.Auth` 9.5.0, `Orleans.Lattice.Api.Telemetry` 9.5.0, `Orleans.Lattice.Api.TreeAdmin` 9.5.0; the Explorer half is held back with the rest of the Explorer sub-family)

- **An unrecognised `LatticeConstantKind` no longer silently nulls a member during schema remediation.** `LatticeValueTransformEvaluator.FromConstant` mapped an unknown kind to `null`, which is written into the remediated document as JSON `null` and is byte-identical to an explicit `LatticeConstantKind.Null` constant. `LatticeSchemaRemediationGrain` applies the transform to every entry as it builds the destination tree, so one unmappable constant replaced that member's existing value across the whole tree while the pass reported success, with nothing downstream able to tell the loss from an intentional null. The kind is wire format and is persisted in `SchemaRemediationState.Transform`, so it is reachable on a mixed-version cluster. It now throws `InvalidOperationException`, matching every other unknown-wire-enum arm in the same evaluator; the remediation grain already treats an evaluation throw as an abort that discards the destination tree, so the pass fails safely with no partial writes. ([#1786](https://github.com/NSTA1/Orleans.Lattice/issues/1786)) (`Orleans.Lattice.Schema` 9.5.0)

- **The `StallWatchdog` failure-arm test no longer races two equal-period timers.** `RunAsync_fires_on_failure_arm_when_writtenTotal_frozen_with_sustained_failure_rate` drove the failure counter from a background pump on its own `Task.Delay(10)` while the watchdog polled on an independent 10ms timer. An unarmed poll resets the stall clock, so whenever the watchdog polled twice between pump ticks - ordinary jitter, thread-pool scheduling, or a GC pause - the window restarted and the watchdog needed another five consecutive armed polls inside a 2-second budget that the cancellation source was also racing. It failed roughly 1 run in 15 on an idle machine and intermittently broke the required `build-and-test` check on unrelated pull requests. The failure stream is now driven from the snapshot callback the watchdog reads once per poll, so every poll observes a fresh delta above the threshold, and the cancellation budget is well clear of the assertion deadline. The sibling healthy-progress test is made deterministic the same way. Test-only; the production watchdog is unchanged and the asserted contract is identical. ([#1794](https://github.com/NSTA1/Orleans.Lattice/issues/1794))

- **The inert `Http2UnencryptedSupport` app switch is gone from the samples and the reference architecture, and three READMEs stop describing it as load-bearing.** Nine hosts set the process-global switch before creating a gRPC channel. On .NET 10 it does nothing: measured on 10.0.11 against a Kestrel host bound `HttpProtocols.Http2` on a plaintext loopback port, h2c succeeded identically with the switch never set, explicitly `false`, and set `true`. It was a .NET Core 3.x affordance, so the calls were dead code teaching a reader to reach for process-global state they do not need. The `PasswordProtection` README was the most misleading, instructing the reader that serving real TLS means they "must remove that switch" - it is binding Kestrel without a certificate that selects plaintext, not the switch. The calls are deleted, the comments now attribute h2c to the binding and to grpc-dotnet's prior-knowledge upgrade, and the `AppContextSwitchHygieneTests` guard is promoted to the shared testing library so it covers `samples/` and `reference-architecture/` as well as `src/lattice.explorer/`. No transport behaviour changes. ([#1796](https://github.com/NSTA1/Orleans.Lattice/issues/1796))

- **The state-API change-feed integration tests no longer race the subscription they are observing.** Their shared `ObserveWhileAsync` helpers opened a fresh-tail subscription, slept 300ms hoping its cursor had seeded, and only then wrote - but seeding runs inside the first `MoveNextAsync`, which resolves the tree, authorizes the caller, and round-trips every WAL partition grain, so on a slow or instrumented run (the coverage job) the write landed ahead of the tail, was never delivered, and the test failed on a silently missed change after burning its full 10-second timeout. Observed twice on `Observe_bootstrap_admin_sees_every_change`. The sleep only narrowed the window; it could not close it. Both helpers now capture the tree's per-partition WAL tail as a continuation token *before* mutating and subscribe from it, so the observed window is fixed by an explicit position rather than by how long the subscription takes to establish. Proven by running the mutation strictly before the subscription opens - the worst case the sleep was gambling against: pinned, all five tests pass; unpinned, the exact CI failure reproduces. Test-only; no production code changes, and the fresh-tail seeding path stays covered by the subscription-lifecycle tests that subscribe without a token. (`Orleans.Lattice.Api.State` 9.5.0)

- **A predicate lambda that passes a `StringComparison` (or culture) to `string.Equals`/`StartsWith`/`EndsWith`/`Contains` is now rejected at translation instead of silently ignoring the argument.** `LatticePredicateTranslator` read only the pattern argument of these string-method calls and dropped any trailing `StringComparison` / `CultureInfo` / `bool ignoreCase` operand, so a caller-specified case-insensitive or culture-aware comparison was silently downgraded to the evaluator's hardcoded `StringComparison.Ordinal` and could match differently from the compiled lambda. The translator now throws `NotSupportedException` for the comparison- and culture-bearing overloads (the one-argument `string` and `char` ordinal overloads still translate), consistent with its documented contract of failing unsupported constructs at translation time. No public API signature changed. (`Orleans.Lattice` 9.5.0)

- **A predicate comparison over a 64-bit integer larger than 2^53 is now exact, instead of being rounded through `double`.** `LatticePredicateEvaluator` funnelled every numeric operand - including an `Int64` constant or a JSON integer member - through a `double`, so two longs that differ only above the 53-bit mantissa (for example `9007199254740992` and `9007199254740993`) collapsed to the same value and compared equal, and ordering between such longs was unreliable. The evaluator's internal operand now preserves an integral value as a `long` and compares two integers exactly, falling back to `double` only when one side is genuinely real; both the fast (`Utf8JsonReader`) and slow (`JsonElement`) decode paths share the fix. The change is internal to the evaluator - the wire `LatticeConstant` already distinguished `Int64` from `Double` - so no public API or wire format changed. (`Orleans.Lattice` 9.5.0)

- **The replication content-digest no longer aliases two structurally distinct mutations when their key or value contains a NUL byte.** `ReplicationContentHash.Compute` framed its fields with a single `0x00` separator byte, so a NUL at a field boundary was indistinguishable from the separator: `{key: "a\0", value: []}` and `{key: "a", value: [0,0]}` folded to the identical byte stream and produced the same digest, defeating the anti-aliasing contract the class documents and its own boundary test asserts. Field framing is now length-prefixed (a 32-bit length folded before each field's bytes), so a NUL in content can no longer imitate a delimiter. The digest is computed in-process only and never travels on the wire, so no public API or wire format changed. (`Orleans.Lattice.Replication` 9.5.0)

- **A wide `ILattice.GetManyAsync` probe no longer times out because the shard batch read awaited one leaf at a time.** `TraverseForBatchReadAsync` awaited each bucket's leaf read sequentially, so a batch read's latency was the **sum** of every bucket's round trip rather than the **max**. A scattered point probe over a large tree buckets into many leaves, so the summed latency walked off the end of the Orleans response deadline and surfaced as a `TimeoutException` - slowness disguised as a fault. The read path now dispatches its per-leaf reads in parallel, mirroring exactly the shape the write path (`SetManyLocalOnlyAsync`) already used with a documented rationale: a sequential resolve pass (the leaf-reference cache and the leaf-access model are mutated only there, never inside the parallel region), parallel dispatch through `Task.WhenAll`, then a sequential merge - so it adds no interleaving hazard the write path did not already carry. A single-bucket shortcut keeps the common case free of the dispatch array, the `Task.WhenAll` and the merge target. Two now-false comments are corrected with it: the saga raw-entries path justified staying sequential by citing the very shape this changes, and `MembershipProbeBatchSize` claimed a bounded batch "can never approach the response deadline", which is precisely what it did. The regression test uses an arrival barrier rather than timing, so it cannot flake - all three leaf reads must be in flight at once, which is reachable only when every bucket is dispatched before the first await. ([#1920](https://github.com/NSTA1/Orleans.Lattice/pull/1920)) (`Orleans.Lattice` 9.5.0)

## Security

- **A tree alias can no longer point at a reserved, system, or foreign-tenant physical tree, closing a privilege-escalation path onto the cluster's own authorization data.** `LatticeRegistryGrain.SetAliasAsync` validated the *logical* tree id against the reserved namespaces but applied no check at all to the `physicalTreeId` it was aimed at. Every data-plane gate (`ThrowIfSystemTree`, `ThrowIfUserOriginSystemDataTree`, and the access-gate authorization in the facades) is evaluated against the logical id *before* alias resolution, 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. An alias therefore silently transplanted an authorized logical identity onto an arbitrary physical tree's shards: a caller holding Admin on a single tree they legitimately own could call `ILatticeTreeAdmin.SetTreeAliasAsync("mine", "sys-auth-policy")` - whose own `ThrowIfReserved` does not cover the `sys-` data prefix and which authorizes only the logical id - and then read and rewrite the cluster's authorization policy, identity, or another tenant's data through the ordinary `ILattice("mine")` surface. `SetAliasAsync` now rejects, before any read or write, a physical target in the `_lattice_` system namespace, a `sys-` system-data target from a logical id that is not itself `sys-`, and a tenant-scoped target whose tenant differs from the logical id's. The rule is namespace-preserving rather than a blanket ban, so every internal caller (tree resize, schema remediation, backup restore) is unaffected, as are all system-origin callers, which are exempted through `LatticeAccessGateContext.IsSystemOrigin` exactly as the sibling reserved guards are. No public API signature changed. (`Orleans.Lattice` 9.5.0)

- **Cross-cluster snapshot export is now gated on the requested tree actually being enrolled for replication, so a peer can no longer dump an arbitrary tree.** `LatticeRemoteSnapshotService.GetMetadataAsync` and `RequestSnapshotAsync` took the tree name straight off the wire and exported it, performing no tree-scope authorization of any kind - the transport's shared-secret interceptor only establishes that *a* peer is talking to us, never *which* trees that peer is entitled to. Any peer clearing that flat check (or any caller reaching the gRPC endpoint with the cluster secret) could stream a full point-in-time dump of any tree on the silo, including never-enrolled `sys-` authorization and identity trees and other tenants' data, none of which replication was ever configured to share. Both operations now consult `ILatticeReplicationContext.ResolveMergeMode` and throw `UnauthorizedAccessException` for a tree with no enrollment, which the gRPC binding maps to `PermissionDenied` ahead of its generic fault arm so the refusal is not laundered into `Internal`. The sender-side gate is the exact mirror of the receiver-side admission gate `ReplicationApplier` already applies, so both directions agree on one enrollment source. The existing two-argument constructor is kept for API compatibility and fails closed (it has no enrollment source, so it refuses every export); a new three-argument overload supplies the context, and the DI registration resolves it. (`Orleans.Lattice.Replication` 9.5.0, `Orleans.Lattice.Replication.Grpc` 9.5.0)

- **The peer-facing digest, Merkle-walk, content-manifest, and high-water-mark RPCs now refuse a tree that is not enrolled for replication, closing a key-existence oracle over every tree on the silo.** All four read paths run under `ReplicationSystemOriginDigestReader`, which bypasses the data-plane access gate on the documented precondition that "the tree id is resolved by the caller against local state; the wire never supplies a bypass" - a precondition none of the four callers actually met, since each passed the wire-supplied tree name through untouched. Because `LeafProjectionDigest` carries `EntryCount` and `ProbeMerkleWalk` accepts caller-chosen range bounds, a peer could bisect an arbitrary tree's keyspace and recover key existence and key distribution from any unenrolled tree, including the reserved authorization trees. Separately, `ExchangeContentManifest` durably advanced a per-origin high-water mark keyed on an unvalidated wire-supplied origin cluster id, letting a peer name a *third* cluster's origin and poison that stream's cursor so every WAL entry became ineligible for re-replay, silently suppressing anti-entropy repair. All four RPCs now require the tree to be enrolled and return `PermissionDenied` otherwise, and `ExchangeContentManifest` additionally rejects a declared origin that disagrees with the origin header the transport observed for the calling channel. The origin check is absent-tolerant, so no honest peer call that previously succeeded now fails. (`Orleans.Lattice.Replication.Grpc` 9.5.0)

- **A snapshot destination can no longer name a control-plane or foreign-tenant tree, closing a namespace-squatting path that plants fully-populated trees where the public surface refuses to create them.** `ILattice.SnapshotAsync` takes a caller-supplied *destination* tree id, and a snapshot does not merely read: `TreeSnapshotGrain` registers that destination in the registry and seeds it with the source tree's entire content. The destination was never namespace-checked at the facade. Its structural sibling `MergeAsync` guards its caller-supplied source with both `ThrowIfReservedMergeSource` and an independent `EnforceSourceTreeReadAsync`, and every other lifecycle verb on the same partial class carries `ThrowIfProtectedView`, but `SnapshotAsync` carried neither - the downstream grain tested only the internal `_lattice_` third of the namespace rule, not the `sys-` system-data prefix or the `t/` tenant prefix. A caller holding nothing beyond Admin on one ordinary tree they legitimately own could therefore call `SnapshotAsync("sys-auth-policy")` or `SnapshotAsync("t/{other-tenant}/orders")` and materialise a registered, readable tree inside a namespace otherwise unreachable from the public API. The `sys-` control-plane trees are created lazily on first write, so squatting one wins the race against the add-on that owns it. `SnapshotAsync` now validates its argument with `ArgumentNullException.ThrowIfNull`, rejects a snapshot of a materialised view through `ThrowIfProtectedView`, and refuses a reserved destination through a new namespace guard modelled directly on `ThrowIfReservedMergeSource`: the internal `_lattice_` namespace, the `sys-` system-data namespace, and any `t/` tenant namespace the ambient active tenant does not own. The guard runs before any registry or coordinator call, so a refused snapshot leaves nothing behind, and it is exempted under `LatticeAccessGateContext.IsSystemOrigin` exactly as its sibling guards are, so first-party machinery that legitimately composes a reserved id (resize, schema remediation, backup restore) is unaffected. No public API signature changed. (`Orleans.Lattice` 9.5.0)

- **An all-trees `Tree:*` wildcard grant no longer confers delegated administration over every tenant in the cluster.** The all-trees authorization tier, promoted by `LatticeAuthOptions.AllTreesGrantsEnabled`, is a data-plane convenience and is deliberately excluded from the control plane so a wildcard data grant can never reach policy, membership, or the cross-tenant registry. `PolicyEvaluator.ShouldConsultAllTrees` implemented that exclusion for the reserved `sys-auth-` namespace and the `sys-tenant-` registry namespace, and `PolicyAccessGate.AuthorizeAsync` documented in its own comment that "a cluster-wide all-trees wildcard never satisfies" the control-plane branch it routes four namespaces into. The delegated per-tenant administration capability is the fourth of those namespaces, and its ids begin with `_lattice_tenant_admin_`, which starts with neither excluded prefix - so it was the one control-plane namespace the wildcard tier still folded into, and the gate's documented invariant was false for it. With the tier enabled, a single `Tree:*` Admin rule (as `LatticeAuthOperations.All` produces) silently satisfied `LatticeTenantAdminAuthorizer.IsAuthorizedAsync` for *every* tenant, including tenants the policy has never named, promoting a data-plane wildcard into unbounded tenant administration. The namespace is now excluded from the tier, and the gate's existence-hiding probe `HasAnyGrantAsync` is corrected to route the same ids to its fail-closed control-plane branch, so the probe and the enforcement decision agree and a wildcard holder cannot enumerate a control plane it cannot act on. Explicitly delegated per-tenant grants and the platform-operator capability are unaffected. No public API signature changed. (`Orleans.Lattice.Auth` 9.5.0)

- **`ILattice.ReshardAsync` now refuses a materialised view, the last structural verb that did not.** A view tree is maintained from its source and is documented as read-only through the public surface; `ThrowIfProtectedView` is the sole mechanism enforcing that, since view classification is consulted nowhere else in the core. Twenty of the twenty-one mutating verbs on `LatticeGrain` called it - including every other whole-tree lifecycle verb, `ResizeAsync`, `UndoResizeAsync`, `DeleteTreeAsync`, `PurgeTreeAsync`, and `RecoverTreeAsync` - and `ReshardAsync` did not, so a caller holding `TreeLifecycle` on a `view-` tree could force a shard-count migration on a tree the surface declares maintainer-owned, racing the maintainer's own writes. `TreeReshardGrain` carries only `EnsureInternalGrainOrigin` and applies no compensating view check, so the facade was the only seam. The guard is added there, matching its siblings exactly, and an authorised view scope is still admitted so the maintainer that owns the view can restructure it. This crosses no namespace, tenant, or authorization boundary and is hardening of a consistency invariant rather than a privilege-boundary fix, but it removes the asymmetry that made the omission invisible. A new fixture now asserts the whole set of structural verbs together rather than one at a time, so a future verb that loses the guard fails a test that names it. No public API signature changed. (`Orleans.Lattice` 9.5.0)

- **The data-API gRPC authorization seam now describes `SetMany`, `CrdtWrite`, and `CrdtRead`, so a transport authorizer can no longer be handed a null target for a call that names a caller-supplied tree.** `LatticeDataApiGrpcAuthInterceptor.DescribeCall` builds the `LatticeDataApiAuthorizationContext` from two hand-maintained switches - gRPC method name to `LatticeDataApiOperation`, and request type to `TargetTreeId` - with no compile-time link to the list of RPCs the service actually binds. Three bound RPCs were absent from both: `SetMany` (a bulk write), `CrdtWrite` (a CRDT mutation), and `CrdtRead`. Each therefore reached `ILatticeDataApiAuthorizer.IsAuthorizedAsync` as `(Unknown, null)` even though all three request records carry a `required string TreeId` the server then honours. `Unknown` is defensible because the enum documents it as the deny-by-default arm, but `TargetTreeId` is documented as "the tree the call targets, or null for operations that are not scoped to a single tree", so an authorizer implementing the surface's own advertised use case - restricting a caller to a specific set of trees - reads null, concludes the call is not tree-scoped, and skips its tree check entirely on two writes and a read that do name a tree. The sibling tenant-admin transport maps all twenty-one of its RPCs, which is what made the drift visible. The three operations are appended to `LatticeDataApiOperation` after `Unknown` so every existing member keeps its ordinal, and both switches gain their arms. This bites where the gRPC surface is the authorization boundary, which is a first-class configuration here: `AddLattice()` registers a no-op core gate that allows everything, the enforcing core gate arrives only with the opt-in `AddLatticeAuth()`, and the transport ships `DenyAllDataAuthorizer` with `RequireAuthorization = true` precisely because it is expected to be the boundary in some deployments. Where `AddLatticeAuth()` is registered this was defence in depth rather than direct escalation. No public API signature changed. (`Orleans.Lattice.Api.Data.Grpc` 9.5.0)

- **The state-API gRPC authorization seam now describes `GetDeadLetterCount` and `ListDeadLetters`, closing an unauthorized read of dead-lettered keys and value bytes.** The same drift as the data-API seam, in `LatticeStateApiGrpcAuthInterceptor.DescribeCall`: sixteen of the eighteen enforced RPCs were mapped, and the two dead-letter reads were in neither switch, so both arrived at `ILatticeStateApiAuthorizer.IsAuthorizedAsync` as `(Unknown, null)` while the service went on to serve them from the `TreeId` on the request. This one leaks content rather than merely permitting an action: `DeadLetterEntryRecord` carries the entry `Key`, a `ValuePreview` of the actual value bytes, `ValueByteLength`, and the failure `Reason`, so a caller scoped to one tree could enumerate another tree's failed writes and read a preview of their contents. The two operations are appended to `LatticeStateApiOperation` after `Unknown` to preserve existing ordinals, and both switches gain their arms. The unauthenticated auth-scheme advertisement RPC remains exempt through the interceptor's own `IsUnauthenticatedMethod`, unchanged. No public API signature changed. (`Orleans.Lattice.Api.State.Grpc` 9.5.0)

- **An Azure Blob backup id can no longer address a blob outside its prefix, or outside the configured container.** `BackupBlobNaming.ManifestBlobName` and `ArtifactBlobName` concatenated a caller-influenced id onto the `manifests/` or `artifacts/` prefix with only a null-or-empty check, on the stated invariant that "the ids never contain a `/`". That invariant was enforced nowhere and is false by construction: `LatticeBackupCaptureService` composes every artifact id as `{scope.TreeId}-{kind}-{ticks}-{guid}`, and a tenant-composed tree id is itself of the form `t/{tenant}/{name}`. Because the Azure SDK addresses a blob through `UriBuilder`, RFC 3986 dot-segment removal is applied to the result, so a `..` segment walks up out of the prefix and, with enough segments, out of the container: an id of `../../../secrets/keys.json` resolves to `/secrets/keys.json` on the storage account. The dot segments may also be percent-encoded, because removal happens after percent-decoding, and a backslash is normalised to a separator. The restore path made this reachable rather than theoretical, since `LatticeBackupControl.RestoreBackupAsync` tolerates a catalog miss and authorizes only the separately supplied target tree, so a crafted backup id is passed to the sink verbatim; its sibling `ExportArtifactAsync` instead requires a catalog hit, authorizes the manifest's own scope, and proves the id is a member of the manifest's content descriptors. Both naming methods now validate the id, as written and after a single percent-decode, rejecting a leading `/`, any backslash or control character, and any empty, `.`, or `..` segment. An interior `/` stays legal so tenant-composed ids keep working, and the false invariant in the type's documentation is corrected. Three of the four packages that build a storage key from a caller-influenced id already carried an encode-or-hash guard; this was the one that did not. `BackupBlobNaming` is `internal`, so no public API signature changed. (`Orleans.Lattice.Backup.AzureBlob` 9.5.0)

Newer release: [Release 2026-09-02: Breaking to Changed](../changelog/2026-09-02-1.md). Older release: [Release 2026-08-31](../changelog/2026-08-31.md). Contents: [Changelog](../CHANGELOG.md).
