Table of Contents

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

  • 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) (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) (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) (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) (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) (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)

  • 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)

  • 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) (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)