Repository navigation
Conversation
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>
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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, \ |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
🤖 Fixed in 644cac2: the background write no longer touches marked_unread, so only mark_through clears a manual unread. Doc and test updated.
| AND f.state <> 'muted' | ||
| AND (f.state = 'following' | ||
| OR f.engaged_at >= clock_timestamp() - make_interval(secs => $5)) |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
🤖 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.
| AND (f.state <> 'default' OR f.marked_unread | ||
| OR f.engaged_at >= $8::timestamptz - make_interval(secs => $9)) |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
🤖 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.
| 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 |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
🤖 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
left a comment
There was a problem hiding this comment.
🤖 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
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>
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 unlessBUZZ_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
GET /me/activity?limit=500, then followsnextif there's morePOST /me/read-stateGET ?since=<marker>GET ?since=<marker>since, with your thread and mention rows in that channel, however old its last message is.reset: trueand reloads from 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_v1withversion: 2,max_rows: 500and the retention window. Every request is signed with NIP-98 for its exact URL.GET /buzz/v1/me/activitykindschannel,dm,solo,thread,mention. Default: all.limitbeforesincemarkerfrom 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}channel,dm,solo) are the channels you're a member of. Athreadrow 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.mentionrows are messages that tag you, within the retention window.previewis 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.atis 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.unreadis 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.totalsis the number of unread rows of each kind across your whole list.mentionis your total of unread mentions, capped at 99. It is null on asinceread where nothing changed; keep the numbers you have.sincereturns rows you changed on any device, rows with a new preview, new mentions, mentions in a scope whose read position moved, and joins.removedlists rows that left the list.POST /buzz/v1/me/read-statetakes 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,followingormuted. 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
What we checked
sincewalks 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.Accepted limits
These are rare. Counters watch the first two, and each should stay at zero.
Open questions
Database
CREATE INDEX CONCURRENTLYbetween 0089 and 0090. The statement is in 0090's header.Testing
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./buzz/v1relay test passes.To try it
Run a relay with
BUZZ_V1_ENABLED=trueandBUZZ_AUTO_MIGRATE=trueon a fresh database. Use two keys, A and B, that share a channel.GET /withAccept: application/nostr+jsonshowsbuzz_v1.version2.GET /buzz/v1/me/activity. The channel is listed as read, and you get amarker.GET ?since=<marker>. You get the channel row (unread), a thread row (unread) and a mention row, with totals to match.POST /buzz/v1/me/read-statewithmark_throughon the channel'spreview.id. The channel comes back read.mark_unreadmakes it unread again.set_statemutedmakes it read, withstate: "muted".GET ?since=<new marker>with nothing changed returns no rows andtotals: null.GET /buzz/v1/me/sidebarreturns 404.