Skip to content

feat(relay): index reactions under their target author for #p reads - #9285

Open
bostonaholic wants to merge 9 commits into
mainfrom
bostonaholic--relay-reaction-recipients
Open

bostonaholic wants to merge 9 commits into
mainfrom
bostonaholic--relay-reaction-recipients

Conversation

@bostonaholic

@bostonaholic bostonaholic commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Why

buzz-app's Activity → Reactions tab (block/buzz-app#2638) finds reactions to your messages with {kinds:[7], "#p":[you]}. A reaction shows up there only if the client that sent it tagged the message author (p, per NIP-25). Today buzz-app and its MCP tool do this. Mobile does it since #9141 (merged); the CLI change is #9121. Agents and third-party clients may never do it. With this PR the relay works out the recipient from the reacted-to message, so the tab works for reactions from every client.

What

  • When the relay stores a reaction, it now also indexes the reacted-to message's author as a #p recipient. That author is the message's effective author: the actor (or leading p) for relay-signed messages, otherwise the signer. The relay looks up the target message during ingest; it never reads the author from the reaction.
  • #p reads and COUNT return those reactions to the message author, whether the filter has one #p value or several. Nothing is added to the signed event, so clients receive exactly what was sent.
  • Derived recipients apply to kind 7 only. Old p index rows for other kinds stay hidden, as they are today (fix: don't notify workflow owners through their attribution tag #8694).
  • scripts/maintenance/backfill_reaction_target_mentions.sql repairs reactions stored before this change, under docs/database.md Rule 3:
    • walks stored kind:7 events, so reactions with no reactions.reaction_event_id link are covered
    • batches of 5,000
    • takes the community-deletion lock
    • idempotent, with a validation query
    • Run it once this relay version is fully deployed.
  • New counter buzz_relay_reaction_recipient_derived_total{outcome} with outcomes inserted, duplicate and fallback_pubkey.
  • NOSTR.md kind 7 now notes the derived recipient. Clients should still tag p, per NIP-25.

Design: relay-derived reaction recipients (slices 1, 2, 4 and the block/buzz docs). The buzz-app side is block/buzz-app#3115. The Activity reactions tab this serves is block/buzz-app#2638.

Testing

  • Matcher (unit): #p matches a derived recipient with no p tag. It does not match when there is none or it is a different pubkey. Literal p tags still match, and the #h fallback is unchanged.
  • Postgres (via ingest, REQ and COUNT):
    • An untagged reaction is returned to the message author and not to the reactor.
    • A relay-signed message with actor resolves to the actor.
    • A reaction that also tags p produces exactly one index row.
    • COUNT matches REQ.
    • A two-value #p REQ and COUNT return the same reactions as a single-value read.
    • Kind-7-only matching is guarded by its own test.
    • Each test fails without its change. Mutation checks turned the tests red when the derived insert was skipped, when the raw signer was used, when the kind-7 restriction was dropped, and when the multi-value #p recipient load was skipped.
  • Backfill against local Postgres: 12,005 seeded reactions covered user-signed, relay-signed (actor / p / neither) and soft-deleted targets.
    • The first run inserted 12,009 rows; the second inserted 0.
    • The validation query returned 0 after both runs.
    • After walking events directly: a reaction with no source-event link was indexed under its target's author, the last valid e tag chose the target, a soft-deleted target was skipped, and a batch size of 1 paged through reactions sharing one timestamp without gaps. Rerun inserted 0; validation returned 0.
    • A quiescing community was untouched.
    • A missing or invalid relay_pubkey_hex exits before writing anything.
  • Checks:
    • cargo fmt --check and cargo clippy --workspace --all-targets -D warnings pass.
    • cargo test passes for buzz-core, for buzz-db (unit tests plus the Postgres tests for event, reaction and feed), and for the buzz-relay handlers and subscription modules.
  • Not run:
    • 2 cluster_global_* relay Postgres tests. They need a database role with CREATEROLE, which the local buzz role lacks; Docker isn't available here.
    • The relay Postgres suite in parallel. It interferes with itself on one shared database; run serially, everything else passed.
  • Not yet done: a human test against a running relay.

Known limits

  • Across relay nodes, a live #p + #h subscription on another node does not receive untagged reactions: the Redis fan-out rebuilds the event without the derived recipient. buzz-app subscribes by #h only, so Activity is not affected.

Rollout (docs/database.md Rule 3 order)

  1. Deploy the relay write path. New reactions are indexed from then on. There is no flag; the change only adds index rows.
  2. Once every older relay is drained, run scripts/maintenance/backfill_reaction_target_mentions.sql, passing relay_pubkey_hex. Its validation query (stored reactions with a live target but no index row for the target's author) must return 0. Re-run the script if it doesn't: it is idempotent.
  3. Ship feat(activity): show reactions that omit the viewer's p tag buzz-app#3115. Until it ships, buzz-app still requires the literal p tag.

Rollback: revert the relay. The extra index rows are inert, because the previous binary's re-check drops them.

🤖 Generated with Claude Code

bostonaholic and others added 4 commits November 9, 2026 09:17
Slice 1 of relay-derived reaction recipients: matcher knows derived
recipients.

StoredEvent gains derived_recipients, hex pubkeys the relay computed
from stored data. A #p filter now matches an event when any filter value
is a literal p tag or a derived recipient. The field is never sent to
clients; the wire event stays the signed event. #h channel fallback is
unchanged.

Co-authored-by: mboston Implementer <932d3b6ebea6cc83e091551ae46d1c7e7f8f7d0fa8e9cef5e6facc8544af413b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matthew Boston <[email protected]>
Slice 2 of relay-derived reaction recipients: write and read path.

Ingest resolves a reaction target's effective author (actor tag for
relay-signed rows, else the signer) and buzz-db indexes the reaction in
event_mentions under that author, in the same transaction as the event,
with ON CONFLICT DO NOTHING so a reaction that already tags the author
keeps one row. The rows are written for channel-less targets too.

The #p read path returns the matched m.pubkey_hex, and a kind:7 row
carries it in derived_recipients, so the REQ and COUNT re-check accepts
a reaction without a p tag. Other kinds still match #p by literal tags
only: their mention rows can name a pubkey no tag matches, such as a
workflow owner's attribution indexed before that stopped.

Adds buzz_relay_reaction_recipient_derived_total{outcome=inserted|
duplicate|fallback_pubkey}; fallback_pubkey is a relay-signed target
with no resolvable author, indexed under the relay key.

Co-authored-by: mboston Implementer <932d3b6ebea6cc83e091551ae46d1c7e7f8f7d0fa8e9cef5e6facc8544af413b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matthew Boston <[email protected]>
Slice 4 of relay-derived reaction recipients: backfill script.

scripts/maintenance/backfill_reaction_target_mentions.sql indexes every
stored kind:7 reaction under its target's effective author in
event_mentions, for reactions stored before ingest wrote that row. It
follows docs/database.md Rule 3: a top-level DO block commits 5,000
reactions per batch, each batch takes the shared community deletion lock
for its communities and writes only to active, undeleted ones, inserts
use ON CONFLICT DO NOTHING, and a final validation query must print 0.
The author resolution matches effective_message_author: actor, then p,
then the signer for relay-signed targets.

Run it after every serving relay writes the row at ingest. Local run
against 12,005 seeded reactions in an active community and 5 in a
quiescing one: first run inserted 12,003 rows in the active community
(plus 6 for older local test reactions), second run inserted 0,
validation printed 0 both times, and the quiescing community kept 0
mention rows.

Co-authored-by: mboston Implementer <932d3b6ebea6cc83e091551ae46d1c7e7f8f7d0fa8e9cef5e6facc8544af413b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matthew Boston <[email protected]>
Slice 5 of relay-derived reaction recipients (block/buzz half): docs.

The kind:7 row in NOSTR.md now says the relay indexes the target's
author as a #p recipient of the reaction, and that clients SHOULD still
tag p per NIP-25.

Co-authored-by: mboston Implementer <932d3b6ebea6cc83e091551ae46d1c7e7f8f7d0fa8e9cef5e6facc8544af413b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matthew Boston <[email protected]>
@bostonaholic

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 9, 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-09T17:38:59.955501Z c957585 Manual request
ℹ️ 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: 9dcc9c90e7

ℹ️ 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".

Comment thread crates/buzz-db/src/store/event.rs Resolved
A multi-value `#p` filter is not pushed into SQL, so reactions came back
without derived recipients and the `#p` post-filter dropped untagged
reactions in REQ history and the COUNT fallback. The store now loads the
matching mention rows for returned reactions when the filter has several
`#p` values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matthew Boston <[email protected]>
@bostonaholic

Copy link
Copy Markdown
Contributor Author

@codex review

@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: a3bfb0ea43

ℹ️ 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".

The backfill walked `reactions` rows with a `reaction_event_id`, so a
reaction stored while `set_reaction_event_id` was best-effort never got
its target-author mention, and the validation query skipped it too.

Walk stored kind:7 events per community instead, resolving the target
from the last 64-hex `e` tag as ingest does. Validation now counts every
stored kind:7.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matthew Boston <[email protected]>
@bostonaholic

Copy link
Copy Markdown
Contributor Author

@codex review

@bostonaholic
bostonaholic marked this pull request as ready for review November 9, 2026 17:21
@bostonaholic
bostonaholic requested a review from a team as a code owner November 9, 2026 17:21

@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: 5b25d76715

ℹ️ 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".

Comment thread crates/buzz-core/src/filter.rs Resolved
A global {kinds:[7], "#p":[author]} subscription now receives an
untagged reaction to a channel-less event live, matching what a
historical REQ returns.

Co-authored-by: mboston Implementer <932d3b6ebea6cc83e091551ae46d1c7e7f8f7d0fa8e9cef5e6facc8544af413b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matthew Boston <[email protected]>
@bostonaholic

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: c95758507e

ℹ️ 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".

@joahg joahg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 No blocking issues found. I am commenting rather than approving, because this changes relay read semantics, ships a data backfill, and I have not traced every consumer that subscribes to #p without kinds (see inline), so a relay maintainer should sign off. I traced the target author, the channel/ACL scoping on REQ, COUNT and live fan-out, and the backfill script.

  • The derived recipient comes from the stored target event (derive_reaction_channel -> effective_message_author), never from the reaction's tags. Targets that are missing, soft-deleted or not readable by the reactor are rejected before any index row is written, so the index cannot be pointed at an attacker-chosen pubkey and a reaction can only attach to a target the reactor could already see.
  • #p reads still go through the same channel scope as before (apply_channel_scope_to_query / channel_ids before LIMIT, then event_visible_to_reader; fan-out keeps its channel gate). The new row copies the reaction's own channel_id, so an author who has left the channel does not get the reaction.
  • One event_mentions row per (pubkey, event): a reaction that also tags the author is a no-op conflict, so there are no duplicates. No schema change or migration; the backfill is out-of-band, idempotent, batched at 5,000 with a correct keyset cursor, and skips deleted targets. The new read-side lookup uses the existing (community_id, event_id) mention index.
  • CI: the Postgres suites pass. The only failure is the cargo-deny advisories job, and this PR touches no Cargo files.

I did not run the backfill or the relay locally. Three optional follow-ups are inline.

.filter_map(|t| t.content())
.any(|event_val| event_val == filter_val.as_str())
});
}) || (tag_key_str == "p"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Optional: this also applies to filters with no kinds. {"#p":[x]} (or #h + #p) now matches untagged kind 7 events, so consumers that subscribe to #p without a kinds list will start receiving reactions to their own messages. Worth checking that such consumers ignore kind 7, or noting it next to the NOSTR.md line.

insert_event_with_thread_metadata_tx(&mut tx, reaction_event, channel_id, thread_meta)
.await?;

let derived_recipient_hex = hex::encode(derived_recipient);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Optional: the PR's "revert is inert" rollback note holds for REQ (the old re-check drops these rows) but not for COUNT. A single-value #p COUNT is pushed entirely into SQL (filter_fully_pushable), so after a revert it would still count the derived rows while REQ omits them. Worth saying so in the rollout notes, or deleting the derived rows (kind 7 rows with no literal p tag) as part of a rollback.

/// multi-value `#p` recipient load → RED.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn a_reaction_is_indexed_under_its_targets_effective_author() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Optional: add a negative access case to this test. Remove the target author from the channel and assert #p REQ and COUNT return nothing for them, and that a reaction to a message in a channel the reader cannot see never matches. That is the property this change must not regress, and today it is only covered by the shared channel-scope code.

bostonaholic and others added 2 commits November 13, 2026 19:12
Once the target's author leaves a private channel, `#p` REQ and COUNT
return none of its reactions to them. Guards the existing channel scope
that the derived-recipient read path relies on (review feedback on #9285).

Co-authored-by: mboston Implementer <932d3b6ebea6cc83e091551ae46d1c7e7f8f7d0fa8e9cef5e6facc8544af413b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matthew Boston <[email protected]>
…eaction-recipients

* origin/main: (111 commits)
  perf(acp): avoid repeated observer batch serialization (#9483)
  docs(readme): point desktop readers at block/buzz-app and describe where each client lives (#9493)
  test(cli,db): freeze the assertion test clock and give pool release tests a test-lane acquire budget (#9442)
  test(desktop): unmount every TerminalSubstrate tree before the window closes (#9440)
  fix(mobile): drop the iOS share extension from the build until it's signed (#9489)
  chore(release): release Buzz Desktop version 0.5.30 (#9491)
  perf(mobile): parse each event's thread reference once (#9460)
  perf(mobile): load only the directory when search opens (#9466)
  perf(mobile): stop rebuilding Home's channel list when a route covers it (#9459)
  perf(mobile): ask typing subscriptions only for the typing window (#9454)
  perf(mobile): skip avatar URL parsing for non-HTTP URLs (#9448)
  perf(mobile): download the identity archive list only when it changed (#9445)
  perf(mobile): don't treat a Control Center pull as a resume (#9443)
  fix(mobile): keep a superseded roster read out of the offline snapshot (#9452)
  perf(mobile): read channel bot roles from the member list (#9451)
  perf(mobile): read Activity once per reconnect (#9455)
  fix(mobile): offer role controls for agents (#9475)
  perf(mobile): stop rebuilding the native nav bar on every scrolled frame (#9458)
  perf(mobile): keep verified-name batches open as people are added (#9450)
  perf(mobile): read only changed custom emoji sets on reconnect (#9446)
  ...

Signed-off-by: Matthew Boston <[email protected]>
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Nov 14, 2026
@github-actions

Copy link
Copy Markdown

🔐 Codex Security Review

Note: This is an automated, security-focused review generated by Codex.
Use it as a supplement to human review; false positives are possible.

Scope

  • Exact PR diff: 5780bd7d94098150a6ff4dc5bc4e8c84f20634e7...4a8ebfd86f628f2c83a947495bdcae5eb86aa189
  • Model: gpt-6.1-sol

💡 Click "edited" above to see earlier reviews for this PR.


Review Summary

Overall Risk: MEDIUM

Derived reaction recipients are lost during cross-node delivery, causing live subscriptions to miss matching reactions.

Findings

[MEDIUM] Preserve derived recipients across Redis delivery

  • Category: Reliability
  • Location: crates/buzz-relay/src/subscription.rs:901 (source)
  • Description: When a reaction omits its target author's p tag, the new live matching depends on derived_recipients. However, dispatch_persistent_event publishes only stored.event through Redis, and the receiving node's pubsub_stored_event reconstructs it with StoredEvent::new, leaving derived_recipients empty. Consequently, an author's #p subscription on a different relay node never matches the reaction, even though local delivery and historical queries match it.
  • Impact: Live reaction delivery silently depends on which relay node accepts the event. Subscribers on other nodes miss reactions until they query history again.
  • Recommendation: Carry derived recipients in the internal pub/sub envelope or reload them using the community-scoped mention index before subscription matching. Cover delivery between two relay nodes with a regression test.

Notes

  • Review used read-only source and history inspection; builds and tests were not executed, as requested.

Generated by Codex Security Review |
Requested by: @bostonaholic |
Workflow run

@joahg joahg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Re-checked at 4a8ebfd (cf2abde plus a merge of main). The new a_derived_recipient_outside_the_channel_reads_nothing test covers the negative ACL case I asked for: after the author is removed from the private channel, #p REQ and COUNT both return nothing. The other two earlier notes (kind-less #p filters now match untagged kind 7, and the COUNT side of the rollback note) are unchanged and still optional.

One point I'd fix before merge, confirmed at this head: the security review's cross-node finding holds. subscription.rs:901 matches live #p subscriptions against event.derived_recipients, which only the accepting node has. dispatch_persistent_event publishes stored_event through Redis, and the receiving node rebuilds it in pubsub_stored_event (handlers/event.rs:415) with StoredEvent::new(channel_event.event, channel_id), so derived_recipients is empty there. A target author whose #p subscription sits on another node won't get the untagged reaction live, even though REQ history and COUNT return it. Either carry the derived recipients in the pub/sub envelope or re-derive them on receipt (the target lookup the accepting node already does), and add a two-node delivery test.

Leaving this as a comment: it's a relay read-semantics change with a backfill, so a relay maintainer should approve it.

This branch was successfully deployed

1 active deployment
codex-review — 4a8ebfd8 Deployed Nov 14, 2026 by bostonaholic via Run Codex Security Review #15137
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

codex-security-review-current The posted Codex security review matches its recorded range.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants