lantern_client_offline

Experimental, storage-neutral offline Repository support for lantern_client. It provides a strict versioned cache/outbox codec, a deterministic non-production in-memory reference store, latency-compensated Put writes, receipt-reconciled mutations, and explicit foreground replay.

The initial independent 0.2.0 release under #1162 used an exact-code physical Android/iOS matrix and a one-time interactive OAuth publication. The published 0.3.0 identity CDC bridge is tracked under #1314 and depends on hosted lantern_client 0.3.0. The receipt-bearing 0.4.0 release uses hosted lantern_client ^0.3.3 and was published after the final source, performance, Android/iPhone, OIDC, and archive gates in #1399. The 0.5.0 contribution Delete candidate uses hosted lantern_client ^0.4.1; its new qualification and publication are tracked separately in #1586. The maintained Flutter example and unpublished SQLite adapter use local path overrides for development. The private offline OIDC binding requires package-admin verification before a later release tag.

It is pure Dart and deliberately does not bundle SQLite, Flutter, connectivity, secure storage, state management, scheduling, or encryption. An application supplies a partitioned transactional OfflineStore, owns at-rest encryption and logout identity policy, and calls drain or resume explicitly. The package never persists credentials and never promises delivery while an app is suspended or killed.

final repository = OfflineLanternRepository(
  store: InMemoryOfflineStore(),
  remote: LanternClientOfflineRemote(client),
);

final write = await repository.putVertex(
  partitionId: 'signed-in-user',
  input: VertexInput(
    key: 'profile:42',
    value: VertexValue.string('Ada'),
    expiresIn: const Duration(minutes: 30),
  ),
);
final statuses = write.statuses.listen((status) {
  // locallyCommitted -> sending -> confirmed, deadLetter, or expired
});
await repository.probeAndDrain('signed-in-user');
await statuses.cancel();

// This aggregate is durable and can be reconstructed after process restart.
final operation = repository.watchWrite(
  'signed-in-user',
  write.operationId,
);

An offline write Future means the local transaction has committed. Watch its handle for remote confirmation, retry, expiry, dead-letter, or outcomeUnknown status. Unconditional PutVertex and PutEdge retain their idempotent replay path. putVertexIfAbsent/putVerticesIfAbsent, deleteVertex/deleteVertices, and deleteEdge/deleteEdges use bounded server receipts and retain each exact original Put/Delete result. addEdge/addEdges require explicit, distinct nonzero 24-byte contribution IDs, persist them with receipt evidence, and retain each exact original effective weight, including signed infinity and semantic NaN if returned by Lantern. Add inputs remain finite. Capped prefix Delete remains outside the durable API.

deleteEdgeContribution/deleteEdgeContributions accept exact EdgeContributionRef values, including a caller-owned nonzero 24-byte ID. They use a distinct durable intent and receipt result, preserving each original existed boolean, including false, after response loss and process restart. Duplicate references retain their request indexes and execute in per-edge FIFO order. A plural enqueue commits atomically; each item has its own receipt group.

final deletion = await repository.deleteEdgeContributions(
  partitionId: 'signed-in-user',
  contributions: [
    EdgeContributionRef(tail: 'alice', head: 'bob', contribId: contributionId),
  ],
);
await repository.probeAndDrain('signed-in-user');
final result = await repository.getWriteStatus(
  'signed-in-user',
  deletion.operationId,
);

While a contribution Delete is pending, a cached edge keeps its confirmed weight with hasPendingWrites=true; the repository cannot infer the remaining weight locally. Receipt confirmation and a contribution Delete CDC event invalidate that cache. An ordinary server read then fetches the authoritative remaining edge. Deleting a contribution never becomes a whole-edge Delete.

Per-item handle streams are process-local conveniences. getWriteStatus and watchWrite read a content-free durable operation aggregate, preserve mixed plural progress, and remain reconstructible after a new repository process. Terminal operation metadata has explicit retention and capacity limits. Caller-supplied operation IDs and generated record IDs are collision checked inside the enqueue transaction. Collisions fail with a typed error and never replace retained work; generated IDs use a bounded retry budget.

Plural writes atomically enqueue every item in one logical local operation with stable item indexes. Relative TTL is resolved to one absolute instant at enqueue and is never rebased during replay. Each receipt-bearing item persists its operation ID, one-item logical receipt group, endpoint NodeID/generation, policy fingerprint, and exact itemIndex=0/itemCount=1 receipt topology before its first mutation send.

