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.