镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

fix(unread): keep the viewer's threads in the Inbox under busier channels - #2401

Open
matt2e wants to merge 15 commits into
mainfrom
inbox-1-channel-windows
Open

matt2e wants to merge 15 commits into
mainfrom
inbox-1-channel-windows

Conversation

@matt2e

@matt2e matt2e commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Stack

This stack splits #1697, the frozen draft umbrella at 34fc40668, into three layers. Each PR is based on the one before and merges in order. #2408's tree equals #1697's, and #1697 closes when #2408 merges.

  1. fix(unread): keep the viewer's threads in the Inbox under busier channels #2401 (PR 1, inbox-1-channel-windows): reads each channel on its own and remembers your threads, so busier channels no longer push a thread out of the Inbox. ← this PR
  2. feat(unread): restore the Inbox's evidence from a device record on launch #2407 (PR 2, inbox-2-device-record): saves the Inbox's evidence on the device and restores it on launch.
  3. perf(unread): catch the Inbox up from a saved mark instead of re-reading every repair #2408 (PR 3, inbox-3-catch-up): catches up from a saved mark instead of re-reading everything on every repair.

Before and after

Main c3c46c503, this PR's base, is on the left and this PR 47e9a8031 on the right. Both run the web app in Chromium on a fresh device against one local fixture relay with the same made-up team. The box at each pane's bottom right shows the time since the pane loaded, its relay queries and the bytes downloaded.

Diego's reply in your thread appears on the right only

Busy channels push Diego's unread reply in your quiet #rollout-team thread out of main's newest-500 read. This PR reads each channel and your threads, so the row and the channel's thread dot appear at launch and again after a reload.

pr2401-s1.mp4

One #incident-review row for one thread, not three

Main never looks up the root of a read reply, and this thread's root is older than main's read, so three read @mentions in it show as three rows. This PR fetches the root and groups them under one row; opening the thread shows the same thread on both sides.

pr2401-s2.mp4

#design-ops shows as unread on the right only

A post from nine hours ago, with no @mention, in a channel you last read three days ago is older than main's read reaches. This PR reads the channel itself and marks it unread; main shows the post only when you open the channel.

pr2401-s3.mp4

A reply in a three-week-old thread appears on the right only

Lena answers, without an @mention, in a 21-day-old #platform-arch thread you replied in. This PR pages back through your own messages, finds the thread and reads it; main never sees the reply.

pr2401-s4.mp4

One failed read: a notice on the right, an error and a lost row on the left

Here the busy channels are lighter, so main's read reaches Diego's reply, and both builds show it when every read answers. Then the relay fails that read: main shows “Relay request failed (500)” and loses the row, while this PR keeps it from its other reads and shows “Some recent activity could not be checked.” with Refresh.

pr2401-s8.mp4

Problem

