Summary
filter_fully_pushable (crates/buzz-relay/src/handlers/req.rs) decides whether a COUNT can be answered by a plain SQL COUNT(*). When it returns true, the WebSocket COUNT handler (handlers/count.rs) and POST /count (api/bridge.rs) call count_events() with no post-filtering, on the promise in its doc comment of "an exact count without post-filtering".
For two filter shapes that promise does not hold, because the constraint never becomes a SQL predicate. The relay answers with the count of every matching event across all of the caller's accessible channels instead of the correct total. There is no error, just a wrong number.
Affected shapes
1. A single #h value that is not a channel UUID. Callers derive channel_id by parsing the #h value, so a bare NIP-29 group id leaves the query with no channel predicate. filter_fully_pushable still reports it as pushable because the "h" arm only checks tag_values.len() > 1.
{"kinds":[9],"#h":["general"]}
2. An empty value set. This reaches every generic-tag arm, plus authors and ids. filters_match rejects a present constraint with no matching value, so these match nothing, but filter_to_query_params drops the constraint rather than emitting a predicate.
{"kinds":[9],"#t":[]}
{"kinds":[9],"authors":[]}
Correct answer in every case above is 0. kinds: [] is already handled correctly, via the match-nothing sentinel the DB layer short-circuits on.
The REQ path is not affected. It always post-filters through filters_match, which enforces these strictly, so REQ and COUNT disagree on the same filter.
Reproduction
Against a relay on current main:
- Create a channel and post two kind:9 messages to it.
POST /count with [{"kinds":[9],"#h":["general"]}].
Expected 0. Actual on my local relay was 4: the two messages in my own channel plus two more from an unrelated open channel left over from an earlier run. The same filter over REQ returns nothing, which is the correct behavior.
Substituting [{"kinds":[9],"#t":[]}] or [{"kinds":[9],"authors":[]}] gives the same shape of wrong answer. A control filter naming the channel's actual UUID ([{"kinds":[9],"#h":["<uuid>"]}]) correctly returns 2, so only the unpushable shapes are affected.
Suggested fix
Report these as not pushable so they take the existing bounded query_events plus filters_match fallback, which already computes the correct total. A single #h that does parse as a UUID keeps its narrower channel_id predicate.
Falling back rather than short-circuiting to 0 matters for the #h case: filters_match compares the h-tag literal, and ingest only requires a parseable h tag for channel-scoped kinds, so an event can legitimately carry a non-UUID h tag and match.
A cheaper alternative for the empty-set half is to emit the match-nothing sentinel from filter_to_query_params, the way kinds: [] already does, keeping the count exact at no query cost. That touches every consumer of the shared query builder, so it may be better as its own change.
Happy to send a PR.
Summary
filter_fully_pushable(crates/buzz-relay/src/handlers/req.rs) decides whether a COUNT can be answered by a plain SQLCOUNT(*). When it returns true, the WebSocket COUNT handler (handlers/count.rs) andPOST /count(api/bridge.rs) callcount_events()with no post-filtering, on the promise in its doc comment of "an exact count without post-filtering".For two filter shapes that promise does not hold, because the constraint never becomes a SQL predicate. The relay answers with the count of every matching event across all of the caller's accessible channels instead of the correct total. There is no error, just a wrong number.
Affected shapes
1. A single
#hvalue that is not a channel UUID. Callers derivechannel_idby parsing the#hvalue, so a bare NIP-29 group id leaves the query with no channel predicate.filter_fully_pushablestill reports it as pushable because the"h"arm only checkstag_values.len() > 1.{"kinds":[9],"#h":["general"]}2. An empty value set. This reaches every generic-tag arm, plus
authorsandids.filters_matchrejects a present constraint with no matching value, so these match nothing, butfilter_to_query_paramsdrops the constraint rather than emitting a predicate.{"kinds":[9],"#t":[]} {"kinds":[9],"authors":[]}Correct answer in every case above is 0.
kinds: []is already handled correctly, via the match-nothing sentinel the DB layer short-circuits on.The REQ path is not affected. It always post-filters through
filters_match, which enforces these strictly, so REQ and COUNT disagree on the same filter.Reproduction
Against a relay on current
main:POST /countwith[{"kinds":[9],"#h":["general"]}].Expected 0. Actual on my local relay was 4: the two messages in my own channel plus two more from an unrelated open channel left over from an earlier run. The same filter over REQ returns nothing, which is the correct behavior.
Substituting
[{"kinds":[9],"#t":[]}]or[{"kinds":[9],"authors":[]}]gives the same shape of wrong answer. A control filter naming the channel's actual UUID ([{"kinds":[9],"#h":["<uuid>"]}]) correctly returns 2, so only the unpushable shapes are affected.Suggested fix
Report these as not pushable so they take the existing bounded
query_eventsplusfilters_matchfallback, which already computes the correct total. A single#hthat does parse as a UUID keeps its narrowerchannel_idpredicate.Falling back rather than short-circuiting to 0 matters for the
#hcase:filters_matchcompares the h-tag literal, and ingest only requires a parseable h tag for channel-scoped kinds, so an event can legitimately carry a non-UUID h tag and match.A cheaper alternative for the empty-set half is to emit the match-nothing sentinel from
filter_to_query_params, the waykinds: []already does, keeping the count exact at no query cost. That touches every consumer of the shared query builder, so it may be better as its own change.Happy to send a PR.