Release 2026-09-02: Fixed to Security
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-09-02-2.md, and llms.txt lists every page.Part of Release 2026-09-02, in Changelog.
Fixed
LatticeValueTransformnow compares by structure, so a schema value-transform tree equals a structurally identical one instead of only its own reference. As apublic readonly record structit inherited the compiler-generated record equality, which compares itsLatticeValueTransform[]? Childrenarray 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 siblingLatticePredicateNode.LatticeValueTransformnow implementsEquals/GetHashCodeexplicitly, recursing overChildrenwith sequence semantics and comparing every scalar field, the embeddedConditionpredicate, and theConstantliteral. No public API signature changed, and the wire format is unchanged. (Orleans.Lattice.Schema9.5.0)A pushed-down predicate comparing against a
ulongliteral greater thanlong.MaxValueno longer silently corrupts the constant to a negative value.LatticePredicateTranslatorcaptured everyulongliteral withInteger(unchecked((long)ul)), so a value abovelong.MaxValue(for exampleulong.MaxValue) wrapped to-1and the translated predicate compared against-1rather than the intended magnitude, never matching the documents the caller meant. Such a literal is now captured as aDouble- the representation the evaluator already reads a largeulongJSON member as, since a JSON integer abovelong.MaxValuefailsTryGetInt64and decodes asdouble- so a translate-then-evaluate round trip agrees with the compiled lambda; aulongwithinlong.MaxValueis still captured as an exactInt64. No public API signature changed. (Orleans.Lattice9.5.0)Re-registering a runtime view carrying a provider payload no longer forces a redundant durable write.
RuntimeViewRegistrationholds abyte[]? ProjectionProviderPayloadthat its compiler-generated record equality compared by reference, andLatticeRuntimeViewProjectionDescriptor.Payloadreturns a fresh array on every access, so re-issuing the sameCreateAsyncproduced a content-identical but distinct payload array.ViewRegistryGrain.RegisterAsyncguards its idempotent re-registration path withexisting.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-basedGetHashCode), so an identical re-registration is correctly deduplicated. The type is internal, so no public API changed. (Orleans.Lattice9.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.
LatticeAtomicWriteBuilderstaged the caller'sbyte[]value (and, on the internal delta andSet(LatticeStagedCrdtWrite)paths, the delta) by reference and forwarded those same arrays into theLatticeTreeBatchlist atCommitAsync, so mutating the buffer after staging but before commit corrupted the committed payload. In the schema package,LatticeSchemaPolicystored the caller'sIReadOnlyList<LatticeSchemaRule>directly andLatticeSchemaDeadLetterEntryboth stored and exposed the caller's previewbyte[]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.Lattice9.5.0,Orleans.Lattice.Schema9.5.0)LatticePredicateNodenow compares by structure, so a restated identical guard predicate is accepted instead of always throwing. As apublic readonly record structit inherited the compiler-generated record equality, which compares itsLatticePredicateNode[]? Childrenarray by reference - so two structurally identical predicate IRs (for example the same lambda compiled twice) never compared equal. That made the deliberate restatement branch inLatticeAtomicWriteBuilder.SetWhereunreachable: guarding a second key in a cross-tree atomic write by restating the same predicate threwInvalidOperationException("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.LatticePredicateNodenow implementsEquals/GetHashCodeexplicitly, recursing overChildrenwith sequence semantics and comparing every scalar field ordinally; the one-predicate-per-tree guard still rejects a genuinely different predicate.LatticeConstantwas checked and is all-scalar, so its record equality was already correct. No public API signature changed. (#1827) (Orleans.Lattice9.5.0)ILatticeTreeAdmin.BeginBulkLoadAsyncnow 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 testedTotalLiveKeys > 0 || TotalTombstones > 0, but the shallow diagnostic path never populatesTotalTombstones(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 thedeepargument, 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) (Orleans.Lattice.Api.TreeAdmin9.5.0)The Commit Path and Replication Grafana dashboards no longer ship panels whose
gridPosrectangles 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 bottomyand a textual merge keeping both. The affected panels are re-spaced into a contiguous, non-overlapping layout (panel content and ids unchanged), and a newDashboardJsonTestsdrift guard fails the build on any pair of intersecting panel rectangles - the coordinate twin of the existing duplicate-panel-id guard. (#1829) (Orleans.Lattice.Dashboards9.5.0)A
ChangeFeedCursorcan no longer be mutated through its exposed offset map.ChangeFeedCursor.PartitionOffsetsdocuments a defensive snapshot owned by the cursor, but the getter returned the mutable backingDictionaryboxed asIReadOnlyDictionary, so a caller that downcast toIDictionary<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 forInitialand empty cursors was a single shared mutable static, so one such downcast-and-mutate poisoned every futureInitialcursor process-wide. The backing map is now wrapped in aReadOnlyDictionary<int, long>and the shared empty isReadOnlyDictionary<int, long>.Empty, so every exposed snapshot rejects mutation and no cross-cursor leak is possible. (Orleans.Lattice.Replication9.5.0)A WAL read from an exclusive lower bound of
long.MaxValueno longer replays the whole log from the head. Three WAL read paths advanced the exclusive cursor withfromOffsetExclusive + 1before comparing it against stored offsets; atlong.MaxValuethat overflows tolong.MinValue, which selects the entire log instead of the empty suffix a maximal lower bound denotes.WalCommitLogReader.ReadAsync(whose deadif (nextSequence < 0)guard turned the overflow into a read-from-zero and polled the shard grain),InMemoryWalStorageProvider.ReadAsync, andFileWalShard.SnapshotAsyncnow each short-circuit to an empty result at that boundary. (Orleans.Lattice9.5.0;Orleans.Lattice.Storage.Fileis not yet published to NuGet)ComputePressureMath.Clamp01now maps positive infinity to the safe floor, as its contract documents. The guard rejectedNaNbut not the infinities, so a non-finite positive infinity fell through and clamped to1.0- the maximum compute-pressure signal - rather than the documented0.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 to0.0. (Orleans.Lattice.Scaling9.5.0)A cluster-wide
Telemetrygrant authored the documented way is now honoured.LatticeScope.ClusterWide()produces a scope over the all-trees sentinel"*"and its own documentation namesLatticeOperation.Telemetryas 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.AuthorizeClusterTelemetryAsyncandTreeAdminAccessAuthorizer.AuthorizeClusterTelemetryAsyncnow both requestLatticeScope.ClusterWideTreeId, matching the documented contract and each other. To keep the fail-closed posture that scoping to the reserved namespace previously supplied,PolicyAccessGatenow 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 ofLatticeAuthOptions.DefaultEffect. The existence probe mirrors it.PolicyEvaluatoralso 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 againstsys-auth-policyto satisfy the previous behaviour no longer confers cluster telemetry - re-author it withLatticeScope.ClusterWide()(in the Explorer, the "all trees (cluster-wide)" affordance with Telemetry ticked). (#1795) (Orleans.Lattice.Auth9.5.0,Orleans.Lattice.Api.Telemetry9.5.0,Orleans.Lattice.Api.TreeAdmin9.5.0; the Explorer half is held back with the rest of the Explorer sub-family)An unrecognised
LatticeConstantKindno longer silently nulls a member during schema remediation.LatticeValueTransformEvaluator.FromConstantmapped an unknown kind tonull, which is written into the remediated document as JSONnulland is byte-identical to an explicitLatticeConstantKind.Nullconstant.LatticeSchemaRemediationGrainapplies 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 inSchemaRemediationState.Transform, so it is reachable on a mixed-version cluster. It now throwsInvalidOperationException, 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) (Orleans.Lattice.Schema9.5.0)The
StallWatchdogfailure-arm test no longer races two equal-period timers.RunAsync_fires_on_failure_arm_when_writtenTotal_frozen_with_sustained_failure_ratedrove the failure counter from a background pump on its ownTask.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 requiredbuild-and-testcheck 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)The inert
Http2UnencryptedSupportapp 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 boundHttpProtocols.Http2on a plaintext loopback port, h2c succeeded identically with the switch never set, explicitlyfalse, and settrue. 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. ThePasswordProtectionREADME 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 theAppContextSwitchHygieneTestsguard is promoted to the shared testing library so it coverssamples/andreference-architecture/as well assrc/lattice.explorer/. No transport behaviour changes. (#1796)The state-API change-feed integration tests no longer race the subscription they are observing. Their shared
ObserveWhileAsynchelpers opened a fresh-tail subscription, slept 300ms hoping its cursor had seeded, and only then wrote - but seeding runs inside the firstMoveNextAsync, 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 onObserve_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.State9.5.0)A predicate lambda that passes a
StringComparison(or culture) tostring.Equals/StartsWith/EndsWith/Containsis now rejected at translation instead of silently ignoring the argument.LatticePredicateTranslatorread only the pattern argument of these string-method calls and dropped any trailingStringComparison/CultureInfo/bool ignoreCaseoperand, so a caller-specified case-insensitive or culture-aware comparison was silently downgraded to the evaluator's hardcodedStringComparison.Ordinaland could match differently from the compiled lambda. The translator now throwsNotSupportedExceptionfor the comparison- and culture-bearing overloads (the one-argumentstringandcharordinal overloads still translate), consistent with its documented contract of failing unsupported constructs at translation time. No public API signature changed. (Orleans.Lattice9.5.0)A predicate comparison over a 64-bit integer larger than 2^53 is now exact, instead of being rounded through
double.LatticePredicateEvaluatorfunnelled every numeric operand - including anInt64constant or a JSON integer member - through adouble, so two longs that differ only above the 53-bit mantissa (for example9007199254740992and9007199254740993) 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 alongand compares two integers exactly, falling back todoubleonly 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 wireLatticeConstantalready distinguishedInt64fromDouble- so no public API or wire format changed. (Orleans.Lattice9.5.0)The replication content-digest no longer aliases two structurally distinct mutations when their key or value contains a NUL byte.
ReplicationContentHash.Computeframed its fields with a single0x00separator 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.Replication9.5.0)A wide
ILattice.GetManyAsyncprobe no longer times out because the shard batch read awaited one leaf at a time.TraverseForBatchReadAsyncawaited 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 aTimeoutException- 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 throughTask.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, theTask.WhenAlland 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, andMembershipProbeBatchSizeclaimed 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) (Orleans.Lattice9.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.SetAliasAsyncvalidated the logical tree id against the reserved namespaces but applied no check at all to thephysicalTreeIdit 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 callILatticeTreeAdmin.SetTreeAliasAsync("mine", "sys-auth-policy")- whose ownThrowIfReserveddoes not cover thesys-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 ordinaryILattice("mine")surface.SetAliasAsyncnow rejects, before any read or write, a physical target in the_lattice_system namespace, asys-system-data target from a logical id that is not itselfsys-, 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 throughLatticeAccessGateContext.IsSystemOriginexactly as the sibling reserved guards are. No public API signature changed. (Orleans.Lattice9.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.GetMetadataAsyncandRequestSnapshotAsynctook 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-enrolledsys-authorization and identity trees and other tenants' data, none of which replication was ever configured to share. Both operations now consultILatticeReplicationContext.ResolveMergeModeand throwUnauthorizedAccessExceptionfor a tree with no enrollment, which the gRPC binding maps toPermissionDeniedahead of its generic fault arm so the refusal is not laundered intoInternal. The sender-side gate is the exact mirror of the receiver-side admission gateReplicationApplieralready 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.Replication9.5.0,Orleans.Lattice.Replication.Grpc9.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. BecauseLeafProjectionDigestcarriesEntryCountandProbeMerkleWalkaccepts 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,ExchangeContentManifestdurably 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 returnPermissionDeniedotherwise, andExchangeContentManifestadditionally 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.Grpc9.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.SnapshotAsynctakes a caller-supplied destination tree id, and a snapshot does not merely read:TreeSnapshotGrainregisters 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 siblingMergeAsyncguards its caller-supplied source with bothThrowIfReservedMergeSourceand an independentEnforceSourceTreeReadAsync, and every other lifecycle verb on the same partial class carriesThrowIfProtectedView, butSnapshotAsynccarried neither - the downstream grain tested only the internal_lattice_third of the namespace rule, not thesys-system-data prefix or thet/tenant prefix. A caller holding nothing beyond Admin on one ordinary tree they legitimately own could therefore callSnapshotAsync("sys-auth-policy")orSnapshotAsync("t/{other-tenant}/orders")and materialise a registered, readable tree inside a namespace otherwise unreachable from the public API. Thesys-control-plane trees are created lazily on first write, so squatting one wins the race against the add-on that owns it.SnapshotAsyncnow validates its argument withArgumentNullException.ThrowIfNull, rejects a snapshot of a materialised view throughThrowIfProtectedView, and refuses a reserved destination through a new namespace guard modelled directly onThrowIfReservedMergeSource: the internal_lattice_namespace, thesys-system-data namespace, and anyt/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 underLatticeAccessGateContext.IsSystemOriginexactly 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.Lattice9.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 byLatticeAuthOptions.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.ShouldConsultAllTreesimplemented that exclusion for the reservedsys-auth-namespace and thesys-tenant-registry namespace, andPolicyAccessGate.AuthorizeAsyncdocumented 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 singleTree:*Admin rule (asLatticeAuthOperations.Allproduces) silently satisfiedLatticeTenantAdminAuthorizer.IsAuthorizedAsyncfor 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 probeHasAnyGrantAsyncis 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.Auth9.5.0)ILattice.ReshardAsyncnow 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;ThrowIfProtectedViewis the sole mechanism enforcing that, since view classification is consulted nowhere else in the core. Twenty of the twenty-one mutating verbs onLatticeGraincalled it - including every other whole-tree lifecycle verb,ResizeAsync,UndoResizeAsync,DeleteTreeAsync,PurgeTreeAsync, andRecoverTreeAsync- andReshardAsyncdid not, so a caller holdingTreeLifecycleon aview-tree could force a shard-count migration on a tree the surface declares maintainer-owned, racing the maintainer's own writes.TreeReshardGraincarries onlyEnsureInternalGrainOriginand 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.Lattice9.5.0)The data-API gRPC authorization seam now describes
SetMany,CrdtWrite, andCrdtRead, so a transport authorizer can no longer be handed a null target for a call that names a caller-supplied tree.LatticeDataApiGrpcAuthInterceptor.DescribeCallbuilds theLatticeDataApiAuthorizationContextfrom two hand-maintained switches - gRPC method name toLatticeDataApiOperation, and request type toTargetTreeId- 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), andCrdtRead. Each therefore reachedILatticeDataApiAuthorizer.IsAuthorizedAsyncas(Unknown, null)even though all three request records carry arequired string TreeIdthe server then honours.Unknownis defensible because the enum documents it as the deny-by-default arm, butTargetTreeIdis 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 toLatticeDataApiOperationafterUnknownso 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-inAddLatticeAuth(), and the transport shipsDenyAllDataAuthorizerwithRequireAuthorization = trueprecisely because it is expected to be the boundary in some deployments. WhereAddLatticeAuth()is registered this was defence in depth rather than direct escalation. No public API signature changed. (Orleans.Lattice.Api.Data.Grpc9.5.0)The state-API gRPC authorization seam now describes
GetDeadLetterCountandListDeadLetters, closing an unauthorized read of dead-lettered keys and value bytes. The same drift as the data-API seam, inLatticeStateApiGrpcAuthInterceptor.DescribeCall: sixteen of the eighteen enforced RPCs were mapped, and the two dead-letter reads were in neither switch, so both arrived atILatticeStateApiAuthorizer.IsAuthorizedAsyncas(Unknown, null)while the service went on to serve them from theTreeIdon the request. This one leaks content rather than merely permitting an action:DeadLetterEntryRecordcarries the entryKey, aValuePreviewof the actual value bytes,ValueByteLength, and the failureReason, 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 toLatticeStateApiOperationafterUnknownto preserve existing ordinals, and both switches gain their arms. The unauthenticated auth-scheme advertisement RPC remains exempt through the interceptor's ownIsUnauthenticatedMethod, unchanged. No public API signature changed. (Orleans.Lattice.Api.State.Grpc9.5.0)An Azure Blob backup id can no longer address a blob outside its prefix, or outside the configured container.
BackupBlobNaming.ManifestBlobNameandArtifactBlobNameconcatenated a caller-influenced id onto themanifests/orartifacts/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:LatticeBackupCaptureServicecomposes every artifact id as{scope.TreeId}-{kind}-{ticks}-{guid}, and a tenant-composed tree id is itself of the formt/{tenant}/{name}. Because the Azure SDK addresses a blob throughUriBuilder, 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.jsonresolves to/secrets/keys.jsonon 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, sinceLatticeBackupControl.RestoreBackupAsynctolerates a catalog miss and authorizes only the separately supplied target tree, so a crafted backup id is passed to the sink verbatim; its siblingExportArtifactAsyncinstead 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.BackupBlobNamingisinternal, so no public API signature changed. (Orleans.Lattice.Backup.AzureBlob9.5.0)