crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

PresenceTracking.md (4372B)


      1 # Plan: Split the presence lease from the read watermark
      2 
      3 ## Status
      4 
      5 Implemented. This document records the design and the rationale.
      6 
      7 ## The problem
      8 
      9 A single field — the CloudKit `Player.readAt` (locally
     10 `GameEntity.lastReadOtherMoveAt`, peer copy `PlayerEntity.readAt`) — was read
     11 with two incompatible meanings:
     12 
     13 1. **Presence lease.** While a puzzle is on screen the app writes
     14    `readAt = now + 10 min` (a forward-dated horizon). `PeerPresence.isPresent`,
     15    the engagement-room teardown, `soonestPresentPeer` and `hasPresentPeer` all
     16    treat a future `readAt` as "this player is here right now."
     17 2. **Read watermark.** The session-end ("stopped solving — filled N") push and
     18    the unread badge treat `readAt` as "this account has *seen* moves through
     19    T."
     20 
     21 The forward-dating makes both work *while you're looking*. It breaks the
     22 moment you stop: the lease stays in the future for up to 10 minutes after you
     23 background, so
     24 
     25 - a **peer** computing what you've missed sees your future `readAt`, concludes
     26   you've already read their later moves, and **suppresses the summary push**;
     27   and
     28 - your **own unread badge** compares "latest other move" against the future
     29   `readAt` and stays clear, **swallowing moves that arrived after you left.**
     30 
     31 Observed in a diagnostics log: Bob solved (+17 fills) at 00:26 while Alice was
     32 backgrounded with a lease valid to 00:34. The moves synced in and surfaced only
     33 as the in-app session-summary banner on reopen — no push, no badge. The lease
     34 frequently does *not* collapse cleanly on background (`self … readAt=00:34:20`
     35 long after leaving), so "collapse the lease harder" is not a fix. The watermark
     36 has to be its own value.
     37 
     38 ## The fix
     39 
     40 Two separate fields:
     41 
     42 | Concept | Local (`GameEntity`) | Peer (`PlayerEntity`) | Wire (`Player`) |
     43 |---|---|---|---|
     44 | Presence lease (forward-dated) | `lastReadOtherMoveAt` | `readAt` | `readAt` |
     45 | Read watermark (never future) | `readThroughAt` | `readThrough` | `readThrough` |
     46 
     47 - **`readAt` keeps its exact current meaning and behaviour** — the forward-dated
     48   presence lease. Presence is the fragile, real-time subsystem and is what
     49   behaves correctly today, so it is left untouched.
     50 - **`readThrough` is new**: the latest other-author move time the account has
     51   *actually observed*. Monotonic, never leased into the future.
     52 
     53 ### Who writes the watermark
     54 - **On open** (`GameStore.markOtherMovesRead`): advance to `latestOtherMoveAt`.
     55 - **While viewing** (`GameStore.noteIncomingMovesUpdate` lockstep): advance to
     56   the latest inbound move, gated on `NotificationState.isSuppressed` (eyes on
     57   the grid).
     58 - **On leave/background** (`publishReadCursor(.currentTime)`): advance to `now`
     59   — present right up to here — *in lockstep with* collapsing the lease.
     60 
     61 `publishReadCursor(.activeLease)` keeps writing only the lease. The two no
     62 longer fight over one field.
     63 
     64 ### Who reads the watermark
     65 - **Session-end push**: `pushPlan` sources `PushRecipient.readThrough` from the
     66   peer's `PlayerEntity.readThrough`; `SessionPushPlanner` windows the tally on
     67   `max(readThrough, notifiedThrough)`. Presence reads are untouched.
     68 - **Unread badge**: `GameSummary.computeHasUnread` compares `latestOtherMoveAt`
     69   against `readThroughAt`.
     70 
     71 ### Wire + multi-device
     72 - `RecordBuilder`/`RecordSerializer` send both fields; `CloudQuery` desiredKeys
     73   request `readThrough`; `RecordApplier` adopts the peer copy, and for our own
     74   record coming back from a sibling device advances the shared
     75   `GameEntity.readThroughAt` monotonically (so the badge clears on every device
     76   once any device reads). The watermark rides the Player record sync; the fast
     77   `accountSeen` push still carries only the lease.
     78 
     79 ## Compatibility
     80 
     81 The app is unreleased (two testers), so **no backward compatibility was kept**:
     82 old records simply have a `nil` `readThrough` (tallies their whole backlog,
     83 self-healing on the next read). Core Data migration is lightweight-additive
     84 (two optional `Date` attributes); `PersistenceController.recreateStore` is the
     85 fallback if a store can't be opened.
     86 
     87 ## Follow-up (v4)
     88 
     89 `readAt` is now purely a presence horizon, so its name is misleading. A `TODO(v4)`
     90 note sits at `GameStore.setReadCursor` and `RecordSerializer.playerRecord`:
     91 rename the `readAt` CloudKit field (and `lastReadOtherMoveAt`) to something like
     92 `presenceLeaseUntil` when the schema next breaks.