Release 2026-09-07
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-07.md, and llms.txt lists every page.Part of the changelog.
Patch release on the 9.6 line. Nine packages advance to 9.6.1: Orleans.Lattice, Orleans.Lattice.Api.Mcp, Orleans.Lattice.Api.TreeAdmin, Orleans.Lattice.Auth, Orleans.Lattice.Backup, Orleans.Lattice.GrainIndex, Orleans.Lattice.Membership, Orleans.Lattice.Replication and Orleans.Lattice.Schema, each tagged per docs/RELEASING.md. Every other published package is untouched and stays on its 9.6.0 tag, and the Explorer sub-family remains deliberately held at 9.4.x exactly as it was for the 9.6.0 wave. This is a security and runtime-correctness patch: it carries no new public API surface, no feature work and no performance changes, and an unbumped package continues to resolve its 9.6.0 dependency floor forward to 9.6.1 transitively.
The bulk of it is authorization, across nine entries. Four close a seam where enforcement was weaker than the contract the surface published: two where a caller-supplied second identifier was composed, namespace-checked and then acted upon without ever being put to the access gate (the tree-admin alias, snapshot and view-rebind verbs; and a cross-tree backup restore, which authorized its target but never the tree its manifest was captured from), and two where an existence probe out-reached the enforcement decision it is required to mirror. A fifth closes a direct-grain-call route around the access gate on three internal surfaces that deliberately bypass the ILattice facade. The remaining four derive a JWT signature-algorithm allow-list from the configured key families, stop a read-only MCP caller being advertised - and therefore handed - the mutating repository-context tools, make the repository-context workspace boundary enforce rather than merely advertise, and validate the distributed lock's two lease-duration options. Two of the nine change behaviour for a host that registers an authorization add-on, and both say so in their own entry: a genuine cross-tree restore now additionally requires the Backup capability over the manifest's captured source tree, and a non-positive distributed-lock lease option is now rejected at startup rather than silently disabling the lease cap. A host with no authorization add-on registered is unaffected by the first, because the no-op gate allows everything at zero cost; the second affects any host whose configuration was already broken in a way nothing previously reported.
Four of the fixes below were separated out of the repository-context replay investigation, and it is worth being explicit that the root cause of that investigation is not fixed here: an interrupted leaf replay banks no progress when a partition meets an unresolved saga prepare during replay pass 1, which is tracked as #2098 and remains open with its repair still under design, because the obvious repair would authorise the collector to trim the very entries a resume needs. What ships here are four independent defects found alongside it, none of which depends on that work. Two are runtime faults. The other two repair the over-budget replay warning itself, which is the primary evidence an operator has for recognising that still-open root cause when it strikes, and which in its 9.6.0 form was both ambiguous about which leaf it described and voluminous enough to roll that evidence out of a container log.
Fixed
A replication receiver's blocked floor now reaches the producer on every ack, so write-ahead-log garbage collection can no longer trim entries a partially-buffered atomic batch still needs to recover.
ReplicationAck.BlockedAtHlcwas transmitted by the receiver on every ack and then discarded on arrival:PublishPeerBlockedFloorAsynchad no caller at all, so the producer'sIWalCursorRegistrynever learned a peer receiver's blocked floor, andLatticeWalGcwas free to trim exactly the entries that receiver still needed. The helper is now wired into all three ack paths - the serial ship, the pipelined completion, and the idle liveness probe - immediately after the ack is in hand and ahead of the cursor advance, so the collector can never observe an advanced cursor without the pin that constrains it. The rejected path publishes too, because a receiver that defers a batch behind its inbound receive fence still stamps its pin, and that deferral is precisely the window the mechanism exists to cover. The probe path matters for the symmetric reason: on an idle link the probe ack is the only channel that can re-arm a new pin or clear a drained one. The affected types areinternal, so no public surface changes. (#2050) (Orleans.Lattice.Replication9.6.1)A faulting write-ahead-log prefix-trim probe is now confined to its own partition, instead of being read as "nothing was trimmed" for the whole leaf.
AnyPartitionWalPrefixTrimmedAsyncgates the snapshot rehydrate by asking each WAL partition whether its oldest still-readable offset has advanced past zero, since a non-zero tail on any partition is positive evidence that the collector trimmed a prefix and therefore that a snapshot at or behind the persisted checkpoint is the sole durable copy of it. Thetrywrapped the entire partition loop, so the first faulting coordinator aborted the probe for every later partition and the method reported "not trimmed" for the whole tree - on an eight-partition tree, one slow coordinator masked the other seven. The consequence is both expensive and self-reinforcing: declining the rehydrate forces a full replay from the oldest readable offset, a large window may not finish that replay inside the activation budget so the tree stays saturated, and because the probe is itself a grain call into the tree being replayed, a saturated tree is exactly where the tail-offset call is most likely to time out and decline again. The failure fires most readily where its cost is highest. Each partition is now probed under its owntry: a fault increments an unprobed counter, captures the first exception and continues, and a positive is returned on the first non-zero tail without being gated on a fault-free sweep, since gating it would reintroduce the same masking one level up. Reading the partition count moves into its owntry, because having no count at all is a different condition from one coordinator timing out and must not be reported as "probed everything, all zero", and a cancellation still returns immediately so teardown semantics are unchanged. Durability semantics are identical: both "could not probe" and "probed everything, all genuinely zero" still decline, because a probe that could not prove a prefix was trimmed must never be read as proof that it was - the distinction is now surfaced in a summarising warning rather than in the return value, so only the diagnosis improves. The affected type isinternal, so no public surface changes. (#2082, #2084) (Orleans.Lattice9.6.1)The write-ahead-log materialiser pin grain now evicts a bucket's cached state holder when its write fails, so a stale ETag can no longer wedge a shard into a retry loop that never terminates.
WalMaterialiserPinGraincaches one grain-state holder per bucket so each slot's ETag carries into later writes, and the write path re-read the slot only when that holder was absent - so a write that failed left the holder cached, still carrying the ETag that had just conflicted. Retrying the bucket is deliberate (the durable-write path re-arms the batch and documents that the bucket stays dirty for the next flush to retry), but every retry reused the stale ETag and conflicted identically, so the designed retry could never succeed. The grain then persisted no pin at all for the life of the activation, and because the retry cadence is far shorter than the idleness threshold Orleans collects on, the loop's own traffic kept the activation hot and suppressed the collection that would otherwise have cleared the cache. The holder is now evicted on any write failure, so the next attempt reads a fresh ETag: a conflict means the slot moved underneath the activation, and any other failure leaves the ETag describing a write whose outcome is unknown, so both are unusable and re-reading is cheap next to a wedged pin shard. Durability is unaffected - a bucket that did not land leaves its pins staler than memory, which retains more WAL, and the in-memory pin map is what the trim floor consults. The regression tests bring their own ETag-enforcing storage double, because the existing bucket fixture models no ETags and a concurrency assertion against it would pass whether or not the defect were present. The affected type isinternal, so no public surface changes. (#2096, #2097) (Orleans.Lattice9.6.1)A work-pump coordinator started inside Orleans' reminder-service startup window no longer fails the caller's operation.
CoordinatorGrain<TSelf>.StartCoordinatorAsync- the shared base for the tree snapshot, resize, shard-split and reshard coordinators - registered its crash-recovery keepalive reminder with a bareRegisterOrUpdateRemindercall. Orleans initialises its reminder service asynchronously after the silo reachesActive, so a coordinator that started inside that window saw the transient "Reminder Service is still initializing" fault propagate straight out and fail the operation that launched it, even though the same registration moments later would have succeeded. The registration is now wrapped in the same boundedReminderServiceReadiness.RetryWhileInitializingAsyncwait-out that the atomic-write saga's essential keepalive already uses, so the transient is retried across the startup window rather than surfaced; any other fault, and a transient that never clears within the retry budget, still surface with their original shape so a genuinely stuck reminder service is not mistaken for a healthy start. Aprotected virtualbackoff seam lets the regression tests drive the retry budget without real delays. The affected type isinternal, so no public surface changes. (#2086) (Orleans.Lattice9.6.1)The over-budget leaf-replay warning now names the leaf grain it describes, and its throttle is keyed per leaf, so consecutive lines can no longer be misread as one leaf failing to make progress. The warning states its own fault criterion - a persisted checkpoint that does not advance across repeats is a replay livelock and IS a fault, rather than a slow but converging replay - but it named only the tree and the WAL partition ordinal, and the partition ordinal is iterated over the full
WalPartitionsrange inside every leaf's activation, so it does not identify a leaf. The throttle compounded this: keyed on (tree, partition) at one line a minute, the first leaf to trip suppressed every other leaf in that tree and partition, so consecutive lines were one-per-minute samples of arbitrary different leaves. Checkpoints that appeared to repeat or move backwards across those lines were an artifact of that sampling, which is precisely the evidence the criterion asks an operator to weigh, so the diagnostic actively misled the diagnosis it exists to support. The message now names the leaf grain id, the fault criterion is qualified to repeats naming the same leaf and partition, and the throttle is re-keyed to (tree, leaf, partition) with a bounded opportunistic sweep so the key set cannot grow without limit. Theorleans.lattice.leaf.activation_replays_over_budgetcounter gains apartitiontag and is deliberately not tagged by leaf, because leaf count is unbounded; the counter therefore measures the rate and the log makes the per-leaf call. The affected type isinternal, so no public surface changes. (#2023, #2043) (Orleans.Lattice9.6.1)The over-budget leaf-replay warning is now bounded in aggregate volume, so it can no longer roll the evidence needed to diagnose it out of the log. Per-leaf keying is what makes the warning diagnostically useful, but it also multiplies volume: a tree with L leaves and P WAL partitions has L x P throttle keys and so L x P times the per-key rate. This is how this single warning reached 46 percent of one deployment's container log and rolled away the older entries that were the evidence needed to diagnose the condition producing it. A per-tree, per-window cap now bounds how many repeat detail lines one tree may emit, on top of the existing per-key backoff. What is deliberately not traded away: a leaf partition reporting over budget for the first time is exempt from the cap, so a newly appearing condition still surfaces promptly; everything the cap withholds is reported as a summary line rather than dropped silently; and the counter remains the exact census. The gate's own state is kept honestly - the per-key backoff stamp is committed only when a line is really emitted, so a repeat the cap withheld does not pay backoff for a line it never printed and still rotates into the budget to yield the two comparable lines the fault criterion needs; the stamp sweep retires rather than deletes, so housekeeping cannot manufacture the first-time exemption on a large estate; a clean in-budget activation retires the backoff, so a leaf that misbehaves, recovers and regresses hours later reports at the base interval instead of inheriting an accumulated ceiling; and both capacity sweeps are rate-limited and bounded. One reporting limit is documented rather than removed: the withheld-repeat summary is carried by a later occurrence on the same tree, so a tree's final withheld tally goes unreported once the condition resolves, and the counter is the exact census for that case. The affected type is
internal, so no public surface changes. (#2100, #2109) (Orleans.Lattice9.6.1)A fractional literal compared against an integral indexed property is now rejected instead of being silently rounded to a wrong exact bound.
GrainIndexValueBinderbrings a literal captured from a lambda back to the indexed property's declared type withConvert.ChangeTypebefore encoding it, butConvert.ChangeTyperounds a fractional value to an integral target (1.5 becomes 2) rather than throwing. The binder then encoded an exact bound the caller never wrote, and because the planner drops the residual predicate for an exact range, a query such asx.Count > 1.5against anintproperty returned wrong or empty results with no error. The binder now round-trips every converted value back to its original type and rejects any conversion that does not compare equal, so a lossy literal fails to encode (the planner keeps the residual filter) while a lossless widening (2.0 to anint) still encodes exactly as the property would. The affected type isinternal, so no public surface changes. (Orleans.Lattice.GrainIndex9.6.1)A value-transform member read now matches input property names case-insensitively, as its documented contract and its sibling predicate evaluator already do.
LatticeValueTransform.Memberis documented to resolve members ordinally and case-insensitively, and the predicate projection path (SchemaValueChecks.TryProjectStringMember) already does so through a case-insensitive lookup. The transform evaluator, however, parsed the input document with the defaultJsonNodeoptions, so aMember("Name")read against an input carrying"name"resolved to nothing and produced a JSON null, contradicting the contract and diverging from the predicate path. The evaluator now parses withPropertyNameCaseInsensitive, which every downstream member operation (read, set, drop, rename) inherits, so a member read resolves regardless of case. The affected type isinternal, so no public surface changes. (Orleans.Lattice.Schema9.6.1)
Security
Three internal grain surfaces that deliberately bypass the
ILatticefacade now assert internal origin, closing a direct-grain-call route around the access gate. All access-gate enforcement lives on theILatticefacade, so the physical grains it delegates to have carried a defense-in-depth internal-origin assertion since the shard and leaf grains were hardened for #1103. Three surfaces reachable by the same route did not, and each was declaredinternalon the understanding that accessibility was itself the boundary. It is not: Orleans binds a grain interface by its stable[Alias]string rather than by CLR identity, so an external client can declare a structurally matching aliased interface and bind to the same activation. That is precisely why the capability-stripping call filter re-derives trust from the call's source at every hop instead of trusting the caller, and why the shard and leaf grains guard at all. The three gaps differ in impact, so all three are closed. (1)ISystemLatticeis the internal read, write, delete and enumerate surface over the reserved system-tree namespace that the public facade refuses to address, which holds the auth policy tree and the replication write-ahead log, so reaching it externally was a full policy-store bypass rather than access to one tree; all six members now assert, and the two streaming members assert on the call itself rather than inside the deferred iterator body, so a caller that never enumerates is refused just the same. (2)IReplicationApplyGraininstalls a remote mutation while preserving the authoring cluster's hybrid logical clock and origin cluster id verbatim, deliberately bypassing both the access gate and local conflict resolution so a receiver reproduces the producer's view exactly; reaching it externally let a client forge replicated writes attributed to another cluster and stamped with an attacker-chosen clock, which conflict resolution would then honour. All ten apply entry points now assert. (3)ILatticeCrossTreeReceiverGrain.NotifyTerminalAsynccarries the commit-or-abort verdict for a whole cross-tree batch on a receiver cluster and, on the first terminal, freezes the wait set, origin cluster and operation id; it validated the shape of a terminal but authorized nothing, so an external caller could force a premature global verdict on a batch in flight and equally poison a barrier that had not started, breaking the all-or-nothing cross-tree visibility the coordinator exists to enforce. It is the receiver-side twin of the single-tree saga'sIAtomicWriteGrain.FinalizeAsync, which was already guarded, and it is now guarded identically and ahead of the redelivery short-circuit so a settled verdict is not disclosed either. The siblingILatticeCrossTreeTxGrain.CommitAsyncwas audited in the same sweep and left unchanged, because it already re-derives the access gate per leg against the original caller's identity before any staging. Every assertion uses the enforcement-gated overload, which resolves theLatticeInternalOriginEnforcementMarkersentinel from the activation's services and returns immediately when it is absent, so a cluster that has not opted intoAddLatticeAuthpays one singleton lookup on a low-frequency coordinator path and behaves exactly as before. No public surface changes: all three interfaces areinternal. Regression tests drive each surface by direct external grain call against a cluster with enforcement registered and assert the refusal, alongside the existing coverage proving the facade path still succeeds and that a forged internal-origin or system-origin marker is stripped from a client call. (Orleans.Lattice9.6.1)The three tree-admin verbs that act on a second caller-supplied identifier now authorize that identifier too, closing three paths by which a caller could reach a tree it holds no grant on. Each of
SetTreeAliasAsync,SnapshotTreeAsyncandCreateViewAsyncaccepts more than one caller-named resource, composes and reserved-namespace-checks every one of them - and then authorized only the first, leaving the far side of a cross-tree operation ungoverned. That is the same shape the core already defends against at its sibling seams (LatticeGrain.MergeAsyncauthorizes its caller-supplied source throughEnforceSourceTreeReadAsyncprecisely so "authorizing only the destination would let a caller holding Admin on a tree it owns siphon any other tree in the cluster", and the backup engine asserts physical-tree provenance at its own alias-swap seam), so all three are now brought into line. (1)SetTreeAliasAsyncauthorized the logical id but not the alias target. Every data-plane gate -LatticeGrain's access-gate enforcement, and the request this facade's own verbs build - is evaluated against the logical id before the registry resolves the alias, 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; a caller holdingAdminon a single tree it legitimately owned could therefore alias that tree onto one it held nothing on and read and rewrite the target through the ordinaryILatticesurface, under its own tree's policy. The registry'sThrowIfAliasEscalatesNamespaceguard is namespace-shaped (it refuses a_lattice_target, an ordinary-to-sys-crossing, and a foreign-tenant target), so two ordinary same-namespace ids passed it unconditionally - which is exactly the case now closed. (2)SnapshotTreeAsyncauthorized the source but not the destination, and a snapshot creates its destination in the registry (at caller-chosen structural sizing) and drains the source's whole contents into it. The core'sEnforceWholeTreeAsync(Admin)is hard-bound to the source tree's own id and itsThrowIfReservedSnapshotDestinationis likewise namespace-only, so an ordinary destination was ungoverned end to end: a caller could stand up a fully-populated tree at an id it had no authority over, bypassing bothCreateTreeAsync(which authorizes the exact id being created) andBeginBulkLoadAsync(which authorizes theBulkLoadcapability on it), and pre-plant forged content at an id a victim's policy grants them but which they have not created yet. (3)CreateViewAsyncauthorized the caller's chosen source but never the incumbent registration behind the caller-supplied view name, and view registration is last-write-wins at both layers (ViewRegistryGrain.RegisterAsyncoverwrites the durable record,ViewCatalog.Registerthe entry). A caller holdingAdminon any tree it owned could therefore re-point an established view at its own source: the maintainer then projected attacker-controlled data into the existingview-tree, which the caller could not write to directly because the maintainer runs under a system-origin bypass, and the rightful owner was locked out ofRebuildViewAsync/ReconcileViewAsync/DropViewAsync, all of which authorize against the resolved source. A first-time create and an idempotent re-create over the same source are unaffected; only a genuine rebind now requiresAdminon the source the view currently tails as well as on the one it is moving to. All three fixes are purely additive authorization calls at the facade seam - no public API signature changed, and no previously-authorized call is now refused. (Orleans.Lattice.Api.TreeAdmin9.6.1)A restore now authorizes the tree its manifest was captured from, closing a cross-tree read primitive gated only on knowledge of a backup id.
LatticeBackupRestoreServiceread the target manifest before any authorization and then retargeted the manifest's own captured scope onto the caller-supplied target tree - so by the time the fail-closed check ran, the capturedScope.TreeIdhad already been discarded and was never put to the gate on any restore path. Only the target was governed, which meant a caller holdingRestoreover a tree it owns could have the entire contents of a tree it holds nothing on replayed into a tree it fully controls, and then read it through the ordinaryILatticesurface under its own tree's policy. That is the same "authorize one identifier, act on another" shape closed on the tree-admin facade above, and the restore path was the outlier within its own package: every other manifest-consuming verb onLatticeBackupControl- describe, delete, export artifact, and the three health verbs - already gates onmanifest.Scope. The captured source is now authorized with theBackupcapability, matching those verbs and matching the authority the caller would have needed to capture the manifest itself, at every entry point that replays a manifest:RestoreAsync(and thereforeRestoreSetAsyncand the catalog-freeColdRestoreAsync, which both delegate to it) and the coordinated engine'sBuildShadowAsync. Two properties keep it tight. The check is skipped when source and target are the same tree, so the ordinary same-tree point-in-time rollback needs noBackupgrant it did not need before, and only a genuine cross-tree retarget - clone, disaster recovery into a fresh id, environment seeding - is affected. And the scope put to the gate is the effective restore scope retargeted back onto the source tree, so a sub-scoped restore authorizes only the range actually replayed rather than the whole captured scope. Every distinct source tree in the manifest chain is gated, not just the tip's: an increment inherits its base's scope on the capture path, but the sink is a trust boundary, so the chain is checked rather than inferred.RevertRestoreAsyncandCommitShadowAsyncare unchanged because neither consults a manifest - they move a registry alias between physical trees whose provenance is already asserted against the target. Potentially breaking for a host with an authorization add-on: a cross-tree restore that previously succeeded on aRestoregrant over the target alone is now refused unless the caller also holdsBackupover the captured source. A host with no authorization add-on registered is unaffected, because the no-op gate allows everything at zero cost. No public API signature changed. (Orleans.Lattice.Backup9.6.1)An existence probe under
DefaultEffect = Allowno longer reports a grant for a tree the subject is wholly denied, closing a listing-visibility disclosure.PolicyEvaluator.HasAnyGrant- the structural "any grant" signal that existence-hiding relies on - returnedtrueunconditionally whenever the default effect was allow, ignoring a matched whole-tree deny. Enforcement resolves that tree-wide deny for every key, so a subject denied the whole tree can read nothing, yet the probe still reported the tree as reachable and kept it visible in listings: the exact out-reach the gate's documented invariant forbids ("an existence probe must never out-reach the enforcement decision"). The probe now mirrors enforcement: under default-allow a tree whose tree-wide decision is deny with no exact or prefix allow carve-out reports no grant and is hidden, while a whole-tree deny paired with a prefix or exact allow keeps the tree visible because some keys remain readable. This fails closed and aligns the probe with the enforcement path. The affected type isinternal, so no public surface changes. (Orleans.Lattice.Auth9.6.1)That same existence probe now mirrors the two remaining enforcement tiers as well, closing three further listing-visibility disclosures wherever the all-trees tier decides the outcome. The fix above aligned
PolicyEvaluator.HasAnyGrantwith enforcement only for a specific tree's own whole-tree deny under default-allow; the probe still ignored the all-trees (Tree:*) tier entirely, so it kept disagreeing withResolveTieredin three further configurations, each of which advertised a tree in listings that the gate denies on every single key. (1) An all-trees deny paired with a specific whole-tree allow: tier 1 gives the all-trees deny precedence over every specific-tree rule, so the gate denies each key, but the probe took the specific allow as proof of a grant and reported the tree visible. This one was not merely unguarded but positively asserted, by a test whose own comment conceded "the specific allow keeps the tree visible even though a real read would be denied by the wildcard" - that test now asserts the corrected behaviour. (2) The same all-trees deny underDefaultEffect = Allowagainst a tree carrying no rules of its own, where the probe returnedtruebefore consulting the wildcard bucket at all. (3) An all-trees allow paired with a specific whole-tree deny and no allow carve-out, underDefaultEffect = Deny: tier 2 gives the specific tree's own verdict precedence over an all-trees allow, so the gate again denies every key, yet the probe fell through to the wildcard allow and reported a grant. The probe now applies the tiers in enforcement's own order: a matched all-trees deny hides the tree outright, because that verdict is resolved tree-wide and is therefore uniform across every key; the "denies every key" test that closed the default-allow case is now applied on the default-deny path too, ahead of the all-trees allow it must outrank. A tree that keeps an exact or prefix allow carve-out, or one reachable only through a wildcard grant, stays visible exactly as before, so nothing legitimately listable is hidden. The stale doc comment claiming a wildcard deny was "deliberately not consulted" by the probe is corrected along with it. The affected type isinternal, so no public surface changes. (Orleans.Lattice.Auth9.6.1)The distributed lock's two lease-duration options are now validated, so a non-positive ceiling can no longer silently disable the lease cap.
LatticeOptions.MaxLockLeaseDurationdocuments itself as "positive and at leastDefaultLockLeaseDuration", and four further places - the threeILatticeLockGrainacquire/renew overloads,LockTypes, and the distributed-lock guide - promise that any requested duration "is capped atMaxLockLeaseDuration". Nothing enforced any of it:LatticeOptionsValidatorchecks around thirty other durations, several with a far smaller blast radius, but neither lock-lease option at all. The clamp is writtenif (max > TimeSpan.Zero && lease > max), so a zero or negative ceiling does not disable leasing, it disables the cap: every caller-supplied duration is then honoured verbatim, and a large one overflows the absolute expiry tick (nowTicks + leaseTicks) and wraps into the past, which the reclaim path reads as an already-expired lease and hands the lock to a waiter while the original holder still believes it holds it. A non-positiveDefaultLockLeaseDurationis worse and needs no overflow at all: it is the fallback taken whenever a caller names no duration, and it is granted verbatim, soLeaseExpiresAtTicksequals the grant instant and mutual exclusion is lost for every default-duration acquire. The validator now rejects a non-positive value for either option, and a ceiling below the default, while explicitly still admitting the boundary case of a ceiling exactly equal to the default. This is validation only: no public API signature changed, and every already-valid configuration still validates. (Orleans.Lattice9.6.1)JwtCredentialAuthenticatorno longer leaves the signature-algorithm allow-list unrestricted by default.JwtAuthenticatorOptions.Algorithmsis empty by default, and the authenticator pinnedTokenValidationParameters.ValidAlgorithmsonly when it was non-empty. An emptyValidAlgorithmsis treated by the token validator as "no restriction", so a deployment that configured an issuer and signing keys and never touchedAlgorithms- the shape in the package's own documentation - accepted a token whose header advertised any algorithm the trusted key set could satisfy, leaving the algorithm-confusion gap (CWE-347) open: a host that pinned both asymmetric and symmetric key material could be induced to verify an attacker-minted HMAC token against a symmetric key configured for an unrelated purpose. Porting the siblingOidcCredentialAuthenticator's deny-all posture was not viable, because there the empty case is a discovery document that named no algorithms whereas here empty is the documented default, so deny-all would take every existing host from "authenticates" to "rejects every token" on upgrade. Instead the allow-list is now derived from the families of the configured signing keys: an RSA-only or EC-only key set accepts only that family's algorithms, and a symmetric-only key set accepts only the HMAC algorithms, so a deployment can never be talked into verifying against a key of the wrong family. The list is left unrestricted only where no family can be established - an empty key set, a set mixing symmetric and asymmetric material, or one containing a key type the derivation does not recognise - so a legitimately mixed host keeps working and can pin explicitly. An explicitAlgorithmspin remains authoritative and is never widened. No public surface changes; the existingAlgorithmsandValidationParametersknobs already express every override a host needs. (Orleans.Lattice.Membership9.6.1)A read-only MCP caller is no longer advertised the mutating repository-context tools.
LatticeApiMcpGroupCapabilityMap.RequiredOperationsreturns one coarse operation mask per facade group, andAuthAdminMcpPermissionResolveradmits a group when any singleAllowrule intersects that mask. The repository-context group's mask spans the whole data plane -Read,Write,Delete,RangeRead,RangeDelete,CrdtApply,AtomicWrite,BulkLoad- so a principal granted nothing butReadwas admitted to the entire group, and the caller's actual operation mask was then discarded rather than consulted again. The session's advertised tool list therefore includedrepocontext_remember,repocontext_update,repocontext_forget,repocontext_add_repo,repocontext_remove_repoandrepocontext_bootstrap, and because that same collection servestools/call, they were invocable, not merely visible - a least-privilege violation that let a read grant write to and delete from the store.LatticeApiMcpAccessSetnow also carries the union of the caller'sAllow-granted operations, and the discovery core applies a per-tool minimum inside a group it has already admitted, via a newILatticeApiMcpToolGroup.RequiredOperationsForwhose default implementation returns the group's own mask so no existing group changes behaviour. The repository-context group overrides it to demand a mutating operation for those six tools. The group masks themselves are unchanged, so thelattice_capabilitieswire response is byte-identical and a read-only caller still legitimately sees the group for its read tools. A resolver that supplies no operation detail keeps the previous group-level-only filtering, so the refinement cannot silently narrow an existing deployment. All the affected types areinternal; no public surface changes. (Orleans.Lattice.Api.Mcp9.6.1)The repository-context workspace boundary is now enforced, not merely advertised.
AddRepoContextTools(enableWrites: true, workspaceMode: true)with noworkspaceRoot- a legal call, because the parameter is optional - registered aRepoContextWorkspaceGuardwith no roots. That guard reports itself as not enforcing and its resolver short-circuits to admit every path, yet workspace mode still contributedrepocontext_add_repo, whose own published contract states that the path is "resolved against the workspace boundary; a path outside it is rejected". A caller could therefore name any directory on the host -/, a home directory, a secrets mount - withrespectGitignore: falseandexcludeBinary: false, have the ingest project every file body into the content tree, and read it straight back out throughrepocontext_context,repocontext_searchandrepocontext_outline: an unauthenticated-at-the-tool-layer arbitrary local file read dressed as repository onboarding. The fix is fail-closed at two seams. The registration extension now computes the workspace roots before building the tool group and withholdsrepocontext_add_repoentirely when there are none, so advertisement matches enforcement; it deliberately does not substituterepocontext_bootstrapin its place, because bootstrap accepts an equally unbounded caller-supplied path and the fallback would reopen the same hole under a different name.repocontext_remove_repoandrepocontext_list_reposare unaffected - neither takes a path. The handler then re-checks the effective dependency-injected guard whenrepocontext_add_repois invoked and refuses with a clearMcpException, which is what covers a host that registered its own non-enforcing guard despite supplying a root. Argument validation still runs first, so a malformed call reports the parameter problem rather than the boundary refusal. The single-repositoryrepocontext_bootstrapsurface is untouched and still accepts an arbitrary path by design: there the path is host configuration rather than caller input, and that deliberate opt-out is now pinned by test. No public surface changes. (Orleans.Lattice.Api.Mcp.RepoContext, not published to NuGet)