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

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

A partial, pre-rewrite security wave ships nine packages from the `release/9.4` release line, cut at the last commit before the Explorer plugin rewrite so the fixes reach consumers without also publishing the in-progress console rewrite or its new package family. `Orleans.Lattice` advances to `9.4.3`; `Orleans.Lattice.Api.Abstractions`, `Orleans.Lattice.Api.Mcp.Telemetry`, `Orleans.Lattice.Api.TenantAdmin`, `Orleans.Lattice.Backup`, `Orleans.Lattice.Explorer.Core`, `Orleans.Lattice.Explorer.UI`, `Orleans.Lattice.Explorer.Web`, and `Orleans.Lattice.Tenancy` each advance one patch digit to `9.4.1`. Every other published package stays where the 2026-08-29 waves left it. The headline is a set of security fixes: a cross-tenant restore primitive on the backup shadow-cutover seams, two Explorer web-head holes that signed an anonymous visitor in as the operator and let one repoint the shared cluster endpoint, a PromQL comment that smuggled a denied metric past the telemetry allow-list, and a backend credential returned verbatim to a remote MCP caller. The backup fix was cherry-picked onto the branch from the post-rewrite `main`; everything else was already in the tree at the branch point. The Explorer plugin rewrite, the cluster-wide telemetry re-gate, and the remaining work stay unreleased pending the next major.

## Changed

- **The three aggregation-view row encoders now serialize into an exact-size buffer instead of a `MemoryStream` + `BinaryWriter`.** `AggregationRowCodec.EncodeMembership`, `EncodeInverse`, and `EncodeFoldInverse` each allocated a growable `MemoryStream`, a `BinaryWriter`, and a UTF-8 encoder per call and then copied the result out with `ToArray`. These run on the materialised-view write path: every source mutation feeding a group-by view encodes one membership row, and every min / max / set-union or custom-fold group-shard update encodes an inverse or fold-inverse row. Each encoder now makes a single sizing pass over its input and writes the row straight into one exact-size `byte[]` through a `RowWriter` cursor that reproduces `BinaryWriter`'s layout exactly (7-bit length-prefixed UTF-8 strings, single-byte bools, little-endian numerics, raw value spans), so the encoded bytes are byte-for-byte identical and persisted rows stay readable across a rolling upgrade. Measured on the new `rowcodec` microbench suite (`BENCH_MICROBENCH_SUITE=rowcodec`, full fidelity, median): a membership row drops from 480 B to 96 B per encode (-80%) and 54.2 ns to 34.8 ns (-36%); a 32-entry inverse row from 2,928 B to 960 B (-67%) and 1,010.8 ns to 859.8 ns (-15%); a 32-entry fold-inverse row from 5,288 B to 1,248 B (-76%) and 1,220.7 ns to 673.1 ns (-45%). The prior-versus-new byte-identity of all three encoders is held closed by new tests in `AggregationRowCodecTests`. (`Orleans.Lattice` 9.4.3)

## Fixed

- **Per-tenant quota burst headroom is no longer silently lost for a capacity ceiling below 100.** `TenantQuotaEvaluator` computed the burst allowance as `ceiling / 100 * burstPercent`, dividing before multiplying, so any ceiling below 100 (a realistic `MaxTreeCount`, `MaxKeys`, or small `MaxMemoryBytes`) floored the allowance to zero and a non-multiple of 100 undercounted it: a 50% burst over a ceiling of 10 resolved to 10 instead of 15, wrongly refusing writes the burst should admit with a `LatticeQuotaExceededException`. The allowance is now computed through a 128-bit intermediate that multiplies first and saturates on overflow, matching the package's sibling rate-burst helpers. (`Orleans.Lattice.Tenancy` 9.4.1)

- **The tenant lease-loop backoff no longer overstates the retry period at the floored boundary.** `TenantRateBudgetCoordinatorHostedService.NextPeriod` compared the interval against the floored `ceiling / multiplier`, so when the interval equalled that floor but the division carried a remainder it clamped straight to the ceiling even though `interval * multiplier` was still strictly below it (interval 93 ticks, multiplier 8, ceiling 750 ticks returned 750 instead of 744). The comparison is now strict, keeping the exact doubled period in that case; the doubling stays overflow-safe. (`Orleans.Lattice.Tenancy` 9.4.1)

- **The telemetry metric-access gate now unescapes an exact `__name__` matcher before checking it against the allow-list.** `PromQlMetricExtractor` returned the raw span between the quotes without processing PromQL string escapes, so an allow-listed metric whose name needs escaping in a matcher (`{__name__="a\"b"}` designating the metric `a"b`) was compared as the literal `a\"b`, never matched, and was wrongly denied. The extractor now unescapes the unambiguous backslash and matching-quote escapes (whose expansion is byte-identical to Prometheus) and leaves any other escape unresolved so the deny-all gate still fails closed, never resolving to a name that could diverge from Prometheus's own interpretation. (`Orleans.Lattice.Api.Mcp.Telemetry` 9.4.1)

- **A concurrent residency change can no longer leave a tenant resident in no region.** `LatticeTenantRegionAdmin.SetResidencyAsync` enforced the last-resident-region invariant with a read-check-write over a CRDT-merged store, which cannot hold it: `TenantRecord.RegionStatuses` is an LWW-element-map keyed by region id, so two callers draining **different** regions each passed the pre-write guard and the join kept both tombstones, emptying the residency set the invariant exists to protect. The guard is now also evaluated on the record the registry commits. Because the pre-write guard has already refused the legitimate single-writer case, a merged record with zero resident regions is by construction evidence of concurrent interference, which is what licenses the repair: the call re-asserts **only the regions it drained**, at strictly later stamps, then refuses with `TenantLastRegionException`. Re-asserting the whole pre-merge set would resurrect the other caller's legitimate removal, so the repair is always scoped to the call's own keys; both racing callers are refused and the tenant keeps at least one resident region. Fixed alongside it, `SetResidencyAsync` and `AuthorizeAllowedRegionsAsync` now build their result from the merged record rather than the caller's pre-merge local view, so a concurrent change from another writer is reported instead of being silently absent from the returned region set. ([#1774](https://github.com/NSTA1/Orleans.Lattice/issues/1774)) (`Orleans.Lattice.Api.TenantAdmin` 9.4.1, `Orleans.Lattice.Api.Abstractions` 9.4.1)

- **The atomic-write saga now reports the right operation id and tree tag on a tenancy-enabled cluster.** `AtomicWriteGrain` split its composite grain key (`{treeId}/{operationId}`) on the **first** separator, but a tenant-composed tree id is itself segmented (`t/{tenantId}/{name}`), so the split landed inside the tree id: for `t/acme/orders/op-123` the extracted operation id came back as `acme/orders/op-123` and the derived tree tag as `t`. That wrong-but-plausible value reached four places - the operation id attributed on a `LatticeIdempotencyKeyMismatchException`, the `operationId` stamped into `RequestContext` and carried onto every `LatticeTreeEvent` the saga's per-key writes emit, the `OperationId` on the emitted `AtomicWriteCompleted` event, and the `tree` tag on the saga histograms (which also mis-keyed the per-tree options lookup, collapsing every tenant's sagas onto one meaningless series). Event correlation therefore broke for any consumer that knew the real operation id, and the fault was invisible on a tenancy-off cluster, where a bare tree id contains exactly one separator and both splits agree. All four sites now split on the last separator through one shared helper. That is correct because a caller-supplied `operationId` may not contain `/` - an invariant `ILattice.SetManyAtomicAsync` and the cross-tree coordinator already enforced, and which `IAtomicActionGrain` now enforces too on the operation id it takes as its own grain key (it composes into the same key space through `{treeId}/{operationId}::aa::{index}`, but validated nothing, so a slash-bearing atomic-action id would have mis-keyed the very split this fix relies on). The rejection is fail-closed, lands before any saga state is seeded, and is documented on the interface and in [Atomic actions](../docs/lattice/atomic-action.md). ([#1776](https://github.com/NSTA1/Orleans.Lattice/issues/1776)) (`Orleans.Lattice` 9.4.3)

## Security

- **A shadow-cutover restore can no longer be aimed at a physical tree the caller was never authorized for.** `LatticeBackupRestoreService` authorizes the **logical** target tree named on a `LatticeRestoreResult` and then repoints that tree's registry alias at the **physical** tree named on the same caller-supplied result, but `PreviousPhysicalTreeId` and `ShadowPhysicalTreeId` were neither composed under the active tenant nor validated, and the alias write runs inside `EnterSystemOrigin()`, so the core access gate and the tenant gate were both bypassed for them. A caller holding `LatticeOperation.Restore` over one tree they legitimately own could therefore hand `RevertRestore` a result naming another tenant's tree as the previous physical tree, pass authorization on their own tree, and have the registry point their tree at the other tree's shards. The data-plane gate is hard-bound to the **logical** tree id and never sees the alias, so every subsequent read and write then landed on the other tenant's data while being authorized against the attacker's own policy - a cross-tenant read, write, and corruption primitive reachable from the backup gRPC facade and from `LatticeTreeAdmin.RevertTreeRestoreAsync`. `CommitShadowAsync` carried the same hole (it validated the result's shape, never the named tree's ownership), and `DeleteShadowAsync` - which purges every shard of the tree it is handed and then unregisters it - performed no authorization at all, unlike every sibling verb on the same service. All three now validate through one shared fail-closed helper at the engine seam that `LatticeBackupControl`, `LatticeTreeAdmin`, and the gRPC service all funnel through, so a single rejection covers every current and future transport rather than being scattered per facade. A physical id is honoured only when it is the target tree itself, when the engine's own build stamped it `TreeRegistryEntry.RestoreShadowOfTreeId` for that same target, or when it is already the target's current physical tree; every other id is ambiguous and is refused with `LatticeRestoreValidationException`. `DeleteShadowAsync` additionally derives the owning tree from the shadow's registry provenance instead of trusting its argument and authorizes `Restore` over that owner, so the garbage-collection seam can only ever destroy a tree the engine itself created as a shadow. A result the engine issued is accepted unchanged, so the coordinated-restore saga, the two-phase build and commit primitives, and the ordinary revert path are unaffected; the deliberate narrowing is that reverting onto a physical tree the registry cannot associate with the target (an alias installed by an online resize or a schema remediation before the restore) is now refused rather than honoured, and is re-pointed with the tree-admin alias verb, which authorizes that swap in its own right. (`Orleans.Lattice.Backup` 9.4.1)

- **The telemetry metric-access allow-list can no longer be bypassed by hiding a metric name behind a PromQL line comment.** `PromQlMetricExtractor` had no rule for `#`, so a `"` opened inside a comment was scanned as a string opener; because the string scan runs across line breaks it swallowed the rest of the query, and `up or #"` followed by a newline, `secret_metric #"` extracted only `up`. The deny-all gate admitted the query and `PrometheusQueryClient` forwards it verbatim, so the backend - which strips comments before parsing - evaluated the denied `secret_metric` and returned its series to the MCP caller. The extractor now recognises `#` outside a string literal and discards the rest of the line exactly as Prometheus's own lexer does, so every name the backend will evaluate is extracted and checked. Discarding a comment is treated as whitespace rather than as a selector break, which also repairs the mirror-image false denial where a comment between a metric name and its `{...}` selector previously split one reference into two. (`Orleans.Lattice.Api.Mcp.Telemetry` 9.4.1)

- **The Explorer web head no longer signs an anonymous visitor in with the operator's seeded environment credential.** The environment bootstrap registered an `IExplorerCredentialSeed` carrying the credential read from `LATTICE_EXPLORER_*`, while the web head's `ICredentialStore` is per browser; `ExplorerAuthSession.InitializeAsync` falls back to the seed when the store holds nothing, and `ConfigurationGate` initialises the session on every circuit before any authentication, so any unauthenticated visitor who could reach the site was silently signed in as the operator and inherited their full grant. The web head now registers a null credential seed by default, so the environment bootstrap seeds only the secret-free endpoint; set the new `LatticeExplorerWebOptions.AllowEnvironmentCredentialSeed` to restore the old behaviour on a genuinely single-operator host. This also repairs Entra interactive sign-in on the web head, which was being short-circuited by the pre-authenticated session. (`Orleans.Lattice.Explorer.Core` 9.4.1, `Orleans.Lattice.Explorer.UI` 9.4.1, `Orleans.Lattice.Explorer.Web` 9.4.1)

- **The Explorer web head no longer lets an unauthenticated visitor repoint the shared cluster endpoint.** `IExplorerConfigStore` is a singleton writing one JSON file shared by every circuit, but `ExplorerSession.ApplyAsync` performed no authorization beyond the transport-security check, which any attacker-controlled `https://` host satisfies. An anonymous visitor could therefore point the deployment at a host they own and harvest the next operator's Basic credential as it was presented to that endpoint, and drive server-side requests to an arbitrary internal address. Interactive endpoint configuration is now off by default on the web head: the config store is wrapped so `SaveAsync` refuses, and the connection-settings affordance is withheld from the navigation panel. A web head that is genuinely configured through its own UI can opt back in with the new `LatticeExplorerWebOptions.AllowInteractiveEndpointConfiguration`; the desktop head, which is inherently single-user, is unaffected. (`Orleans.Lattice.Explorer.Core` 9.4.1, `Orleans.Lattice.Explorer.UI` 9.4.1, `Orleans.Lattice.Explorer.Web` 9.4.1)

- **A telemetry backend fault no longer returns the backend credential to a remote MCP caller.** `TelemetryToolHandlers` interpolated the caught exception's `Message` verbatim into the tool result's `Error` field, so any `HttpMessageHandler` in the backend pipeline that included the outbound `Authorization` header value in an exception message handed the **backend** credential straight back to the caller - a value deliberately isolated from caller identity precisely so a granted caller can query telemetry without ever obtaining it. The proxy does not own every handler in its own chain (a host may insert delegating handlers, and diagnostic or retry handlers commonly include request headers in their messages), so exception text is an uncontrolled channel and no amount of anticipating known credential shapes can close it. A backend transport, timeout, or payload fault now reports a **fixed, non-interpolated** message, which cannot leak a value nobody anticipated. The distinction callers act on is preserved - a guardrail rejection, a non-success backend status, and a metric-access denial each keep their own specific message, and the metadata tool's `404` still degrades to an empty success - and the fault detail is not discarded but logged server-side by the backend proxy, the trusted side of the boundary, so an outage stays diagnosable. That server-side log deliberately covers credential *stamping* faults too (an unregistered dynamic-token provider, an empty minted token, a provider auth failure), which are pure misconfigurations the caller-facing message now points the operator at the logs to diagnose. ([#1775](https://github.com/NSTA1/Orleans.Lattice/issues/1775)) (`Orleans.Lattice.Api.Mcp.Telemetry` 9.4.1)

Newer release: [Release 2026-09-02: Fixed to Security](../changelog/2026-09-02-2.md). Older release: [Release 2026-08-29](../changelog/2026-08-29.md). Contents: [Changelog](../CHANGELOG.md).
