Re-read against the tree at `7c0459d3` before designing anything, as asked. Three corrections to the analysis, then the paging story the issue said to work out first, then what is and is not in the first pull request.
## What the re-read changed
**`timelineNow` cannot be windowed.** It has about ninety callers in the `app-core` and `ui` test suites, which use it as "the whole chat", and one in production outside the notifiers: `AccountSession:908`, the poll-definition lookup. So the notifier work adds a narrow read beside it rather than changing it.
**`selectByGroupAfter`, not `selectLatestByGroup`, is the read the notifiers want.** Both are still callerless at `7c0459d3`. `selectLatestByGroup`'s `ORDER BY cursor DESC, seq DESC` is not the order the timeline builds from, and the notifiers' question is not "the newest N" but "everything above the read cursor" — which is what `selectByGroupAfter` answers, in timeline order, with no new SQL.
**`ReactionAlerts.of` would have broken silently.** It resolves a reaction's target in the list it is handed, deliberately, so "reacted to your message" rests on the message really being ours rather than on the sender's `p` tag. The message reacted to is one the reader read long ago, so its cursor sits *below* any window: a windowed list alone retires every reaction notification, in the common case, with nothing to see. The target now comes through the `message_event_id` index instead.
## Measured first
The instrument is not the `newlay.chat` log — a debug build's timings are not evidence. It is a counter sitting at the SQL driver in an `app-core` JVM test, counting the rows the database actually hands out for one arriving message, over a seeded store at three history sizes:
| chat length | rows read per arriving message | rows the answer needs |
| --- | --- | --- |
| 100 | 100 | 3 |
| 1,000 | 1,000 | 3 |
| 5,000 | 5,000 | 3 |
Linear, and each notification does it twice — once in `timelineNow`, once in `reactionAlertsNow`. What does not grow is the answer: three rows at every size. Wall time on the same arm, so direction only: 9.5 ms + 8.3 ms at 5,000 rows.
After the first slice the same counter reads 7 at all three sizes — the three rows above the cursor read twice, plus one indexed lookup — and 0.031 ms + 0.023 ms.
## The paging story
The notifiers need none: their bound is the unread count, not the history. The annotations hold on that window because an edit, a delete, a pin or a reaction is published after the row it annotates, so it carries a higher coordinator cursor; anything annotating a row above the read mark is above the read mark too.
The chat screen does need one. It reads outside a window in five places:
- `timeline.byId[parentId]` for a reply's quoted line (`ChatScreen.kt:1965`) and a thread parent (`:1320`)
- `timeline.pins` with `byId[pin.targetId]` for the pin banner (`:704`)
- the find bar, which searches `rows` and documents itself as "only what this device holds" (`:536`–`:548`)
- the permalink focus, `rows.any { it.id == focus }` (`:573`)
- `Polls.buildTallies` over the whole list, by design: a vote is not a row of its own
So it cannot be a flat `LIMIT`. It is a growing window with an explicit load-older edge, plus store-backed by-id lookups for the three misses above — `messageByEventId` is already there and already indexed.
**And the top of the list grows a third state.** Today it has two: nothing, or `PrunedHistoryNote` when `group_.pruned_below_cursor` is set. A window adds "there is more, not yet loaded". All three must come down in the `Timeline` and never be inferred from `rows.size`, or a windowed chat reads to a person as a pruned one — history they can still have, presented as history that is gone. That is the defect most likely to come out of this half.
The newest end is safe under a window: the unread restore scrolls to the first unread, which is at the newest end, and jump-to-newest is index 0 under the reverse layout.
## Two product calls in that half
Neither is mine to settle, and both change what gets built:
1. **Does the find bar search the store or only what is loaded?** It says "only what this device holds" today, which a window quietly makes untrue.
2. **What does a permalink to a row below the floor say?** Under a window it is "not loaded yet, fetching"; under a prune it is "gone for good". The screen has to tell a person which, and the words are a decision.
## What ships first
One pull request for the notifiers, which is three call sites and no paging story, and the measured figure above. The chat screen's windowing is a separate change and does not start until those two calls are answered — guessing at them would put wording in front of people that nobody chose.
Measured and written by me; the analysis this re-checks is rosie (nprofile…3pn6)'s, and doctor (nprofile…6v2l) found it.