Problem
updated_at (added in PR #273) is a wall-clock stamp. If the device clock steps backwards, a write can land at or below a cursor the client has already synced, and the client's own updated_at >= watermark scan never picks it up. The row is changed locally and never reaches the server.
Three tables behave differently today:
| table |
on write |
under a backwards clock step |
boards |
raw now |
cursor regresses below the watermark — write lost |
alerts |
raw now |
cursor regresses below the watermark — write lost |
telemetry_minute_buckets |
MAX(existing, now) |
cursor freezes; >= still catches it, so complete but stale |
Found by a Codex review of PR #273. Confirmed against vescape-server design intent.
Why it was not fixed in PR #273
The obvious fix — a ratcheting logical clock, updated_at = max(existing + 1, now) — is actively wrong here. Per the server side:
updated_at is the last-write-wins key, and its only planned server-side use.
- A ratcheting counter never comes back down. One clock rewind on device A permanently inflates its stamps, so A wins every future conflict against device B — even after A's clock is correct. That trades a temporary completeness gap for a permanent correctness bias in the exact mechanism that resolves conflicts.
So updated_at stays a truthful wall clock. The bucket MAX fold is kept because it is bounded and self-correcting (the stamp becomes truthful again once the clock passes the old value), unlike +1.
Context that shapes the fix
From vescape-server (no sync code exists yet — this is design intent from its ADR-0003/0004 and spec, all still cheap to change):
- The Sync Cursor is client-held. It never crosses the wire. The server stores no watermark and has no opinion about one. The cursor query runs against the phone's own SQLite.
- Recommended query is
>=, not >, with idempotent upsert absorbing the duplicate. That removes the same-millisecond straddle without a tie-break column.
- Over-sending is free by design. Idempotent upsert is an acceptance criterion; cost is bandwidth only. There is no server-side mtime that reflects back to other devices.
- A full resync is a unilateral client decision. Set the local watermark to 0 and re-send; the server upserts idempotently. No server cooperation and no new endpoint required.
- Multi-device is currently out of scope, so the LWW bias above is latent rather than live.
Options
- Separate the cursor from the timestamp. Add a strictly-monotonic local sequence column per syncable table, bumped on every write, and scan on that instead of
updated_at. Clock-immune completeness with zero contract impact, since the cursor never leaves the device. updated_at stays purely the LWW timestamp. This is the server side's preferred option.
- Clock-anomaly full resync. Detect a backwards step (persist last-seen wall clock, compare on boot/write) and reset the local watermark to 0. Needs no schema change and no server support. Cheap insurance if option 1 is too much native work now.
- Make
boards/alerts match buckets (MAX fold), so all three at least freeze rather than regress. Requires INSERT OR REPLACE -> ON CONFLICT DO UPDATE on iOS and a read-modify-write on Android's @Insert(REPLACE). Superseded by option 1.
Also worth recording here
Deletes are designed as an append-only log of Delete Actions (server ADR-0004), not tombstones and not full-set reconciliation. Hard constraint for this client: a Delete Action records Rider intent. Local retention/GC pruning must never emit one — if the existing DELETE FROM telemetry_frames WHERE captured_at_ms < :beforeMs sweep reached that log, the server would delete exactly the rides the backup exists to preserve.
Related: #274 (telemetry rows are keyed on a mutable BLE id, which is likely wrong for sync regardless).
Likely files
modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt - bucket merge() fold, setAlertRuleEnabled
modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt - toBoardEntity / toAlertRuleEntity stamp sites
modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt - where a sequence column would land
modules/vescape-core/ios/telemetry/AppDataRepository.swift - iOS board/alert upserts and the toggle
modules/vescape-core/ios/telemetry/TelemetryDao.swift - upsertBucket conflict clause
modules/vescape-core/ios/telemetry/TelemetryDatabase.swift - migration home for any new column
Problem
updated_at(added in PR #273) is a wall-clock stamp. If the device clock steps backwards, a write can land at or below a cursor the client has already synced, and the client's ownupdated_at >= watermarkscan never picks it up. The row is changed locally and never reaches the server.Three tables behave differently today:
boardsnowalertsnowtelemetry_minute_bucketsMAX(existing, now)>=still catches it, so complete but staleFound by a Codex review of PR #273. Confirmed against
vescape-serverdesign intent.Why it was not fixed in PR #273
The obvious fix — a ratcheting logical clock,
updated_at = max(existing + 1, now)— is actively wrong here. Per the server side:updated_atis the last-write-wins key, and its only planned server-side use.So
updated_atstays a truthful wall clock. The bucketMAXfold is kept because it is bounded and self-correcting (the stamp becomes truthful again once the clock passes the old value), unlike+1.Context that shapes the fix
From
vescape-server(no sync code exists yet — this is design intent from its ADR-0003/0004 and spec, all still cheap to change):>=, not>, with idempotent upsert absorbing the duplicate. That removes the same-millisecond straddle without a tie-break column.Options
updated_at. Clock-immune completeness with zero contract impact, since the cursor never leaves the device.updated_atstays purely the LWW timestamp. This is the server side's preferred option.boards/alertsmatch buckets (MAXfold), so all three at least freeze rather than regress. RequiresINSERT OR REPLACE->ON CONFLICT DO UPDATEon iOS and a read-modify-write on Android's@Insert(REPLACE). Superseded by option 1.Also worth recording here
Deletes are designed as an append-only log of Delete Actions (server ADR-0004), not tombstones and not full-set reconciliation. Hard constraint for this client: a Delete Action records Rider intent. Local retention/GC pruning must never emit one — if the existing
DELETE FROM telemetry_frames WHERE captured_at_ms < :beforeMssweep reached that log, the server would delete exactly the rides the backup exists to preserve.Related: #274 (telemetry rows are keyed on a mutable BLE id, which is likely wrong for sync regardless).
Likely files
modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt- bucketmerge()fold,setAlertRuleEnabledmodules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/AppDataRepository.kt-toBoardEntity/toAlertRuleEntitystamp sitesmodules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt- where a sequence column would landmodules/vescape-core/ios/telemetry/AppDataRepository.swift- iOS board/alert upserts and the togglemodules/vescape-core/ios/telemetry/TelemetryDao.swift-upsertBucketconflict clausemodules/vescape-core/ios/telemetry/TelemetryDatabase.swift- migration home for any new column