25
Backend
04
Security
08
iOS
07
Infra

Replay the effect log on-device through a shared Rust core

ADR-0073 ACCEPTED · 2026-07-20
Replay the effect log on-device through a shared Rust core

Decision

Build the trip on the device by replaying the device's own copy of the modification log. One Rust crate, ztp-core, is compiled into the server and, through UniFFI, into the app. It holds effect application, discrepancy detection, lineage walking and the commands that turn an edit into effects. The app always renders from local replay. The server keeps ordering, the write check and AI work.

The app replays confirmed and pending modifications together and mints each modification's id, so a resend is idempotent and edits to offline-created entities need no fix-up. A write returns only an acknowledgement: id, position and new head. A pending edit retires only when a later sync shows it confirmed. Sync is by head id: the app sends its head and receives everything after it across every version.

The app's effects are authoritative. The server validates them as input, refusing any effect only the server may author, and never derives them again. The server alone sets position, parent and author. An effect the server cannot parse means the server is behind, so the app retries it.

Anything the server computes, such as enrichment, becomes a modification and syncs like the rest. A discrepancy dismissal is an effect too. Only data that was never trip content stays outside replay: a member's role, which version is active, a document's download link.

The write check is ancestry alone, which supersedes the index-shift guard of ADR-0071. ADR-0075 decides what the server refuses and how concurrent edits converge.

Why

The app has to show and edit a trip offline. The server already derives a trip by replaying an append-only log (ADR-0033, ADR-0065), so the same code on the device yields the same trip by construction. ADR-0071 had to inspect every interleaved write because effects named destinations by index. Effects now name targets by id (ADR-0074), so an interleaving has no index to invalidate and ancestry suffices.

Rejected alternatives

  • Fetch online, fall back to a local copy offline. The path that runs only on a plane rots unseen.
  • The app names the edit and the server derives the effects. Both sides run the same code, so deriving again checks nothing. Where they diverge, the trip changes under the user at the next sync.
  • Server-assigned modification ids. A resend could apply twice, and the app could not reference entities it created offline.
  • Writes that return the trip. Two trip shapes in the app.
  • Retire a pending edit on acknowledgement. A partial sync could lose or duplicate it.
  • Sync by position. Position is shared across versions, so "after N" can return another branch.
  • Version negotiation with old apps. Offline there is no server to ask, so the app must recognise unknown effects itself.
  • Dismissals in a side table. Offline they would be lost or need a separate sync path.

Consequences

An app that meets an unknown effect kind cannot render the trip; ztp-core distinguishes that from corrupt local data so the app can ask for an update. Pending edits exist nowhere else, so each is decoded separately and an unreadable one is lost visibly without taking the rest. The iOS build cross-compiles Rust, and sync runs only while the app is open. Trip creation stays online because a server-side model composes the trip from free text. A switch of the active version produces no modification, so it is online-only and last switch wins.

This amends ADR-0003: the local log, instead of Apollo's cache, is the trip's source of truth. It amends ADR-0044: the app's trip type is ztp-core's own.