ADR 0020: Offline Sync Engine

Status

Proposed, 2026-10-22. A working prototype backs it: the cratestack-sync crate (publish = false) and the examples/sync-notes demo, in the framework PR linked from this ADR’s pull request. The full design, including what the prototype measured, is docs/design/sync-engine.md in the framework repository. Nothing here is a supported surface until this ADR is accepted. The number is the next free one across both repositories. 0007–0010 are reserved by ADR 0001, and 0011–0019 are the framework’s internal-shape series. This ADR lives here rather than in the framework because it fixes a user-visible protocol and proposes schema grammar.

Date

  • 2026-10-22: proposed, with prototype

Decisions for the maintainer

Context

CrateStack already runs one .cstack schema as a Postgres server (include_server_schema!) and as an embedded SQLite store on native and wasm32 with OPFS (include_embedded_schema!). The embedded role enforces no policy, because a device is single-user. What was missing is the link between the two: a write on a device reaching the server, a write on the server reaching the devices, and devices that keep working while offline. The roadmap has listed “sync engine design” as the blocker for embedded @@audit/@@emit since 0.3. The only existing attempt is application code in examples/react-nextjs-daisyui. It uses an updatedAt timestamp cursor with last-write-wins. That trusts device clocks and device rows, bypasses policies, and can skip transactions that commit late.

Decision

Adopt server-authoritative mutation replay (D1) over a trigger-captured, compacted, commit-ordered change log (D2–D4), with an undo-log rebase on the device (D5). The protocol has four endpoints: Acks travel in the change log, not only in the push response. A device can then never drop a pending mutation before it can see the server state that includes it.

TTLs and protections

The TTLs are in D6.
  • Authentication. Every endpoint goes through the application’s own AuthProvider.
  • Client binding. Client ids are server-issued and bound to a principal; any other principal gets 403 without the owner being named.
  • Scopes are derived on the server and never named by the device.
  • Bounds. Request bodies, mutations per push, bytes per mutation, pull pages, registrations per principal, poke streams per principal and the device’s offline queue are all limited.
  • Rate limiting. Optional per-principal limits through any RateLimitStore. A store error fails closed.
  • Post-dating. A mutation dated in the future beyond the allowed clock skew is rejected, so it cannot dodge the TTL.
  • Account switch. On client_mismatch the device engine halts rather than pushing one principal’s queue as another’s.
The design doc’s §7 maps each of these to the test that proves it.

Consequences

Benefits:
  • A pushed mutation has exactly the power of a direct call.
  • Server-side writes from any code path propagate without application code.
  • Late-committing transactions cannot be skipped.
  • Revoked visibility reaches the device as a delete.
  • Offline writes are exact under rebase.
  • TTLs bound both server history and stale intent.
Costs:
  • One trigger per synced table, and one compacted log row per synced key.
  • Shadow tables on the device, sized by the offline queue.
  • Pull latency tied to the oldest open transaction in the cluster, so deployments should set idle_in_transaction_session_timeout and alert on transaction age.
  • One HTTP/1.1 connection per device for pokes.
  • Local mutators and server handlers must agree on what each mutation name means until @@sync generates both (D8).

Alternatives considered

Follow-ups

  1. @@sync grammar and codegen for both roles (D8). Then delete the hand-written registries from the example.
  2. CBOR (D10), RPC addressing of the sync endpoints (D7), and metrics for transaction age and log size per scope.
  3. Mutation-name versioning across app releases with a non-empty offline queue.
  4. Device-key binding through COSE (ADR 0006) so a stolen bearer token plus a client id is not enough.
  5. A security review before the crate is published.