Files
buzz/crates
Jemiah Westerman 695c1c3aae fix(relay): match #a tag filters in SQL before LIMIT (#9339)
## Summary

**Intent:** let clients find and count reactions to an **addressable
event** at the SQL layer. Addressable events are NIP-01 kinds
30000–39999, which NIP-33 called "parameterized replaceable events".
Clients reference one by its address, not its event id.

An addressable event is identified by its address `<kind>:<pubkey>:<d>`.
Each edit publishes a new event with a new id at the same address. A
reaction (kind:7) that tags the address with `a` therefore still applies
after the target is edited. A reaction that tags only the current
revision's id with `e` is left behind on the old revision.

Use cases:
- **Agent personas (kind:30175).** A ⭐ on a shared persona is a kind:7
tagged `["a", "30175:<owner>:<slug>"]`. A client that lists shared
personas by popularity needs `{kinds:[7], "#a":[...]}` and the matching
COUNT to return every star across persona edits, not only stars on the
current revision.
- **Other addressable kinds**, such as teams and channel metadata, work
the same way with no extra code.
- **Replaceable events, such as user profiles.** Kind:0 profiles and
kind:10100 agent profiles are *replaceable* (kinds 0, 3 and
10000–19999), not addressable. NIP-01 still gives them an `a` address
with an empty `d`, `0:<pubkey>:`, so reactions that tag a profile that
way are matched by the same filter.

Before this change, the relay applied `#a` only after the SQL `LIMIT`. A
selective read fetched the newest kind:7 rows across the whole community
and then dropped the ones that didn't match. It could return fewer than
`limit` matches, or none, while older matches existed. COUNT also could
not take its exact fast path.

What changed:

- **`EventQuery.a_tags`** (buzz-db) works like `e_tags`. It is an exact
positional tag match (`tag->>0 = 'a' AND tag->>1 = ANY(..)`) with JSONB
`@>` as the prefilter on the existing GIN index `idx_events_tags_gin`.
  - **No schema change, migration or backfill.**
- It gets the same MATERIALIZED CTE fence as `#e`, so the planner cannot
walk `created_at` backward looking for a rare match.
  - It reads the table whole rather than recent partitions first.
  - It is counted in `count_events_sql`.
  - An empty set matches nothing, as the NIP-01 post-filter does.
- **`filter_to_query_params`** fills `a_tags` for every filter, and
**`filter_fully_pushable`** treats `#a` as pushed. REQ, COUNT and the
HTTP bridge `/query` and `/count` all go through these two functions.
- **`per_channel_scope`** now skips per-channel reads for every fenced
query, through a shared `reference_tag_fence` helper. Before this, it
excluded `#e` reads explicitly, and `#a` with a multi-value `#h` would
have wrapped the fence around the per-channel lateral, which is invalid
SQL.

Not included: `#k` and other low-cardinality tags. Postgres's estimate
for JSONB `@>` assumes any match is rare, so pushing a common value
could switch to a slower plan. They remain post-filters.

### Related issue

None found.

### Testing

- `req.rs` unit test: `#a` maps to `a_tags` (including empty and absent)
and is fully pushable; `#a` + `#k` is not.
- buzz-db shape tests: an `#a` read is fenced, COUNT applies the match,
an `#a` read is never split into recent and older passes, and `#a` makes
a read ineligible for per-channel scope. An empty `#a` is answered
without a database read.
- buzz-db Postgres: an `#a` case added to
`pushed_down_tag_filters_reject_positional_decoys`, covering query and
COUNT.
- New `handlers/tag_pushdown_postgres_tests.rs`:
- REQ returns an older `#a` match ahead of a full page of newer
reactions to another address. COUNT is exact, and an empty `#a` counts
0. This test fails when `a_tags` is removed from
`filter_to_query_params`.
- REQ and COUNT with `#a` + a multi-value `#h`. This test fails, with
`CLOSED database error`, without the `per_channel_scope` fix.
- Full buzz-db and buzz-relay suites, unit and Postgres lanes, run
locally.
- Live check against a local relay built from this branch, over
WebSocket with NIP-42 auth. Two reactions to one address (each on a
different head event, as after an edit) and six newer reactions to
another address were published. `REQ {kinds:[7], #a:[needle], limit:2}`
returned exactly the two needle reactions, newest first. COUNT returned
2 for the needle, 6 for the hay and 0 for an empty `#a`. `#a` + `#e`
with `limit:1` returned the newest needle reaction.

buzz-review-completed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Jemiah Westerman <jemiah@squareup.com>
Co-authored-by: Son of Anton <8e0ef2ab02d69a7ac089ef5eca90d1a3fbbdc546f6322dc34d0c4f3deecea7f4@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 15:41:05 -08:00
..