- Move immutable-channel binding check inside replace_parameterized_event
under the advisory lock (atomically safe, race-proof). Remove the
preflight get_draft_head_channel_id call. Add DraftChannelMismatch
error variant. All other replace_parameterized_event callers pass None.
- Move validate_draft_wrap_envelope before channel extraction so that
structural failures (missing/duplicate/non-UUID h-tag, p-tag) report
via the right gate rather than the channel-scope gate.
- Require canonical lowercase-hyphenated UUID in h-tag validator
(parsed.to_string() == h); reject uppercase and simple-hex forms.
- Fix test_draft_same_second_tie_break: add per-candidate _tiebreak tag
to force distinct event hashes and non-empty candidate set.
- Replace two timing-prone kindless WS privacy tests with explicit
kinds=[0,31234] and kinds=[30023,31234] mixed-kinds tests.
- FTS test: use explicit kinds=[1,31234] search filter as author.
- excluded_kinds_are_storage_level_unsearchable: add kind:31234 row,
update event count and forbidden list.
- Add Postgres DB integration tests: draft_is_confined_to_its_community
(two-community tenant confinement) and
concurrent_different_channel_drafts_one_wins_one_loses (race guard).
- Add workflow-dispatch tripwire unit test in event.rs confirming
AUTHOR_ONLY_KINDS.contains(&KIND_DRAFT) at the guard seam.
- Add DM channel path test (kind:41010 draft acceptance/replacement/
tombstone) and removed-member read-denial test (historical REQ/COUNT
+ live fan-out denial after removal).
- Fix stale-write test to assert accepted:true result before head check.
- Update stale 'channel-less/global' docs in kind.rs, ingest.rs,
event.rs to reflect channel-bound reality.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Add kind:31234 as an author-only, channel-less, parameterized-replaceable
event kind for encrypted draft wraps per NIP-37.
Privacy enforcement spans every relay read path:
- WS REQ: AUTHOR_ONLY_KINDS gate closes the subscription with
restricted: for any requester who isn't the author
- WS COUNT: same gate applied before the count query executes
- HTTP bridge /query + /count: post-filter and guard use AUTHOR_ONLY_KINDS
- Live fan-out: AUTHOR_ONLY_KINDS check in dispatch_persistent_event_inner
prevents draft events from being pushed to non-author subscribers
- FTS (NIP-50): migration 0007 sets search_tsv = NULL for kind:31234,
making drafts storage-level unsearchable
Ingest validation (validate_draft_wrap_envelope):
- Exactly one non-empty d tag (any bounded value; relay is grammar-agnostic)
- Exactly one k tag with canonical u16 decimal (no leading zeros, fits u16)
- No h or p outer tags (compose context belongs in encrypted payload only)
- Content: empty string (tombstone) or NIP-44 v2 ciphertext shape check
- Optional expiration: at most one, decimal, strictly future, ≤ safe integer
NIP-11 now advertises NIP-37. NIP-40 is intentionally not advertised
because Buzz does not yet suppress expired rows on read.
Schema migration 0007 extends the search_tsv generated column exclusion
list with kind 31234.
New tests:
- 23 unit tests for validate_draft_wrap_envelope in ingest.rs covering
every acceptance and rejection path
- Comprehensive E2E test suite in e2e_nip37_draft.rs covering write
validation, NIP-01 replacement ordering, tombstone persistence,
author-only REQ/COUNT/HTTP, kindless/mixed filter privacy, known-d
privacy tripwires, live fan-out isolation, and NIP-11 advertisement
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>