Table of Contents

Release 2026-08-31

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

Part of the changelog.

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) (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. (#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) (Orleans.Lattice.Api.Mcp.Telemetry 9.4.1)