Skip to content

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
tyler/pr2-nip33-parameterized-replaceable
Open

NIP-33: d-tag keyed replacement for parameterized replaceable events#246
tlongwell-block wants to merge 4 commits intotyler/pr1-text-notes-and-followsfrom
tyler/pr2-nip33-parameterized-replaceable

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

@tlongwell-block tlongwell-block commented Apr 6, 2026

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

Schemad_tag TEXT column on the events table + idx_events_parameterized partial index on (kind, pubkey, d_tag, deleted_at) WHERE d_tag IS NOT NULL.

Storageextract_d_tag() at the shared insert layer populates d_tag for all events: NIP-33 kinds get the d tag value (or "" per spec), everything else gets NULL. New replace_parameterized_event() uses pg_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. Oversized d tags (>1024 bytes) are rejected at ingest to preserve NIP-33 identity key fidelity — no silent truncation.

Query#d tag filter pushed into SQL for NIP-33-only kind filters, preventing under-fetch on authors + kinds + #d lookups 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 into dev-setup.sh.

NIP-11 — NIP-33 added to supported_nips (alongside NIP-16 from PR 1).

What does NOT change

  • NIP-29 group metadata (kind 39000–39002) continues using replace_addressable_event() via side_effects.rs — those events bypass ingest entirely
  • Kind allowlist — no new user-submitted kinds are accepted (that is PR 3)
  • Desktop UI, MCP tools

Testing

  • 682 unit tests pass (cargo test --workspace --lib)
  • Clippy clean (-D warnings), fmt clean
  • All 7 pre-push hooks pass (rust-fmt, desktop-tauri-fmt, desktop-check, desktop-tauri-check, desktop-build, rust-clippy, rust-tests)
  • Live tested against freshly built relay: channel creation, messaging, threading, reactions, profile replacement (kind:0), contact list replacement (kind:3), ACP agent discovery + reply — no regressions, zero errors in relay log
  • Schema verified: d_tag column present on all partitions, partial index used by EXPLAIN, correct NULL/value population across all event kinds

Crossfire 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:

  1. NIP-33 in NIP-11supported_nips was inconsistently adding NIP-16 but not NIP-33. Fixed.
  2. d_tag length bound — unbounded TEXT column 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.
  3. Multi-value #d pushdown test — added coverage for the case where multiple #d values correctly fall back to post-filtering.

Files changed

File What
schema/schema.sql d_tag TEXT column + partial index
crates/sprout-core/src/kind.rs is_parameterized_replaceable() + constants + tests
crates/sprout-db/src/event.rs extract_d_tag(), D_TAG_MAX_LEN, d_tag in INSERT paths, EventQuery.d_tag, query builder, 10 unit tests
crates/sprout-db/src/lib.rs replace_parameterized_event(), backfill_d_tags(), d_tag in replace_addressable_event()
crates/sprout-relay/src/handlers/ingest.rs Parameterized replaceable routing + d_tag length rejection
crates/sprout-relay/src/handlers/req.rs #d SQL pushdown (NIP-33 gated) + tests
crates/sprout-relay/src/main.rs Startup backfill
crates/sprout-relay/src/nip11.rs NIP-33 in supported_nips
scripts/backfill-d-tag.sql Idempotent backfill script
scripts/dev-setup.sh Backfill integration

Rollout

  1. pgschema apply adds the column and index (declarative, automatic)
  2. Relay startup runs backfill_d_tags() (idempotent, populates existing NIP-33 rows)
  3. New events get d_tag populated automatically via extract_d_tag() at the insert layer

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant