NIP-33: d-tag keyed replacement for parameterized replaceable events#246
Open
tlongwell-block wants to merge 4 commits intotyler/pr1-text-notes-and-followsfrom
Open
NIP-33: d-tag keyed replacement for parameterized replaceable events#246tlongwell-block wants to merge 4 commits intotyler/pr1-text-notes-and-followsfrom
tlongwell-block wants to merge 4 commits intotyler/pr1-text-notes-and-followsfrom
Conversation
Add correct NIP-33 support for parameterized replaceable events (kind 30000-39999), where the latest event per (author, kind, d_tag) tuple wins. Storage plumbing that unblocks long-form articles (kind:30023) and other user-owned addressable content. Schema: - d_tag TEXT column on events table (nullable) - idx_events_parameterized partial index on (kind, pubkey, d_tag, deleted_at) WHERE d_tag IS NOT NULL Storage: - extract_d_tag() at the shared insert layer — NIP-33 kinds get the d tag value (or empty string per spec), all others get NULL - replace_parameterized_event() with pg_advisory_xact_lock, stale-write protection, and transactional insert (prevents delete-without-reinsert) - d_tag wired through all INSERT paths including replace_addressable_event Ingest: - Route parameterized replaceable kinds through the new replacement function between the existing replaceable and default paths Query: - Push #d tag filter into SQL for NIP-33-only kind filters, preventing under-fetch on authors + kinds + #d lookups under LIMIT pressure - Gated to NIP-33 kinds only so non-NIP-33 events (d_tag=NULL) are not silently excluded Backfill: - Idempotent startup backfill in relay main (no-ops when populated) - scripts/backfill-d-tag.sql for manual use - Integrated into dev-setup.sh after pgschema apply NIP-29 group metadata (kind 39000-39002) continues using replace_addressable_event() via side_effects.rs — unchanged. Those events get d_tag populated via the shared insert layer for consistency.
… bound, multi-value #d test Crossfire review findings (3 reviewers: codex GPT-5.4, 2× Claude 4.6 Opus): - Add NIP-33 to NIP-11 supported_nips (was inconsistently adding NIP-16 only) - Cap d_tag at 1024 bytes in extract_d_tag() to bound index/storage cost - Add test for multi-value #d pushdown (verifies it correctly falls back) - Add test for oversized d_tag truncation
Both branches independently added KIND_CHANNEL_METADATA, updated is_replaceable doc comments, added NIP-16 to supported_nips, and added the FNV collision comment. Resolved by keeping PR2's superset (includes is_parameterized_replaceable, NIP-33 in supported_nips, and the correct doc comment referencing replace_parameterized_event).
Codex review correctly identified that truncating d_tag values mutates the NIP-33 identity key — two events sharing the first 1024 bytes of their d-tag would incorrectly replace each other, and REQ queries by the full #d value would miss the stored row. Fix: extract_d_tag() now returns the full value (pure extraction), and the ingest handler rejects events with d_tag > 1024 bytes via IngestError::Rejected. D_TAG_MAX_LEN is pub so the ingest layer can reference it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Correct NIP-33 support for parameterized replaceable events (kind 30000–39999). The latest event per
(author, kind, d_tag)wins — the foundation for editable, author-owned content on Nostr.This is storage plumbing that unblocks PR 3 (kind:30023 long-form articles). No new kinds are accepted by the allowlist yet.
Why
Sprout already stores events in the 30000–39999 range (NIP-29 group metadata), but replacement keys on
(kind, pubkey, channel_id)instead of the NIP-33-correct(kind, pubkey, d_tag). This works for relay-signed group metadata only because each group has a unique channel_id — but it is not correct NIP-33 behavior and would break for any user-submitted parameterized replaceable events.How
Schema —
d_tag TEXTcolumn on the events table +idx_events_parameterizedpartial index on(kind, pubkey, d_tag, deleted_at) WHERE d_tag IS NOT NULL.Storage —
extract_d_tag()at the shared insert layer populatesd_tagfor all events: NIP-33 kinds get thedtag value (or""per spec), everything else getsNULL. Newreplace_parameterized_event()usespg_advisory_xact_lock+ stale-write protection + transactional insert to prevent both concurrent-insert races and the delete-without-reinsert bug.Ingest — New routing arm between the existing replaceable and default paths for
is_parameterized_replaceable()kinds. Oversizeddtags (>1024 bytes) are rejected at ingest to preserve NIP-33 identity key fidelity — no silent truncation.Query —
#dtag filter pushed into SQL for NIP-33-only kind filters, preventing under-fetch onauthors + kinds + #dlookups under LIMIT pressure. Gated to NIP-33 kinds so non-NIP-33 events (d_tag=NULL) are not silently excluded.Backfill — Idempotent startup backfill in relay main +
scripts/backfill-d-tag.sql+ integrated intodev-setup.sh.NIP-11 — NIP-33 added to
supported_nips(alongside NIP-16 from PR 1).What does NOT change
replace_addressable_event()viaside_effects.rs— those events bypass ingest entirelyTesting
cargo test --workspace --lib)-D warnings), fmt cleand_tagcolumn present on all partitions, partial index used by EXPLAIN, correct NULL/value population across all event kindsCrossfire review
Three-model crossfire review (Codex GPT-5.4, 2× Claude 4.6 Opus). Final score: 10/10 APPROVE from Codex after two rounds of fixes:
supported_nipswas inconsistently adding NIP-16 but not NIP-33. Fixed.TEXTcolumn accepted arbitrarily large values. Initially fixed with truncation, but Codex correctly identified that silent truncation mutates the NIP-33 identity key (two events sharing the first 1024 bytes would incorrectly replace each other). Changed to rejection at ingest.#dpushdown test — added coverage for the case where multiple#dvalues correctly fall back to post-filtering.Files changed
schema/schema.sqld_tag TEXTcolumn + partial indexcrates/sprout-core/src/kind.rsis_parameterized_replaceable()+ constants + testscrates/sprout-db/src/event.rsextract_d_tag(),D_TAG_MAX_LEN, d_tag in INSERT paths,EventQuery.d_tag, query builder, 10 unit testscrates/sprout-db/src/lib.rsreplace_parameterized_event(),backfill_d_tags(), d_tag inreplace_addressable_event()crates/sprout-relay/src/handlers/ingest.rscrates/sprout-relay/src/handlers/req.rs#dSQL pushdown (NIP-33 gated) + testscrates/sprout-relay/src/main.rscrates/sprout-relay/src/nip11.rssupported_nipsscripts/backfill-d-tag.sqlscripts/dev-setup.shRollout
pgschema applyadds the column and index (declarative, automatic)backfill_d_tags()(idempotent, populates existing NIP-33 rows)d_tagpopulated automatically viaextract_d_tag()at the insert layer