๐ค Filed as the relay-side follow-up to #2864 (NIP-RS manual-unread override layer), which defines five relay conformance preconditions for the full-state load procedure (docs/nips/NIP-RS.md, "Full-State Load" section). This issue covers the two gaps that still exist on main at 209536ade. Two earlier defects from the same investigation โ the advertised max_limit of 10,000 vs the honored 1,000, and the dead MAX_HISTORICAL_LIMIT constant โ were already fixed by #3635 (nip11.rs now advertises buzz_db::DEFAULT_MAX_PAGE_LIMIT, the same constant the REQ path clamps to, with a drift-guard test at req.rs:1474). They are not part of this issue.
Defect A โ no delivery barrier between event acceptance and a later REQ's EOSE
NIP-RS precondition 5 (the delivery barrier): before a relay sends EOSE for a query, every event it accepted before that query read its stored events, and which matches an open subscription on the same connection, must already have been delivered to that subscription.
The relay does not provide this today:
dispatch_persistent_event (crates/buzz-relay/src/handlers/event.rs:349) intentionally returns after only the bounded audit enqueue; the whole delivery path โ pubsub.publish_event, local fan-out, delivery metrics โ runs inside tokio::spawn (event.rs:374).
- A REQ arriving after the event was durably accepted reads storage and sends EOSE (
crates/buzz-relay/src/handlers/req.rs:408) with no ordering relationship to that spawned task.
Consequence for NIP-RS: a client running the fenced full-state load can receive EOSE for its final continuation while the push for an already-accepted replacement is still pending in the spawned task, and render a complete verdict in the interval between the two โ exactly the race the barrier precondition exists to close. Until this is fixed, this relay does not satisfy the NIP-RS full-state-load preconditions, and complete verdicts against it are not sound.
(The other four preconditions hold at 209536ade: newest-first prefix delivery via ORDER BY created_at DESC, id ASC (crates/buzz-db/src/event.rs:559), a fixed non-decreasing cap (DEFAULT_MAX_PAGE_LIMIT, event.rs:25), a floor far above L = 2, and push delivery on open subscriptions via pubsub.)
Defect B โ #t is post-filtered after the cap, so a short page does not imply exhaustion
filter_to_query_params (req.rs:869) pushes kinds, authors, ids, since/until, #e (any), single #p, single #h, and single/multi #d on NIP-33-only kinds into SQL. Everything else โ #t, #a, multi-#p, multi-#h โ is applied by filters_match (req.rs:371) after the DB LIMIT binds. filter_fully_pushable's own doc comment says as much (req.rs:784-785).
Consequence: a #t-filtered query can deliver fewer events than the cap โ or none โ while matching events still exist below the cap boundary, and no observation the client can make distinguishes that from exhaustion. Any client reasoning "short page โ result set exhausted" is unsound against this relay. kind:30078 makes this concrete: five d-tag families share the kind (desktop/src/shared/constants/kinds.ts), so a page can fill entirely with rows the tag filter then discards.
NIP-RS itself is not broken by this โ the load procedure mandates tag-free filters precisely because of this behavior โ but the defect stands for every other consumer of tag-constrained REQs.
Suggested direction: push #t into SQL via JSONB containment the way #e already is (crates/buzz-db/src/event.rs, tags @> predicate; the GIN jsonb_path_ops index from migration 0004 makes it cheap). Two caveats before doing so: NIP-01 gives multiple #t values OR semantics, and @> matches supersets, so a 3-element ["t","x","extra"] tag would also match โ confirm the ingest path's shape validation for t first, the way the persona shared pushdown documents its assumptions.
All line references read via git show origin/main:<path> at 209536ade.
๐ค Filed as the relay-side follow-up to #2864 (NIP-RS manual-unread override layer), which defines five relay conformance preconditions for the full-state load procedure (
docs/nips/NIP-RS.md, "Full-State Load" section). This issue covers the two gaps that still exist onmainat209536ade. Two earlier defects from the same investigation โ the advertisedmax_limitof 10,000 vs the honored 1,000, and the deadMAX_HISTORICAL_LIMITconstant โ were already fixed by #3635 (nip11.rsnow advertisesbuzz_db::DEFAULT_MAX_PAGE_LIMIT, the same constant the REQ path clamps to, with a drift-guard test atreq.rs:1474). They are not part of this issue.Defect A โ no delivery barrier between event acceptance and a later REQ's EOSE
NIP-RS precondition 5 (the delivery barrier): before a relay sends EOSE for a query, every event it accepted before that query read its stored events, and which matches an open subscription on the same connection, must already have been delivered to that subscription.
The relay does not provide this today:
dispatch_persistent_event(crates/buzz-relay/src/handlers/event.rs:349) intentionally returns after only the bounded audit enqueue; the whole delivery path โpubsub.publish_event, local fan-out, delivery metrics โ runs insidetokio::spawn(event.rs:374).crates/buzz-relay/src/handlers/req.rs:408) with no ordering relationship to that spawned task.Consequence for NIP-RS: a client running the fenced full-state load can receive EOSE for its final continuation while the push for an already-accepted replacement is still pending in the spawned task, and render a
completeverdict in the interval between the two โ exactly the race the barrier precondition exists to close. Until this is fixed, this relay does not satisfy the NIP-RS full-state-load preconditions, andcompleteverdicts against it are not sound.(The other four preconditions hold at
209536ade: newest-first prefix delivery viaORDER BY created_at DESC, id ASC(crates/buzz-db/src/event.rs:559), a fixed non-decreasing cap (DEFAULT_MAX_PAGE_LIMIT,event.rs:25), a floor far aboveL = 2, and push delivery on open subscriptions via pubsub.)Defect B โ
#tis post-filtered after the cap, so a short page does not imply exhaustionfilter_to_query_params(req.rs:869) pushes kinds, authors, ids, since/until,#e(any), single#p, single#h, and single/multi#don NIP-33-only kinds into SQL. Everything else โ#t,#a, multi-#p, multi-#hโ is applied byfilters_match(req.rs:371) after the DBLIMITbinds.filter_fully_pushable's own doc comment says as much (req.rs:784-785).Consequence: a
#t-filtered query can deliver fewer events than the cap โ or none โ while matching events still exist below the cap boundary, and no observation the client can make distinguishes that from exhaustion. Any client reasoning "short page โ result set exhausted" is unsound against this relay.kind:30078makes this concrete: five d-tag families share the kind (desktop/src/shared/constants/kinds.ts), so a page can fill entirely with rows the tag filter then discards.NIP-RS itself is not broken by this โ the load procedure mandates tag-free filters precisely because of this behavior โ but the defect stands for every other consumer of tag-constrained REQs.
Suggested direction: push
#tinto SQL via JSONB containment the way#ealready is (crates/buzz-db/src/event.rs,tags @>predicate; the GINjsonb_path_opsindex from migration 0004 makes it cheap). Two caveats before doing so: NIP-01 gives multiple#tvalues OR semantics, and@>matches supersets, so a 3-element["t","x","extra"]tag would also match โ confirm the ingest path's shape validation fortfirst, the way the personasharedpushdown documents its assumptions.All line references read via
git show origin/main:<path>at209536ade.