Skip to content

fix(unread): keep participated threads in the Inbox and persist its evidence across launches - #1697

Draft
matt2e wants to merge 49 commits into
mainfrom
missing-messages-in-inbox
Draft

matt2e wants to merge 49 commits into
mainfrom
missing-messages-in-inbox

Conversation

@matt2e

@matt2e matt2e commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

This PR is now a draft umbrella, frozen at 34fc40668. It is split for review into three PRs, each stacked on the one before:

  1. fix(unread): keep the viewer's threads in the Inbox under busier channels #2401: per-channel windows and remembered threads, which fix the bug;
  2. feat(unread): restore the Inbox's evidence from a device record on launch #2407: the device record and restore;
  3. perf(unread): catch the Inbox up from a saved mark instead of re-reading every repair #2408: catch-up from a saved mark.

This PR closes when #2408 merges.

Problem

A thread you had replied in vanished from the Inbox (reported in #buzz-team). The Inbox was rebuilt on every launch and check from a single read: the newest 500 messages across all your channels. On a busy roster that reaches back only a few hours. Once busier channels pushed a thread out, nothing brought it back, and a read reply whose root had aged out showed up as a loose row of its own.

reel-pr.mp4

Main 67e404917 is on the left and this branch 9cce1329c on the right. Both run the web app on a fresh device against one local fixture relay with the same made-up team. Playback is 1×, with long waits cut.

What the reel shows

  • 1 · A reply in your thread. Diego answers, without an @mention, in a quiet channel's thread you replied in. 660 newer messages elsewhere push it out of main's read. Only the branch shows the row and the thread dot, at launch and after a reload.
  • 2 · One thread, one row. Three read @mentions sit in a three-day-old thread you replied in. The Inbox lists one row per thread, so the branch's single #incident-review row is correct. Main's three rows are the degraded case. The thread's root and your reply are older than its read, and it never looks up the root of a read reply, so each mention stands alone (the #buzz-team symptom). Main regroups only once the thread is opened.
  • 3 · A quiet channel. A post from nine hours ago, with no @mention, in a channel you last read three days ago. The branch marks the channel unread; main does not until you open it.
  • 4 · A thread from three weeks ago. A new reply in an old thread you replied in, behind 130 newer messages of yours. The branch finds it by paging back through your own messages; main never does.
  • 5b · Relaunch while message reads are held for 15 s. The branch shows the rows it saved on the device at about 0.5 s and keeps them through the read error. Main stays empty, even once the relay answers again.
  • 6 · The once-a-minute check. Both make the same number of queries, but main downloads about 250 kB and the branch about 50 kB. The Inbox does not change. This was recorded before fix(unread): repair unread from the recovery tick only every 15 minutes #2252. Since then, on both builds, a healthy minute check reads nothing of unread; it repairs unread only while a repair is owed or when the 15-minute backstop is due.
  • 7 · Same result on both. A reply older than a channel's "caught up" point, which scrolling to the end on another device leaves. An earlier version of this branch missed it; both show it now.

How it works now

  • Each channel on its own, plus your threads. Each joined channel is read from its read mark, so a busy channel cannot crowd out a quiet one. The Inbox also remembers the threads you take part in: your replies, found once by paging back through your messages, and mentions, answers and follows. The 50 most recent are read whole; the rest are read only for new replies. The full read also keeps main's newest-500 read, so it holds everything main's read holds.
  • Evidence saved on the device. The signed events behind the Inbox, the remembered threads and a "caught up to here" mark are saved in IndexedDB (buzz-unread-v1), per relay and viewer. They are re-verified on launch and purged when you leave a community.
  • Later checks read only what changed. Launch, reconnect and each unread repair the minute recovery check makes read one filter per 128 channels, starting 31 minutes before the mark to catch late commits. The minute check is a recovery hint. Since main's fix(unread): repair unread from the recovery tick only every 15 minutes #2252, which this branch keeps, it repairs unread only while a repair is owed, as after a failed or dropped read, or when the 15-minute healthy backstop is due. Otherwise it reads nothing of unread, on main as on the branch. The full read runs only on a fresh device, from a record that is incomplete (over its cap, for example) or whose mark is over a day old, when a catch-up is still full after six pages, and on Refresh.
  • Failed reads say so. A read that fails shows "Some recent activity could not be checked" with Refresh, instead of counting zero.

Details and limits are in docs/inbox.md.

Measured

These are medians of 3 runs against the live relay: read-only, one viewer, during a quiet hour. The branch side is this branch's production code, measured as a11a08388. Since then the branch has been rebased onto newer main, which brought #2252 (below), and has taken fixes for review findings. None of that was re-measured.

main branch
Each unread repair by the minute check, before #2252 1 request, 481 KB, ~56 ms relay time 1 request, 1–15 KB, 10–16 ms relay time
Warm launch, all /query 122 requests, 10.3 MB, 5.6 s relay time 116 requests, 9.4 MB, 5.6 s relay time
First Inbox row, warm launch 7.1 s 6.4 s
Unread CPU per idle minute, 1× / 4× throttle 77 / 246 ms 151 / 685 ms
Inbox rows listed 51 80–82
IndexedDB written per open hour none ~0.44 MB

Both sides were measured before #2252 (merged 2026-11-03): main as 67e404917, the branch as a11a08388. At that point the minute check repaired unread every minute, so the first row was each minute's read. Those main figures no longer describe main. Since #2252, a healthy minute check on main reads nothing of unread and repairs only while a repair is owed or once 15 minutes have passed since the last repair. The branch keeps that gate. The first row is now the cost of one such repair, as measured then. The idle-CPU and IndexedDB rows come from the same pre-#2252 runs and were not re-measured either.

Known limits

  • A relaunch with the relay not answering at all. This is scenario 5, which is not in the reel. Like main, the branch shows no saved rows until the relay answers. Saved rows appear early only while channel, profile and read-state reads still answer (5b). Which of those reads they wait on has not been narrowed down.
  • No runtime kill switch. Nothing is behind a flag. Rollback is a revert and a release.
  • Over-cap records. If what the Inbox would save is over 2,048 events or 4 MiB, every launch runs the full read and shows no saved rows. In the measured case, with the cap lowered to simulate this, that was ~7 requests and ~3 MB. Within that session, later checks still read only what changed.
  • Not exercised: the native Tauri host, and live traffic in a busy hour.

For reviewers

  • src/features/relay/session.ts is FOUNDATION and needs its owner's review. The change (+8/−2) passes the device storage and a roster-settled check to createUnread, and makes the cache clear wait for the saved record to be wiped.

  • Browser cases: one file added, none removed. tests/browser/unread-storage.spec.mjs has two cases, both needing a real browser: lower layers stub IndexedDB, the reload and the broker's minute check.

    • The IndexedDB boundary: a record survives a reload with its mark stored apart, a refused write keeps the last record, a purge clears only its partition, and the purge fence holds across windows.
    • A warm relaunch of the built app: the saved rows show before the relay answers, then only the catch-up read is sent, as on a minute check once the 15-minute backstop is due, and a reply deleted meanwhile is dropped.

    Each failed on the code before its change; the commits give the details.

🤖 Generated with Claude Code

@matt2e
matt2e marked this pull request as ready for review October 27, 2026 10:13
@matt2e
matt2e requested a review from a team as a code owner October 27, 2026 10:13
@matt2e matt2e changed the title fix(unread): keep the viewer's threads in the Inbox under busier channels fix(unread): keep participated threads in the Inbox with per-channel windows and persisted participation Oct 27, 2026
@matt2e
matt2e force-pushed the missing-messages-in-inbox branch from 72cb30b to 4d69bfb Compare October 27, 2026 10: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 review via Wes’s account.

Three P2 correctness fixes and one P3 keyboard-focus fix are detailed inline. Rocket, Mantis, Groot and integration review are complete.

Head 4d69bfbf85fdf6a1e2ee94c70c6679a21f3b40dc; base b6e1941e6e29f2f688ba8e4920c5aaa175397221. Source only: no tests or app execution. CI was still running in the snapshot; native behavior, real IndexedDB lifecycle and failure-path interactions remain unverified.

Comment thread src/features/relay/unread.ts Resolved
Comment thread src/features/relay/unread.ts Resolved
Comment thread src/features/relay/unread.ts Resolved
Comment thread src/bundled/inbox/InboxPage.tsx Resolved

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

🤖 Two product regressions and one browser-test ordering defect are detailed inline. The duplicate participation-budget defect is already covered by comment 4387616679 and is not repeated.

[P2] The required profile-preview journey also fails in Chromium and WebKit at this head: tests/fixtures/profiles.tsx:247 returns room evidence for the new per-channel queries, adding an unread badge so the exact button name "One" no longer matches. The failure was reproduced; this fixture/locator explanation is inferred from the fixture and failure snapshot. Update the fixture query handling or locator while retaining the profile-preview assertions.

Comment thread src/features/relay/unread.ts Resolved
Comment thread src/features/relay/unread.ts Resolved
Comment thread tests/browser/sidebar-unread.spec.mjs Resolved
matt2e added a commit that referenced this pull request Oct 27, 2026
… split oversized reads

Review of 4d69bfb (PR #1697, wesbillman and kalvinnchau): seven inline
threads and one failing required browser journey.

Participation budget: a full-page thread read's split rereads replies the
page already admitted, and they were charged again, so four remembered
roots of 200 replies could exhaust the 1,024-reply budget on duplicates
and drop a thread. Admitted replies are now tracked by ID across the pass
and a full page is judged against the filter's own limit.

Overlays at capacity: a peer edit or deletion ranked as unpinned because
its `e` reference has no reply marker, so once pinned evidence filled the
budget it was silently dropped and the superseded body stayed counted.
An edit or deletion now ranks with its newest-ranked content target and
is admitted while the target is; when its own target goes first, the
target is retired with it.

Remembered threads after restart: a remembered thread whose viewer reply
is older than both the authored page and the 200-reply thread page lost
its membership once its replies were read, since `want()` suppressed the
lookup for read replies. Read replies in a remembered thread now run the
membership lookup, which recovers the viewer's reply as a verified
witness or finds it deleted and lets the thread leave; mention-only
threads still gain no membership.

Capacity across channels: eviction was oldest-first across every channel,
so a busy channel's live traffic could evict a quiet channel's only
unread message, which was then rejected as too old on refresh and read
as a confident zero. Ordinary evidence is now evicted from the channel
holding the most of the exhausted budget (events or bytes), oldest first;
pinned evidence still goes last. A channel left with nothing retained
loses observed coverage instead of counting zero.

Oversized responses: a roster's windows can exceed the reader's 8 MiB
response cap (40 channels of 76 messages with 3,000-byte bodies), and the
identical retry failed the repair with no Inbox items. A response over
the cap now splits the request deterministically — filters in halves, a
lone filter's `ids` in halves, a lone filter's limit halved, dropping a
filter at limit 1 — and the parts are read next; `READ_BUDGET_EXCEEDED`
names the reader's error.

Inbox partial notice: keyboard-activating its Refresh removed the focused
button with no destination. It now joins the `retryButton` ownership
handoff; focus moves to the alert's Retry when the refresh fails and to a
stable Inbox control when it succeeds, and stays wherever the person
moved it meanwhile. Ownership is only claimed by the ref release, since a
control's ref is also released when its props change.

Tests: the sidebar-unread journey waits until Alpha's window is held
rather than asserting on whichever concurrent batch arrived first; the
profiles fixture answers the repair's own filter shapes with nothing, as
its former single evidence query did, so "One" carries no badge.
Regressions cover each fix, including an owner-through-real-reader run of
the oversized-response split.

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

matt2e commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Re: the failing profile-preview journey (kalvinnchau's review body) — fixed in 29a55a7 by the fixture: tests/fixtures/profiles.tsx now answers any filter matching isUnreadWindowBatch([filter]) (per-channel windows, authored page, thread reads) with no events, as the former single #h: [one, two] evidence query effectively did, so channel "One" carries no unread badge and the exact-name locator matches. profiles.spec.mjs passes in Chromium and WebKit on this head.

@matt2e
matt2e marked this pull request as draft October 27, 2026 11:36
matt2e added a commit that referenced this pull request Oct 27, 2026
… split oversized reads

Review of 4d69bfb (PR #1697, wesbillman and kalvinnchau): seven inline
threads and one failing required browser journey.

Participation budget: a full-page thread read's split rereads replies the
page already admitted, and they were charged again, so four remembered
roots of 200 replies could exhaust the 1,024-reply budget on duplicates
and drop a thread. Admitted replies are now tracked by ID across the pass
and a full page is judged against the filter's own limit.

Overlays at capacity: a peer edit or deletion ranked as unpinned because
its `e` reference has no reply marker, so once pinned evidence filled the
budget it was silently dropped and the superseded body stayed counted.
An edit or deletion now ranks with its newest-ranked content target and
is admitted while the target is; when its own target goes first, the
target is retired with it.

Remembered threads after restart: a remembered thread whose viewer reply
is older than both the authored page and the 200-reply thread page lost
its membership once its replies were read, since `want()` suppressed the
lookup for read replies. Read replies in a remembered thread now run the
membership lookup, which recovers the viewer's reply as a verified
witness or finds it deleted and lets the thread leave; mention-only
threads still gain no membership.

Capacity across channels: eviction was oldest-first across every channel,
so a busy channel's live traffic could evict a quiet channel's only
unread message, which was then rejected as too old on refresh and read
as a confident zero. Ordinary evidence is now evicted from the channel
holding the most of the exhausted budget (events or bytes), oldest first;
pinned evidence still goes last. A channel left with nothing retained
loses observed coverage instead of counting zero.

Oversized responses: a roster's windows can exceed the reader's 8 MiB
response cap (40 channels of 76 messages with 3,000-byte bodies), and the
identical retry failed the repair with no Inbox items. A response over
the cap now splits the request deterministically — filters in halves, a
lone filter's `ids` in halves, a lone filter's limit halved, dropping a
filter at limit 1 — and the parts are read next; `READ_BUDGET_EXCEEDED`
names the reader's error.

Inbox partial notice: keyboard-activating its Refresh removed the focused
button with no destination. It now joins the `retryButton` ownership
handoff; focus moves to the alert's Retry when the refresh fails and to a
stable Inbox control when it succeeds, and stays wherever the person
moved it meanwhile. Ownership is only claimed by the ref release, since a
control's ref is also released when its props change.

Tests: the sidebar-unread journey waits until Alpha's window is held
rather than asserting on whichever concurrent batch arrived first; the
profiles fixture answers the repair's own filter shapes with nothing, as
its former single evidence query did, so "One" carries no badge.
Regressions cover each fix, including an owner-through-real-reader run of
the oversized-response split.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e force-pushed the missing-messages-in-inbox branch from 29a55a7 to ffdf357 Compare October 27, 2026 22:07
matt2e added a commit that referenced this pull request Oct 28, 2026
…loader, count only channel cursor pages

CI on ffdf357 (PR #1697): the JavaScript shard and two browser journeys.

The broker's Vite config reaches `unread-window-batch.ts` through
`relay-broker.mjs`, and its extension-less `./events` import drew Vite's
`configLoader: 'native'` warning that `vite-config.test.mjs` forbids. The
import now names `./events.ts`, as `channel-details-protocol.ts` does.

The message-navigation and scroll-paging journeys counted every query
with `until` as a channel cursor page. Their fixture history is written
by the viewer, so the authored page is full and the walk continues with
`until` + `before_id` pages of its own (one and two respectively). Both
now count cursor pages by `#h`.

Checks: `vite-config.test.mjs` passes; biome and tsc clean; Playwright
chromium message-navigation (13) and chromium-measurements scroll cursor
paging pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e marked this pull request as ready for review October 28, 2026 05:14

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

🤖 Two recovery defects remain at this head; details inline.

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

@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. One additional P2—missing retry feedback after dropped thread reads—is detailed inline.

Source review also corroborates the existing cold-thread disappearance and native size-rejection mismatch; those findings are not duplicated here. The earlier admission, overlay, retention and focus fixes are present.

P3 — Sanitize public review material. The PR description and performance narratives in several commit messages include live workspace channel names and channel/message ID prefixes. Replace those identifiers with generic labels while retaining measurements and legitimate attribution.

Rocket, Mantis, Groot and integration review complete. Head 1cd1136aab64d8b22cb7e8c73df05cd2d9e9f9aa; base 1db9a6ca8294c356b8d845b941af3339e22f1578. Source only: no tests or app execution; native, IndexedDB and failure-path behavior unverified. CI snapshot successful except Windows validation skipped.

Comment thread src/features/relay/unread.ts Resolved
@matt2e
matt2e marked this pull request as draft October 28, 2026 06:07
matt2e added a commit that referenced this pull request Oct 28, 2026
… thread read is dropped, state the cold-tier row contract

Review of 1cd1136 (PR #1697, kalvinnchau and wesbillman): three P2
threads.

Native size cap: the native host reads at most 16 MiB (`read_response`)
and rejects before the reader can measure, with the plain string `Relay
response is too large` its IPC carries; the repair's `oversized()` knew
only the reader's 8 MiB `ReadError`, so a roster whose windows came back
over the cap was retried once identically, dropped, and left the Inbox
stale with unknown counts. `READ_BUDGET_EXCEEDED` moves to errors.ts
beside the other native messages, and `isOversizedResponse()` names both
rejections; the repair splits on either. Regression (unread-startup):
a 40-channel roster whose 41-filter request the native host rejects
splits to 21 + 20 and every channel is observed (pre-fix: `[41, 41]`,
stale, as the reviewer's probe found).

Dropped thread reads: a dropped `#e`/`ids` read reached `pass.landed`
with no result and bypassed `unchecked`, so the repair ended `observed`
with no notice while a participated thread's unread reply was missing.
A dropped thread read or root fetch now sets `partial` too; its channels'
counts stay observed, the Inbox shows the soft notice with Refresh, and
a dropped walk page still leaves only the walk's mark for the next
repair. The notice reads "Some recent activity could not be checked."
since what is missing need not be a channel. The dropped-cold-read
regression now asserts `partial: true`, no error, the channel observed,
and `partial` cleared by a Refresh that lands the reply.

Cold-tier rows after relaunch: a remembered thread outside the 50 hot
ones whose replies the viewer has all read is not a row on later
launches, since its boundary reply is passed over by ID and the walk
resumes from its mark. This is the contract 520f885 chose on
measurement (137 warm rows where 52 belong, two extra root-fetch
requests), consistent with channel evidence older than its window;
docs/inbox.md now says so explicitly — cold threads are rows while they
hold a reply the viewer has not read — rather than "stay in the Inbox",
and the walk test names it where it asserts the quiet thread absent.

Checks (this checkout, load 25–33): `tsc --noEmit` clean; biome clean
on the changed files; vitest unread-repair, unread-startup,
unread-lookups, unread, reader, thread-participation and InboxPage
suites 7 files, 378 tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e added a commit that referenced this pull request Oct 28, 2026
… split oversized reads

Review of 4d69bfb (PR #1697, wesbillman and kalvinnchau): seven inline
threads and one failing required browser journey.

Participation budget: a full-page thread read's split rereads replies the
page already admitted, and they were charged again, so four remembered
roots of 200 replies could exhaust the 1,024-reply budget on duplicates
and drop a thread. Admitted replies are now tracked by ID across the pass
and a full page is judged against the filter's own limit.

Overlays at capacity: a peer edit or deletion ranked as unpinned because
its `e` reference has no reply marker, so once pinned evidence filled the
budget it was silently dropped and the superseded body stayed counted.
An edit or deletion now ranks with its newest-ranked content target and
is admitted while the target is; when its own target goes first, the
target is retired with it.

Remembered threads after restart: a remembered thread whose viewer reply
is older than both the authored page and the 200-reply thread page lost
its membership once its replies were read, since `want()` suppressed the
lookup for read replies. Read replies in a remembered thread now run the
membership lookup, which recovers the viewer's reply as a verified
witness or finds it deleted and lets the thread leave; mention-only
threads still gain no membership.

Capacity across channels: eviction was oldest-first across every channel,
so a busy channel's live traffic could evict a quiet channel's only
unread message, which was then rejected as too old on refresh and read
as a confident zero. Ordinary evidence is now evicted from the channel
holding the most of the exhausted budget (events or bytes), oldest first;
pinned evidence still goes last. A channel left with nothing retained
loses observed coverage instead of counting zero.

Oversized responses: a roster's windows can exceed the reader's 8 MiB
response cap (40 channels of 76 messages with 3,000-byte bodies), and the
identical retry failed the repair with no Inbox items. A response over
the cap now splits the request deterministically — filters in halves, a
lone filter's `ids` in halves, a lone filter's limit halved, dropping a
filter at limit 1 — and the parts are read next; `READ_BUDGET_EXCEEDED`
names the reader's error.

Inbox partial notice: keyboard-activating its Refresh removed the focused
button with no destination. It now joins the `retryButton` ownership
handoff; focus moves to the alert's Retry when the refresh fails and to a
stable Inbox control when it succeeds, and stays wherever the person
moved it meanwhile. Ownership is only claimed by the ref release, since a
control's ref is also released when its props change.

Tests: the sidebar-unread journey waits until Alpha's window is held
rather than asserting on whichever concurrent batch arrived first; the
profiles fixture answers the repair's own filter shapes with nothing, as
its former single evidence query did, so "One" carries no badge.
Regressions cover each fix, including an owner-through-real-reader run of
the oversized-response split.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e added a commit that referenced this pull request Oct 28, 2026
…loader, count only channel cursor pages

CI on ffdf357 (PR #1697): the JavaScript shard and two browser journeys.

The broker's Vite config reaches `unread-window-batch.ts` through
`relay-broker.mjs`, and its extension-less `./events` import drew Vite's
`configLoader: 'native'` warning that `vite-config.test.mjs` forbids. The
import now names `./events.ts`, as `channel-details-protocol.ts` does.

The message-navigation and scroll-paging journeys counted every query
with `until` as a channel cursor page. Their fixture history is written
by the viewer, so the authored page is full and the walk continues with
`until` + `before_id` pages of its own (one and two respectively). Both
now count cursor pages by `#h`.

Checks: `vite-config.test.mjs` passes; biome and tsc clean; Playwright
chromium message-navigation (13) and chromium-measurements scroll cursor
paging pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e force-pushed the missing-messages-in-inbox branch from dfb0339 to 1c42832 Compare October 28, 2026 10:49
matt2e added a commit that referenced this pull request Oct 28, 2026
… thread read is dropped, state the cold-tier row contract

Review of 1cd1136 (PR #1697, kalvinnchau and wesbillman): three P2
threads.

Native size cap: the native host reads at most 16 MiB (`read_response`)
and rejects before the reader can measure, with the plain string `Relay
response is too large` its IPC carries; the repair's `oversized()` knew
only the reader's 8 MiB `ReadError`, so a roster whose windows came back
over the cap was retried once identically, dropped, and left the Inbox
stale with unknown counts. `READ_BUDGET_EXCEEDED` moves to errors.ts
beside the other native messages, and `isOversizedResponse()` names both
rejections; the repair splits on either. Regression (unread-startup):
a 40-channel roster whose 41-filter request the native host rejects
splits to 21 + 20 and every channel is observed (pre-fix: `[41, 41]`,
stale, as the reviewer's probe found).

Dropped thread reads: a dropped `#e`/`ids` read reached `pass.landed`
with no result and bypassed `unchecked`, so the repair ended `observed`
with no notice while a participated thread's unread reply was missing.
A dropped thread read or root fetch now sets `partial` too; its channels'
counts stay observed, the Inbox shows the soft notice with Refresh, and
a dropped walk page still leaves only the walk's mark for the next
repair. The notice reads "Some recent activity could not be checked."
since what is missing need not be a channel. The dropped-cold-read
regression now asserts `partial: true`, no error, the channel observed,
and `partial` cleared by a Refresh that lands the reply.

Cold-tier rows after relaunch: a remembered thread outside the 50 hot
ones whose replies the viewer has all read is not a row on later
launches, since its boundary reply is passed over by ID and the walk
resumes from its mark. This is the contract 520f885 chose on
measurement (137 warm rows where 52 belong, two extra root-fetch
requests), consistent with channel evidence older than its window;
docs/inbox.md now says so explicitly — cold threads are rows while they
hold a reply the viewer has not read — rather than "stay in the Inbox",
and the walk test names it where it asserts the quiet thread absent.

Checks (this checkout, load 25–33): `tsc --noEmit` clean; biome clean
on the changed files; vitest unread-repair, unread-startup,
unread-lookups, unread, reader, thread-participation and InboxPage
suites 7 files, 378 tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e matt2e changed the title fix(unread): keep participated threads in the Inbox with per-channel windows and persisted participation fix(unread): keep participated threads in the Inbox and persist its evidence across launches Oct 29, 2026
matt2e added a commit that referenced this pull request Oct 29, 2026
… split oversized reads

Review of 4d69bfb (PR #1697, wesbillman and kalvinnchau): seven inline
threads and one failing required browser journey.

Participation budget: a full-page thread read's split rereads replies the
page already admitted, and they were charged again, so four remembered
roots of 200 replies could exhaust the 1,024-reply budget on duplicates
and drop a thread. Admitted replies are now tracked by ID across the pass
and a full page is judged against the filter's own limit.

Overlays at capacity: a peer edit or deletion ranked as unpinned because
its `e` reference has no reply marker, so once pinned evidence filled the
budget it was silently dropped and the superseded body stayed counted.
An edit or deletion now ranks with its newest-ranked content target and
is admitted while the target is; when its own target goes first, the
target is retired with it.

Remembered threads after restart: a remembered thread whose viewer reply
is older than both the authored page and the 200-reply thread page lost
its membership once its replies were read, since `want()` suppressed the
lookup for read replies. Read replies in a remembered thread now run the
membership lookup, which recovers the viewer's reply as a verified
witness or finds it deleted and lets the thread leave; mention-only
threads still gain no membership.

Capacity across channels: eviction was oldest-first across every channel,
so a busy channel's live traffic could evict a quiet channel's only
unread message, which was then rejected as too old on refresh and read
as a confident zero. Ordinary evidence is now evicted from the channel
holding the most of the exhausted budget (events or bytes), oldest first;
pinned evidence still goes last. A channel left with nothing retained
loses observed coverage instead of counting zero.

Oversized responses: a roster's windows can exceed the reader's 8 MiB
response cap (40 channels of 76 messages with 3,000-byte bodies), and the
identical retry failed the repair with no Inbox items. A response over
the cap now splits the request deterministically — filters in halves, a
lone filter's `ids` in halves, a lone filter's limit halved, dropping a
filter at limit 1 — and the parts are read next; `READ_BUDGET_EXCEEDED`
names the reader's error.

Inbox partial notice: keyboard-activating its Refresh removed the focused
button with no destination. It now joins the `retryButton` ownership
handoff; focus moves to the alert's Retry when the refresh fails and to a
stable Inbox control when it succeeds, and stays wherever the person
moved it meanwhile. Ownership is only claimed by the ref release, since a
control's ref is also released when its props change.

Tests: the sidebar-unread journey waits until Alpha's window is held
rather than asserting on whichever concurrent batch arrived first; the
profiles fixture answers the repair's own filter shapes with nothing, as
its former single evidence query did, so "One" carries no badge.
Regressions cover each fix, including an owner-through-real-reader run of
the oversized-response split.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e force-pushed the missing-messages-in-inbox branch from 1c42832 to ea5d85d Compare October 29, 2026 14:17
matt2e added a commit that referenced this pull request Oct 29, 2026
…loader, count only channel cursor pages

CI on ffdf357 (PR #1697): the JavaScript shard and two browser journeys.

The broker's Vite config reaches `unread-window-batch.ts` through
`relay-broker.mjs`, and its extension-less `./events` import drew Vite's
`configLoader: 'native'` warning that `vite-config.test.mjs` forbids. The
import now names `./events.ts`, as `channel-details-protocol.ts` does.

The message-navigation and scroll-paging journeys counted every query
with `until` as a channel cursor page. Their fixture history is written
by the viewer, so the authored page is full and the walk continues with
`until` + `before_id` pages of its own (one and two respectively). Both
now count cursor pages by `#h`.

Checks: `vite-config.test.mjs` passes; biome and tsc clean; Playwright
chromium message-navigation (13) and chromium-measurements scroll cursor
paging pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e added a commit that referenced this pull request Oct 29, 2026
… thread read is dropped, state the cold-tier row contract

Review of 1cd1136 (PR #1697, kalvinnchau and wesbillman): three P2
threads.

Native size cap: the native host reads at most 16 MiB (`read_response`)
and rejects before the reader can measure, with the plain string `Relay
response is too large` its IPC carries; the repair's `oversized()` knew
only the reader's 8 MiB `ReadError`, so a roster whose windows came back
over the cap was retried once identically, dropped, and left the Inbox
stale with unknown counts. `READ_BUDGET_EXCEEDED` moves to errors.ts
beside the other native messages, and `isOversizedResponse()` names both
rejections; the repair splits on either. Regression (unread-startup):
a 40-channel roster whose 41-filter request the native host rejects
splits to 21 + 20 and every channel is observed (pre-fix: `[41, 41]`,
stale, as the reviewer's probe found).

Dropped thread reads: a dropped `#e`/`ids` read reached `pass.landed`
with no result and bypassed `unchecked`, so the repair ended `observed`
with no notice while a participated thread's unread reply was missing.
A dropped thread read or root fetch now sets `partial` too; its channels'
counts stay observed, the Inbox shows the soft notice with Refresh, and
a dropped walk page still leaves only the walk's mark for the next
repair. The notice reads "Some recent activity could not be checked."
since what is missing need not be a channel. The dropped-cold-read
regression now asserts `partial: true`, no error, the channel observed,
and `partial` cleared by a Refresh that lands the reply.

Cold-tier rows after relaunch: a remembered thread outside the 50 hot
ones whose replies the viewer has all read is not a row on later
launches, since its boundary reply is passed over by ID and the walk
resumes from its mark. This is the contract 520f885 chose on
measurement (137 warm rows where 52 belong, two extra root-fetch
requests), consistent with channel evidence older than its window;
docs/inbox.md now says so explicitly — cold threads are rows while they
hold a reply the viewer has not read — rather than "stay in the Inbox",
and the walk test names it where it asserts the quiet thread absent.

Checks (this checkout, load 25–33): `tsc --noEmit` clean; biome clean
on the changed files; vitest unread-repair, unread-startup,
unread-lookups, unread, reader, thread-participation and InboxPage
suites 7 files, 378 tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e added a commit that referenced this pull request Nov 2, 2026
… split oversized reads

Review of 4d69bfb (PR #1697, wesbillman and kalvinnchau): seven inline
threads and one failing required browser journey.

Participation budget: a full-page thread read's split rereads replies the
page already admitted, and they were charged again, so four remembered
roots of 200 replies could exhaust the 1,024-reply budget on duplicates
and drop a thread. Admitted replies are now tracked by ID across the pass
and a full page is judged against the filter's own limit.

Overlays at capacity: a peer edit or deletion ranked as unpinned because
its `e` reference has no reply marker, so once pinned evidence filled the
budget it was silently dropped and the superseded body stayed counted.
An edit or deletion now ranks with its newest-ranked content target and
is admitted while the target is; when its own target goes first, the
target is retired with it.

Remembered threads after restart: a remembered thread whose viewer reply
is older than both the authored page and the 200-reply thread page lost
its membership once its replies were read, since `want()` suppressed the
lookup for read replies. Read replies in a remembered thread now run the
membership lookup, which recovers the viewer's reply as a verified
witness or finds it deleted and lets the thread leave; mention-only
threads still gain no membership.

Capacity across channels: eviction was oldest-first across every channel,
so a busy channel's live traffic could evict a quiet channel's only
unread message, which was then rejected as too old on refresh and read
as a confident zero. Ordinary evidence is now evicted from the channel
holding the most of the exhausted budget (events or bytes), oldest first;
pinned evidence still goes last. A channel left with nothing retained
loses observed coverage instead of counting zero.

Oversized responses: a roster's windows can exceed the reader's 8 MiB
response cap (40 channels of 76 messages with 3,000-byte bodies), and the
identical retry failed the repair with no Inbox items. A response over
the cap now splits the request deterministically — filters in halves, a
lone filter's `ids` in halves, a lone filter's limit halved, dropping a
filter at limit 1 — and the parts are read next; `READ_BUDGET_EXCEEDED`
names the reader's error.

Inbox partial notice: keyboard-activating its Refresh removed the focused
button with no destination. It now joins the `retryButton` ownership
handoff; focus moves to the alert's Retry when the refresh fails and to a
stable Inbox control when it succeeds, and stays wherever the person
moved it meanwhile. Ownership is only claimed by the ref release, since a
control's ref is also released when its props change.

Tests: the sidebar-unread journey waits until Alpha's window is held
rather than asserting on whichever concurrent batch arrived first; the
profiles fixture answers the repair's own filter shapes with nothing, as
its former single evidence query did, so "One" carries no badge.
Regressions cover each fix, including an owner-through-real-reader run of
the oversized-response split.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e force-pushed the missing-messages-in-inbox branch from 6cf1014 to 1280aa1 Compare November 2, 2026 11:22
matt2e added a commit that referenced this pull request Nov 2, 2026
…loader, count only channel cursor pages

CI on ffdf357 (PR #1697): the JavaScript shard and two browser journeys.

The broker's Vite config reaches `unread-window-batch.ts` through
`relay-broker.mjs`, and its extension-less `./events` import drew Vite's
`configLoader: 'native'` warning that `vite-config.test.mjs` forbids. The
import now names `./events.ts`, as `channel-details-protocol.ts` does.

The message-navigation and scroll-paging journeys counted every query
with `until` as a channel cursor page. Their fixture history is written
by the viewer, so the authored page is full and the walk continues with
`until` + `before_id` pages of its own (one and two respectively). Both
now count cursor pages by `#h`.

Checks: `vite-config.test.mjs` passes; biome and tsc clean; Playwright
chromium message-navigation (13) and chromium-measurements scroll cursor
paging pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e and others added 7 commits November 4, 2026 13:50
…over-cap session's catch-up, and rewrap two Inbox runs

Answers review 7082f452 of a11a083: its three optional notes. No
behaviour changes: comments and docs only, no constant or test moves.

1. unread.ts, ID_BATCH and HOME_BATCH. The two comments read as a
   contradiction: ID_BATCH said the relay plans 100 IDs as a scan
   (5 → 66 ms), while HOME_BATCH said the live relay answers 100 in
   about the time of 50 (44 against 40 ms). They measured different
   relays.
   - ID_BATCH now says the 66 ms came from a local relay on PostgreSQL
     18.6 (the study in note 28de0d7e), and that the live relay did not
     reproduce it.
   - HOME_BATCH says its figures are the live relay's relay time,
     replaying real findHomes ID sets with no cliff at 100 (note
     7adcdfc7, "(a) ID lookups"). Should the live relay's planner show
     that cliff, HOME_BATCH is the constant to lower back to ID_BATCH.
   - Neither constant changes. No EXPLAIN was run, since the live relay
     is read-only to us. The review accepts a statement of where each
     figure came from in its place.

2. docs/unread.md. "While that record is complete ... catches up" read
   as the saved record alone. Since 63c7ffa a session whose selection
   is too large to save writes an incomplete record but keeps catching
   up from what it holds, as docs/inbox.md says. A clause now says so.
   The rest of the bullet is rewrapped, with the link kept on one line.

3. docs/inbox.md. Two runs of the ownership-and-limits paragraph are
   rewrapped to its width: the 84-character line, the "while the
   device" break and the 91-character line after them, then "replies
   dropped (below)," and the short lines around it. The words are
   unchanged.

Checks: bin/pnpm typecheck; biome check --error-on-warnings on
unread.ts; git diff --check.

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

The fixture failed any read carrying kind 9, so Inbox unread reads that
run first consumed the scripted failures and the page opened without
ever failing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…set pages, and skip the palette search in unreadReads

CI on 3bfb504 (PR #1697): the same four journeys failed on every
push since the delta reads arrived, while main at the merged base
passed.

The fixture and the policy relay held any `/query` whose filter has
`until` and `#h`, as a timeline or notice page reading older rows. The
unread owner's catch-up pages have that shape too: the fixtures' message
times all fall within the delta's 1,865 s lookback, so page one is full
and page two is asked with `until` and `before_id`. history-loading:93,
live:217 and scroll:114 then found it in `app.pending` (live.spec
found it twice: the held read passed the deadline and was retried).
A history page never carries `since`; the catch-up's pages always do,
so a filter with `since` is no longer held.

unread-storage:236's `unreadReads` counted the message search the
Search Buzz palette sends while `openPage` types "Inbox". On CI it lost
all 20 flake-check repeats; locally it passed. It now excludes
`search`.

Checks: history-loading:93, live:217 and the warm relaunch pass on
chromium and webkit; scroll:114 passes under chromium-measurements;
membership.spec and unread-storage.spec pass whole on both engines (12);
biome clean on the changed files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…, not only of what it did not

Review thread on 9cce132 (PR #1697, wesbillman), P2.

When a full repair's read of the edits and deletions since a stale mark
was still full at its last page, `vouch` read by ID only the held
content that no read of the pass returned. Windows, the roster sample,
the authored page and thread reads ask for content kinds alone and carry
no edits, so a message they returned whose newer edit sat below the two
1,000-event overlay pages was skipped. The read by ID came back `read`,
the roster sample moved the mark past the edit, and the Inbox kept the
original preview with no later delta reaching the edit.

The read by ID now covers every held content event in the chunks not
read to their end except those received live while the pass ran, whose
edits arrive live too. Being returned by a read still proves an event
is not deleted, but not that its edits were read. The extra IDs ride the
same path: still bounded by the evidence budget, 100 a filter and nine
filters a request, halved and resumed as before. This path runs only
when the overlay read is full.

docs/inbox.md says the read by ID covers the events held in those
channels, other than what arrived live, since the other reads return no
edits.

Tests (unread-restore):
- New: a peer's answer in the viewer's thread, returned by team's window
  and the thread read, with its edit older than 1,000 newer deletions
  since a stale mark. The read by ID names the root and the answer, the
  row shows "answer, edited", there is no partial notice and the mark
  moves. Before this change no read by ID was made ("expected [] to
  deeply equal ArrayContaining{…}").
- Changed, since the read by ID now also names what the reads returned:
  - the stale-mark by-ID test asserts its two requests name the held
    messages, team's window and the roster sample, and not the live one;
  - the full-window cut test expects every held team and busy event;
  - the shared-request test asserts the first filter is the 100 held and
    the second starts with the 101st;
  - the halving it.each drops `newer` and busy, so the read by ID is
    still the two messages and its trace is unchanged; the counts are
    0/1 and the sample confirms the mark at its own time;
  - the resumed-read test compares every top-level filter of both passes;
  - the >128-channel and dropped-window tests add the sample's events.

Checks: `bin/pnpm typecheck`; biome check --error-on-warnings on the
changed files; `git diff --check`; vitest src/features/relay/unread*,
inbox-feed, reader and src/bundled/inbox: 17 files, 635 passed (load
~30). Not run: Playwright (no journey reaches a read by ID), the native
host, a live relay.

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

Review of 3f417fa (PR #1697, wesbillman): the warm-relaunch journey
timed out on hosted Chromium 7/9 waiting 75 s for the broker's minute
tick, with no unread reads.

Since the rebase onto main brought #2252, the session's `recover()`
repairs unread only when unread is owed or UNREAD_BACKSTOP_INTERVAL
(15 minutes) has passed since the last repair. After the healthy
relaunch nothing is owed and the relaunch has just repaired, so the tick
reads nothing. The tick itself comes from the broker's subscriber in
Node on a real 60 s chain, so a page clock cannot speed it up.

The test now installs the renderer's Playwright clock before the first
launch, flowing in real time, and moves its time 15 minutes forward
before waiting for the tick. It moves the time back to real time before
the next reload. Installing the clock in a running page would replace
timers the app already set and briefly read the time as 0, so it is
installed up front. The one-delta assertion is unchanged; the
production interval is unchanged.

Checks: `bin/pnpm test:browser --project=chromium --project=webkit
--no-deps tests/browser/unread-storage.spec.mjs`: 4 passed (both cases on
each engine; the warm relaunch case 1.1 m on each), at load ~60. With the
time jump removed (install kept), chromium fails at the same poll with 0
reads after 75 s, as on CI. Biome clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…unt the delta as a tick's read, and reuse the cached fold

The rebase onto main cbc71c8 brings #1286 (a 30-day read floor) and
#2366 (folded rows cached per channel), and leaves two #2252 tests
counting reads the delta does not make.

- Read floor. `createReadState` now reads everything older than 30 days
  before the device clock as read. The branch-only setups in
  unread-restore, unread-repair and unread-fold, and live-session's
  `caughtUp`, date their fixtures in 1970 and 2023, so 56 of their
  tests saw nothing unread. They take `floor: () => 0` (or
  `readStateFloor`), as main's setups do.
- #2252's recovery tests. "a live message after a failed unread repair
  does not cancel its retry" and "a covered reconnect pays the drop's
  debt" counted a tick's evidence read as an `include_aux` filter with
  kind 40002. A catch-up reads the delta, which carries no `include_aux`,
  so neither saw the tick's read. Both failed on hosted CI at 3f417fa
  (run 41733593360, JavaScript 4/4 and both Vitest flake checks).
  `tickReads` now counts a channel read (`#h`, no `#e` or `ids`, kind
  40002), full or delta, and the first test uses it.
- Fold cache. With #2366 an owner reuses a channel's folded rows until
  the evidence index rebuilds, so the 2,000-event fold test's
  `activity("c7")` after `inbox()` folded nothing. It now asks c7's
  activity first, which folds c7's own 23 events, and `inbox()` then
  folds the other 79 channels and reuses c7's.

The rebase resolved `afterFrontier` as the branch's `catchUp` term plus
main's `reads.floor()` in the `Math.max`, and main's `folded()` cache to
fold `foldable(channelId)`, the channel's own evidence and its overlays.

Checks: bin/pnpm typecheck; biome check --error-on-warnings on the
changed files; vitest src/features/relay, src/bundled/inbox and
src/features/notifications: 157 files, 3,348 passed, 4 skipped.

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

Review thread on 5f4836a (PR #1697, 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 full repair. A pass whose read was dropped after its retry
(a delta chunk, a window, a thread read, a root fetch, the read by ID)
ended with `partial = unchecked` but `owing = false`, so every later tick
skipped unread. The reviewer's 129-channel probe, with the second delta
chunk failing and the relay healthy after, kept the partial notice and
unknown coverage through five minute ticks, with no reads, until the
backstop. A pass in which nothing landed throws and stays owed, and was
read again on the next tick. docs/inbox.md promised "the next check"
in one place and "no automatic retry" in another.

The pass's tail now sets `owing = unchecked`. A pass that drops a read
stays owed, as one that throws does, and the next tick repairs. What each
dropped read leaves behind is already the next pass's to read: a dropped
delta keeps the mark, a dropped window or thread read leaves its channel
outside the roster set, and a dropped read by ID resumes. A pass with
every read landed pays the debt, and the tick after it waits for the
backstop again. So a relay that keeps dropping one read costs one repair
a minute, the same as a relay that keeps failing whole repairs, never
more. `session.ts` (FOUNDATION) is unchanged: its gate reads
`unread.owed()`, which reads `owing`.

Docs: docs/inbox.md now says the repair stays owed after a dropped read,
so the next periodic check, a minute later, repairs again, as after a
repair that fails. The passages that already said "the next check"
stand. docs/relay-queries.md counts a repair that dropped a read as
evidence debt, cleared only by one with every read landed. The
unread-repair comment on an owner that does not retry on its own says
the session's tick does.

Test (unread-startup, through the session's real recovery gate): "a pass
that drops a delta chunk leaves unread owed: the next recovery tick reads
it again, not the 15-minute backstop, and the tick after that reads
nothing". 129 channels, so the delta is two chunks. A full reconnect
pays the marker debt. A covered reconnect then resumes the markers, and
its one-channel chunk fails twice and is dropped (`partial: true`). A
minute later, with the relay answering, the tick reads that chunk again
and `partial` clears. A minute after that, the tick reads no evidence;
the barrier is `retrySync()`, which settles after any read-state refresh
the tick began, then `ensure()`, which waits for the repair chained to
it.
- On the previous unread.ts the tick a minute later reads nothing:
  "expected 3 to be 4" at the chunk's count.
- With `owing = true` at the tail instead (always owed), the last tick
  repairs: "expected 12 to be 10" at its evidence count.
The live-session `caughtUp` harness was tried first and is not used:
its read-state snapshot reads after the first fail, so marker debt never
clears there and every tick repairs either way.

Checks: bin/pnpm typecheck; biome check --error-on-warnings on the
changed files; git diff --check; vitest src/features/relay,
src/bundled/inbox and src/features/notifications: 157 files, 3,349
passed, 4 skipped. Not run: Playwright, the native host, a live relay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e force-pushed the missing-messages-in-inbox branch from 5f4836a to dd306db Compare November 4, 2026 03:07

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

One P2 recovery finding is detailed inline: retaining a boolean debt does not ensure the next delta retries the failed cold-thread scope. The preceding test-counter finding is addressed in source: tickReads now recognizes both full and delta evidence reads. The rebase’s read-floor and per-channel fold-cache integrations introduce no additional actionable finding in the inspected paths.

Reviewed head dd306db07686b1e82adc01c8065005dac070f3ac, base 8668dbd46a12d01fb5583c1b520093ff96930a6b (merge-base cbc71c85afc89abbb5120a3a584649231c26e91d), against previously reviewed head 5f4836ac903c30d7b8bf2b7feaf06f6a52caa1de.

Source only: no local tests, builds or app execution. CI was still running in the single snapshot; JavaScript 4/4 had passed and Windows validation was skipped. Full-suite counts/timings, native storage, automatic-recovery focus and performance remain unverified. The public description and 12 sampled frames from its synthetic demo were inspected; the video was not reviewed frame by frame. This COMMENT review is not approval or merge authorization.

Comment thread src/features/relay/unread.ts Resolved
…in it drops, covered before or not

Review thread on dd306db (PR #1697, wesbillman), P2.

A full Refresh over a channel the mark covers reads each remembered
thread not read through on its own, since the newest reply the viewer
wrote or read in it. When that read was dropped, `short` only kept the
channel from joining the roster set; one already in it stayed. The
record saved complete with the mark held, so the tick it is now owed
(dd306db) routed to the delta. A delta reads from the mark less
1,865 s and reads no cold thread of a covered channel, so the older
reply was never fetched, while `partial` and `owing` cleared.

The pass's tail now takes every `short` channel out of `covered`,
after adding the channels whose window and sample landed. The next
catch-up then reads that channel once, as it does a joined one:
- its window, with the viewer's authored page, beside the delta;
- the roster sample;
- its cold threads' own reads (`pass.uncovered`);
- whole reads of its hot threads, which are no longer settled.
The record saves the roster without it, so a relaunch does the same.
Only after those reads land is the channel covered again and the
debt paid.

A dropped thread read asking only for replies from the delta's start
on is left out of `short`. That is the read past the mark, whose
`since` is the delta's own: a full repair's drop holds the mark, so
the next delta reads every content event in those channels from the
same point. Retiring coverage there would re-read up to 128 channels
for nothing. Passes with no mark keep the old rule. No delta follows
them, and the first pass to confirm a mark sets the roster set afresh.

The other reads dropped in a full repair over a covered channel:
- Hot whole reads: a hot thread not read whole is promoted by the next
  catch-up while it stays among the 50; one that falls out of the 50
  first became a cold thread no catch-up read. A whole read has no
  `since`, so it now takes its channel out too.
- The read past the mark: no gap, as above. The existing tests "a
  dropped read past the mark ..." and "a tick after a dropped read past
  the mark ..." fail if it is not left out.
- Root fetches: `ids` reads name no channel. A dropped fetch leaves a
  remembered thread's reply ungrouped, so the record saves incomplete
  and the next pass runs the full repair and fetches again (e677c00's
  launch and tick tests).
- A catch-up's own drops (a promotion or a lookup-fed cold read in a
  covered channel): the delta moves the mark there, so a fed cold read
  from before its start had the same gap. The same rule closes it.

docs/inbox.md: a thread read that fails takes its channel out of what
the point covers, covered before or not, unless a catch-up reads all
it asked for, as for the read of the threads read through to the
point. The sentence saying a catch-up only adds channels now says so.

Test (unread-restore, it.each tick/launch): "a cold thread's own read
dropped in a Refresh over a channel the mark covers takes the channel
out of the roster set: ...".
- Setup: a complete record caught up a minute ago covering every
  channel, with 50 hotter threads read whole and through. The viewer's
  root in team is remembered but not read through, and has a peer's
  unread answer that only its own read returns: newer posts fill
  team's window and, with busy's traffic, the roster sample.
- The launch reads one delta. Refresh drops the thread's own read
  after its retry, with `partial` and `owed()` set and the record
  saved complete.
- The next tick (or a relaunch) reads team's window and the thread's
  own read since T. When the tick first publishes the notice clear,
  the answer's row (unread 1) is already there, and `owed()` is false.
- The tick after that reads the delta alone.
Fail-before, on dd306db's unread.ts: both variants fail at team's
window ("expected [] to have a length of 1 but got +0"). With the
read-shape assertions removed, the tick variant fails where the notice
clears without the reply, and the launch variant where the row is
missing.

Checks: bin/pnpm typecheck; biome check --error-on-warnings on the
changed files; git diff --check; vitest src/features/relay/unread*:
9 files, 377 passed. Not run: Playwright, the native host, a live
relay.

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 follow-up via Wes’s account, with independent Rocket, Mantis and Groot coverage.

No new actionable findings in the fix since dd306db. The prior cold-thread recovery issue is addressed in source: failed reads whose range is not covered by the delta now remove even an already-covered channel from the saved roster, so the next catch-up or relaunch requests its cold threads before clearing the incomplete-activity notice. Reads fully covered by the delta retain coverage.

Reviewed head: c4ab76b. Target base: 8668dbd (merge base cbc71c8). Follow-up scope: the three-file correction, recovery/persistence callers and error/retry focus behavior.

Source-only: regression tests were inspected, not run; no builds, app launches, live relay exercise, CI verification or native accessibility/performance validation. The public description and 12 sampled frames of its unchanged fixture-demo video were inspected; the remaining footage was not reviewed. This comment is not approval or runtime acceptance.

@matt2e
matt2e marked this pull request as draft November 4, 2026 05:30
…ants

Cut the tests this branch adds to about half, keeping a behavioural
test for each user-visible contract and every regression test for a
defect found in review or live measurement. No production code and no
test that exists on main changes; the fixture and policy-relay changes
production request shapes need stay.

Added lines against the merge-base with main (cbc71c8), before/after:

  unread-restore.test.ts    4,692 -> 1,883
  unread-repair.test.ts     2,216 -> 1,083
  unread-storage.test.ts      658 ->   465
  unread-startup.test.ts      418 ->   384
  unread-storage.spec.mjs     383 ->   334
  unread-fold.test.ts         327 ->   195
  unread-lookups.test.ts      239 ->   112
  live-session.test.ts        217 ->   214
  unread.test.ts              135 ->   125
  in scope                  9,582 -> 5,092
  all test files           10,051 -> 5,561

unread-startup's deletions against the merge-base read 514 -> 522 only
because the diff realigns; every test main has is still there.

Removed from unread-restore.test.ts:
- Mark and delta variants, covered by the kept delta paging, clamp,
  nothing-newer, six-pages, day-old-mark and late-commit tests and by
  unread-startup's dropped-chunk regression (dd306db): "an event dated
  900 s ahead raises the mark…", "a delta with a chunk that fails…",
  "a delta that drops keeps the mark though the roster sample lands…",
  "a pass that could not read the viewer's authored page…", "a pass
  whose roster sample fails…", "a resume launch whose cached roster is
  confirmed unchanged…", "a delta page is the relay's whole response…",
  "a full window's cut is its own limit-th match…", "a delta over a
  partly confirmed roster…" (near-duplicate of the unconfirmed
  membership test), "a delta-only pass advances the walk mark…", "a
  walk beside the delta that drops…" and "a catch-up that ends while
  the channel list is still being read…" (the walk-cut-short test
  covers the walk's record).
- Thread reads beside the delta, whose hot/cold and walk contracts the
  kept unread-repair tests and the join test cover: "a channel the mark
  does not cover has its remembered threads past the 50 hot ones…", "a
  channel joined while this device was closed…", "a reply of the
  viewer's in an old thread…", "following a thread…", "unfollowing a
  thread…", "a remembered thread not read through since the mark…",
  "a full repair from a mark confirmed more than a day ago…", "a warm
  Refresh reads the threads read through…", "a full read past the mark
  is read again in halves…" (unread-startup's two split tests cover
  oversized responses), both variants of "a read past the mark … still
  full after eight splits…" (the dropped-read-past-mark test covers the
  outcome), "a dropped whole read of a thread…" and "a delta-only pass
  fetches by ID the root…" (the lookup-root and 1,000-roots tests).
- Dropped reads and the roster set, beside the kept c4ab76b
  regression: "a channel the mark does not cover whose cold thread read
  drops…" (same fix), "a channel whose window dropped stays out of the
  roster set…", "a channel the delta observed is not observed once
  restore discards…", "unread messages in a channel whose window
  dropped beside a busy one's…", and the regression's launch variant
  (the tick variant stays).
- Read-by-ID variants, beside the kept a0614a4 regression and the
  16-filter bound: "… not received live read by ID…" (the regression
  extends its setup), "a held message … has that read halved…", "a
  held message deleted since the mark is read gone…", "a held unread
  mention edited 3,000 times…" (the edited-more-than-evidence test),
  "a read by ID asks 50 IDs a filter at most…", "a read by ID dropped
  at 200 halves is resumed…", "a read by ID that would need more than
  200 halves…", "of the reads by ID sharing a request…" and "on a
  roster of more than 128 channels holding evidence…".
- Restored-evidence discards, beside the kept tampered-event test and
  the cold-thread forget test (tick variant): "… discarded after a full
  repair from the mark…", "… discarded after the delta's first page…",
  "… discarded after the pass routed to the delta…", both variants of
  "restored evidence forgotten by …, whose roster sample drops…", the
  forget test's launch variant, and "restored evidence whose read of
  deletions is dropped…".
- Record and root variants, beside the kept walk-cut-short, over-cap,
  root-fetch-drop, deleted-root and full-repair-roots tests: "a
  remembered thread's reply saved without its root…", "a remembered
  thread's root the relay returns nothing for…", "a root that full
  repair's fetch returns with no channel…", "a remembered thread's root
  outlasts 1,024 other structure fetches…", "a cold thread's read root,
  which only a fresh launch's walk lists…", "a repair in which nothing
  lands…", "a full repair whose reads land before restore is in…", the
  root-fetch-drop test's launch variant, the over-cap test's bytes
  variant, the mark clamp's delta variant (its fresh-device variant
  stays), and "the viewer's root a thread read fetches…".
- Lifecycle and save edges beside the kept Refresh, cache-clear and
  dropped-read tests: "Refresh pressed while a pass asked to catch
  up…", "a cache clear settles once the record is wiped…", "a pass an
  access change retires…", "an access change while the launch reads
  storage…", "a save in flight is not written again…", "a save that is
  refused is written again…", "leaving a busy channel reads the roster
  sample once more…", "a tick after a dropped read past the mark…" and
  "each pass reports whether it read the full repair…" (a metrics pin;
  client-metrics.test.ts covers the export).
- Inside kept tests: the CPU verify-count and overlay-read trace pins,
  the relaunch loop in the lookup-root test, and helpers left unused.

Removed from unread-repair.test.ts:
- Read-order pins, covered by the lost-thread and walk tests: "reads
  the viewer's page with 127 windows first…", "a thread read's
  full-page split goes ahead…", "a lookup that finds the viewer's reply
  while the windows read…" and "the Inbox leaves loading once the
  viewer's page…" (the dropped-first-request test covers loading).
- Budget variants, beside the kept busiest-eviction, per-thread slot
  and quarter-reserve tests: "each thread in a landing is given its
  newest unread reply…", "a dropped read releases its thread's reserved
  slot…", "a full page's split rereads count each reply once…" and "an
  edit or deletion of a pinned message is admitted…".
- Cold-thread variants, beside the kept return-until-read, boundary
  passed-over and boundary-marked-unread tests: "a cold thread whose
  page comes back full…", "a cold thread's boundary reply that is still
  unread returns to a warm repair…", "a cold thread's read is inclusive
  of its newest time…", "a cold thread marked unread by its …", "a cold
  thread's reply marked unread below its mark…" and "a dropped cold
  read leaves its thread to the next repair…" (the c4ab76b
  regression).
- Dropped reads, beside dd306db and c4ab76b: "a window dropped after
  its retry…", "a window that times out once…", "a dropped walk page…"
  (the walk-cut-short test) and "clearing the owner while a walk page
  reads…".
- Participation edges, beside the kept remembered-participation and
  walk tests: "remembers threads the viewer took part in, not every
  message…", "a followed thread is remembered as of the follow…", "a
  remembered thread in a channel the roster lacks…", "a thread the walk
  teaches in a channel the roster lacks…", "a remembered thread the
  viewer's deleted reply joined…", "a read reply outside the viewer's
  conversations…" and "a lookup feeding a thread whose mark was just
  lowered…".
- "the roster sample keeps a read mention older than its channel's
  mark…", beside the catch-up-mark and large-roster tests.

Removed elsewhere:
- unread-storage.test.ts: "a field added after the participation…"
  (merged into the malformed-record test), "a purge made during a
  storage's session…" and "a storage whose first read failed saves over
  a purge…" (the purge-fence test and journey cover it), "a record
  saved with the mark in its meta…" and "a save of the same meta writes
  it whole over a meta its storage did not write…" (beside the kept
  mark-apart and same-meta tests).
- unread-lookups.test.ts: three follow-up scheduling edges ("pre-empted
  by a new repair", "a follow-up read that fails", "lands as the
  repair's last read"); the kept lookup tests cover the thread read.
- unread-fold.test.ts: the per-channel equivalence test; the 2,000-event
  scale test stays, its spy trimmed to the calls it asserts.
- unread-startup.test.ts: "a relay that accepts reads but answers none
  costs one read per request…" (a count pin) and "a cache clear reports
  an Inbox record it could not wipe" (restore's cache-clear test).
- unread.test.ts and live-session.test.ts: the deleted-edit and tick
  it.each blocks keep one case each.
- unread-storage.spec.mjs: the same-meta-after-refusal block, the second
  fenced save and the rejoin (the vitest storage tests cover them), and
  the warm relaunch's minute tick and clock install (live-session's tick
  test covers what a tick reads).

Checked on this tree: bin/pnpm typecheck; biome check on the changed
files; git diff --check; vitest for src/features/relay,
src/bundled/inbox and src/features/notifications (156 files, 3,245
passed, 4 skipped); Playwright unread-storage, sidebar-unread and
notifications on chromium with --no-deps (18 passed). No test timed out
on this loaded machine, so none was rerun.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e added a commit that referenced this pull request Nov 4, 2026
…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>
matt2e added a commit that referenced this pull request Nov 4, 2026
…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 added a commit that referenced this pull request Nov 4, 2026
…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>
matt2e added a commit that referenced this pull request Nov 5, 2026
…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>
matt2e added a commit that referenced this pull request Nov 5, 2026
…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 added a commit that referenced this pull request Nov 5, 2026
…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>
matt2e added a commit that referenced this pull request Nov 5, 2026
…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>
matt2e added a commit that referenced this pull request Nov 5, 2026
…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 added a commit that referenced this pull request Nov 5, 2026
…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>
matt2e added a commit that referenced this pull request Nov 5, 2026
…ing every repair

PR 3 of the split of PR #1697 (plan: note c2f2f5fd, layer 3). This
commit is `git checkout 34fc406 -- <the 45 files #1697 changes>` on
top of PR 2 (`inbox-2-device-record` at 1c79684). 34fc406 removes
no file, and every file PR 1 and PR 2 change is among the 45, so
nothing is deleted. Main changed none of the 45 files between #1697's
merge-base cbc71c8 and this stack's c3c46c5. Over those files,
`git diff 34fc406 HEAD` is empty, so this tree is #1697's, and its
review history and checks apply.

What this layer adds over PR 2:
- route(): a pass whose record is complete and whose mark was
  confirmed within 24 h reads the delta; otherwise, and on Refresh,
  the full repair from PR 1 and PR 2.
- The delta: one `#h` filter per 128 channels from the mark less
  1,865 s, keyset-paged to a short page, falling to the full repair
  when a chunk is still full after six pages. The mark moves only when
  every chunk reads to its end, and is held at most 900 s past the
  device clock.
- vouch and the read by ID: the edits and deletions since the prior
  mark, and, when that read is full, the held content's edits and
  deletions read by ID, halved down to single events and resumed when
  dropped.
- The roster set (`covered`): the channels the mark covers. A channel
  outside it (joined, left, dropped) is read once beside the delta; a
  dropped thread read takes its channel out.
- `whole` and `through`: remembered threads read whole, and threads
  read through to the mark, which a full repair reads past the mark in
  one multi-root read.
- Over-cap catch-up: a selection too large to save keeps `complete`,
  `whole` and `through` in memory, so the session's ticks still read
  the delta.
- A pass that drops a read stays owed, so the next recovery tick
  repairs again.
- The mark is saved in its own meta entry apart from the record's
  meta, so a tick that only moves the mark writes ~93 B.
- `complete`, deferred from PR 2, and the bookkeeping that serves it:
  evicted content, the ungrouped-root and lost-walk checks, and absent
  roots.
- ID batches: 50 IDs a filter and 16 filters a request for the pass's
  reads by ID; 100 IDs a request for mark homes.

FOUNDATION: src/features/relay/session.ts gains one line in the
createUnread options, `rosterSettled: () =>
channels.roster().state === "verified"`. It is #1697's line from
8034d96, approved by Matt Toohey, and moves here as part of the
split he asked for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e added a commit that referenced this pull request Nov 5, 2026
…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>
matt2e added a commit that referenced this pull request Nov 5, 2026
…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 added a commit that referenced this pull request Nov 5, 2026
…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>
matt2e added a commit that referenced this pull request Nov 5, 2026
…ing every repair

PR 3 of the split of PR #1697 (plan: note c2f2f5fd, layer 3). This
commit is `git checkout 34fc406 -- <the 45 files #1697 changes>` on
top of PR 2 (`inbox-2-device-record` at 1c79684). 34fc406 removes
no file, and every file PR 1 and PR 2 change is among the 45, so
nothing is deleted. Main changed none of the 45 files between #1697's
merge-base cbc71c8 and this stack's c3c46c5. Over those files,
`git diff 34fc406 HEAD` is empty, so this tree is #1697's, and its
review history and checks apply.

What this layer adds over PR 2:
- route(): a pass whose record is complete and whose mark was
  confirmed within 24 h reads the delta; otherwise, and on Refresh,
  the full repair from PR 1 and PR 2.
- The delta: one `#h` filter per 128 channels from the mark less
  1,865 s, keyset-paged to a short page, falling to the full repair
  when a chunk is still full after six pages. The mark moves only when
  every chunk reads to its end, and is held at most 900 s past the
  device clock.
- vouch and the read by ID: the edits and deletions since the prior
  mark, and, when that read is full, the held content's edits and
  deletions read by ID, halved down to single events and resumed when
  dropped.
- The roster set (`covered`): the channels the mark covers. A channel
  outside it (joined, left, dropped) is read once beside the delta; a
  dropped thread read takes its channel out.
- `whole` and `through`: remembered threads read whole, and threads
  read through to the mark, which a full repair reads past the mark in
  one multi-root read.
- Over-cap catch-up: a selection too large to save keeps `complete`,
  `whole` and `through` in memory, so the session's ticks still read
  the delta.
- A pass that drops a read stays owed, so the next recovery tick
  repairs again.
- The mark is saved in its own meta entry apart from the record's
  meta, so a tick that only moves the mark writes ~93 B.
- `complete`, deferred from PR 2, and the bookkeeping that serves it:
  evicted content, the ungrouped-root and lost-walk checks, and absent
  roots.
- ID batches: 50 IDs a filter and 16 filters a request for the pass's
  reads by ID; 100 IDs a request for mark homes.

FOUNDATION: src/features/relay/session.ts gains one line in the
createUnread options, `rosterSettled: () =>
channels.roster().state === "verified"`. It is #1697's line from
8034d96, approved by Matt Toohey, and moves here as part of the
split he asked for.

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