---
title: "ILattice: Cancellation to Stateful cursors - Lattice Public API Reference"
url: "https://nsta1.github.io/Orleans.Lattice/docs/lattice/api/ilattice-1.html"
source: "https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/docs/lattice/api.md?plain=1#L178-L487"
package: "Orleans.Lattice"
version: "9.9.0"
documents: "Orleans.Lattice 9.9.0 (release line 9.9)"
built: "2026-10-04"
all-pages: "https://nsta1.github.io/Orleans.Lattice/llms.txt"
bundle: "https://nsta1.github.io/Orleans.Lattice/docs/lattice/llms-full.txt"
---
# `ILattice`: Cancellation to Stateful cursors

Part of [ILattice](ilattice.md), in [Lattice Public API Reference](../api.md).

## Cancellation

Every method on `ILattice` - including scans (via the
`ScanKeysAsync` / `ScanEntriesAsync` extension wrappers), range
deletes, counts, fan-out batch operations, bulk load, stateful
cursors, and all tree-lifecycle orchestrators - accepts an optional
trailing `CancellationToken cancellationToken = default` parameter.
The signatures in the tables below omit the parameter for readability.

- A pre-cancelled token fails fast before any shard is contacted.
- Scan iterators apply `[EnumeratorCancellation]`, so
  `await foreach (...).WithCancellation(ct)` propagates correctly.
- Once a long-running coordinator (saga, resize, snapshot, reshard,
  merge) has accepted a request it drives itself to a terminal state
  and is not cooperatively cancelled.

The typed extensions in `TypedLatticeExtensions` and the streaming
`BulkLoadAsync` extension in `LatticeExtensions` also thread the
token.

## Runtime operations

These methods are used during normal application flow to read, write,
and enumerate data. They are safe to call concurrently and do not
affect tree availability.

### Single-key

| Method | Signature | Description |
|--------|-----------|-------------|
| `GetAsync` | `Task<byte[]?> GetAsync(string key)` | Returns the value for `key`, or `null` when absent or tombstoned. Staleness is bounded by `LatticeOptions.CacheTtl` (default zero - refresh on every read). |
| `GetWithVersionAsync` | `Task<VersionedValue> GetWithVersionAsync(string key)` | Returns the value paired with its `HybridLogicalClock` version; returns a default `VersionedValue` with `null` value and zero version when absent or tombstoned. Use the returned version with `SetIfVersionAsync` for optimistic concurrency. Bypasses the read cache. |
| `ExistsAsync` | `Task<bool> ExistsAsync(string key)` | Returns `true` when `key` is live (not absent and not tombstoned). |
| `SetAsync` | `Task SetAsync(string key, byte[] value)` | Inserts or updates the value for `key`. Throws `LatticeReplicationModeMismatchException` when the tree is declared for cross-cluster replication as a typed CRDT mode; author values through the matching accessor instead (see [Replication modes - Single shape per tree](../../lattice.replication/replication-modes.md#single-shape-per-tree)). |
| `SetAsync` (TTL) | `Task SetAsync(string key, byte[] value, TimeSpan ttl)` | Inserts or updates `key` with a time-to-live. The entry is treated as tombstoned on every read once `ttl` has elapsed from the server-side write instant; physical reclamation follows `LatticeOptions.TombstoneGracePeriod`. Throws `ArgumentOutOfRangeException` when `ttl` is zero or negative, or so large that the absolute expiry would overflow. A typed `SetAsync<T>(this ILattice, string, T, TimeSpan, ILatticeSerializer<T>)` overload exists in [`TypedLatticeExtensions`](../api.md#typedlatticeextensions). Throws `LatticeReplicationModeMismatchException` when the tree is declared for cross-cluster replication as a typed CRDT mode (see [Replication modes - Single shape per tree](../../lattice.replication/replication-modes.md#single-shape-per-tree)). |
| `SetIfVersionAsync` | `Task<bool> SetIfVersionAsync(string key, byte[] value, HybridLogicalClock expectedVersion)` | Atomic compare-and-set: writes only when the entry's current version equals `expectedVersion`. Returns `true` on success, `false` on version mismatch. Pass `HybridLogicalClock.Zero` for a key that must not exist. Throws `LatticeReplicationModeMismatchException` when the tree is declared for cross-cluster replication as a typed CRDT mode (see [Replication modes - Single shape per tree](../../lattice.replication/replication-modes.md#single-shape-per-tree)). |
| `GetOrSetAsync` | `Task<byte[]?> GetOrSetAsync(string key, byte[] value)` | Inserts `value` only when `key` is absent or tombstoned. Returns the existing value when live, or `null` when the new value was written. No read-then-write race. Throws `LatticeReplicationModeMismatchException` when the tree is declared for cross-cluster replication as a typed CRDT mode (see [Replication modes - Single shape per tree](../../lattice.replication/replication-modes.md#single-shape-per-tree)). |
| `DeleteAsync` | `Task<bool> DeleteAsync(string key)` | Tombstones `key`. Returns `true` if the key was live. See [Tombstone Compaction](../tombstone-compaction.md) for retention. Throws `LatticeReplicationModeMismatchException` when the tree is declared for cross-cluster replication as a typed CRDT mode (see [Replication modes - Single shape per tree](../../lattice.replication/replication-modes.md#single-shape-per-tree)). |
| `ApplyCrdtDeltaAsync` | `Task<HybridLogicalClock> ApplyCrdtDeltaAsync(string key, LatticeMergeMode mode, byte[] deltaBytes)` | Applies a producer-side typed CRDT delta to `key` under the declared `mode`. The owning leaf resolves the registered `CrdtShape`, folds the delta into the current state via the shape's `MergeDelta`, and appends a single WAL record carrying only the delta bytes. Returns the `HybridLogicalClock` stamped on the committed entry. CRDT merges are convergent, so this surface deliberately omits the optimistic-CAS guard `SetIfVersionAsync` carries. `LatticeMergeMode.OrMap` requires a per-tree shape registered via `ISiloBuilder.AddOrMapShape<TKey, TValue>(treeName)`; the closed-shape modes (`OrSet`, `PnCounter`, `VersionVector`, `MvRegister`, `Sequence`, `OrFlag`, `RwFlag`, `GCounter`, `GSet`, `RwSet`, `MaxRegister`, and `MinRegister`) resolve through the registry's global fallback without per-tree registration. An OR-Map verb against a tree with no registered shape throws `LatticeCrdtShapeNotRegisteredException` (a subclass of `InvalidOperationException`), a deterministic host-configuration precondition that the API bindings map to a client-error status rather than a server fault. `LatticeMergeMode.LwwRegister` is rejected with `ArgumentException` - use `SetAsync` for LWW. The typed accessors listed under [CRDT value-surface accessors](crdt-value-surface-accessors.md) wrap this surface and are the recommended caller-facing seam. Throws `LatticeReplicationModeMismatchException` when the tree is declared for cross-cluster replication under a mode other than `mode` (see [Replication modes - Single shape per tree](../../lattice.replication/replication-modes.md#single-shape-per-tree)). |
| `ApplyCrdtDeltaAsync` (TTL) | `Task<HybridLogicalClock> ApplyCrdtDeltaAsync(string key, LatticeMergeMode mode, byte[] deltaBytes, TimeSpan ttl)` | Time-to-live overload of `ApplyCrdtDeltaAsync`: the entry expires `ttl` after the server-side write instant, resolved to an absolute UTC instant on the accepting silo. Unlike the last-writer-wins `SetAsync` TTL path, CRDT expiry converges by an order-independent max-absolute-ticks join, so a later or concurrent TTL'd write extends the entry's life and every replica agrees on the same expiry regardless of merge order; a durable (no-TTL) write leaves any existing expiry unchanged. Throws `ArgumentOutOfRangeException` when `ttl` is zero, negative, or overflows. Every typed accessor's primary write carries a matching `TimeSpan ttl` overload. See [TTL - Per-entry TTL on CRDT writes](../ttl.md#per-entry-ttl-on-crdt-writes). |

Single-key operations transparently retry when a concurrent topology
change invalidates their routing - an adaptive split or reshard that
moved the key's virtual slot to another shard, or an online resize (or
other shadow-cutover swap) that redirected the tree to a new physical
tree. The retry refreshes routing and tries again for up to a 60-second
wall-clock budget; only a topology that is still changing when that
budget runs out surfaces the last internal stale-routing fault to the
caller, as an exception of a non-public type.

### Batch

| Method | Signature | Description |
|--------|-----------|-------------|
| `GetManyAsync` | `Task<Dictionary<string, byte[]>> GetManyAsync(List<string> keys)` | Fetches multiple keys in parallel. Missing or tombstoned keys are omitted from the result. A concurrent `SetManyAtomicAsync` is observed atomically tree-wide: the fan-out is re-run when a saga commits or the shard map moves while it is in flight, bounded by `LatticeOptions.MaxScanRetries` (default 3), and throws `InvalidOperationException` when every attempt is invalidated. |
| `GetManyWithGateAccountingAsync` | `Task<GatedMultiReadResult> GetManyWithGateAccountingAsync(List<string> keys)` | Returns exactly what `GetManyAsync` returns (`GatedMultiReadResult.Values`) plus `PrunedByAccessGate`, the number of requested keys the read-path access gate removed before fan-out. Use it instead of `GetManyAsync` when you draw a conclusion from a key's absence: absence is sound only when the count is `0`, which `GatedMultiReadResult.IsComplete` reports directly. The count never names the pruned keys. See [Reading an empty range read under a gate](caller-credential-propagation-latticecredentialcontext.md#reading-an-empty-range-read-under-a-gate). |
| `SetManyAsync` | `Task SetManyAsync(List<KeyValuePair<string, byte[]>> entries)` | Writes multiple entries in parallel. **Not atomic** - partial failure leaves the batch half-applied with no rollback. Use `SetManyAtomicAsync` when all-or-nothing semantics are required. **Fails fast**: the first branch to fault surfaces at once rather than after the slowest branch settles, and sibling branches are deliberately not cancelled, so they may still be in flight when the exception is observed - re-read the affected keys rather than assuming the batch has quiesced. When no branch faults every branch is still awaited, because the per-entry `Set` events publish only once all shard writes have committed. When `LatticeOptions.SetManyFanOutBudget` (the multi-shard fan-out) or `SetManyEnvelopeBudget` (the whole call) is set - both are unbounded by default - and runs out, the call is refused with `LatticeSaturatedException` (`SaturationSource` = `SetManyFanOut` or `SetManyEnvelope`) and nothing is rolled back (see [Saturation back-pressure](saturation-back-pressure-latticesaturatedexception.md)). Per-leaf batches collapse their per-key WAL grain hops into a single batched dispatch (see [WAL - Batched leaf write path](../wal.md#batched-leaf-write-path)). Throws `LatticeReplicationModeMismatchException` when the tree is declared for cross-cluster replication as a typed CRDT mode (see [Replication modes - Single shape per tree](../../lattice.replication/replication-modes.md#single-shape-per-tree)). |
| `ApplyCrdtDeltaManyAsync` | `Task ApplyCrdtDeltaManyAsync(List<KeyValuePair<string, byte[]>> deltas, LatticeMergeMode mode)` | Applies multiple typed CRDT deltas, fanning out to shards in parallel and collapsing each leaf's slice into a single batched WAL dispatch, so an N-key batch costs one commit-log round trip per leaf instead of N. The batched counterpart of `ApplyCrdtDeltaAsync` and the CRDT counterpart of `SetManyAsync`. The merge mode is declared once for the whole batch rather than per entry, because a tree resolves exactly one CRDT shape - mixing shapes within a tree converges locally but diverges at replication. **Not atomic** - a partial failure leaves the batch half-applied; because every entry folds a delta rather than overwriting a value, a caller retry converges instead of clobbering a concurrent writer. Use the staged cross-tree atomic path (`LatticeAtomicWriteBuilder.SetMany`) when all-or-nothing semantics are required. `LatticeMergeMode.LwwRegister` is rejected with `ArgumentException` - use `SetManyAsync` for LWW. Throws `LatticeReplicationModeMismatchException` when the tree is declared for cross-cluster replication under a mode other than `mode` (see [Replication modes - Single shape per tree](../../lattice.replication/replication-modes.md#single-shape-per-tree)). |
| `SetManyAtomicAsync` | `Task SetManyAtomicAsync(List<KeyValuePair<string, byte[]>> entries)` | Atomically writes multiple entries: on success every key holds its new value, on any failure every key holds its pre-saga value. Concurrent readers observe the saga atomically tree-wide and across every cluster the tree replicates to. Throws `ArgumentException` on duplicate keys or null values; before the saga starts, an entry that violates `LatticeOptions.MaxKeyLength` or `MaxValueSizeBytes` throws `ArgumentException` and an enforcing `MaxLiveKeys` or `MaxEstimatedBytes` cap throws `LatticeQuotaExceededException`; throws `InvalidOperationException` when compensation completes for a failed write, and `LatticeStateWriteFailedException` when a saga state write keeps losing an optimistic-concurrency check through the call's bounded re-attach attempts (see [Exception reference](extension-seams-and-supporting-types.md#exception-reference)). After completion, saga state is retained for `LatticeOptions.AtomicWriteRetention` (default 48 h). See [Atomic Writes](../atomic-writes.md). Throws `LatticeReplicationModeMismatchException` when the tree is declared for cross-cluster replication as a typed CRDT mode (see [Replication modes - Single shape per tree](../../lattice.replication/replication-modes.md#single-shape-per-tree)). |
| `SetManyAtomicAsync` (idempotency key) | `Task SetManyAtomicAsync(List<KeyValuePair<string, byte[]>> entries, string operationId)` | Caller-supplied idempotency-key overload. Re-submitting the same `operationId` re-attaches to the original saga and inherits its outcome, turning a transport-level failure - or a `LatticeStateWriteFailedException` whose `Conflict` is `true` - into a safe client retry. The write-size and quota checks run before the call re-attaches, so a retry can be refused by a bound in force at retry time before it observes the original outcome. The `operationId` is bound to the exact sorted key set of the first call; mismatched key sets throw `LatticeIdempotencyKeyMismatchException` (a subclass of `InvalidOperationException`). Reordering keys or changing values is allowed. `operationId` must not be null, empty, or whitespace and must not contain `'/'`; otherwise throws `ArgumentException`. See [Atomic Writes - Caller-supplied idempotency keys](../atomic-writes.md#caller-supplied-idempotency-keys). Throws `LatticeReplicationModeMismatchException` when the tree is declared for cross-cluster replication as a typed CRDT mode (see [Replication modes - Single shape per tree](../../lattice.replication/replication-modes.md#single-shape-per-tree)). |
| `SetManyAtomicAsync` (mixed set + delete) | `Task SetManyAtomicAsync(List<KeyValuePair<string, byte[]>> upserts, IReadOnlyList<string> deletes, string operationId)` | Mixed atomic batch: applies the `upserts` and the `deletes` all-or-nothing in one visibility flip, so no reader observes a partial set/delete. Each delete is staged as a tombstone that becomes visible on commit and is dropped on abort, riding the same saga terminal as the upserts. The defining use is a re-key retraction (move a row from view key A to view key B by upserting B and deleting A atomically). The fingerprinted key set is the union of upsert and delete keys; a key may not appear in both, and either collection may be empty. Same idempotency and retention semantics as the keyed overload. See [Atomic Writes](../atomic-writes.md). Throws `LatticeReplicationModeMismatchException` when the tree is declared for cross-cluster replication as a typed CRDT mode (see [Replication modes - Single shape per tree](../../lattice.replication/replication-modes.md#single-shape-per-tree)). |
| `DeleteRangeAsync` | `Task<int> DeleteRangeAsync(string startInclusive, string endExclusive)` | Tombstones every live key in [`startInclusive`, `endExclusive`). Returns the total count tombstoned. For resumable or crash-safe range deletes, use [`OpenDeleteRangeCursorAsync`](#stateful-cursors). Throws `LatticeReplicationModeMismatchException` when the tree is declared for cross-cluster replication as a typed CRDT mode (see [Replication modes - Single shape per tree](../../lattice.replication/replication-modes.md#single-shape-per-tree)). |
| `CountAsync` | `Task<int> CountAsync()` | Returns the exact live key count across all shards under the topology snapshot observed during the call. A concurrent `SetManyAtomicAsync` is observed atomically (included or excluded as a unit). Bounded by `LatticeOptions.MaxScanRetries` (default 3); throws `InvalidOperationException` on retry exhaustion. |
| `CountAsync` (ranged) | `Task<int> CountAsync(string? startInclusive, string? endExclusive)` | Advanced ranged variant (hidden from IntelliSense). Counts only live keys in the half-open range [`startInclusive`, `endExclusive`); a `null` bound is unbounded on that side, so `(null, null)` matches the unbounded `CountAsync()`. Reuses the whole-tree count machinery - fully-covered leaves contribute their full count and only boundary leaf(s) are partial-counted, so no keys are materialised across the wire - and carries the identical strong-consistency and concurrent-split guarantees. Used internally by aggregation `ILatticeView.CountAsync` to count materialised group values above the reserved-row floor. |
| `CountPerShardAsync` | `Task<IReadOnlyList<int>> CountPerShardAsync()` | Returns the per-shard live-key count, one entry per physical shard in ascending shard-index order. A position is a shard index only while the indices run contiguously from 0, which a shard consolidation that folds a shard away breaks, so read the indices from `GetRoutingAsync` to address a shard. Same consistency guarantees as `CountAsync`. Useful for diagnostics and load-balancing analysis. |

### Enumeration

Long-running scans use the resilient extension wrappers on
`ILattice`. Each iterator survives mid-scan reconnects (silo
failover, idle expiry, cold start) and resumes deterministically -
no duplicates, no gaps, original ordering preserved.

| Method | Signature | Description |
|--------|-----------|-------------|
| `ScanKeysAsync` | `IAsyncEnumerable<string> ScanKeysAsync(this ILattice, string? startInclusive, string? endExclusive, bool reverse, bool? prefetch, int? maxAttempts)` | Streams live keys in strict lexicographic order. `prefetch=true` (or `null` with `LatticeOptions.PrefetchKeysScan = true`) overlaps the next page fetch with the current page consumption. `maxAttempts` overrides the wrapper's reconnect budget (default `LatticeExtensions.DefaultScanReconnectAttempts = 8`) and, capped at `LatticeExtensions.DefaultScanStallResumeAttempts = 2`, its budget for resuming a `ScanPageStalledException`; `maxAttempts: 0` disables both. The stall budget counts consecutive stalls and is replenished whenever the walk yields a record, and a single enumeration takes at most `LatticeExtensions.DefaultScanStallResumeCeiling = 64` stall resumptions in total. A stalled scan resumes from its last yielded key after a backoff derived from the stall's reported ceiling, and rethrows the stall once either bound is spent, so it either yields the full range or rethrows the stall - it never returns a short prefix as though the range were complete. A concurrent `SetManyAtomicAsync` is observed atomically across every page of each uninterrupted underlying enumeration; a transparent reconnect or stall resume reopens the scan under a freshly captured saga-decision view, so a saga that commits in between can be visible in only the part of the range yielded after the resume. Use a [point-in-time cursor](#point-in-time-cursors) when one view must cover the whole range. |
| `ScanEntriesAsync` | `IAsyncEnumerable<KeyValuePair<string, byte[]>> ScanEntriesAsync(this ILattice, string? startInclusive, string? endExclusive, bool reverse, bool? prefetch, int? maxAttempts)` | Streams live key-value entries in strict lexicographic key order. `prefetch` is gated by `LatticeOptions.PrefetchEntriesScan` (separate flag from keys because entry pages also carry `byte[]` values). Same atomic-visibility, reconnect, and stall-resume guarantees as `ScanKeysAsync`. |
| `DeleteRangeAsync` | `Task<int> DeleteRangeAsync(this ILattice, string startInclusive, string endExclusive, int stepSize, int? maxAttempts = null, CancellationToken cancellationToken = default)` | Resiliently drains a delete-range cursor over the half-open range `[startInclusive, endExclusive)` to completion, returning the total number of keys tombstoned. Deletes in batches of `stepSize` (required, and it must be positive - a call without it binds to the single-call `ILattice.DeleteRangeAsync` instead) and, if the durable enumerator is lost mid-drain (`EnumerationAbortedException`), transparently reopens a fresh cursor over the same range and continues; already-tombstoned keys are skipped on reopen so the count never double-counts. `maxAttempts` overrides the reconnect budget (default `LatticeExtensions.DefaultScanReconnectAttempts = 8`). A `ScanPageStalledException` is not retried: it propagates to the caller. Both bounds are required. Authorization is all-or-nothing across the span (`LatticeOperation.RangeDelete`): a caller who may not delete the whole range is denied and nothing is removed. Prefer this one-shot helper over hand-rolling an `OpenDeleteRangeCursorAsync` / `DeleteRangeStepAsync` loop when you simply need a range gone. |

Prefer the resilient `DeleteRangeAsync` drain helper over an
`OpenDeleteRangeCursorAsync` / `DeleteRangeStepAsync` loop when you
simply need a whole range removed; it reopens the cursor across a
transient enumerator loss so a large purge completes rather than
aborting part-way.

```csharp verify
int removed = await tree.DeleteRangeAsync("repo/r1/", "repo/r1/\uffff", stepSize: 256);
Console.WriteLine($"tombstoned {removed} keys");
```

### Scan reliability

`CountAsync`, `CountPerShardAsync`, `GetManyAsync`, `ScanKeysAsync`, and
`ScanEntriesAsync` use a bounded retry budget
(`LatticeOptions.MaxScanRetries`, default 3) to reconcile against
concurrent topology changes. If the topology continues to mutate
beyond the budget, the call throws `InvalidOperationException` rather
than returning a silently incomplete result. Under default settings
(`MaxConcurrentAutoSplits = 2`, `HotShardSplitCooldown = 2 min`)
exhaustion is not a realistic operational concern.

Three options for multi-minute exports in aggressively split-prone
workloads:

1. Raise `LatticeOptions.MaxScanRetries`.
2. Wrap the scan in an application-level retry that resumes from the
   last successfully yielded key using `startInclusive`.
3. Use a **stateful cursor** (below). A cursor checkpoints
   server-side after every page and survives silo failover, client
   restart, and topology changes without caller retry code.

## Stateful cursors

`ILattice` exposes a stateful cursor API for long-running scans and
resumable range deletes that survive silo failovers, client restarts,
and topology changes. Each cursor is a server-side, checkpointed
iterator identified by an opaque GUID returned at open time. See
[Durable Cursors](../durable-cursors.md) for the full design and cost
model.

### Method reference

| Method | Signature | Description |
|--------|-----------|-------------|
| `OpenKeyCursorAsync` | `Task<string> OpenKeyCursorAsync(string? startInclusive = null, string? endExclusive = null, bool reverse = false, bool pointInTime = false)` | Opens a key-enumeration cursor and returns a server-assigned opaque cursor ID. `null` bounds are unbounded; `reverse=true` walks descending. `pointInTime=true` freezes the saga-decision view at open time so every page sees the same in-flight-saga view (see [Point-in-time cursors](#point-in-time-cursors)). |
| `OpenEntryCursorAsync` | `Task<string> OpenEntryCursorAsync(string? startInclusive = null, string? endExclusive = null, bool reverse = false, bool pointInTime = false)` | Opens an entry-enumeration cursor (key + value pairs). Same bounds, direction, and point-in-time semantics as `OpenKeyCursorAsync`. |
| `OpenSnapshotKeyCursorAsync` | `Task<string> OpenSnapshotKeyCursorAsync(string? startInclusive = null, string? endExclusive = null, bool reverse = false)` | Opens a **zero-observable-writes snapshot** key-enumeration cursor. At open time it captures a frozen baseline of every shard's projection, plus a tree-wide `LatticeSnapshotCoordinate` (tree map version, per-shard WAL heads, registry HLC, and the per-open baseline token), and serves every page from those frozen baselines rather than replaying the WAL, so a later WAL trim cannot perturb the view. Subsequent foreground writes, saga commits, range deletes, and replication applies are invisible to this cursor. Open-time cost is gated by `LatticeOptions.MaxSnapshotReplayEntries`, compared against the deepest shard's baseline row count; failure throws `LatticeSnapshotReplayBudgetExceededException`. A tree whose saturation signal reads `Saturated` sheds the open with `LatticeSaturatedException` (see [Saturation back-pressure](saturation-back-pressure-latticesaturatedexception.md)); the shed never fires on an aliased tree, because it looks the signal up under the logical tree id (see [Resolution and scope](../wal-saturation-signal.md#resolution-and-scope)). The cursor registers with `IWalCursorRegistry` for its lifetime, but that registration does not hold back WAL trimming (see [WAL retention](../snapshot-cursors.md#wal-retention)). See [Snapshot cursors](../snapshot-cursors.md). |
| `OpenSnapshotEntryCursorAsync` | `Task<string> OpenSnapshotEntryCursorAsync(string? startInclusive = null, string? endExclusive = null, bool reverse = false)` | Opens a zero-observable-writes snapshot entry cursor. Same isolation guarantees and open-time cost gate as `OpenSnapshotKeyCursorAsync`. |
| `OpenDeleteRangeCursorAsync` | `Task<string> OpenDeleteRangeCursorAsync(string startInclusive, string endExclusive)` | Opens a resumable range-delete cursor. Both bounds are **required** (non-null). Reverse mode is not supported. Throws `ArgumentException` on null bounds or a reverse spec. |
| `NextKeysAsync` | `Task<LatticeCursorKeysPage> NextKeysAsync(string cursorId, int pageSize)` | Returns up to `pageSize` keys and advances the cursor. `HasMore=false` signals exhaustion. Throws `ArgumentOutOfRangeException` for non-positive `pageSize`; throws `InvalidOperationException` if the cursor was opened for a different kind or has been closed. |
| `NextEntriesAsync` | `Task<LatticeCursorEntriesPage> NextEntriesAsync(string cursorId, int pageSize)` | Returns up to `pageSize` entries and advances the cursor. Same error surface as `NextKeysAsync` with expected kind `Entries`. |
| `DeleteRangeStepAsync` | `Task<LatticeCursorDeleteProgress> DeleteRangeStepAsync(string cursorId, int maxToDelete)` | Deletes up to `maxToDelete` keys in a single step. `IsComplete=true` when the full range has been drained; subsequent calls return `DeletedThisStep=0`. Throws `InvalidOperationException` if the cursor is not of kind `DeleteRange`. |
| `CloseCursorAsync` | `Task CloseCursorAsync(string cursorId)` | Closes the cursor, clears its state, and deactivates the grain. Idempotent. |

### Return types

| Type | Members | Description |
|------|---------|-------------|
| `LatticeCursorKeysPage` | `IReadOnlyList<string> Keys`, `bool HasMore` | A page returned by `NextKeysAsync`. Keys are in the cursor's scan order. |
| `LatticeCursorEntriesPage` | `IReadOnlyList<KeyValuePair<string, byte[]>> Entries`, `bool HasMore` | A page returned by `NextEntriesAsync`. |
| `LatticeCursorDeleteProgress` | `int DeletedThisStep`, `int DeletedTotal`, `bool IsComplete` | Returned by `DeleteRangeStepAsync`. `DeletedTotal` accumulates across every step. |
| `LatticeCursorKind` | `Keys`, `Entries`, `DeleteRange` | The kind of scan a cursor performs. |
| `LatticeCursorSpec` | `Kind`, `StartInclusive`, `EndExclusive`, `Reverse`, `PointInTime`, `ZeroObservableWrites`, `Predicate` | Immutable cursor specification, frozen at open time. `ZeroObservableWrites=true` selects the frozen-baseline snapshot path; `PointInTime=true` selects the registry-snapshot path; both `false` is the live path. `Predicate` is the optional server-side predicate IR a predicate cursor applies to every page it yields (or, for a `DeleteRange` cursor, to every key it tombstones); `null` means unfiltered. |
| `LatticeSnapshotCoordinate` | `long TreeMapVersion`, `IReadOnlyDictionary<int, long> PerShardWalOffsets`, `HybridLogicalClock RegistrySnapshotHlc`, `IReadOnlyDictionary<int, IReadOnlyList<long>>? PerShardPerPartitionWalOffsets`, `ShardMap? PinnedShardMap`, `Guid SnapshotBaselineToken`, `string? PhysicalTreeId` | Tree-wide snapshot coordinate captured at `OpenSnapshot*CursorAsync` time. Records the routing map (`PinnedShardMap`, at `TreeMapVersion`), every shard's WAL head (as a per-shard scalar and per WAL partition - diagnostic bounds that hold back no WAL trimming), the registry HLC (a diagnostic anchor the current build always records as `HybridLogicalClock.Zero`), the per-open token that identifies the frozen baselines the cursor reads, and the physical tree id they were captured against, so paging is deterministic across silo failovers. Two public constructors take the tree map version, either the per-shard scalar or the per-partition WAL heads, and the registry HLC. |
| `LatticeScopedCursor` | `LatticeScopedCursor(ILattice lattice, string cursorId)`, `string Id`, `ValueTask DisposeAsync()`, implicit conversion to `string` | `IAsyncDisposable` wrapper returned by the `LatticeExtensions.Open*CursorScopeAsync` family. Disposing the scope calls `CloseCursorAsync` exactly once; idempotent. The implicit `string` conversion lets a scope be passed directly to `NextKeysAsync` / `NextEntriesAsync` / `DeleteRangeStepAsync`. Does **not** change the durability contract of the underlying cursor; for cursors whose ID must survive a process boundary, keep using the raw `Open*CursorAsync` / `CloseCursorAsync` shape. |

### Scoped cursor extensions

`LatticeExtensions` exposes an `IAsyncDisposable`-returning overload
for every `Open*CursorAsync` method. They are pure conveniences -
the underlying cursor grain semantics are unchanged - but they
let callers whose cursor lifetime fits in one stack frame avoid the
`try` / `finally` boilerplate.

| Method | Signature | Description |
|--------|-----------|-------------|
| `OpenKeyCursorScopeAsync` | `Task<LatticeScopedCursor> OpenKeyCursorScopeAsync(this ILattice, string? startInclusive = null, string? endExclusive = null, bool reverse = false, bool pointInTime = false, CancellationToken cancellationToken = default)` | Scoped variant of `OpenKeyCursorAsync`. |
| `OpenEntryCursorScopeAsync` | `Task<LatticeScopedCursor> OpenEntryCursorScopeAsync(this ILattice, string? startInclusive = null, string? endExclusive = null, bool reverse = false, bool pointInTime = false, CancellationToken cancellationToken = default)` | Scoped variant of `OpenEntryCursorAsync`. |
| `OpenSnapshotKeyCursorScopeAsync` | `Task<LatticeScopedCursor> OpenSnapshotKeyCursorScopeAsync(this ILattice, string? startInclusive = null, string? endExclusive = null, bool reverse = false, CancellationToken cancellationToken = default)` | Scoped variant of `OpenSnapshotKeyCursorAsync`. |
| `OpenSnapshotEntryCursorScopeAsync` | `Task<LatticeScopedCursor> OpenSnapshotEntryCursorScopeAsync(this ILattice, string? startInclusive = null, string? endExclusive = null, bool reverse = false, CancellationToken cancellationToken = default)` | Scoped variant of `OpenSnapshotEntryCursorAsync`. |
| `OpenDeleteRangeCursorScopeAsync` | `Task<LatticeScopedCursor> OpenDeleteRangeCursorScopeAsync(this ILattice, string startInclusive, string endExclusive, CancellationToken cancellationToken = default)` | Scoped variant of `OpenDeleteRangeCursorAsync`. |

```csharp verify
await using var scope = await tree.OpenEntryCursorScopeAsync();
while (true)
{
    var page = await tree.NextEntriesAsync(scope, pageSize: 500);
    foreach (var (k, v) in page.Entries)
        Console.WriteLine($"{k}={v.Length} bytes");
    if (!page.HasMore) break;
}
// scope.DisposeAsync() runs here.
```

### Ordering and visibility

- Each step yields keys in strict lexicographic order. Once a key has
  been yielded by a cursor it is never re-yielded by the same cursor.
- In **live mode** (`pointInTime: false`, the default), values reflect
  the latest committed state at the moment each key is yielded. A
  saga that commits between two pages may have its keys split across
  the pre-commit and post-commit pages.
- In **point-in-time mode** (`pointInTime: true`), every page reads
  against the saga-decision view captured when the cursor was opened. A saga
  that commits between two pages is observed identically on every
  page - either every key the saga touched is visible, or none.

### Idle TTL and self-cleanup

Cursors auto-clean if `CloseCursorAsync` is never called. Each
successful call slides an idle-TTL reminder
(`LatticeOptions.CursorIdleTtl`, default 48 h). When the reminder
fires without intervening activity the cursor grain clears its state
and deactivates.

```csharp verify
siloBuilder.ConfigureLattice(o => o.CursorIdleTtl = TimeSpan.FromHours(6));
```

Minimum effective interval is **1 minute** (Orleans reminder
granularity); smaller values are clamped. Set
`CursorIdleTtl = Timeout.InfiniteTimeSpan` to disable auto-cleanup.

### Example - resumable export across a silo failover

```csharp verify
var cursorId = await tree.OpenEntryCursorAsync();
while (true)
{
    var page = await tree.NextEntriesAsync(cursorId, pageSize: 500);
    foreach (var (k, v) in page.Entries)
        Console.WriteLine($"{k}={v.Length} bytes");
    if (!page.HasMore) break;
}
await tree.CloseCursorAsync(cursorId);
```

If the client crashes mid-export it can persist the `cursorId` and
resume on restart - the cursor grain reactivates on demand.

### Example - bounded, resumable range delete

```csharp verify
var cursorId = await tree.OpenDeleteRangeCursorAsync("2024/", "2025/");
int total = 0;
while (true)
{
    var progress = await tree.DeleteRangeStepAsync(cursorId, maxToDelete: 1000);
    total = progress.DeletedTotal;
    if (progress.IsComplete) break;
}
await tree.CloseCursorAsync(cursorId);
Console.WriteLine($"Deleted {total} keys.");
```

### Error surface

| Call | Condition | Exception |
|------|-----------|-----------|
| `OpenDeleteRangeCursorAsync` | `startInclusive` or `endExclusive` is `null` | `ArgumentException` |
| `OpenDeleteRangeCursorAsync` | `reverse=true` passed through the internal spec | `ArgumentException` |
| `Open*` (point-in-time) | `pointInTime=true` passed for a `DeleteRange` cursor | `ArgumentException` |
| `Open*` (point-in-time) | A saga decision registry shard the snapshot touches would exceed `LatticeOptions.MaxPinnedSagaDecisions` | `LatticeCursorRegistryPinExhaustedException` |
| `OpenSnapshot*CursorAsync` | The deepest shard's frozen baseline would exceed `LatticeOptions.MaxSnapshotReplayEntries` rows | `LatticeSnapshotReplayBudgetExceededException` |
| `OpenSnapshot*CursorAsync` | The tree's saturation signal reads `Saturated` and `LatticeOptions.ShedSnapshotOpensWhenSaturated` is on (the default); never on an aliased tree (see [Resolution and scope](../wal-saturation-signal.md#resolution-and-scope)) | `LatticeSaturatedException` (`SaturationSource` = `SnapshotCursorOpen`) |
| `Next*` (snapshot) | A shard's frozen baseline can no longer be loaded - it was lost before it became durable, or reclaimed after `LatticeOptions.SnapshotBaselineTtl` of inactivity; open a fresh snapshot cursor | `LatticeSnapshotExpiredException` |
| `DeleteRangeAsync` (extension) | `startInclusive` or `endExclusive` is `null` | `ArgumentNullException` |
| `DeleteRangeAsync` (extension) | `stepSize` <= 0 | `ArgumentOutOfRangeException` |
| `DeleteRangeAsync` (extension) | Enumerator lost more than `maxAttempts` times | `EnumerationAbortedException` (rethrown after the budget is exhausted) |
| Any `Open*` | Re-open with a different spec on the same cursor ID | `InvalidOperationException` |
| `Next*` / `DeleteRangeStep*` | Cursor kind mismatch | `InvalidOperationException` |
| `Next*` / `DeleteRangeStep*` | Cursor was closed | `InvalidOperationException` |
| `Next*` / `DeleteRangeStep*` | `pageSize` / `maxToDelete` <= 0 | `ArgumentOutOfRangeException` |
| `Next*` (point-in-time) | Pin lifetime exceeded `LatticeOptions.MaxCursorSnapshotPinTtl` | `LatticeCursorSnapshotExpiredException` |

### Point-in-time cursors

`OpenKeyCursorAsync` and `OpenEntryCursorAsync` accept a
`pointInTime` flag. When set, every page issued by the cursor reads
against the saga-decision view captured at open time, so a
`SetManyAtomicAsync` batch that commits between two pages is
observed identically on every page (either all of its keys, or none).
The all-or-nothing guarantee that single-call reads
(`GetManyAsync`, `CountAsync`, `CountPerShardAsync`) already provide
is extended to a multi-page enumeration.

Streaming `ScanKeysAsync` / `ScanEntriesAsync` enumerations (no
cursor) provide the same all-or-nothing view automatically - no opt-in
required - but only for each uninterrupted underlying enumeration: a
transparent reconnect or stall resume reopens the scan under a freshly
captured saga-decision view, so the guarantee does not span it. A
point-in-time cursor holds its one view across every page.

`DeleteRange` cursors cannot be opened in point-in-time mode (range
deletes are mutations, not snapshot reads).

Three independent caps bound the registry footprint of point-in-time
cursors:

| Cap | Default | Effect |
|-----|---------|--------|
| `LatticeOptions.CursorIdleTtl` | 48 h | Cursor idle-TTL reminder releases the pin on inactivity. |
| `LatticeOptions.MaxCursorSnapshotPinTtl` | 7 d | Hard cap on a single pin's lifetime. A live cursor slides this on every step; a stalled cursor surfaces `LatticeCursorSnapshotExpiredException` on its next call. |
| `LatticeOptions.MaxPinnedSagaDecisions` | 100 000 | Footprint cap on the saga decisions pinned across all live point-in-time cursors, enforced by each saga decision registry shard of the tree: one cap for the whole tree with the default single shard (`TxRegistryShardCount = 1`), and one for each shard and for the legacy registry with more. `OpenKeyCursorAsync` / `OpenEntryCursorAsync`, or their `WherePredicate` variants, opened with `pointInTime: true` throw `LatticeCursorRegistryPinExhaustedException` if accepting the snapshot would breach the cap on any shard it touches. With a single shard no pin is installed; when the snapshot spans several, a shard that accepted its part of the pin keeps it until the pin expires after `MaxCursorSnapshotPinTtl` - never, when that cap is disabled. |

```csharp verify
var cursorId = await tree.OpenEntryCursorAsync(
    startInclusive: null,
    endExclusive: null,
    reverse: false,
    pointInTime: true);
try
{
    while (true)
    {
        var page = await tree.NextEntriesAsync(cursorId, pageSize: 500);
        foreach (var (k, v) in page.Entries)
            Console.WriteLine($"{k}={v.Length} bytes");
        if (!page.HasMore) break;
    }
}
finally
{
    await tree.CloseCursorAsync(cursorId);
}
```

See [Durable Cursors - Point-in-time cursors](../durable-cursors.md#point-in-time-cursors)
for the full design.

Previous: [ILattice](ilattice.md). Next: [ILattice: Maintenance operations](ilattice-2.md). Contents: [Lattice Public API Reference](../api.md).