Receipt replay checks status before every mutation send, including after an ambiguous response, lease recovery, or process restart. A freshly enqueued receipt also retains a never-dispatched marker. If the server clock shows its provisional ID nearing the five-minute admission limit, replay may replace that ID and group under a live claim before status lookup, but only while the durable marker proves no send could have begun. The marker becomes mayHaveDispatched before the first mutation RPC; missing markers in older records mean it may already have been sent and can never authorize rekeying. The freshness check includes elapsed time since the capability request began, including response latency; a delayed preparation cannot make an old ID safe. A retained CONFIRMED receipt completes the local aggregate with the exact original result. NOT_YET_OBSERVED permits one send only after mutation support, endpoint continuity, deployment epoch, retention, caps, and policy fingerprint still match the persisted evidence. A lookup failure remains retryable and unresolved; NO_LONGER_PROVABLE, changed continuity, exhausted attempts, or local max age becomes terminal outcomeUnknown, never false, zero, or success. Receipt dead letters cannot be generically retried. Token rotation alone does not change endpoint continuity: rotate credentials, then call resume to perform status-first reconciliation.

CI runs the real-wire receipt matrix against a separate authenticated receipt-WAL server while retaining the graph-only fixture for older Put tests. The receipt endpoint and ephemeral token are required for that CI run; the three-replica ambiguity test also requires LANTERN_DART_RECEIPT_HA_TLS_DIR, a private directory of per-replica CA-signed certificates created by go run ./testbed/bench/receipttls generate from the repository root. It pins that CA for both peer and client/proxy HTTPS traffic; missing TLS material or a certificate mismatch fails the test. The SQLite adapter's separate --receipt crash gate proves process-kill recovery with a file-backed store.

Snapshots written by the earlier experimental Add implementation remain readable for migration only. Those legacy OfflineAddEdgeIntent records are distinct from the current receipt-backed OfflineReceiptAddEdgeIntent. Opening snapshot schema v1, v2, or v3 converts every legacy Add item to an inspectable terminal dead letter with diagnostic unsupported_add, without incrementing its attempt count, overlaying it on reads, or invoking the remote. Schema v4 fails closed on Add. Schema v5 accepts only the exact terminal unsupported_add shape produced by that migration; every live or noncanonical Add still fails closed. retryDeadLetter rejects a migrated item with OfflineUnsupportedOperationException; applications may inspect it through their authorization callback and then retain or delete it. Current Add is admitted only through its separate receipt-bearing API and never upgrades, replays, or aliases a legacy persisted Add record.

readVertex/readEdge expose cache-only, cache-first, and server-only policies. After a checkpoint reset, a bounded resident identity stays Unknown until the explicit plural recovery batch revalidates it. An ordinary serverOnly Get does not clear this marker or return its unverified response; a pending Put overlay remains visible. Accepted CDC chunks advance a durable epoch, so an ordinary Get already in flight cannot publish a late confirmed result. watchVertex/watchEdge emit the cache immediately, revalidate once against Lantern, coalesce identical snapshots, and then follow local store changes. Snapshots distinguish fresh, stale, missing, expired, and unknown states and carry hasPendingWrites for exact Put overlays. No stale policy serves a value at or after its Lantern expiration. Distinct remote reads, queued reads, active watchers, per-partition watchers, and watcher-owning partitions all have explicit bounds. Queued cancellation does not consume a remote permit, and lifecycle cancellation closes a watch without surfacing a cancellation error as application failure. Same-key reads share one remote flight while retaining per-caller cancellation; the underlying call is canceled only after its final waiter leaves. Watches subscribe to local changes before their initial snapshot and reconcile any mutation that arrives during that handoff.

Replay is never inferred from network type. Call drain, start, or resume from explicit foreground work, or probeAndDrain to require a successful real Lantern health probe first. Use listPending, listDeadLetters, inspectDeadLetter, retryDeadLetter, and deleteDeadLetter for recovery UI; sensitive intent inspection requires an application authorization callback. Public read, list, status, watch, enqueue, and replay entry points lazily sweep expired work transactionally, so TTL expiry and capacity reclamation do not depend on a network drain. Dead-letter retention begins at the dead-letter transition rather than at original enqueue. Adapter-owned deadline indexes are scoped by partition, operation, and entity, so each observation inspects at most maxSweepRecordsPerObservation due records without walking live FIFO entries.

