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, andEncodeFoldInverseeach allocated a growableMemoryStream, aBinaryWriter, and a UTF-8 encoder per call and then copied the result out withToArray. 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-sizebyte[]through aRowWritercursor that reproducesBinaryWriter'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 newrowcodecmicrobench 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 inAggregationRowCodecTests. (Orleans.Lattice9.4.3)
Fixed
Per-tenant quota burst headroom is no longer silently lost for a capacity ceiling below 100.
TenantQuotaEvaluatorcomputed the burst allowance asceiling / 100 * burstPercent, dividing before multiplying, so any ceiling below 100 (a realisticMaxTreeCount,MaxKeys, or smallMaxMemoryBytes) 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 aLatticeQuotaExceededException. 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.Tenancy9.4.1)The tenant lease-loop backoff no longer overstates the retry period at the floored boundary.
TenantRateBudgetCoordinatorHostedService.NextPeriodcompared the interval against the flooredceiling / multiplier, so when the interval equalled that floor but the division carried a remainder it clamped straight to the ceiling even thoughinterval * multiplierwas 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.Tenancy9.4.1)The telemetry metric-access gate now unescapes an exact
__name__matcher before checking it against the allow-list.PromQlMetricExtractorreturned 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 metrica"b) was compared as the literala\"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.Telemetry9.4.1)A concurrent residency change can no longer leave a tenant resident in no region.
LatticeTenantRegionAdmin.SetResidencyAsyncenforced the last-resident-region invariant with a read-check-write over a CRDT-merged store, which cannot hold it:TenantRecord.RegionStatusesis 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 withTenantLastRegionException. 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,SetResidencyAsyncandAuthorizeAllowedRegionsAsyncnow 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.TenantAdmin9.4.1,Orleans.Lattice.Api.Abstractions9.4.1)The atomic-write saga now reports the right operation id and tree tag on a tenancy-enabled cluster.
AtomicWriteGrainsplit 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: fort/acme/orders/op-123the extracted operation id came back asacme/orders/op-123and the derived tree tag ast. That wrong-but-plausible value reached four places - the operation id attributed on aLatticeIdempotencyKeyMismatchException, theoperationIdstamped intoRequestContextand carried onto everyLatticeTreeEventthe saga's per-key writes emit, theOperationIdon the emittedAtomicWriteCompletedevent, and thetreetag 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-suppliedoperationIdmay not contain/- an invariantILattice.SetManyAtomicAsyncand the cross-tree coordinator already enforced, and whichIAtomicActionGrainnow 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.Lattice9.4.3)
Security
A shadow-cutover restore can no longer be aimed at a physical tree the caller was never authorized for.
LatticeBackupRestoreServiceauthorizes the logical target tree named on aLatticeRestoreResultand then repoints that tree's registry alias at the physical tree named on the same caller-supplied result, butPreviousPhysicalTreeIdandShadowPhysicalTreeIdwere neither composed under the active tenant nor validated, and the alias write runs insideEnterSystemOrigin(), so the core access gate and the tenant gate were both bypassed for them. A caller holdingLatticeOperation.Restoreover one tree they legitimately own could therefore handRevertRestorea 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 fromLatticeTreeAdmin.RevertTreeRestoreAsync.CommitShadowAsynccarried the same hole (it validated the result's shape, never the named tree's ownership), andDeleteShadowAsync- 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 thatLatticeBackupControl,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 itTreeRegistryEntry.RestoreShadowOfTreeIdfor that same target, or when it is already the target's current physical tree; every other id is ambiguous and is refused withLatticeRestoreValidationException.DeleteShadowAsyncadditionally derives the owning tree from the shadow's registry provenance instead of trusting its argument and authorizesRestoreover 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.Backup9.4.1)The telemetry metric-access allow-list can no longer be bypassed by hiding a metric name behind a PromQL line comment.
PromQlMetricExtractorhad 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, andup or #"followed by a newline,secret_metric #"extracted onlyup. The deny-all gate admitted the query andPrometheusQueryClientforwards it verbatim, so the backend - which strips comments before parsing - evaluated the deniedsecret_metricand 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.Telemetry9.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
IExplorerCredentialSeedcarrying the credential read fromLATTICE_EXPLORER_*, while the web head'sICredentialStoreis per browser;ExplorerAuthSession.InitializeAsyncfalls back to the seed when the store holds nothing, andConfigurationGateinitialises 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 newLatticeExplorerWebOptions.AllowEnvironmentCredentialSeedto 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.Core9.4.1,Orleans.Lattice.Explorer.UI9.4.1,Orleans.Lattice.Explorer.Web9.4.1)The Explorer web head no longer lets an unauthenticated visitor repoint the shared cluster endpoint.
IExplorerConfigStoreis a singleton writing one JSON file shared by every circuit, butExplorerSession.ApplyAsyncperformed no authorization beyond the transport-security check, which any attacker-controlledhttps://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 soSaveAsyncrefuses, 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 newLatticeExplorerWebOptions.AllowInteractiveEndpointConfiguration; the desktop head, which is inherently single-user, is unaffected. (Orleans.Lattice.Explorer.Core9.4.1,Orleans.Lattice.Explorer.UI9.4.1,Orleans.Lattice.Explorer.Web9.4.1)A telemetry backend fault no longer returns the backend credential to a remote MCP caller.
TelemetryToolHandlersinterpolated the caught exception'sMessageverbatim into the tool result'sErrorfield, so anyHttpMessageHandlerin the backend pipeline that included the outboundAuthorizationheader 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's404still 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.Telemetry9.4.1)