Skip to content

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

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

matt2e wants to merge 11 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 and others added 10 commits November 5, 2026 16:55
…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>
…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>
@matt2e
matt2e force-pushed the inbox-1-channel-windows branch from 13ffa9f to e3a9a92 Compare November 5, 2026 06:01

@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>
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.

3 participants