A thread you had replied in vanished from the Inbox (reported in #buzz-team). Main builds the Inbox from one sample per 128 joined channels: their newest 500 messages, and since #1860 only what is newer than that batch's last read. On a busy roster the sample reaches back only a few hours. Once busier channels push a thread out of it, nothing brings it back. A read reply whose root has aged out then shows up as a loose row of its own.

What this PR contains

This is PR 1 of three. It contains the per-channel windows and the viewer's remembered threads. It does not restore evidence from the device and does not catch up from a mark: every repair (launch, reconnect, the 15-minute backstop, and recovery while a repair is owed) reads in full.

  • Each channel on its own. Each joined channel is read since its read mark (or the last week, when never read), up to 100 recent events. On a large roster the windows shrink so the pass fits the 4,096-event budget. The newest 500 events of each 128 channels are read too, so a repair holds everything main's sample holds.
  • The viewer's threads. Unread remembers up to 1,000 threads the viewer took part in, per relay and viewer: replies, mentions, answers and follows. A device's first repair learns the older ones by walking back through the viewer's own messages. The 50 most recently read or written in are read whole on every repair. The rest are read only for replies since the newest one the viewer wrote or read. The record is kept in IndexedDB buzz-unread-v1, in its meta store. Its events store is created empty, for PR 2.
  • Budget. When the budget is full, the busiest channel's oldest ordinary evidence goes first. A channel left with none is unknown, not zero. Roots fetched only to group replies are structure, not evidence. They are bounded at 1,024, and the roots of remembered threads go last.
  • Failures. A response too large for the reader or the native host is split and read in parts. A read dropped after its retry shows "Some recent activity could not be checked." with Refresh, instead of counting zero. The repair stays owed, so the next minute's recovery check repairs again.
  • inbox() folds each channel's own evidence, and the edits and deletions on it, rather than every retained event.

#1860's per-batch incremental marks go: per-channel windows replace the batches they bounded. Its 14 tests are retired; see the coverage map below.

Details and limits: docs/inbox.md and docs/unread.md.

Commits

  1. b64d85cf0 Snapshot. Its production tree equals 4d3639704's, the layer-1 tip of fix(unread): keep participated threads in the Inbox and persist its evidence across launches #1697 before its device record and catch-up. Tests are 4d3639704's. This commit is red on its own and was committed with --no-verify.
  2. 9f220aa62 applies fix(unread): keep participated threads in the Inbox and persist its evidence across launches #1697's test trim (34fc40668) by name to those tests.
  3. Forward-ports from fix(unread): keep participated threads in the Inbox and persist its evidence across launches #1697's later commits:
    • a5b5ee48a: fold per channel (5a5225718). Its unread-fold.test.ts comes from 34fc40668, so it also carries that file's 30-day read floor and perf(unread): reuse folded rows until the evidence index changes #2366 cached-fold reorder.
    • 031332b5a: keep unread owed after a pass that drops a read (dd306db07). It adds the test "a pass that drops a window chunk leaves unread owed…", shown failing before owing = unchecked.
    • 5211b06fe: storage under the record's final names (buzz-unread-v1, meta and events, IDB version 1).
    • 711d93cb7: request shapes named.
    • 9b346befd: keepRoot, with the cache order and exact bound (9815e47a0, de2d02efb, da8ed62cc).
    • c0b98bb58: tests read past main's 30-day read floor, count a repair's channel read, and fail only the timeline's page read (367b38c45, 11f22a6f4).
    • 47e9a8031: retire fix(relay): resume live routes from the last connection and spread restart reconnects #1860's tests.

keepRoot. The test "a remembered thread's root the repair fetches by ID, in a channel the viewer never read, groups its read mention and never counts" fails on the layer-1 tree without keepRoot (observedCount 1, expected 0). So keepRoot came into this PR, with the cache order and exact bound.

Cost against main

These are the plan's figures. None was re-measured on this branch. Each was taken at the commits named, and every one of those builds predates #2252 (45caf7fd2), when every minute tick repaired. None is this branch's tree. The branch-side probes ran on #1697 before its rebase (5cc123c61 or 1c428329f), which holds the windows, remembered threads and walk, but not the roster sample or this PR's forward-ports.

main this PR measured at
Full repair (launch, reconnect, 15-minute backstop, owed recovery) 1 request, ~0.5 MB, 1 filter ~6 requests, ~2.6 MB, ~2.3k events, ~230 filters main: live probe at 7d275a808 (510 KB, 500 events). This PR: derived. 1c428329f's live per-tick reads (5 requests, ~2.1 MB, ~1.8k events) plus an estimated ~0.5 MB roster sample. 229 filters in a replay of 1c428329f's requests.
Relay DB time per launch ~12 ms ~280 ms (~23×) Local replay, not the live relay: requests rebuilt from 848288b67 and 1c428329f against the block/buzz relay at 24a370606 on a seeded Postgres 18.6. The repair read alone is ~77×.
Idle relay bytes per hour (4 backstops) ~2 MB ~10.5 MB Arithmetic, 4 × the full-repair row; not measured.
Warm launch 1.82 s ensure() to first inbox() ~3.2 s; 7 /query, 1,571 events, 1.77 MB, plus the ~0.5 MB sample; first row +0.8 s, caption +1.7 s main: live probe at 848288b67. This PR: live probe at 5cc123c61, without the sample. Deltas: headless Chromium, live relay, 7d275a808 vs 1c428329f, medians of 5.
Fresh device (once) — ~8.6 s; 12 /query, ~3k events, 3.1 MB; caption +7.9 s ~8.6 s: live probe at 5cc123c61. Caption: 7d275a808 vs 1c428329f, as above.
inbox() CPU 5.6 ms ~10 ms (31 ms without the per-channel fold) main: live recompute at 67e404917 (~530 events). This PR: Vitest micro-benchmark on 2,000 events across 80 channels, 5a5225718 vs its parent f4770e830. The two use different fixtures, so they do not compare directly.

A minute tick still reads nothing unless a repair is owed, as on main since #2252. Each full repair costs about 5× main's bytes, by the derived 2.6 ÷ 0.5 MB. Main's figure is for a busy roster, where #1860's marks drop at the limit; at 67e404917, which has #1860, main's repair read 481 KB. On quiet rosters main reads less, so this PR's relative cost is higher there. There is no kill switch. PR 3 brings the backstop and reconnect down to one delta.

#1860 coverage map

e8b13d4 (#1860) made main's evidence read incremental per 128-channel batch: a batch that came back short was next read from 960 s before the newest row it returned. This PR reads each channel's window on its own and keeps no mark, so its 14 tests in unread-startup.test.ts, and their helpers, tested behaviour the branch does not have. All 400 lines go (47e9a8031); they match 1d8c52742's deletion line for line. For each removed test, here is the test covering the equivalent behaviour, or the exception:

Removed test Layer-1 coverage, or exception
a repair after a complete observation reads only near the newest event it returned Exception: there is no mark, so no repair reads less than its windows. The cost is in the table above. PR 3's catch-up replaces it.
a batch that returned nothing is read unbounded again Exception, nothing to port: every repair reads unbounded.
a future-dated event cannot move the next repair past the present Exception, nothing to port: there is no mark for an event to move. PR 3 ports the clamp with its mark.
a repair never reads from before the newest event it already returned Exception, nothing to port, as above.
regaining access reads the batch unbounded, not from the old mark unread-startup "observes a channel again when the viewer regains access to it" stays. In "late chunk cannot publish or dispatch another after %s" (revoke), the abandoned read starts over against the current membership.
an explicit refresh re-observes unbounded unread-startup "explicit refresh bypasses optional profiles for both markers and evidence" stays; its trace shows the refresh's full repair.
clearing the cache re-observes unbounded Exception: there is no mark to forget, so the repair after a clear reads in full, as every repair does. unread-startup "late chunk cannot publish or dispatch another after %s" (clear) and "a lookup in flight during a cache clear cannot restore its result" keep the clear boundary.
capacity overflow discards the mark with the evidence it described Exception: the branch never clears evidence on overflow. It evicts the busiest channel's oldest ordinary evidence, and a channel left with none is unknown, not zero. Covered by unread-repair "a full budget evicts the busiest channel's oldest ordinary evidence, never a quiet channel's only message or the viewer's thread" and unread-startup "a full budget evicts the oldest evidence and the observation still completes".
a read cut off at its limit installs no bound, so a freed slot still shows an older unread message Every channel has its own window, so a busy channel cannot crowd out a quiet one's message. Covered by unread-repair "a first run with a large roster sizes each window so the pass fits the budget, and a quiet channel's mention survives" and unread-startup "a full channel window does not starve later channels, and a dropped request leaves its channels unknown until explicit refresh".
a bounded read that comes back at its limit drops the bound for the next read Exception, nothing to port: no read is bounded by a mark.
a bounded repair answered after a live overflow restores no mark and keeps the overflow error Exception, as for the overflow above: there is no overflow error, reset or mark.
an explicit refresh during a bounded repair waits for it, then reads in full Exception: a refresh during a repair joins it (repair() returns the running pass), as on main before e8b13d4, and that pass already reads in full. No layer-1 test pins the join for refresh; "concurrent ensure callers share the active marker/evidence work" pins it for ensure.
a refresh asked while a repair's answer is being accepted still reads in full Exception, as above.
renaming a channel does not move the batches, so they keep their bounds Exception, nothing to port: windows are per channel ID and carry no bound.

FOUNDATION

src/features/relay/session.ts is FOUNDATION. This PR adds one import and one line to it, storage: browserUnreadStorage(readScope). Matt Toohey approved this before the change.

Checks

All of these were run on 47e9a8031, on a loaded machine (load average 9–16):

  • git diff 4d3639704 HEAD -- src tests. On the branch's files, the snapshot equals 4d3639704, so everything past it is the trim and the seven forward-ports above. The other 37 files that differ were changed only by main, between 4d3639704's base cbc71c85a and c3c46c503.
  • bin/pnpm typecheck: clean.
  • biome check --error-on-warnings on the 33 changed code files: clean.
  • git diff --check c3c46c503 HEAD: clean.
  • Vitest on src/features/relay, src/bundled/inbox, src/features/notifications, src/features/messages and src/features/communities: 273 files passed and 1 skipped; 6,136 tests passed and 4 skipped.
  • Playwright --no-deps on sidebar-unread, message-navigation, scroll, navigation-thread-history, inbox, inbox-edit, thread-unread and channel-lifecycle, one engine after the other: Chromium 76 passed (2.7 min) and WebKit 76 passed (3.1 min), with no timeouts or retries.
  • 031332b5a's new test fails before owing = unchecked and passes after it. The keepRoot test fails on the layer-1 tree without keepRoot.

Browser cases: none were added or removed. Four journeys' request assertions are adapted to per-channel windows and the authored walk: sidebar-unread, scroll, message-navigation and navigation-thread-history.

Not verified: the native host (its 16 MiB split, and the dev broker's validFilters), a live relay, the live probe and just scan. Nothing was exercised in the running app, and there has been no human test, so this stays a draft.

🤖 Generated with Claude Code

@kalvinnchau kalvinnchau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 One maintainability finding; no functional or security blocker established.

Comment thread src/features/relay/unread.ts Resolved
tulsi-builder added a commit that referenced this pull request Nov 4, 2026
Trim the branch to avoid overlap with #2337 (Inbox badge) and #2401
(unread discovery). Keep Solo flat-row grouping, the toolbar badge
description, and Inbox selection-restore regression tests.

Co-authored-by: BasilBot <399581b6165ab01343e3a1324774b1024d76942aea563468f93c975e5ee5193c@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: tulsi <tulsi@block.xyz>
tulsi-builder added a commit that referenced this pull request Nov 4, 2026
Trim the branch to avoid overlap with #2337 (Inbox badge) and #2401
(unread discovery). Keep Solo flat-row grouping, the toolbar badge
description, and Inbox selection-restore regression tests.

Co-authored-by: BasilBot <399581b6165ab01343e3a1324774b1024d76942aea563468f93c975e5ee5193c@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: tulsi <tulsi@block.xyz>
Comment thread src/features/relay/unread.ts Resolved
tulsi-builder added a commit that referenced this pull request Nov 4, 2026
…pen conversation, describe toolbar badges (#2275)

## Summary

AIDA-2353. This PR has been cut down so it doesn't overlap two open PRs:
- **#2337** owns the Inbox unread count. tulsi left design feedback
there.
- **#2401** and its stack own finding replies in your threads and old
roots.

The badge, older-thread discovery and the 30-day discovery window that
used to be here were dropped in favor of those PRs. What's left is what
neither of them covers, plus one reading fix from beta feedback:

- **Solo chats in Inbox.** Ordinary incoming top-level responses in a
Solo chat appear as one flat conversation under All. They stay out of
DMs, matching the sidebar's separate Solo and Direct messages sections.
Its nested threads stay separate, and agent-only coordination and system
events stay out. A fresh Solo response reopens an archived flat row, the
same way ordinary DM activity does. Opening the flat row reads it in one
save (`markTimelineRead`): the channel catch-up mark covers ordinary
responses, as at the live bottom, and mentions get individual marks, so
thread rows stay unread. (`unread.ts` evidence filter and
`markTimelineRead`, `InboxItem.timeline`, `archive.ts` ordinary-activity
check.)
- **Toolbar page badges are announced.** In the toolbar placement a
page's badge sits beside its icon-only button, not inside it.
`PageNavigation` now points the button's `aria-describedby` at the
badge, so focusing the button announces its count. This applies to
#2337's Inbox count and Reminders' due count.
- **Live replies in the open conversation are read.** Reported in beta
feedback: a conversation open in the Inbox reader kept showing unread
after a new reply arrived. Opening a row leaves focus on the list, and
the reader's focus gate (`use-reading.ts`) only granted side-pane
reading to Channels' plain visits (`data-reading-thread`, #1835).
`PageTabs` now sets the same grant while the page shows its own content
in the main pane and a selected tab in the side pane. A tab moved to the
main pane still reads only by its own focus, and the reply must still be
in view. New `inbox-live-read.spec.mjs` fails without the fix.
- **Selection survives page navigation (tests only).** This behavior is
already on main. New regression coverage:
- `inbox-restoration.spec.mjs` (real shell) leaves Inbox for Messages
and Reminders and comes back.
  - An RTL check that restoring the page doesn't replay the row's read.

## Tests

- Full Vitest, `tsc --noEmit` and `biome check` pass.
- `inbox-live-read` passes 10/10 repeated in Chromium and WebKit, and
fails in both without the `PageTabs` change.
- Chromium: `inbox-live-read`, `inbox-restoration`, `inbox`,
`thread-unread` and `page-placement` pass. Vitest for
`bundled/channels`, `bundled/inbox` and `use-reading` passes.
- Toolbar description:
- Chromium's AX tree (via CDP) reports name "Inbox" and description "3
unread conversations".
  - Playwright's `toHaveAccessibleDescription` passes in WebKit.
- jsdom's accname reads the wrapper text instead, so the unit test
asserts the `aria-describedby` association.

## Checklist

- Agent review: an independent review of the reading grant found no
defects (closed or kept-mounted panes, inert retained tabs and main-pane
tabs never get the grant; only Inbox uses `ui.Tabs`).
- Agent exercise: Playwright journeys above, in Chromium and WebKit.
- Human test: tulsi tested Solo rows under All and the open-conversation
read in the desktop dev app (2026-11-04).

buzz-review-completed

## Not covered

- Older Solo activity outside the retained unread evidence isn't fetched
here. #2401's per-channel windows cover it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: tulsi <tulsi@block.xyz>
Co-authored-by: BasilBot <399581b6165ab01343e3a1324774b1024d76942aea563468f93c975e5ee5193c@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@matt2e
matt2e force-pushed the inbox-1-channel-windows branch from 47e9a80 to 7bf54b5 Compare November 4, 2026 23:17
matt2e added a commit that referenced this pull request Nov 4, 2026
…across repairs, from one budget total

PR #2401 review, two comments on src/features/relay/unread.ts.

kalvinnchau (P3): WINDOW_BUDGET = 3072 and PARTICIPATION_BUDGET = 1024
encoded the split of EVENT_BUDGET = 4096, declared 1,000 lines away, so
changing the total alone broke the split. EVENT_BUDGET now sits with
the window and participation constants: PARTICIPATION_BUDGET is a
quarter of it and WINDOW_BUDGET the rest. The values are unchanged.

kazemek21 (P2): the participation budget bounded only what one repair
admits. Replies already retained cost a repair nothing, so each repair
of busy remembered threads added up to 1,024 more. Pinned evidence went
last, so on a full budget it evicted ordinary evidence and refused new
ordinary evidence: a quiet channel's mention left, and a fresh mention
was refused even after Refresh.

The bound now applies where retained evidence lives, in `accept`'s
eviction:
- Once the event budget is full, the viewer's threads' evidence beyond
  PARTICIPATION_BUDGET events goes first. An ordinary event evicts that
  excess; a pinned event competes within the threads' share. Within the
  share the order is unchanged: ordinary evidence goes first, the
  busiest channel's oldest, and a channel left with none is unknown.
- The threads' evidence is pooled by thread, as ordinary evidence is
  by channel: the busiest thread's oldest goes first. A single pinned
  pool, oldest first, empties the oldest threads once the share is
  full, and drops a remembered mention, an old one-event thread, as
  soon as newer replies arrive. The participation pass gives each
  thread its newest unread reply first; oldest-first retention undid
  that on the next repair.
- `pools()` derives each event's pool key once and totals the threads'
  count from their pools. On a full budget, 300 single-event live
  accepts took 475-496 ms, against HEAD's 566-677 ms (load 55-65).
The byte budget is unchanged: under byte pressure alone, the threads'
evidence still goes last.

Docs: docs/inbox.md and docs/unread.md say the remembered threads go
last while they hold at most a quarter of a full budget, and that
beyond that the busiest thread's oldest goes first.

Test (unread-repair): "30 remembered threads of 200 replies, repaired
again and again, keep a quarter of a full budget: a quiet channel's
mentions are kept and admitted, and every thread keeps its row". One
thread a channel, its replies read, so the windows return nothing and
only the thread reads add the threads' evidence. Six repairs, a fresh
mention with Refresh, then 4,096 live events in a third channel.
- Before (HEAD's unread.ts, soft assertions, one run): the mention is
  gone and quiet unknown from repair 5; 21 of 30 thread rows remain;
  the fresh mention is refused after Refresh (quiet null, expected 2);
  the live channel is refused whole (null, expected 3072).
- With the share but one pinned pool, oldest first: repair 5 loses the
  mention.
- After: passes in about 0.8 s. The live channel holds 3,072,
  WINDOW_BUDGET, and the threads 1,024, the mentions among them; all
  30 rows remain.
Once balanced, about six repairs in, 7 to 9 of the 4,096 retained IDs
change per repair as equally full threads trade a reply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e force-pushed the inbox-1-channel-windows branch from 7bf54b5 to c7de758 Compare November 5, 2026 01:13
matt2e added a commit that referenced this pull request Nov 5, 2026
…across repairs, from one budget total

PR #2401 review, two comments on src/features/relay/unread.ts.

kalvinnchau (P3): WINDOW_BUDGET = 3072 and PARTICIPATION_BUDGET = 1024
encoded the split of EVENT_BUDGET = 4096, declared 1,000 lines away, so
changing the total alone broke the split. EVENT_BUDGET now sits with
the window and participation constants: PARTICIPATION_BUDGET is a
quarter of it and WINDOW_BUDGET the rest. The values are unchanged.

kazemek21 (P2): the participation budget bounded only what one repair
admits. Replies already retained cost a repair nothing, so each repair
of busy remembered threads added up to 1,024 more. Pinned evidence went
last, so on a full budget it evicted ordinary evidence and refused new
ordinary evidence: a quiet channel's mention left, and a fresh mention
was refused even after Refresh.

The bound now applies where retained evidence lives, in `accept`'s
eviction:
- Once the event budget is full, the viewer's threads' evidence beyond
  PARTICIPATION_BUDGET events goes first. An ordinary event evicts that
  excess; a pinned event competes within the threads' share. Within the
  share the order is unchanged: ordinary evidence goes first, the
  busiest channel's oldest, and a channel left with none is unknown.
- The threads' evidence is pooled by thread, as ordinary evidence is
  by channel: the busiest thread's oldest goes first. A single pinned
  pool, oldest first, empties the oldest threads once the share is
  full, and drops a remembered mention, an old one-event thread, as
  soon as newer replies arrive. The participation pass gives each
  thread its newest unread reply first; oldest-first retention undid
  that on the next repair.
- `pools()` derives each event's pool key once and totals the threads'
  count from their pools. On a full budget, 300 single-event live
  accepts took 475-496 ms, against HEAD's 566-677 ms (load 55-65).
The byte budget is unchanged: under byte pressure alone, the threads'
evidence still goes last.

Docs: docs/inbox.md and docs/unread.md say the remembered threads go
last while they hold at most a quarter of a full budget, and that
beyond that the busiest thread's oldest goes first.

Test (unread-repair): "30 remembered threads of 200 replies, repaired
again and again, keep a quarter of a full budget: a quiet channel's
mentions are kept and admitted, and every thread keeps its row". One
thread a channel, its replies read, so the windows return nothing and
only the thread reads add the threads' evidence. Six repairs, a fresh
mention with Refresh, then 4,096 live events in a third channel.
- Before (HEAD's unread.ts, soft assertions, one run): the mention is
  gone and quiet unknown from repair 5; 21 of 30 thread rows remain;
  the fresh mention is refused after Refresh (quiet null, expected 2);
  the live channel is refused whole (null, expected 3072).
- With the share but one pinned pool, oldest first: repair 5 loses the
  mention.
- After: passes in about 0.8 s. The live channel holds 3,072,
  WINDOW_BUDGET, and the threads 1,024, the mentions among them; all
  30 rows remain.
Once balanced, about six repairs in, 7 to 9 of the 4,096 retained IDs
change per repair as equally full threads trade a reply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e force-pushed the inbox-1-channel-windows branch from c7de758 to 01f0b61 Compare November 5, 2026 01:29
matt2e added a commit that referenced this pull request Nov 5, 2026
…across repairs, from one budget total

PR #2401 review, two comments on src/features/relay/unread.ts.

kalvinnchau (P3): WINDOW_BUDGET = 3072 and PARTICIPATION_BUDGET = 1024
encoded the split of EVENT_BUDGET = 4096, declared 1,000 lines away, so
changing the total alone broke the split. EVENT_BUDGET now sits with
the window and participation constants: PARTICIPATION_BUDGET is a
quarter of it and WINDOW_BUDGET the rest. The values are unchanged.

kazemek21 (P2): the participation budget bounded only what one repair
admits. Replies already retained cost a repair nothing, so each repair
of busy remembered threads added up to 1,024 more. Pinned evidence went
last, so on a full budget it evicted ordinary evidence and refused new
ordinary evidence: a quiet channel's mention left, and a fresh mention
was refused even after Refresh.

The bound now applies where retained evidence lives, in `accept`'s
eviction:
- Once the event budget is full, the viewer's threads' evidence beyond
  PARTICIPATION_BUDGET events goes first. An ordinary event evicts that
  excess; a pinned event competes within the threads' share. Within the
  share the order is unchanged: ordinary evidence goes first, the
  busiest channel's oldest, and a channel left with none is unknown.
- The threads' evidence is pooled by thread, as ordinary evidence is
  by channel: the busiest thread's oldest goes first. A single pinned
  pool, oldest first, empties the oldest threads once the share is
  full, and drops a remembered mention, an old one-event thread, as
  soon as newer replies arrive. The participation pass gives each
  thread its newest unread reply first; oldest-first retention undid
  that on the next repair.
- `pools()` derives each event's pool key once and totals the threads'
  count from their pools. On a full budget, 300 single-event live
  accepts took 475-496 ms, against HEAD's 566-677 ms (load 55-65).
The byte budget is unchanged: under byte pressure alone, the threads'
evidence still goes last.

Docs: docs/inbox.md and docs/unread.md say the remembered threads go
last while they hold at most a quarter of a full budget, and that
beyond that the busiest thread's oldest goes first.

Test (unread-repair): "30 remembered threads of 200 replies, repaired
again and again, keep a quarter of a full budget: a quiet channel's
mentions are kept and admitted, and every thread keeps its row". One
thread a channel, its replies read, so the windows return nothing and
only the thread reads add the threads' evidence. Six repairs, a fresh
mention with Refresh, then 4,096 live events in a third channel.
- Before (HEAD's unread.ts, soft assertions, one run): the mention is
  gone and quiet unknown from repair 5; 21 of 30 thread rows remain;
  the fresh mention is refused after Refresh (quiet null, expected 2);
  the live channel is refused whole (null, expected 3072).
- With the share but one pinned pool, oldest first: repair 5 loses the
  mention.
- After: passes in about 0.8 s. The live channel holds 3,072,
  WINDOW_BUDGET, and the threads 1,024, the mentions among them; all
  30 rows remain.
Once balanced, about six repairs in, 7 to 9 of the 4,096 retained IDs
change per repair as equally full threads trade a reply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>

@kalvinnchau kalvinnchau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Three test-quality findings: the modeled relay rejects valid split reads, a startup mock hides assertion failures, and admission cases miss the rule they intend to exercise. No production defect established.

Comment thread tests/browser/policy-relay.mjs Resolved
Comment thread src/features/relay/reader.test.ts Resolved
@matt2e
matt2e force-pushed the inbox-1-channel-windows branch from 13ffa9f to e3a9a92 Compare November 5, 2026 06:01
matt2e added a commit that referenced this pull request Nov 5, 2026
…across repairs, from one budget total

PR #2401 review, two comments on src/features/relay/unread.ts.

kalvinnchau (P3): WINDOW_BUDGET = 3072 and PARTICIPATION_BUDGET = 1024
encoded the split of EVENT_BUDGET = 4096, declared 1,000 lines away, so
changing the total alone broke the split. EVENT_BUDGET now sits with
the window and participation constants: PARTICIPATION_BUDGET is a
quarter of it and WINDOW_BUDGET the rest. The values are unchanged.

kazemek21 (P2): the participation budget bounded only what one repair
admits. Replies already retained cost a repair nothing, so each repair
of busy remembered threads added up to 1,024 more. Pinned evidence went
last, so on a full budget it evicted ordinary evidence and refused new
ordinary evidence: a quiet channel's mention left, and a fresh mention
was refused even after Refresh.

The bound now applies where retained evidence lives, in `accept`'s
eviction:
- Once the event budget is full, the viewer's threads' evidence beyond
  PARTICIPATION_BUDGET events goes first. An ordinary event evicts that
  excess; a pinned event competes within the threads' share. Within the
  share the order is unchanged: ordinary evidence goes first, the
  busiest channel's oldest, and a channel left with none is unknown.
- The threads' evidence is pooled by thread, as ordinary evidence is
  by channel: the busiest thread's oldest goes first. A single pinned
  pool, oldest first, empties the oldest threads once the share is
  full, and drops a remembered mention, an old one-event thread, as
  soon as newer replies arrive. The participation pass gives each
  thread its newest unread reply first; oldest-first retention undid
  that on the next repair.
- `pools()` derives each event's pool key once and totals the threads'
  count from their pools. On a full budget, 300 single-event live
  accepts took 475-496 ms, against HEAD's 566-677 ms (load 55-65).
The byte budget is unchanged: under byte pressure alone, the threads'
evidence still goes last.

Docs: docs/inbox.md and docs/unread.md say the remembered threads go
last while they hold at most a quarter of a full budget, and that
beyond that the busiest thread's oldest goes first.

Test (unread-repair): "30 remembered threads of 200 replies, repaired
again and again, keep a quarter of a full budget: a quiet channel's
mentions are kept and admitted, and every thread keeps its row". One
thread a channel, its replies read, so the windows return nothing and
only the thread reads add the threads' evidence. Six repairs, a fresh
mention with Refresh, then 4,096 live events in a third channel.
- Before (HEAD's unread.ts, soft assertions, one run): the mention is
  gone and quiet unknown from repair 5; 21 of 30 thread rows remain;
  the fresh mention is refused after Refresh (quiet null, expected 2);
  the live channel is refused whole (null, expected 3072).
- With the share but one pinned pool, oldest first: repair 5 loses the
  mention.
- After: passes in about 0.8 s. The live channel holds 3,072,
  WINDOW_BUDGET, and the threads 1,024, the mentions among them; all
  30 rows remain.
Once balanced, about six repairs in, 7 to 9 of the 4,096 retained IDs
change per repair as equally full threads trade a reply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>

@kalvinnchau kalvinnchau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [P3] Accept reduced authored-page limits in the modeled relay

At tests/browser/policy-relay.mjs:465–469, the model still requires exactly 100 for the viewer's authored page and 500 for its continuation. The adjacent window fix now accepts split limits, but unread.ts:2003–2025 halves any lone filter and :2297–2302 preserves reduced continuation limits. An authored response exceeding 8 MiB can therefore produce valid limit-50 or limit-250 reads that the fixture rejects with 500.

This is a statically identified test-model mismatch, not a demonstrated production defect or a reproduced failure in current browser journeys. Accept 1..100 and 1..500 respectively when the authored filter is alone, and cover these cases beside the new lone-window integration test.

matt2e added a commit that referenced this pull request Nov 5, 2026
…and overlay reads

Restacking onto #2401's tip brought in unread-window-batch.test.ts, which
admits a lone fetch of 100 IDs. This layer caps a fetch by ID at 50 and adds
the overlay reads (the edits and deletions of held events, fresh or resumed),
so the table failed at the 100-ID case.

The lone fetch is now 50 IDs, 51 is refused, and the table admits a fresh and
a resumed overlay read and refuses one without `since` or with content kinds.
Production code is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Star Lord’s automated source review via Wes’s account, integrating independent Rocket, Mantis and Groot reviews.

One P2 finding is attached inline. No additional production defects were established in the scoped review. The existing authored-page fixture-limit advisory remains applicable; it is not duplicated here.

Reviewed head e3a9a92cb1629beac22e58634e424674f53a4983 against base/merge-base 15febc12eb31b96d9bf8a65e754f57eb4d91f79a, using immutable Git objects. The documented full-repair cost and bounded history are treated as stated tradeoffs.

Source-only: no tests, app/native execution, live-relay checks, performance measurements or human acceptance were performed. One hosted-check snapshot showed CI required and executed checks successful, with Windows native validation skipped. Public description and commit text were inspected; the five attached MP4s were downloaded but not visually inspected. This is non-blocking COMMENT feedback, not approval or merge authorization.

Comment thread src/features/relay/unread.ts Resolved
@matt2e

matt2e commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Re kalvinnchau's review (P3, reduced authored-page limits in the modeled relay): fixed in 035a2bc, with tighter bounds than the suggested 1..100 and 1..500.

The model now accepts only the limits the reader can send:

  • A lone authored page may use 100 or any rounded-down halving of it: 100, 50, 25, 12, 6, 3, 1. Beside other filters it must still be exactly 100.
  • A walk page, which is always alone, may use 500 or any rounded-down halving of it: 500, 250, 125, 62, 31, 15, 7, 3, 1.

Why not ranges: split halves a lone filter's limit, rounding down, and a walk page after a halved page keeps that limit. These are the only values a valid repair produces. A range would also accept limits the reader never sends, such as 75, and the model exists to catch that kind of drift.

tests/integration/policy-relay.test.mjs answers lone authored pages at 100/50/25/1, an authored page at 100 beside a window, and walk pages at 500/250/62/1. It refuses authored pages at 0/75/101, an authored page at 50 beside a window, walk pages at 0/300/501, and a walk page beside a window. With the change undone:

  • the old exact bounds: Expected: 100, Received: 50
  • only the walk page's old bound: Expected: 500, Received: 250
  • 1..100 and 1..500 ranges: the authored page at 75 is answered (Missing expected rejection)
  • halvings accepted beside a window: the page at 50 beside a window is answered

@kalvinnchau kalvinnchau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 One documentation-contract finding. No new production defect established; the WebKit flake-check failure remains unresolved.

Comment thread src/features/relay/unread.ts Resolved
tulsi-builder added a commit that referenced this pull request Nov 5, 2026
## Why

People said Inbox's filter bar was crowded and Archive didn't make
sense. They liked the tabs and the Unread toggle, and they wanted the
page called **Activity**, like Slack's. This PR keeps those parts and
removes the rest, so the toolbar fits on one row for as long as
possible.

## What

- **Rename:** Inbox is now **Activity**, in the page title, the sidebar
row, the shortcut titles ("Open … in Activity") and the help docs. The
bell icon stays. Plugin id `buzz.inbox`, page id `inbox` and storage
keys are unchanged.
- **Toolbar:** one left-aligned row.
- Type tabs come first: All, Threads, Mentions, DMs. The first tab lines
up with the title-bar bell.
- A text-only **From** menu (Anyone / People / Agents) follows the tabs.
It gets a subtle fill while it's narrowing the list.
- The **Unread** switch sits right after From, so it stays in view on a
full-width page.
- When the window is too narrow, Unread wraps to the next line first,
then From.
- **Archive removed:** every conversation stays in the list. The row ✓
is now **Mark as read**. It only shows on unread rows.
- With Unread on, the row leaves the list and an **Undo** toast appears.
- When nothing unread is left, the list says **All caught up** and
offers **Show all**.
- **Drafts removed:** a draft stays in the conversation where it was
written.
- **Focus:** after Mark as read, focus moves to the next row only if
focus is still in the list. If someone moves to the composer while the
read is still saving, their cursor stays where they put it.
- **Sidebar hover card (#2347):** now titled Activity, with an **Open
Activity** button. It no longer hides conversations that were archived
before, so it lists the same conversations as the page.
- **Filter routes (#2394):** the route contract drops `show` and
`drafts`. Older links that still carry them open the page with its saved
filters instead of failing: `show` is ignored, and `{ drafts: true }`
alone opens the saved filters.

## Screenshots

<!-- Default toolbar · From menu · From: People · Marked as read + Undo
· All caught up · Narrow (560px) · Hover card -->

## Not in this PR

- The Inbox badge and bold label: #2337.
- Which items count as unread: #2401.
- The unread logic from #2275.
- "Retry inbox" and "Inbox history unavailable" in
`src/features/relay/session.ts` and `inbox-feed.ts`. Those files are
shared relay code, so the strings are unchanged.
- `archive.ts` no longer has a caller in the page or the card. Removing
it is a follow-up, so this PR doesn't touch the files #2275 edited.

## Testing

- **Unit:** `vitest run src/bundled/inbox src/app src/features/pages
src/plugins` passes, 1347 tests after the latest rebase.
- New: Mark as read via the ✓ and via the context menu, each when the
save succeeds and when it fails. In all four, focus stays in the
composer the user moved to. All four fail without the fix.
- New: route cases for sender-only routes and links that still carry
`show` or `drafts`.
- **Browser, Chromium and WebKit:** `tests/browser/inbox*` and
`sidenav-polish` pass, 74 tests after the latest rebase.
- New `inbox-mark-read.spec.mjs` covers the one-row toolbar layout, bell
alignment, Mark as read with Undo, and All caught up.
- **Static checks:** `tsc`, `lint` and `design:check` are clean.
- **Agent review:** Crack Catcher's blocking finding was a focus jump
during a pending read. It's fixed, and Crack Catcher confirmed it.
- **Human testing:** tulsi tried it in the desktop dev app and confirmed
it all works.

buzz-review-completed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<img width="1280" height="800" alt="1 Default toolbar"
src="https://github.com/user-attachments/assets/4e3d23ac-5529-48b6-ab91-594ce3933e52"
/>
<img width="1280" height="800" alt="2 From menu open"
src="https://github.com/user-attachments/assets/7e2c86df-998a-467d-9ae8-10ea8c73bcbc"
/>
<img width="1280" height="800" alt="3 From People (dark)"
src="https://github.com/user-attachments/assets/3dd58da4-204b-46de-b4e5-cc531f7bd580"
/>
<img width="1280" height="800" alt="4 Marked as read with Undo"
src="https://github.com/user-attachments/assets/d54214a4-2990-4080-bf1d-15d6cacfd4fc"
/>
<img width="1280" height="800" alt="5 All caught up"
src="https://github.com/user-attachments/assets/215850bb-8ed3-49c4-a4a7-a7cf6e2530ff"
/>
<img width="560" height="800" alt="6 Narrow window"
src="https://github.com/user-attachments/assets/e4a1fa0c-a19e-45de-b082-43c5295f7cba"
/>
<img width="1280" height="800" alt="7 Hover card"
src="https://github.com/user-attachments/assets/63947445-59ae-4c49-97b4-d04ae12c6875"
/>

---------

Signed-off-by: tulsi <tulsi@block.xyz>
Co-authored-by: BasilBot <399581b6165ab01343e3a1324774b1024d76942aea563468f93c975e5ee5193c@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
matt2e and others added 2 commits November 6, 2026 11:11
…nels

A thread the viewer replied in, was mentioned in or follows vanished
from the Inbox once busier channels' traffic aged it out of the single
global evidence read (`#h` over up to 128 channels, limit 500, about
3.6 h of a busy roster). Nothing could recover it: membership lookups
were suppressed for read replies, and a read reply whose root had left
the sample fell back to a row keyed by its own ID.

This commit is the production tree of 4d36397 on missing-messages-in-
inbox (PR #1697, frozen at 34fc406), the reviewed and live-probed tip
of the first fifteen commits there (34ffd57..4d36397), taken file
for file onto main. The commits after it in this series forward-port
later fixes; their bodies say what each changes.

Repair now reads:
- one window per joined channel, since the older of its read mark and
  catch-up mark (the last week when never read), sized to the roster so
  a first run fits the budget;
- the viewer's authored page, and on a device's first repair a keyset
  walk of their authored history (up to 6 pages of 500), with the
  newest authored time kept as the walk mark;
- the viewer's remembered threads: the 50 most recently read or written
  in whole, the rest (up to 1,000 remembered) one filter per root since
  the newest reply the viewer wrote or read there, passing over that
  boundary reply by ID;
- main's roster sample (newest 500 a 128-channel chunk) behind the
  windows, so the evidence is a superset of main's.
Windows, the authored page and thread reads ride up to 128 filters a
request (`isUnreadWindowBatch`), two requests in flight. A request that
fails is read once more; a response over the reader's 8 MiB budget or
the native host's 16 MiB cap is split. A repair in which nothing lands
fails as before; one that drops some reads ends `observed` with
`partial: true`, and the Inbox shows "Some recent activity could not be
checked." with Refresh.

Retained evidence no longer resets when the budget fills: the busiest
channel's oldest ordinary evidence is evicted first, the viewer's
threads last, and a channel left with nothing is unknown rather than
zero. Each asked thread holds one slot of the 1,024-reply participation
budget, filled with its newest unread reply first. Lookup witnesses
feed the running repair, or a background follow-up after it.

Participation is remembered per relay and viewer in IndexedDB
(`buzz-thread-participation-v1` here; the next commits rename it).

Known at this commit on main: main's fourteen #1860 tests in
unread-startup.test.ts fail, and the branch's fixtures predate main's
30-day read floor. Later commits in this series retire and fix them.

session.ts (FOUNDATION): two lines wire the participation store into
createUnread, approved by Matt Toohey for this project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…ants

Layer 1's share of 34fc406 on missing-messages-in-inbox, the trim of
PR #1697's tests: the same tests are removed from the snapshot's test
files by name, and nothing else changes. Kept tests keep the snapshot's
bodies, which assert layer 1's behaviour; 34fc406's bodies of the same
tests also assert the device record and catch-up the later PRs add.

Removed (each covered by a kept test, as 34fc406's body maps them):

unread-repair.test.ts:
- the roster sample keeps a read mention older than its channel's mark and a mention older than a never-read channel's week
- an edit or deletion of a pinned message is admitted once pinned evidence fills the budget
- a window that times out once is read again in its slot and the repair completes
- a window dropped after its retry leaves its channel unknown and the Inbox partial, with the viewer's thread still read
- reads the viewer's page with 127 windows first, remembered and newly taught threads next, then the remaining windows and the roster sample
- a thread read's full-page split goes ahead of the remaining windows
- a full page's split rereads count each reply once against the participation budget
- each thread in a landing is given its newest unread reply before the busiest one takes the rest
- a dropped read releases its thread's reserved slot to the reads still to land
- remembers threads the viewer took part in, not every message they wrote
- a followed thread is remembered as of the follow and refetched
- the Inbox leaves loading once the viewer's page and remembered threads land, observing until the windows do
- a read reply outside the viewer's conversations fetches only its root, never a membership lookup
- a remembered thread the viewer's deleted reply joined leaves the Inbox after restart
- a dropped walk page leaves the walk to the next repair without marking channels unchecked
- a cold thread's read is inclusive of its newest time, so a peer reply in the same second as the viewer's is recovered
- a cold thread's boundary reply that is still unread returns to a warm repair and shows unread, and a new reply in the same second joins it
- a cold thread's reply marked unread below its mark moves the mark back to it, and the mark stays there across launches until the mark is cleared
- for (const [name, target] of [
- a lookup feeding a thread whose mark was just lowered cannot re-advance it before another thread's mark is saved
- a cold thread whose page comes back full is read whole, losing none of its replies
- a dropped cold read leaves its thread to the next repair and the Inbox partial, with its channel still observed
- a remembered thread in a channel the roster lacks is kept through a save and read again when the channel returns
- a thread the walk teaches in a channel the roster lacks is remembered, not read, so the walk mark does not pass it
- clearing the owner while a walk page reads retires the walk: no further page, thread read or save
- a lookup that finds the viewer's reply while the windows read puts the thread's read ahead of the remaining windows

unread-lookups.test.ts:
- a lookup that lands as the repair's last read does still gets its thread read, in a follow-up
- a follow-up read that fails is dropped without a retry, and the thread is still remembered
- a follow-up pre-empted by a new repair leaves the thread it fed to that repair

unread-startup.test.ts:
- a relay that accepts reads but answers none costs one read per request after the first drop

unread-lookups.test.ts also drops its `Priority` and `ReadOptions`
import, which only a removed test used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e and others added 13 commits November 6, 2026 11:12
…tions on it, not every retained event

Forward-port 1 of the split (#note:c2f2f5fd): 5a52257 on
missing-messages-in-inbox, which came after the layer-1 tip.

`inbox()` and the thread-activity snapshot folded every retained event
for each channel (`foldMessages(channel, "", [...events.values()])`), so
a recompute cost channels × retained events. Layer 1 retains several
times main's events over more channels; on the live relay `inbox()`
reached 42 ms a recompute warm (main 7.7 ms).

- unread.ts: `indexEvidence()` also builds `byTarget`, the retained
  edits and deletions by the event they reference, and `foldable()`
  returns a channel's content, the overlays on it and the deletions of
  those, whatever channel they name. main's per-channel `folded()` cache
  (#2366) folds that. The fold reads overlays two levels deep and skips
  deleted rows, so its rows are unchanged. Layer 1 holds every edit it
  is sent, where the later layer holds each sender's newest; `foldable`
  gathers all of them either way, as the fold read them before.
- fold.ts: a row's thread reference is parsed only for a pending reply,
  which only the channel view passes. Output is unchanged.

Tests:
- unread-fold.test.ts, as on 34fc406 (already written against main's
  read floor and folded-row cache): with 2,000 retained events across 80
  channels, each fold is handed only its channel's 23 events. With the
  fold handed every retained event again it fails: "expected Set{ …(1999)
  } to deeply equal Set{ …(22) }".
- fold.test.ts: rows with no pending replies equal those with an empty
  or unrelated set, with and without `includeReplies` (a guard).

5a52257 measured `inbox()` at 31 → 10 ms a recompute on this fixture.
Not re-measured here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…next recovery tick reads it again

Forward-port 2 of the split (#note:c2f2f5fd): the layer-1 part of
dd306db on missing-messages-in-inbox (PR #1697 review, kalvinnchau,
P3).

Since #2252 the session's minute recovery tick repairs unread only while
a repair is owed or UNREAD_BACKSTOP_INTERVAL (15 minutes) has passed
since the last one. A pass that dropped a read after its retry (a window
request, a thread read, a root fetch) ended with `partial` set but
`owing` false, so every later tick skipped unread and the partial notice
and unknown channels stayed until the backstop. A pass in which nothing
landed throws and stays owed.

The repair's tail now sets `owing = unchecked`. A pass that drops a read
stays owed, as one that fails does, and the next tick repairs; a pass
with every read landed pays the debt. A relay that keeps dropping one
read costs one repair a minute, as one that keeps failing whole repairs
does. session.ts (FOUNDATION) is unchanged: its gate reads
`unread.owed()`.

Docs: docs/inbox.md says the repair stays owed after a dropped read, so
the next periodic check, a minute later, repairs again, where it said
"no automatic retry". docs/relay-queries.md counts a repair that dropped
a read as evidence debt, cleared only by one with every read landed. Its
"an evidence overflow" goes from the same sentence: this series' evidence
never resets on overflow, it evicts.

Test (unread-startup, through the session's real recovery gate): "a pass
that drops a window chunk leaves unread owed: ...". 129 channels, so the
windows are two requests: the viewer's page with 127 windows, then two
windows. A full reconnect pays the marker debt; a covered reconnect then
drops the second window request after its retry (`partial: true`). A
minute later, with the relay answering, the tick reads it again and
`partial` clears; a minute after that, the tick reads no evidence.
- With `owing = false` at the tail, as before: "expected 4 to be 5" at
  the tick a minute later.
- With the tail always owed: "expected 21 to be 17" at the last tick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…he record's final names

Forward-port 3 of the layer-1 split: 76e4193 without its events.
Participation moves from `buzz-thread-participation-v1` into the store
the device record later grows in, so the next layer only adds fields.

- `unread-storage.ts` owns IndexedDB `buzz-unread-v1`, version 1, with
  two stores: `meta`, one entry per relay and viewer holding the record
  version and the participation (roots, their marks, the walk mark), and
  `events` (keyPath `[scope, id]`, index `scope`), which nothing writes
  yet. Creating both now means the later build needs no upgrade branch.
- A record is read whole or not at all: a meta of another version or with
  any malformed part reads as no record, so the next repair starts as a
  fresh device's. The old parse took the bare list and `[key, at]` pairs;
  that database was never released, so nothing migrates. A save refuses a
  record that would not read back and keeps the saved one.
- `purgeUnreadStorage` is a plain delete, as read state and the outbox
  purge; partition purge names it "unread state".
- `createUnread` takes `storage` (`UnreadStorage`) in place of
  `participation`, reads `record.participation` and saves
  `{ participation }`. A cache clear still re-reads it from disk.
- `thread-participation.ts` keeps the types, the 1,000 limit and
  `NO_THREAD_PARTICIPATION`; its storage half and its test are gone.

`session.ts` (FOUNDATION) wires `browserUnreadStorage(readScope)` in
place of `browserThreadParticipation(readScope)`: the call and its
import, approved by Matt Toohey.

Tests: `unread-storage.test.ts` (Node, an in-memory IndexedDB whose
transactions run one at a time with structured clones) covers a
round-trip through a fresh connection with both stores created, each
malformed meta, a refused save keeping the saved record, a blocked open
not held against the next read, and a purge clearing only its partition.
Mutations each failed a case: a lenient root parse, no version check, a
cached failed open, no save guard, a purge of other partitions, and no
`events` store. The repair and lookup tests read and seed threads
through the store; device-state's purge test names it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Forward-port 4 of the layer-1 split. The roster's windows and sample, a
thread read and the viewer's authored page were built inline, in the
repair and in the participation pass; they are now `rosterReads`,
`threadRead` and `authoredRead` beside `split`, in the form the later
layers use, so their diffs do not carry the extraction. Requests are
unchanged: the same filters, in the same order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…remembered threads' roots last

Forward-port 5 of the layer-1 split, conditional on a test written first
on the layer-1 tree: a remembered thread's root, older than a never-read
channel's week of window and past the roster sample, fetched by ID when
the pass reads the thread, should not count. It counted (observedCount
1, expected 0), so `keepRoot` comes into layer 1 with the structure
cache's order and exact bound, from 9815e47, de2d02e and da8ed62.

- `landed()` hands a root fetched by ID to `keepRoot` before it accepts
  the replies, so they group under it at once. The root goes to the
  lookup cache as structure, as a lookup's fetch does, and counts only
  once a window or the sample returns it. The viewer's own root is
  remembered as its thread's lookup and witnessed.
- The cache holds 1,024 events besides the remembered threads' roots it
  holds, counted once per remembered set and incrementally after that.
  Those roots go last, as their evidence does; one passed over moves to
  the newest end. Keeping an event again makes it the newest.
- A root let go releases its `:structure` lookup. `want()` asks again
  only for a reply in a remembered thread, or one that teaches the pass
  its thread; any other released lookup stays done, so replies needing
  more roots than the cache holds cannot fetch one another's forever.
- docs/inbox.md says roots a remembered thread's read fetched leave the
  row's exact-message target, and that remembered threads' roots go last.

Left for layer 2: restore's `admitRestored`, the `clears` guard and the
cache-clear wipe from 9815e47, and de2d02e's incomplete save. From
11139eb (layer 3), `keepRoot`'s return and the absent roots.

Tests (unread-repair.test.ts):
- "a remembered thread's root the repair fetches by ID, in a channel the
  viewer never read, groups its read mention and never counts": fails
  before the port at the count, and with `keepRoot` skipped.
- "a remembered thread's root outlasts 1,024 other structure fetches,
  and a root let go is fetched again for a thread it teaches", adapted
  from the restore test the head's trim dropped. It fails with
  oldest-first eviction, a bound of 1,024 plus every remembered thread,
  no re-ask, a re-ask of every released lookup, and no release.
Both ran with `floor: () => 0` in the setup, which forward-port 6 adds;
until then main's 30-day floor reads the 1970 fixtures, as it does for
the 10 tests that commit fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…t a repair's channel read, and fail only the timeline's page read

Forward-port 6 of the layer-1 split: the test fixes main's later work
needs, from 367b38c and 11f22a6.

- Read floor (#1286). `createReadState` reads everything older than 30
  days before the device clock as read, and the repair fixtures date in
  1970, so 10 unread-repair tests saw nothing unread (and the two
  `keepRoot` tests passed without checking anything). Their setup takes
  `floor: () => 0`, as main's setups do. unread-fold's floor and #2366's
  fold-cache reorder came with forward-port 1, whose fold test needed
  them.
- #2252's recovery tests counted a repair as one evidence read: a kind-9
  request, or an `include_aux` filter with kind 40002 first. A layer-1
  repair sends the authored page and windows, then the roster sample,
  so each counted two. `tickReads` counts a channel read (`#h`, no `#e`
  or `ids`, kind 40002) leading a request, which is the sample once a
  repair, and the four tests count with it.
- entity-navigation's failed-message-read test failed any kind-9 read,
  so the Inbox's reads, which run first, used up its scripted failures.
  It fails only the timeline's `top_level` page read (11f22a6).

The new "a pass that drops a window chunk leaves unread owed" test still
fails with `owing = false` (4 reads, expected 5) and with unread always
owed (16, expected 13) under the new count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…windows replace

e8b13d4 (#1860) made main's evidence read incremental per 128-channel
batch: a batch that came back short was next read from 960 s before the
newest row it returned. This branch's unread.ts replaces those batches
with per-channel windows and keeps no mark, so every repair reads each
window in full. The 14 tests e8b13d4 added to unread-startup.test.ts,
and their helpers (evidenceReads, nowSeconds, DISCOVERED, serve,
discovered, serveWindow, holdNextRepair), tested behaviour this branch
does not have. All 400 lines go; they match 1d8c527's deletion line
for line. docs/unread.md's description of the batches and marks goes
too.

Coverage map. Each removed contract, then the layer-1 test covering the
equivalent behaviour, or the exception:

- "a repair after a complete observation reads only near the newest
  event it returned": exception. There is no mark, so no repair reads
  less than its windows. What that costs is in the PR's cost table; the
  catch-up stacked on this branch replaces it.
- "a batch that returned nothing is read unbounded again": exception,
  nothing to port. Every repair reads unbounded.
- "a future-dated event cannot move the next repair past the present":
  exception, nothing to port. There is no mark for an event to move. The
  catch-up ports the clamp with its mark.
- "a repair never reads from before the newest event it already
  returned": exception, nothing to port, as above.
- "regaining access reads the batch unbounded, not from the old mark":
  unread-startup "observes a channel again when the viewer regains
  access to it" stays, and "late chunk cannot publish or dispatch
  another after %s" (revoke) has the abandoned read start over against
  the current membership.
- "an explicit refresh re-observes unbounded": unread-startup "explicit
  refresh bypasses optional profiles for both markers and evidence"
  stays; its trace shows the refresh's full repair.
- "clearing the cache re-observes unbounded": exception. There is no
  mark to forget, so the repair after a clear reads in full, as every
  repair does. unread-startup "late chunk cannot publish or dispatch
  another after %s" (clear) and "a lookup in flight during a cache clear
  cannot restore its result" keep the clear boundary.
- "capacity overflow discards the mark with the evidence it described":
  exception. The branch never clears evidence on overflow. It evicts the
  busiest channel's oldest ordinary evidence, and a channel left with
  none is unknown, not zero. Tests: unread-repair "a full budget evicts
  the busiest channel's oldest ordinary evidence, never a quiet
  channel's only message or the viewer's thread"; unread-startup "a full
  budget evicts the oldest evidence and the observation still
  completes".
- "a read cut off at its limit installs no bound, so a freed slot still
  shows an older unread message": every channel has its own window, so
  a busy channel cannot crowd out a quiet one's message. Tests:
  unread-repair "a first run with a large roster sizes each window so
  the pass fits the budget, and a quiet channel's mention survives";
  unread-startup "a full channel window does not starve later channels,
  and a dropped request leaves its channels unknown until explicit
  refresh".
- "a bounded read that comes back at its limit drops the bound for the
  next read": exception, nothing to port. No read is bounded by a mark.
- "a bounded repair answered after a live overflow restores no mark and
  keeps the overflow error": exception, as for the overflow above. There
  is no overflow error, reset or mark.
- "an explicit refresh during a bounded repair waits for it, then reads
  in full", and "a refresh asked while a repair's answer is being
  accepted still reads in full": exception. A refresh during a repair
  joins it (`repair()` returns the running pass), as on main before
  e8b13d4, and that pass already reads in full. No layer-1 test pins
  the join for refresh; "concurrent ensure callers share the active
  marker/evidence work" pins it for ensure.
- "renaming a channel does not move the batches, so they keep their
  bounds": exception, nothing to port. Windows are per channel ID and
  carry no bound.

docs/unread.md: the coverage paragraph now covers, briefly, the cap and
eviction and the per-channel windows, and defers to
docs/inbox.md#ownership-and-limits for what a repair reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…across repairs, from one budget total

PR #2401 review, two comments on src/features/relay/unread.ts.

kalvinnchau (P3): WINDOW_BUDGET = 3072 and PARTICIPATION_BUDGET = 1024
encoded the split of EVENT_BUDGET = 4096, declared 1,000 lines away, so
changing the total alone broke the split. EVENT_BUDGET now sits with
the window and participation constants: PARTICIPATION_BUDGET is a
quarter of it and WINDOW_BUDGET the rest. The values are unchanged.

kazemek21 (P2): the participation budget bounded only what one repair
admits. Replies already retained cost a repair nothing, so each repair
of busy remembered threads added up to 1,024 more. Pinned evidence went
last, so on a full budget it evicted ordinary evidence and refused new
ordinary evidence: a quiet channel's mention left, and a fresh mention
was refused even after Refresh.

The bound now applies where retained evidence lives, in `accept`'s
eviction:
- Once the event budget is full, the viewer's threads' evidence beyond
  PARTICIPATION_BUDGET events goes first. An ordinary event evicts that
  excess; a pinned event competes within the threads' share. Within the
  share the order is unchanged: ordinary evidence goes first, the
  busiest channel's oldest, and a channel left with none is unknown.
- The threads' evidence is pooled by thread, as ordinary evidence is
  by channel: the busiest thread's oldest goes first. A single pinned
  pool, oldest first, empties the oldest threads once the share is
  full, and drops a remembered mention, an old one-event thread, as
  soon as newer replies arrive. The participation pass gives each
  thread its newest unread reply first; oldest-first retention undid
  that on the next repair.
- `pools()` derives each event's pool key once and totals the threads'
  count from their pools. On a full budget, 300 single-event live
  accepts took 475-496 ms, against HEAD's 566-677 ms (load 55-65).
The byte budget is unchanged: under byte pressure alone, the threads'
evidence still goes last.

Docs: docs/inbox.md and docs/unread.md say the remembered threads go
last while they hold at most a quarter of a full budget, and that
beyond that the busiest thread's oldest goes first.

Test (unread-repair): "30 remembered threads of 200 replies, repaired
again and again, keep a quarter of a full budget: a quiet channel's
mentions are kept and admitted, and every thread keeps its row". One
thread a channel, its replies read, so the windows return nothing and
only the thread reads add the threads' evidence. Six repairs, a fresh
mention with Refresh, then 4,096 live events in a third channel.
- Before (HEAD's unread.ts, soft assertions, one run): the mention is
  gone and quiet unknown from repair 5; 21 of 30 thread rows remain;
  the fresh mention is refused after Refresh (quiet null, expected 2);
  the live channel is refused whole (null, expected 3072).
- With the share but one pinned pool, oldest first: repair 5 loses the
  mention.
- After: passes in about 0.8 s. The live channel holds 3,072,
  WINDOW_BUDGET, and the threads 1,024, the mentions among them; all
  30 rows remain.
Once balanced, about six repairs in, 7 to 9 of the 4,096 retained IDs
change per repair as equally full threads trade a reply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…, answer the roster sample in the 278-channel test, and refuse a second authored page within 128 filters

PR 2401 review, three P3 threads from kalvinnchau on 01f0b61.
Production code is unchanged.

tests/browser/policy-relay.mjs:472. The modeled relay held every unread
window to a limit of at least 20, the least a repair sizes a window to.
The reader halves a lone window's limit when its page comes back over
the byte budget (`split` in unread.ts: 24 → 12 → 6 → 3 → 1), so the
model 500ed the valid split reads the live probe produced. The model
now accepts a limit of 1 to 100 for a lone window and keeps the bound
of 20 to 100 for a window beside others, which is the shape every
initial window request has; the unread-startup tests still assert the
initial sizing (limit 20 on 278 channels). tests/integration/
policy-relay.test.mjs gains a focused case against the model itself,
signed as the broker signs: a lone window at limits 12, 1, 20 and 100
is answered, two windows at 20 and 100 are answered, and two windows at
12, a lone window at 0 and a lone window at 101 are refused, each
recorded in report.unexpected. With the old bound restored it fails:
"Expected: >= 20, Received: 12".

src/features/relay/unread-startup.test.ts:217, "queries all 278
membership IDs". The mock answered every kind-9 request with an `#h`
and asserted each filter named one channel, so the roster sample (kind
9, up to 128 `#h` IDs) threw inside the mock; `pool()` retried and
dropped each sample request, and the test passed with the pass partial.
The sample, with no `since`, is now answered empty as the next test
answers it; the mock only records each window request and the busy
channel's count when it arrived; and after `ensure()` the test asserts
the Inbox is ready, observed and not partial, then the recorded
requests: [128, 128, 23] filters, the authored page once in the first,
every window one channel at limit 20 with a `since`, all 278 channels,
and a count of 1 when the third request started. With the sample
throwing in the mock as before, it fails: "expected true to be
undefined" at `partial`.

src/features/relay/reader.test.ts:349 and browser-host/
relay-broker.test.mjs. The duplicate-authored case, `[...batch.slice(1),
batch[0], batch[0]]`, had 129 filters, so the length guard refused it
before the authored rule ran. The predicate's shapes now live once in
src/features/relay/unread-window-batch.test.ts (16 cases: the 128
batch, the four lone shapes, and each refusal: none, 129, a second
authored page within 128, one kind, no include_aux, a window without
since, two windows of one channel, until, limits 0 and 501, since -1,
two authors, two roots since, an ids limit not its count, null). The
reader and the broker, which import the same predicate, keep the valid
batch and two refusals each: 129 filters, and the second authored page
within 128, `[...batch.slice(0, 127), batch[0]]`, which proves the batch
reaches the predicate and not only its length. With `authored ||`
removed from the rule, all three fail (the unit case "expected true to
be false"; the reader's and broker's duplicate case admitted), where
the 129-filter case still passed.

Checks: vitest on unread-window-batch, reader, unread-startup and
relay-broker (82 passed); node --test on the integration file; biome
check and tsc --noEmit clean. At load average 112, unread-startup's
"a failed membership lookup stays pending and retries with backoff"
timed out at 5 s in every run, including HEAD's copy of the file run
alone (9 s); it is untouched here. No browser journey was run under
that load; the model change is covered by the integration case.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…n disk, so their roots leave the structure cache

PR 2401 review thread PRRT_kwDOUVULN860F-k9 (wesbillman, P2, on
src/features/relay/unread.ts:1607 at e3a9a92): bound the structural
roots exempted from eviction.

The claim holds. `save()` capped only the persisted copy of the
remembered threads at 1,000. `remembered` is the pass's own map, which
`rememberedRoots()` hands untrimmed to the next repair, so in one
session it kept every thread ever learned. `keep()` exempts a root
whose thread is in it, so the structure cache held every such root
fetched, besides its 1,024 other events, without bound.

- `save()` now trims the pass's map in place to the same newest 1,000
  it persists, ranked over every channel as before, so a thread in a
  channel the roster lacks is kept or forgotten exactly as on disk.
- The cache's count of remembered roots is reset there: the map can
  now shrink, so its identity and size no longer prove the members are
  the same.
- The eviction loop moves out of `keep()` into `letGo()`, which `save()`
  runs right away: a forgotten thread's root is ordinary structure, the
  oldest goes first past 1,024, and its `:structure` lookup is released,
  as before. If a root went, the evidence index is rebuilt, since
  retained replies may name it.
Between saves the set still grows by what one repair or follow-up
teaches (the pass relearns threads from retained evidence), and the
next save trims it again. After each save the cache holds at most the
1,000 remembered threads' roots and 1,024 other events.

Docs: docs/unread.md counts the remembered threads' roots beside the
lookup store's 1,024 fetched events, and docs/inbox.md says a thread no
longer among the 1,000 remembered gives its root up like any other.

Test (unread-repair): "as the viewer moves on to new threads, the
structure cache keeps the 1,000 remembered threads' roots and 1,024
others, letting the oldest go". In one session, 42 repairs each follow
the viewer replying in 50 new threads, whose roots no window or sample
returns, so each repair fetches them by ID: 2,100 remembered roots. A
retained reply's `attention().rootId` shows whether the cache still
holds its root. It expects 2,024 held, the record at 1,000, the first
round's roots gone and every root after the second round's held.
- On e3a9a92's unread.ts: "expected [ true, … ] to have a length of
  2024 but got 2100".
- With the trim but no eviction at the save: 2100.
- With the eviction but the index left as it was: 2074.
- Without the count reset it passes. The reset guards a count taken at
  the trimmed size before the map grew; that count could be too high
  (too little let go) or too low (the loop then cycles protected roots).

Checks: vitest on unread-repair, unread-lookups, unread-startup,
unread-storage and unread-fold (72 passed); biome check and tsc
--noEmit clean. The new test takes 1.3 to 1.7 s at load average 30
to 125.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…its in the modeled relay

PR 2401 review 5780704057 (kalvinnchau, P3, in the review body): accept
reduced authored-page limits in the modeled relay. Production code is
unchanged.

The claim holds, more narrowly than the review's 1..100 and 1..500. In
unread.ts on 88b9405, `authoredRead` sends the viewer's newest page at
WINDOW_LIMIT, 100, ahead of the windows in the first request. `split`
halves an oversized request until the page is alone, then halves its
limit, rounding down: 100 → 50 → 25 → 12 → 6 → 3 → 1. `walk.landed`
queues each walk page alone. The walk page after the newest page is
SEED_PAGE, 500, even when that page was halved, and a walk page after a
halved walk page keeps its limit: 500 → 250 → 125 → 62 → 31 → 15 → 7 →
3 → 1. The model required exactly 100 and 500, so an authored response
over the 8 MiB budget made it 500 the reader's valid follow-up read.

tests/browser/policy-relay.mjs: a lone authored page may carry any of
`halvings(100)`, a walk page any of `halvings(500)`, and other limits
are refused. An authored page beside other filters is held to 100, and
a walk page must still be alone. `halvings` lists a limit and each
rounded-down halving of it to 1.

tests/integration/policy-relay.test.mjs: the lone-window case now
covers authored pages too, signed as before. It answers lone authored
pages at 100, 50, 25 and 1, an authored page at 100 beside a window,
and lone walk pages at 500, 250, 62 and 1. It refuses authored pages at
0, 75 and 101, an authored page at 50 beside a window, walk pages at 0,
300 and 501, and a walk page beside a window. Each refusal is recorded
in report.unexpected.
- With the old bounds restored: "Expected: 100, Received: 50".
- With only the walk page's old bound restored: "Expected: 500,
  Received: 250".
- With a range of 1..100 and 1..500 for lone pages: the authored page at
  75 is answered ("Missing expected rejection").
- With the authored page's halvings accepted beside a window: the page
  at 50 beside a window is answered.

Checks: node --test on the integration file (2 passed), biome check and
tsc --noEmit clean. No browser journey was run. Journeys that send only
100 and 500 are unaffected, because both are still accepted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…y while among the newest 1,000

PR 2401 review comment r4485048425 (kalvinnchau, P3, on
src/features/relay/unread.ts:2563 at 035a2bc): update the retention
comments to match the live-map trim. Comments only; no code changes.

88b9405 made `participating().save` trim the live `remembered` map to
the newest 1,000 threads it persists. `pinned` and `rememberedRoot` read
that map, so a thread the save forgets has ordinary evidence and an
ordinary root again. Three comments still described the earlier
lifecycle:

- `remembered`: it said the live map was bounded only by what the walk,
  the evidence and follows teach, with the 1,000 limit applied only in
  `newest()` and the saved record. It now says the map grows between
  saves with what the walk, the evidence, lookups and follows teach, and
  each save trims it to the 1,000 it persists. A forgotten thread is
  ordinary again until something teaches it again, and the next save
  ranks it with the rest. The same comment said the threads' evidence is
  evicted last on overflow. Since b1619ba52 that holds only while they
  hold at most a quarter of a full budget, and the comment now says so.
- `want()`: it said a re-taught root is kept "from then on". Now: a root
  that was let go is fetched again for a reply in a remembered thread or
  one that teaches its thread, and held only while that thread is
  remembered. Once a save forgets the thread, its root is let go when
  1,024 newer events are held besides the remembered roots, and the
  next teaching reply fetches it again. `keep()` makes each fetched root
  the newest, so a refetch repeats no faster than that turnover. So
  lookup scheduling must not count on the root staying held.
- `save()`: it said forgotten threads' roots are ordinary structure
  "from then on". Now that holds until a reply, the walk or a follow
  teaches their threads again.

The docs already match: docs/inbox.md says a thread no longer among the
1,000 remembered gives its root up like any other, and docs/unread.md
counts the cache as 1,024 events besides the roots of at most 1,000
remembered threads.

Checks: biome check on unread.ts and tsc --noEmit, both clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…gets before polling for the repair's window and Alpha's head

PR 2401's WebKit flake check (run 42047786425) failed "channel
establishment preserves an in-flight unread batch and its sidebar
badges" in 4 of 10 repeats. Its first wait was one 10 s `expect.poll`
from `page.goto`, so that one budget covered app boot, the startup reads
up to the roster, the read queue and a stalled worker. In each failing
repeat the page had sent Alpha's window before the last probe, but the
relay recorded it 137-208 ms after the poll gave up. The startup path
and first request match main's; main never repeats this test, since its
spec is unchanged there. Production code is unchanged.

tests/browser/sidebar-unread.spec.mjs:
- The establishment test waits for the Community switcher, then for
  Alpha's sidebar row, before polling for the window. With unread held,
  the rows reveal SIDEBAR_REVEAL_BUDGET_MS after the roster that also
  starts the repair (`useSidebarStartup`), so a row proves the repair
  was asked for. Each stage gets its own 10 s budget, as `open()` gives
  its stages.
- "edge controls ..." polled `alphaHeads().length === 1` from goto, the
  same exposure; on the rebased main the flake check may repeat the
  whole file. It waits for the switcher first. Not the rows: they can
  follow the head, and the test releases Alpha's EOSE as soon as the
  head is sent, for the live-setup deadline.
No timeout is raised and no assertion changes.

Checks, run one at a time at load average 5-8:
- The spec in WebKit (7 passed, 40.0 s), then in Chromium (7 passed,
  35.9 s).
- Fail-before, in WebKit, with temporary uncommitted delays and logged
  copies of the HEAD spec and this one. The note proposed delaying the
  second `k[39002] #p limit=500` roster read. It gates Alpha's second
  head, not the windows, and goto to switcher is 0.5 s locally, so one
  roster delay leaves the two versions about 0.5 s apart. Instead the
  document response was held 4 s (stage 1) and the first roster read 5 s
  (stage 2), as CI's slowness spread across stages. The establishment
  test: HEAD's fails at its window poll ("Timeout 10000ms exceeded while
  waiting on the predicate"); this one passes, 4.6 s to the switcher,
  7.1 s to the row, the window already sent. The edge test: HEAD's
  fails at its head poll; this one passes, the head 5.9 s after the
  switcher and 10.4 s after goto.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e force-pushed the inbox-1-channel-windows branch from 035a2bc to b59f116 Compare November 6, 2026 00:24

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Star Lord’s automated source follow-up via Wes’s account, integrating independent Rocket, Mantis and Groot reviews.

The prior structural-retention P2 is resolved in source: saves now trim the live remembered-thread map and evict excess structural roots. The authored/walk fixture-limit advisory is also addressed. One new P3 is attached inline: save-time eviction during background follow-up can leave an already-published Inbox row stale. The documented full-repair cost and bounded-history tradeoffs are retained.

Reviewed head b59f1168293c56e452cd2468060c76148d979d22, target base 8b908c8329616645db5432170498cb73c833be8c, merge base ef5a784ec7f0fe492f56a0e441f1510f55ce6acd, using immutable Git objects. Scope includes the four follow-up commits and rebased index/coalescing interactions.

Source-only: no tests, app/native execution, live-relay checks or performance measurements were performed. One hosted-check snapshot at this head showed CI required and executed checks successful, including both engines’ flake checks; Windows native validation was skipped. The public description was inspected, but the five attached videos were not visually inspected. Historical measurements and test results in the description are not current-head validation. Non-blocking COMMENT feedback, not approval or merge authorization.

for (const [key] of ranked.slice(nextRoots.size)) roots.delete(key);
heldRoots = undefined;
// Retained replies may name a root let go.
if (letGo()) indexed = false;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [P3] Publish structural eviction from background saves

When a background follow-up exceeds the remembered-thread cap and this letGo() evicts a displayed thread’s only cached root, followUp() returns after pass.save() without publishing (lines 2699–2700). A preceding root landing can already have synchronously published and cleared the Inbox timer, so setting indexed = false here does not invalidate inboxSnapshot: inbox() returns it while inboxDirty is false. The displayed row can therefore retain a thread readThrough target whose root is gone, and Mark read reaches requireMessage() and fails with “Message does not belong to the read target” until another publish refreshes the row.

Please invalidate/notify the projections when save-time eviction occurs, including background follow-up, and cover that path with an Inbox subscriber and a read action after eviction. The new rotation test exercises ensure/refresh, whose final publish masks this path. This is a source-traced edge case, not a runtime reproduction.

matt2e added a commit that referenced this pull request Nov 10, 2026
…3071)

## Problem

The "New messages" divider from #2083 never appeared in a channel with
more than 20 top-level messages, which is most channels anyone is behind
in. The channel window loads 20 rows on entry, so such a channel always
enters with `hasMore`, and the entry boundary refused to settle while
history was incomplete. The guard existed for a real reason: with
unloaded rows above the window, the first unread *loaded* row need not
be the first unread row.

**Repro** (fixture relay, `readState: true`, `historyCounts: { alpha:
25, beta: 15 }`):

1. Open #Alpha (25 messages), stay at the bottom for a second so the
catch-up read saves.
2. Open #Beta (15 messages) and stay at the bottom.
3. From another account, post three messages to #Alpha, then reopen it:
three new rows, **no line**.
4. Post three messages to #Beta, reopen it: the line sits above the
first new row.

Same read state, same three arrivals; only the history length differs.

## Fix

- **`oldestUnreadId` on the channel snapshot**
(`src/features/relay/unread.ts`): names the oldest unread message the
timeline shows (a top-level post or broadcast reply, in rendered order).
It ignores own messages, quiet agent rows and non-broadcast replies,
follows reads, and clears when nothing is unread. It comes from the same
bounded evidence as `observedCount`. `equal()` compares it so
subscribers see it move.
- **Timeline check** (`src/features/messages/ChannelTimeline.tsx`): when
entry history is incomplete, the boundary settles with no line unless
`oldestUnreadId` is a row loaded on entry. Complete-history visits still
place the line at the first unread entry row, but no longer wait on
`hasMore` or `historyLimited`, so a window that gains `hasMore` after
entry (live arrivals trimming it) settles from the entry rows instead of
staying pending. The pending boundary filters rows to the entry set
before classifying them. The line still stays put for the visit as older
pages load, rows arrive and the reader scrolls. `MessageManagement.tsx`
is untouched; `entryHistoryComplete` means what it meant.
- **`sampled` wait**: a new snapshot flag saying a repair has read every
channel in unread scope since evidence was last emptied (cache clear or
capacity overflow) or a channel entered that scope. Later repairs
(reconnect, backstop, refresh) only add evidence and leave it set, so
they publish no extra snapshots. Incomplete-history visits wait for it
before settling. Without it, a Chromium probe that held the repair read
and re-entered a 25-message channel drew the line at message 5 while
messages 0–4 were unread too.
- **Read-row guard**: with incomplete history, the line also needs a
read entry row above the first unread one. This covers a repair batch
that filled its 500-row limit and stopped inside the window, leaving the
first loaded row the oldest unread message known. Catch-up's `activity:`
mark reads all ordinary rows up to a timestamp, so a read ordinary row
vouches for the rows above it.
- **Cache clear now restarts the repair**: `clear()` used to leave a
connected session without a repair until the 15-minute backstop or a
reconnect; it now starts one at once, as a newly accessible channel
does. An overflow still waits for the next recovery tick.
- **Help and docs**: `docs/buzz-help.md` describes the rule instead of
the old limitation; `docs/unread.md` documents both fields with the
bounded-evidence caveat.

## Known limits

- The line can appear a moment after entry, once the repair finishes.
Until then the channel holds its own reading (dwell and bottom
catch-up), so entering does not erase the evidence its divider waits
for; the timeline asks the store for the repair itself, so a host
without the shell's startup request still gets one.
- If that repair fails or evidence overflows (freshness `stale`,
including a stale reconnect), the hold is released and reading resumes
with no divider for that visit.
- Rows dwelt on during the hold and left before it lifts are not marked
read by that dwell; the next dwell reads them. Catch-up at the bottom
still reads the ordinary rows once the hold lifts.
- No line when the unread messages begin exactly at the first loaded row
(the read-row guard has nothing to vouch for them).
- Mentions, broadcasts, broadcast replies and messages marked unread
above the window are read only by their own `msg:`/`thread:` marks,
never by `activity:`. A repair sample that stopped short can still hide
one of them, and the line then lands below it. Recorded in
`docs/unread.md`.
- The flipped pinned test variant depends on mount timing: a remote read
that leaves a loaded row the oldest unread places the line there only if
it lands before the timeline is presented. The same read after the
boundary has settled leaves no line, because the boundary is captured
once per visit.

## Reviewer notes

- **FOUNDATION**: `UnreadSnapshot` is re-exported through the FOUNDATION
type entry `src/plugins/author.ts`. This adds two optional fields in
`unread.ts` and does not edit `author.ts`, the same shape as #2998's
`row` field. Named here so the rule is applied knowingly.
- **Conflicts with #2401**, which rewrites `compute()` in `unread.ts`.
The `oldestUnreadId` hunks will conflict with whichever lands second;
this one is about 50 lines to re-apply. #2049 moves the divider JSX and
#2652 touches a nearby effect; neither overlaps the boundary logic.
- **Side effect**: because a cache clear now restarts the repair at
once, sidebar unread counts and Inbox mentions return immediately after
a clear instead of after the backstop. Three startup tests that pinned
"nothing is read after a clear" now expect the restart; an Inbox test
that expected activity to stay hidden after a clear now checks it is not
reported as nothing-to-read before the repair returns.
- The flipped variant of the pinned "outside the entry window" test is a
deliberate product call (see Known limits). A reviewer who wants the
stricter #2083 reading can keep the third variant and drop the new case,
at the cost of no line in that race.

## Testing

| Check | Result |
|---|---|
| `tsc --noEmit`, Biome on changed files | clean |
| Four related Vitest files (`ChannelTimeline.restore`, `unread`,
`MessageManagement`, `ChannelTimeline`) | 479 passed |
| Relay, messages, channel-navigation, bundled/channels suites | 7,706
passed |
| Full Vitest | 1,034 files passed |
| `tests/browser/new-message-boundary.spec.mjs` | 4/4 Chromium, 4/4
WebKit, 20/20 repeated |
| Mutation checks | removing each guard, the restart or the reset breaks
at least one test |

The new browser case seeds 25 messages, reads to the bottom, receives
three from a peer and asserts the line sits above the first; it fails on
main and passes here. New restore cases place the line inside incomplete
history (presented and late mount, stable while older rows page in) and
omit it at a first loaded row with no read row before it.
`unread.test.ts` and `unread-startup.test.ts` cover `oldestUnreadId` and
`sampled` through refresh, clear, overflow and a channel entering scope
mid-repair.

**Not run**: the native app via `just desktop`, the notification and
Inbox DM entry paths, and the restart after a clear against a real
relay. The final amend changed only tests and the publish order of one
reset, so the browser spec and wider suites were not re-run after it.

## Before and after

Same Playwright flow against the fixture relay (`readState: true`,
`historyCounts: { alpha: 25, beta: 15 }`) on main `a905c4865` (left) and
this branch (right). Around 8–13 s #Alpha (25 messages) is reopened
after three peer messages: the left has nothing above "Later message 1",
the right shows the "New messages" line between message 24 and "Later
message 1". From about 15 s #Beta (15 messages) shows the line on both
sides.


https://github.com/user-attachments/assets/07f643fa-65b6-47f0-9012-44e37901a27c

| Before (main `a905c4865`) | After |
|---|---|
| ![Before: #Alpha reopened after three new messages, no line above
"Later message
1"](https://github.com/user-attachments/assets/1e6e06d8-378a-4c36-8716-488148b1f289)
| ![After: the "New messages" line sits between message 24 and "Later
message
1"](https://github.com/user-attachments/assets/476cc5f6-57a0-4571-8d9b-8f8bac1e1d52)
|

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: mtoohey <mtoohey@squareup.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants