Skip to content

feat(relay): one activity list with unread state for /buzz/v1 - #9504

Open
baxen wants to merge 4 commits into
mainfrom
mbot/me-activity
Open

baxen wants to merge 4 commits into
mainfrom
mbot/me-activity

Conversation

@baxen

@baxen baxen commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

What this is

One relay API that serves a person's whole activity list: their conversations, threads and mentions, with unread state worked out by the relay. The Team view in buzz-app (#3248) loads from it, so the list opens fast and stays the same across devices and after time offline. It replaces /buzz/v1/me/sidebar, which no released client used. It stays off unless BUZZ_V1_ENABLED=true.

The event log stays the source of truth. The new tables are caches the relay can check and rebuild, so nothing needs a backfill.

When the client calls what, and what it costs

When The client Database cost (heavy account / typical account)
App start GET /me/activity?limit=500, then follows next if there's more A full pass over your list, plus a rebuild of anything stale. ~110 ms / ~5 ms on the earlier version of this read; not yet measured on the final code
New message while connected Updates its rows from the live event. No API call. Nothing extra in the message write. Each relay process writes one background update per channel, at most every ~25 ms.
Mark read, mark unread, mute POST /me/read-state One small transaction per change. Your other devices get the updated rows pushed to them.
Reconnect (sleep, network change, relay deploy) Waits a random 0–10 s, then GET ?since=<marker> Only your changed rows. With nothing changed: ~4–8 ms / ~1 ms, plus a freshness check of ~7 ms / ~1 ms. After a day away (~950 changed rows): ~26 ms for the heavy account.
After joining a channel GET ?since=<marker> Same as reconnect. The join shows up in since, with your thread and mention rows in that channel, however old its last message is.
Marker over 30 days old, or 1,000+ changes Gets reset: true and reloads from app start Same as app start

"Heavy" is baxen's Block account: 620 live channels, ~680 threads replied to in 30 days and ~25k mentions in 30 days. "Typical" is a simulated person in 125 channels. Reconnect is the cost that matters most, because a relay deploy makes every client connected to it reconnect at once. The random delay spreads those reads out. At these costs the burst is small.

One-time cost at rollout: a channel that has had no message since the relay deployed has no cached row yet. The first read that needs it rebuilds it, at about 0.7 ms per channel, with each read capped at 2.5 s of rebuilding. Until it's rebuilt, the channel is still listed, without a preview, and its unread state is still correct. Channels that get a message after the deploy are written directly, so shipping the relay ahead of the client means most active channels are ready before anyone reads.

API

Discovery. NIP-11 advertises buzz_v1 with version: 2, max_rows: 500 and the retention window. Every request is signed with NIP-98 for its exact URL.

GET /buzz/v1/me/activity

Parameter Meaning
kinds Any of channel, dm, solo, thread, mention. Default: all.
limit 1–500 rows, default 100.
before Cursor for the next page.
since A marker from an earlier read. Returns only what changed.
{"rows":[{"kind":"thread","id":"<root id>","channel_id":"<uuid>","root_id":"<root id>",
   "at":1731550000,
   "preview":{"id":"<event id>","pubkey":"<hex>","created_at":1731549990,"text":"first 256 chars"},
   "unread":true,"state":"default"}],
 "removed":[{"kind":"channel","id":"<uuid>"}],
 "totals":{"channel":2,"dm":0,"solo":0,"thread":1,"mention":1},
 "next":"<cursor>|null","marker":"<opaque>","reset":false}
  • Rows. Every row has the same shape. Conversations (channel, dm, solo) are the channels you're a member of. A thread row is a thread you're part of: you replied, someone replied to you, or you were tagged in the last 30 days. Threads you follow, muted or flagged unread are also listed. mention rows are messages that tag you, within the retention window.
  • preview is the newest top-level message for a conversation (edits applied), the newest reply for a thread, or the message itself for a mention. A deleted message is never the preview. Agent-only messages are skipped for conversations but not for threads.
  • at is the sort time: when the previewed message arrived at the relay. A reply in a thread moves the thread's row, not its channel's.
  • unread is a yes/no flag. A muted conversation or thread is never unread, but a mention inside it is. Nothing before your first request to this API counts as unread.
  • totals is the number of unread rows of each kind across your whole list. mention is your total of unread mentions, capped at 99. It is null on a since read where nothing changed; keep the numbers you have.
  • since returns rows you changed on any device, rows with a new preview, new mentions, mentions in a scope whose read position moved, and joins. removed lists rows that left the list.

POST /buzz/v1/me/read-state takes 1–100 intents. Each one commits on its own:

  • mark_through: marks a channel timeline or a thread read through a message. A read position never moves back.
  • mark_unread: flags the channel or thread unread until it is next marked read.
  • set_state: default, following or muted. Mute moves here from kind 30078 once clients migrate.

The response returns an outcome for each intent and the updated rows. Those rows are also pushed to your other connected devices as ["BUZZ","activity",{"rows":[…]}]. New messages are not pushed this way, because clients already receive them as events.

Left out on purpose: per-row mention counts, reply counts, the message a scope was last read through, and a server-side unread filter. No view uses them yet.

The full contract is in docs/buzz-v1-activity.md.

Storage

  • Two new cache tables, one row per conversation and one row per thread. Each holds the newest previewed message. New columns on the existing read-position, account and mention tables.
  • Message writes are untouched. After a message commits, a background writer in the same process batches updates per channel and writes them about 25 ms later.
  • On shutdown, the relay waits up to 3 s for those pending writes, so a deploy loses none. Only a crash or a kill can lose them.
  • Each read checks the cached rows against the event log, with one index lookup per row, and rebuilds any that are behind.
  • Indexes: one new index, on mentions by person and arrival time, which the mentions list needs. The shared tables are only ever reached by key from your own rows, so they need no index beyond their primary keys.

What we checked

  • Real data. Measured on baxen's Block account. Per-row mention counts made every read slow: the same subquery ran five times per request. Those counts are gone.
  • Indexes. A local simulation of a 12k-person community compared "find changed rows by index" with "walk your own rows." The indexes saved about 3–5 ms on reconnect for the heavy account and nothing for a typical one. They made every message and every mark-read cost more, and they bloat over time. So since walks your own rows instead. That also keeps its cost tied to your list size rather than to how much the whole community changed since your last read.
  • Where the tradeoff turns. The walk grows with list size, at about 4 ms per 600 channels, so a 5,000-channel account would pay about 30 ms. If that ever matters, an index can be added later.

Accepted limits

These are rare. Counters watch the first two, and each should stay at zero.

  • If a background write is lost (the process was killed, or 8 retries failed), "you're part of this thread" is not recovered. The thread is missing from that person's list until its next reply.
  • A lost channel update is caught by the next read, unless a reaction, thread reply or agents-only message lands in that channel first. In that case it's fixed by the channel's next top-level message.
  • A rebuild only looks at a channel's newest 512 events.
  • A burst in one channel beyond 32 edits or 256 threads within one ~25 ms write interval is not rebuilt until that row is rebuilt for another reason.
  • The full list is under Limits in the doc.

Open questions

  • Warm-up job. Should an operator job build every channel's cached row before clients ship? Not built. Measure cold first reads on a staging copy of a large community first.
  • Threads at launch. The Threads list starts empty and fills as new replies arrive. Is that OK for a first release, or do we fill the last 30 days?
  • Following. Should thread following move to the relay along with mute?
  • Agent-only thread previews. Should thread previews skip agent-only messages, as conversation previews do?
  • Older clients. How long must mutes also be written to kind 30078 for older clients and mobile?

Database

  • No triggers, functions or foreign keys on the new tables, and no backfill.
  • 0089 only creates tables and adds nullable or defaulted columns. The deployed relay keeps working against the migrated schema.
  • 0090 builds the mentions index inline, with a time budget, and verifies it. A deployment that applies migrations out of band should build it with CREATE INDEX CONCURRENTLY between 0089 and 0090. The statement is in 0090's header.
  • There is no down migration, and a relay built before 0089 refuses to start on the migrated schema. See Privacy and lifecycle in the doc.

Testing

  • Postgres tests cover the list, paging, since / removed / reset, intents, rebuilds, and community and membership scoping. Relay tests cover the HTTP contract and auth. A unit test checks that a push reaches only that person's sockets.
  • Locally on PostgreSQL 14: fmt and clippy are clean, all activity database tests pass (including leave and rejoin), and every /buzz/v1 relay test passes.
  • The remaining local failures are in code this PR doesn't touch: partition, push-enqueue, media, huddle, profile search, audit and identity-archive tests. Several are Postgres 14 syntax errors; CI runs 16. They were judged unrelated by reading them, not by rerunning on the base branch.

To try it

Run a relay with BUZZ_V1_ENABLED=true and BUZZ_AUTO_MIGRATE=true on a fresh database. Use two keys, A and B, that share a channel.

  1. GET / with Accept: application/nostr+json shows buzz_v1.version 2.
  2. As A, GET /buzz/v1/me/activity. The channel is listed as read, and you get a marker.
  3. As B, post a message that tags A, then reply to a message A wrote.
  4. As A, GET ?since=<marker>. You get the channel row (unread), a thread row (unread) and a mention row, with totals to match.
  5. As A, POST /buzz/v1/me/read-state with mark_through on the channel's preview.id. The channel comes back read.
  6. mark_unread makes it unread again. set_state muted makes it read, with state: "muted".
  7. GET ?since=<new marker> with nothing changed returns no rows and totals: null.
  8. GET /buzz/v1/me/sidebar returns 404.

Replace GET /buzz/v1/me/sidebar, which no released client used, with
GET /buzz/v1/me/activity: a person's conversations, threads and mentions
as rows of one shape, newest arrival first, with unread state, paging
(`before`), a change feed (`since`) and refresh by id (`ids`).

POST /buzz/v1/me/read-state now takes mark_through, mark_unread and
set_state intents and returns the updated rows, which are also pushed to
the person's admitted sockets as a ["BUZZ","activity",{rows}] frame.

Migration 0089 adds channel_heads and thread_heads, caches of the event
log that a read verifies and rebuilds, and columns on
personal_read_frontiers, personal_read_accounts and event_mentions. 0090
builds the indexes on those columns, so they can be prebuilt
concurrently. Message transactions do not write the shared rows: a
per-process writer coalesces them after commit.

The contract is in docs/buzz-v1-activity.md. NIP-11 buzz_v1.version is 2.

Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
@baxen
baxen marked this pull request as ready for review November 14, 2026 21:35
@baxen
baxen requested a review from a team as a code owner November 14, 2026 21:35
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-14T21:39:24.367084Z 91064ba Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 91064ba61e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

> COALESCE(f.through_timestamp, '-infinity') \
THEN EXCLUDED.through_message_id ELSE f.through_message_id END, \
through_timestamp = GREATEST(f.through_timestamp, EXCLUDED.through_timestamp), \
marked_unread = f.marked_unread AND EXCLUDED.through_timestamp IS NULL, \

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve newer manual unread marks during background writes

If someone posts a message and then marks that conversation or thread unread before the coalesced writer runs, this update unconditionally clears the newer unread flag. The normal 25 ms delay, database contention, and retries all allow that ordering. Because the earlier post's deferred work has no generation check against the subsequent intent, an acknowledged manual unread mark can disappear without another user action. Preserve the ordering of private state changes, or fence the deferred update so it cannot clear a mark made after its message.

AGENTS.md reference: AGENTS.md:L231-L234

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Fixed in 644cac2: the background write no longer touches marked_unread, so only mark_through clears a manual unread. Doc and test updated.

Comment on lines +86 to +88
AND f.state <> 'muted'
AND (f.state = 'following'
OR f.engaged_at >= clock_timestamp() - make_interval(secs => $5))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Rebuild explicitly retained threads as well as live threads

For a historical thread without a thread_heads row, mark_unread creates a frontier with marked_unread = true but no engaged_at, so this predicate excludes it from repair. Setting that thread to muted also excludes it. The listing explicitly includes flagged and muted threads, but its inner join to thread_heads then hides these rows indefinitely despite returning applied for the intents. Make repair eligibility include the same explicitly retained states as the thread listing, including manual unread marks.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Fixed in 644cac2: the rebuild check now covers every thread the list shows (followed, muted, flagged, or recently part of), with a test that a flagged thread with no shared row is rebuilt and listed.

Comment on lines +98 to +99
AND (f.state <> 'default' OR f.marked_unread
OR f.engaged_at >= $8::timestamptz - make_interval(secs => $9))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Report threads removed by explicit state changes

If a client has loaded a followed thread whose engagement is absent or older than 30 days, setting its state back to default makes it fail this predicate. Neither the refreshed POST rows nor subsequent since reads return it, and removed() only reports conversations for channel departures or deletion. Consequently, the client retains the old followed row indefinitely even though the personal counter advanced. Emit a thread removal for explicit changes that make a previously listed thread ineligible, rather than silently filtering it out of the delta.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Fixed in 644cac2: a since read now returns removed: [{kind: thread, id}] for a thread the person's own change took off the list. A thread that only ages out is still not reported; the doc says so.

Comment on lines +27 to +30
JOIN channel_members cm ON cm.community_id = $1 AND cm.channel_id = m.channel_id
AND cm.pubkey = $2 AND cm.removed_at IS NULL
JOIN events e ON e.community_id = $1 AND e.created_at = m.event_created_at
AND e.id = m.event_id

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude soft-deleted channels from mention totals

When a channel containing an unread mention is soft-deleted, channel::soft_delete_on only sets the channel's deleted_at; its membership and event rows remain. This mention-count query checks membership and event deletion but never checks the channel's deletion state. As a result, totals.mention, which sums this CTE directly, continues to count mentions from an inaccessible channel even though the mention listing correctly excludes them. Join live channels here as well so deletion cannot leave an unread badge that the user cannot clear.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Fixed in 644cac2: the mention-count query joins live channels, with a test that deleting the channel clears totals.mention.

- A post no longer clears a manual unread: the deferred write cannot tell
  whether the flag was set before or after its message.
- Rebuild the shared row of every listed thread, including muted and
  flagged ones, not only live ones.
- A `since` read reports a thread the person's own change took off the
  list.
- Mention counts and totals skip soft-deleted channels.

Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>

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

🤖 The pre-review recommends rework before this lands. Once BUZZ_V1_ENABLED is on, each read-state POST pushes a new BUZZ frame to every admitted socket of that person, and the installed CLI (buzz-ws-client) and agent harness (buzz-acp) reject an unknown frame type while waiting for a publish's OK or the AUTH challenge, so a message the relay accepted reports failure on another device; the push needs a per-socket opt-in. With the feature off, every ingest still stages activity rows and writes them from the background writer on the serving writer pool, an extra write transaction per batch for deployments that get nothing from it; either skip the writer while disabled (which leaves the personal rows that nothing rebuilds unwritten) or give it its own pool as push_enqueue has. Resolving both lifts this request.
buzz://message?channel=e71f9a2a-ac46-473d-a24e-5e16fd8cb7b1&id=74556b04a840b2e2768ba916acae0a84eebe51fe59fac12880b286c6a725e61c

baxen and others added 2 commits November 14, 2026 17:12
Agreed revisions to the /buzz/v1 activity list:

- Rows carry `at` and a preview; drop `activity`, `mentions`,
  `read_through_id`, `reply_count` and `preview.edited`. Totals are one
  flat count per kind. Drop the `unread` and `ids` parameters.
- A conversation sorts by the arrival of the message it shows, so a
  thread reply does not move it. Drop `channel_heads.activity_*` and
  `thread_heads.reply_count`.
- `since` walks the person's own memberships and thread rows and reads
  only what changed. It returns new mentions, mentions whose read
  position moved, and the threads and mentions of a joined channel;
  totals are omitted when nothing changed. Removals past the limit
  reset. This needs no index by update time, so drop
  `thread_heads_updated` and `personal_read_frontiers_seq`.
- Rebuild on read is bounded by a 2.5 s budget instead of a row count.
- The relay flushes pending activity writes at shutdown, and counters
  report dropped shared rows, dropped people's rows and rows unwritten
  at shutdown. Channels dropped at the writer's bound are marked for
  rebuild.
- Document what a client does, the accepted losses and open questions.

Co-authored-by: fabletime <e71552e8d81e81b677af6895eb3885f7304cdaaec1af32074c4501e6aee79fe9@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
Co-authored-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz>

* origin/main:
  Revert "feat(relay): agents cannot write in DMs except with their own… (#9500)

Signed-off-by: Bradley Axen <baxen@squareup.com>

This branch was successfully deployed

No deployments
codex-review — 83a1f5c8 Deployed Nov 15, 2026 by baxen via Run Codex Security Review #15224
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.

2 participants