ADR 0020: Offline Sync Engine
Status
Proposed, 2026-10-22. A working prototype backs it: thecratestack-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
403without 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_mismatchthe device engine halts rather than pushing one principal’s queue as another’s.
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.
- 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_timeoutand 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
@@syncgenerates both (D8).
Alternatives considered
Follow-ups
@@syncgrammar and codegen for both roles (D8). Then delete the hand-written registries from the example.- CBOR (D10), RPC addressing of the sync endpoints (D7), and metrics for transaction age and log size per scope.
- Mutation-name versioning across app releases with a non-empty offline queue.
- Device-key binding through COSE (ADR 0006) so a stolen bearer token plus a client id is not enough.
- A security review before the crate is published.