The Repository serializes replay entry points per partition and applies one repository-wide/per-partition send limit to every entry point. A wrapped online client's nested retry policy is suppressed. Each attemptCount increment is one completed durable adapter attempt that permits at most one singular RPC; a credential-provider or cancellation failure can finish before any wire send. There are no hidden nested transport attempts. Receipt-less Put replay consumes the online SDK's server-authoritative PutOutcome. appliedAndLive confirms only while the resolved expiration is live before send, at response observation, and at the local commit; a clock rollback cannot revive an already expired sample. expired terminalizes and invalidates older confirmed cache state. conditionNotMet and superseded become inspectable dead letters because the attempted value is not the authoritative server value. An observed outcome consumes one attempt; local pre-send expiration consumes none. Receipt-bearing operations instead retain their original typed result in the durable operation aggregate. Their outbox codec includes immutable receipt identity and continuity evidence; confirmed receipt replay invalidates any possibly stale cache entry rather than claiming the receipt describes current graph state after later mutations. Unauthenticated sets a durable partition pause without burning an attempt; the partition auth epoch also cancels same-batch sibling token acquisition before another send can start. drain, start, and probeAndDrain then fail with OfflineAuthPausedException without acquiring a token or sending. Rotate the credential and call resume explicitly to clear the pause. Retry backoff is stored as nextAttemptAt; the Repository never owns one Timer per record and does not sleep between foreground drain invocations.

OfflineConfig makes cache freshness, negative-cache TTL, replay attempts and age, dead-letter/operation retention, lease duration/renewal, read/replay concurrency, the active partition-runtime cap, queue/watch/status-controller limits, bounded sweep work, and jitter explicit. Idle partition runtimes are released, while concurrent unique partitions fail with OfflineCapacityException before the process-local map can exceed that cap. OfflineStoreLimits bounds global and per-partition cache, outbox, and operation-metadata bytes and record counts, plus per-record lease-owner and diagnostic-code bytes. Receipt reconciliation requires at least 28 diagnostic UTF-8 bytes; smaller custom limits fail closed at configuration time. Outbox admission charges each immutable payload for its full bounded lifecycle envelope, so claim, retry, and dead-letter metadata cannot make an accepted snapshot exceed the same configured byte limits. Confirmed cache and retained terminal operation entries may be evicted under pressure; live outbox and non-terminal operation records are never discarded to admit a new write.

InMemoryOfflineStore is useful for deterministic tests and examples only; it does not survive process termination. Production adapters must preserve the OfflineStore transaction, generation, byte-accounting, lease, and canonical codec contracts. Every changed partition—and every durably mutated partition such as an LRU-only touchCache—must form a complete cache/outbox/operation graph before commit. Invalid graphs fail atomically with OfflineDurableGraphException, publish no state or change notification, and must not strand capacity. Cache keys must match their embedded Vertex or Edge, negative markers must not end before validation, and every retained outbox item must have a request-index-aligned aggregate status whose lifecycle matches. Claim/recovery and aggregate status transitions therefore commit together; wipePartition is the explicit barrier that cancels earlier enqueue obligations in the same transaction.

Adapter packages must invoke runStoreConformanceSuite from their own tests and provide its required reopen callback. That callback crosses the adapter's real close/reopen persistence boundary under identical limits; every accepted commit must survive it. The suite includes concurrent claimers, lease renew and release CAS failures, generation barriers, monotone no-gap change delivery, notification-controller cleanup, LRU-only cache pressure, and atomic outbox/operation capacity rejection. Configure the adapter's test limits below the default probe bounds or raise maxCapacityProbeRecords and maxNotificationControllerProbe explicitly. exportSnapshot and InMemoryOfflineStore.fromSnapshot exist only for deterministic fresh-process conformance tests; snapshot schema v7 persists operation aggregates, exact dead-letter transition time, durable auth pause, per-origin CDC chunk progress, the change epoch, and key-only Unknown residents. Schema v6 restores with an empty resident queue and epoch zero; schemas v1–v5 restore with empty CDC state. Restore transactionally reconstructs active v1 metadata, recovers auth pause from v1-v4 durable metadata, quarantines legacy Add records only from v1-v3, reopens only that exact terminal quarantine in v5–v7, migrates v1-v3 outbox retention metadata conservatively, and fails closed when cache, outbox, operation, ordinal, generation, lease, or state relationships contradict each other. A child Dart VM restores canonical bytes, recovers an expired lease, and proves stable IDs/order/TTL before re-export; this is a process-neutral codec/state-machine test, not an fsync or OS-kill claim. Transaction objects are sealed after both commit and rollback. The reference snapshot is not a persistence adapter. The checked-in tool/performance_probe.dart records content-free p50/p95/p99, RSS, enqueue/read/watch/replay/recovery/dispose latency, exact send and terminal-state counts, bounded concurrent/outstanding sends, decoded status objects, remaining claims/leases, controller/watch lifecycle cycles, and snapshot-size evidence against conservative checked-in bounds. Lease-renewal Timer cleanup is a blocking Repository test. See the ADR at docs/decisions/0002-dart-offline-repository-contract.md.

Security

Every operation requires an application-defined non-empty partition ID. That partitionId is only a local persistence namespace: it is never sent on the Lantern wire and is not a server tenant, identity, or authorization boundary. Tenant isolation belongs to the application, gateway, credential scope, and storage/security domain; distinct partition IDs alone do not create that isolation. wipePartition first blocks new partition work, cancels and awaits owned reads, sends, probes, leases, and watchers, then transactionally removes cache, outbox, operations, dead letters, and leases while incrementing the generation. Rotate to another user's credential only after wipe completes; old-partition intent can therefore never acquire the new token. A mutation the server already accepted cannot be recalled: wipe guarantees local isolation and no further sends, not remote rollback. dispose similarly completes only after every repository-owned call, queue, timer, lease, and watcher quiesces. The package does not receive storage encryption keys and does not emit keys, values, contribution IDs, tokens, or partition identifiers in diagnostics.

The maintained Flutter example shows cached/pending Put state, explicit probe/replay, lifecycle cancellation/resume, and authorized dead-letter controls. It now uses the opt-in sibling lantern_client_offline_sqlite adapter through sqflite. Applications own protected storage, any additional encryption, and backup policy, and must call wipePartition before a different user can open the same application session. The core retains no platform dependencies.

Transaction operations return FutureOr<T> so adapters can use asynchronous database APIs. Await every operation inside the transaction callback. The changeCursor, applyChangeChunk, and resetChangeCursor atomically persist identity-only invalidation and per-origin progress. Accepted partial and final chunks advance a durable partition change epoch. A checkpoint reset removes confirmed values but retains bounded resident identities as Unknown until revalidateResidentBatch finishes plural Get calls and their epoch-checked cache commits. Late ordinary Get results and server-first failure fallbacks cannot restore a record invalidated during the read. Adapter tests run runChangeStoreConformanceSuite across a real reopen boundary.

consumeIdentityChanges(partitionId, source: ...) is an explicit foreground CDC session. The application injects an OfflineIdentitySource that opens an identity-only Subscribe stream and performs plural reads against that same responder. The source must pin a real responder for the entire checkpoint and revalidation, propagate stream pause/resume, map retention and slow-subscriber gaps to OfflineChangeGapException, and acquire credentials at call time. The core owns a single session per partition and cancels it on logout or disposal. It resumes each origin at the durable last-applied sequence plus one, checks sequence, chunk, operation, and item-index continuity, and advances the cursor only with a final chunk. A gap hides confirmed cache as durable Unknown and permits one checkpoint retry per invocation. The stream is read one frame at a time, so recovery does not create an unbounded Dart event queue. The server's bounded stream buffer may still gap during a slow revalidation; that also triggers Unknown recovery. No network subscription starts at Repository construction, and the application remains responsible for foreground timing.

The production bridge is LanternClientIdentitySource(pinnedClient). Pass the same application-owned client to LanternClientOfflineRemote for ordinary reads and writes, then explicitly run the foreground CDC session:

final cancellation = LanternCancellationToken();
final source = LanternClientIdentitySource(pinnedClient);
await repository.consumeIdentityChanges(
  partitionId,
  source: source,
  cancellation: cancellation,
);

Cancel that token when the app leaves the foreground or the account logs out; the repository also cancels an active session on partition wipe or disposal. The client must route its stream, status checks, and plural reads to one real responder for the session. The adapter checks the node ID around plural reads, but the Subscribe frames do not expose the responder ID; an ordinary load-balancing endpoint cannot be validated as pinned by this adapter. Application configuration must guarantee that routing property. The client acquires its configured credentials at each RPC call.

The 0.3.0 CDC bridge requires hosted lantern_client 0.3.0 with subscribeIdentity; the published 0.4.0 receipt release requires hosted lantern_client ^0.3.3 for receipt APIs and single-attempt receipt-less Add. The 0.5.0 candidate requires hosted lantern_client ^0.4.1 for targeted contribution Delete and its typed identity CDC event. Earlier published versions retain their original parent constraints.

Libraries

lantern_client_offline
Experimental storage-neutral offline Repository support for Lantern.