Skip to content

feat: accept kind:1 text notes and kind:3 follow lists (NIP-01/NIP-02)#245

Merged
tlongwell-block merged 2 commits intomainfrom
tyler/pr1-text-notes-and-follows
Apr 7, 2026
Merged

feat: accept kind:1 text notes and kind:3 follow lists (NIP-01/NIP-02)#245
tlongwell-block merged 2 commits intomainfrom
tyler/pr1-text-notes-and-follows

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

Summary

Open Sprout to standard Nostr social events — kind:1 (text notes) and kind:3 (follow/contact lists). These are the two most fundamental Nostr event types; without them, standard clients like Damus and Amethyst pointed at Sprout cannot post or read basic notes.

kind:1 gives Sprout a global "company Twitter" feed. kind:3 gives clients the follow graph to filter it. Together they let Sprout act as a superset of a standard Nostr relay: a decent relay that also has enterprise team collaboration features.

Also fixes three bugs in replace_addressable_event() that affected existing kind:0 profile replacement.

Changes

File What
sprout-core/kind.rs Add KIND_TEXT_NOTE = 1, is_replaceable() helper, update ALL_KINDS
sprout-relay/handlers/ingest.rs Allow kind:1 (MessagesWrite) and kind:3 (UsersWrite); route all replaceables through replace_addressable_event(); force channel_id = None for global-only kinds; generalize channel-scoped token guard
sprout-db/lib.rs Rewrite replace_addressable_event() — atomic tx, pg_advisory_xact_lock, stale-write protection with same-second id tie-break, IS NOT DISTINCT FROM for NULL safety, checked INSERT with rollback
sprout-relay/handlers/req.rs NIP-50 search includes global events via __global__ sentinel
sprout-relay/api/search.rs /api/search includes global events; nullable channel_name
sprout-relay/handlers/side_effects.rs Honor was_inserted before dispatching discovery events
sprout-search/index.rs __global__ sentinel for Typesense (see below)
sprout-search/query.rs Map __global__ back to None in search hits
sprout-auth/scope.rs Update UsersWrite comment for kind:3
sprout-relay/nip11.rs Add NIP-2 to supported_nips
desktop/src-tauri/models.rs SearchHitInfo channel fields → Option<String>
desktop/src/*.ts{,x} Nullable channel_id/channel_name across types + UI null-guards

Bugs fixed in replace_addressable_event()

These affected existing kind:0 profile replacement:

  1. Non-atomic delete+insert — DELETE committed, then insert_event() called outside the tx. Crash between = data loss. → Single transaction.
  2. No stale-write protection — Older event could overwrite newer. → created_at comparison + same-second id tie-break (lowest wins, per NIP-16).
  3. NULL comparison bugchannel_id = $3 is false when NULL. Global events silently failed to replace. → IS NOT DISTINCT FROM.
  4. No concurrency protection — Two concurrent first-inserts could both succeed. → pg_advisory_xact_lock with FNV-1a hash key.

Typesense discovery

During live testing, Typesense 27.1's channel_id:__missing__ filter returned 0 results even for documents where the field was truly absent. Tested absent, null, and omitted — all return 0. Appears to be a Typesense bug or undocumented limitation.

Fix: Use __global__ sentinel value instead. Collision-proof (channel_id is UUID-shaped everywhere else), mapped back to None in search hits. Requires just reindex-search after deploy to backfill historical docs. Not a regression — pre-existing global events (kind:0 only) were already excluded from search by the old .filter(|h| h.channel_id.is_some()).

Testing

  • 302 unit tests pass (40 core + 53 auth + 50 db + 148 relay + 11 search), 0 failed
  • 9 live acceptance tests pass against a running local relay:
    • kind:1 accepted, queryable via REQ, searchable via /api/search
    • kind:3 accepted, replacement works, stale-write rejected
    • kind:0 replacement still works (regression)
    • NIP-11 shows NIP-2
    • Combined search returns channel + global events together
  • cargo clippy clean, cargo fmt clean, biome check clean

Deploy notes

  • Run just reindex-search after deploy to backfill the __global__ sentinel for historical global events in Typesense.

Out of scope

  • Desktop UI for notes feed / follow management (separate frontend PR)
  • MCP tools for notes/follows (separate PR)
  • NIP-33 parameterized replaceable events (PR 2)
  • NIP-16 in supported_nips (deferred until more replaceable kinds are accepted)

…/NIP-16)

- Add KIND_TEXT_NOTE constant and is_replaceable() helper to sprout-core
- Allow kind:1 (MessagesWrite) and kind:3 (UsersWrite) in ingest allowlist
- Route all NIP-16 replaceable kinds through replace_addressable_event()
- Rewrite replace_addressable_event() for atomicity, stale-write protection,
  and NULL channel_id safety (pg_advisory_xact_lock + IS NOT DISTINCT FROM)
- Include global events (channel_id=NULL) in NIP-50 and /api/search results
- Add NIPs 2 and 16 to NIP-11 supported_nips
- Update UsersWrite scope comment for kind:3
…tant, doc comments

- Add NIP 16 to supported_nips (was missing despite commit message claiming it)
- Add KIND_CHANNEL_METADATA constant for kind:41 (was a bare literal in is_replaceable)
- Add NIP-33 exclusion comment on is_replaceable() to prevent future confusion
- Add advisory lock collision semantics comment (extra serialization, not incorrectness)
@tlongwell-block tlongwell-block merged commit d5bd783 into main Apr 7, 2026
8 checks passed
@tlongwell-block tlongwell-block deleted the tyler/pr1-text-notes-and-follows branch April 7, 2026 00:59
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