Skip to content

feat(relay): add NIP-37 channel-bound draft wrap support (kind:31234)#1757

Draft
wpfleger96 wants to merge 13 commits into
mainfrom
duncan/nip37-relay-drafts
Draft

feat(relay): add NIP-37 channel-bound draft wrap support (kind:31234)#1757
wpfleger96 wants to merge 13 commits into
mainfrom
duncan/nip37-relay-drafts

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Add kind:31234 as an author-only, channel-bound, parameterized-replaceable event kind for encrypted draft wraps per NIP-37.

Every kind:31234 carries exactly one h UUID binding it to a specific Buzz channel or DM. The relay enforces channel existence, membership, and immutable binding.

What this does

New kind

  • KIND_DRAFT = 31234 in buzz-core with full doc comment
  • Added to AUTHOR_ONLY_KINDS — propagates author-only protection to every path that iterates it
  • Added to requires_h_channel_scope — exactly one canonical lowercase-hyphenated UUID h tag required

Channel-bound enforcement

Check Where enforced
Exactly one canonical UUID h tag validate_draft_wrap_envelope (runs before channel extraction)
Channel must exist in current community get_channel lookup in ingest — unknown UUID rejected
Author must be a member Generic membership gate (channel-scoped event path)
Immutable binding: rebind rejected replace_parameterized_event under advisory lock (atomic)
NIP-09 a-tag deletion rejected Pre-storage ingest gate — kind-5 targeting 31234:… address rejected
NIP-09 e-tag deletion rejected Pre-storage ingest gate — kind-5 e tag resolved; rejected if target is kind:31234
kind:9005 deletion rejected Pre-storage admin-event gate — author/agent-owner get tombstone-guidance error; all others (including channel admins) get "target event not found" (oracle mask)

Privacy enforcement (all read paths)

Path Mechanism
WS REQ AUTHOR_ONLY_KINDS gate → CLOSED with restricted: for non-author
WS COUNT Same gate before count query
HTTP /query Returns 403 for exclusive other-author kind:31234 filter
HTTP /count Returns 403 for exclusive other-author kind:31234 filter
Mixed/kindless filters Per-event author-only check silently omits drafts for non-owners
Live fan-out AUTHOR_ONLY_KINDS.contains check in dispatch_persistent_event_inner
FTS / NIP-50 Migration 0007 sets search_tsv = NULL for kind:31234
Workflow dispatch AUTHOR_ONLY_KINDS.contains guard at event.rs:514

Ingest validation (validate_draft_wrap_envelope, runs before channel extraction)

  • Exactly one non-empty d tag within D_TAG_MAX_LEN
  • Exactly one k tag: canonical decimal, no leading zeros, fits u16
  • Exactly one h tag: canonical lowercase-hyphenated UUID (parsed.to_string() == h); uppercase and simple-hex forms rejected
  • No p outer tags — compose context belongs inside encrypted payload only
  • Content: empty string (tombstone) or NIP-44 v2 ciphertext shape
  • Optional expiration: at most one, decimal, strictly future, ≤ safe integer

Immutable channel binding (race-safe)

The (community, author, 31234, d_tag) address is permanently bound to the first h UUID on write. Subsequent updates with a different h return DraftChannelMismatch. The check runs inside the advisory lock in replace_parameterized_event so no concurrent write can sneak a different channel between read and insert.

NIP-11

  • Advertises NIP-37 ✓
  • Does not advertise NIP-40 (relay does not suppress expired rows on read)

Schema migration

migrations/0007_draft_wrap_fts.sql extends the search_tsv generated column exclusion list with 31234.

Tests

28 unit tests in ingest.rs covering all acceptance/rejection paths for validate_draft_wrap_envelope.

3 Postgres DB integration tests in buzz-db:

  • draft_channel_binding_is_immutable_across_sequential_calls — sequential rebind returns DraftChannelMismatch
  • draft_is_confined_to_its_community — two-community tenant confinement (A and B have independent heads)
  • concurrent_different_channel_drafts_one_wins_one_loses — advisory-lock race guard

1 unit test in event.rs:

  • draft_kind_is_excluded_from_workflow_dispatch_by_author_only_guard — workflow dispatch tripwire

E2E test suite in e2e_nip37_draft.rs (all #[ignore], run against live relay via CI):

  • h-tag validation: missing h, duplicate h, non-UUID h, channel absent from current community, non-member author, removed-member rejection
  • Canonical UUID: uppercase/simple-hex h values rejected
  • Immutable binding: rebind to different channel rejected; same-channel replacement accepted
  • Structural validation: ciphertext accept, blank tombstone, future expiration, oversized-d reject, missing/duplicate/malformed d/k, p reject (third-party pubkey), past expiration, malformed ciphertext
  • NIP-01 replacement: newer wins, stale write is a no-op (accepted), same-second tie-break (lower ID wins via distinct per-candidate tags), tombstone replaces live draft
  • Author-only WS REQ: author receives own draft; attacker gets CLOSED
  • Mixed-kinds privacy (kinds=[0,31234] and kinds=[30023,31234] with positive controls)
  • known-#d tripwires: kind:31234+#d filter closed for non-author; kindless #d filter empty for attacker
  • WS COUNT / HTTP /count / HTTP /query: 403/CLOSED for exclusive other-author filter
  • Live fan-out: public kind:0 arrives for all; kind:31234 fan-out reaches author only
  • Kindless channel query (attacker): author-only privacy holds for non-author on kindless h query
  • Workflow exclusion: source-level AUTHOR_ONLY_KINDS guard prevents kind:31234 from triggering workflow dispatch
  • Community-local channel existence: h UUID for a channel absent from the current community is rejected at ingest (community-local existence validation; cross-community Postgres confinement covered by draft_is_confined_to_its_community)
  • FTS/NIP-50: explicit kinds=[1,31234] search — draft never appears (author or attacker)
  • DM channel path: draft acceptance/replacement/tombstone via kind:41010 DM channel
  • NIP-09 deletion guard: a-tag deletion targeting 31234:… address rejected pre-storage; e-tag deletion resolved and rejected pre-storage if target is kind:31234; kind:9005 admin deletion of kind:31234 rejected pre-storage (author/agent-owner: tombstone-guidance error; all others: "target event not found" oracle mask); full bypass sequences verified for both kind:5 and kind:9005 paths (draft h=A → delete rejected → head live → h=B rebind rejected)
  • Removed-member read denial: historical REQ/COUNT returns nothing + live fan-out denied
  • NIP-11: NIP-37 advertised, NIP-40 absent

CI: e2e_nip37_draft and buzz-db draft tests wired into the backend-integration job.

Verification

Pre-fix baseline — CI run 29169576592, job 86588900453:

  • e2e_nip37_draft -- --ignored: 47 passed; 0 failed
  • buzz-db -- draft --ignored: 3 passed; 0 failed (draft_channel_binding_is_immutable_across_sequential_calls, draft_is_confined_to_its_community, concurrent_different_channel_drafts_one_wins_one_loses)

Post kind:5 fix — CI run 29170930030, job 86592405456 (f84020b770ae7c855bfea8e07003796900d12437):

  • e2e_nip37_draft -- --ignored: 48 passed; 0 failed (new test: test_nip09_e_tag_deletion_of_draft_is_rejected_and_binding_holds)
  • buzz-db -- draft --ignored: 3 passed; 0 failed (draft_channel_binding_is_immutable_across_sequential_calls, draft_is_confined_to_its_community, concurrent_different_channel_drafts_one_wins_one_loses)

Post kind:9005 fix — CI run 29172409166, job 86596010223 (b56bf14bcde6c3549b85df70025e745ce6496047):

  • e2e_nip37_draft -- --ignored: 50 passed; 0 failed (2 new tests: test_nip09_kind9005_deletion_of_draft_is_rejected_and_binding_holds, test_nip09_kind9005_admin_deletion_of_draft_is_masked_as_not_found)
  • buzz-db -- draft --ignored: 3 passed; 0 failed (draft_channel_binding_is_immutable_across_sequential_calls, draft_is_confined_to_its_community, concurrent_different_channel_drafts_one_wins_one_loses)

Head SHA: b56bf14bcde6c3549b85df70025e745ce6496047

@wpfleger96 wpfleger96 requested a review from a team as a code owner July 11, 2026 16:31
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>
@wpfleger96 wpfleger96 force-pushed the duncan/nip37-relay-drafts branch from 300b964 to d29515f Compare July 11, 2026 16:39
Draft wraps (kind:31234) now require exactly one `h` UUID tag binding
them to a specific Buzz channel or DM. The relay enforces:

- Exactly one valid UUID `h` tag on every kind:31234 event
- Channel existence: the `h` UUID must resolve to a live channel
- Membership: author must be a member of that channel at write time
- Immutable binding: once a (author, d_tag) draft is written to
  channel A, replacement events must carry the same h=A; rebinding
  to a different channel is rejected at the ingest layer

The previous channel-less/global-state design is removed. Draft fan-out
already applied the author-only gate (AUTHOR_ONLY_KINDS); with channel_id
now non-NULL for kind:31234, the existing channel visibility/membership
filter in fan-out applies naturally with no additional changes.

E2E test suite rewritten for the channel-bound contract:
- h-tag validation: missing, duplicate, non-UUID, nonexistent channel
- Non-member author rejection + removed-member regression
- Immutable binding: rebind rejected, same-channel replacement accepted
- Author-only reads: WS REQ/COUNT, HTTP /query, /count, live fan-out
- known-#d privacy tripwires (exclusive and kindless)
- Tombstone head queryable by author, tombstone replaces live draft
- NIP-01 same-second tie-break (distinct candidates enforced)
- Stale write cannot supersede current head
- Workflow / channel kindless query exclusion
- Tenant confinement (alien channel rejected)
- FTS exclusion (NULL search_tsv confirmed)
- NIP-11 advertises NIP-37, not NIP-40

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 changed the title feat(relay): add NIP-37 draft wrap support (kind:31234) feat(relay): add NIP-37 channel-bound draft wrap support (kind:31234) Jul 11, 2026
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 4 commits July 11, 2026 13:51
- 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>
…get_draft_head_channel_id

validate_draft_wrap_envelope already had the parsed.to_string() != h guard
(hardening commit 2f27d7c). Add two explicit unit tests exercising:
- uppercase UUID form (parse-valid, non-canonical)
- simple 32-hex form without hyphens (parse-valid, non-canonical)

Both tests confirm the relay rejects these forms with a diagnostic message
mentioning 'lowercase', 'canonical', or 'UUID'.

Also removes the now-dead get_draft_head_channel_id function from
buzz-db (event.rs + lib.rs wrapper). The preflight call was replaced by
the atomic binding check inside replace_parameterized_event; no callers
remain outside buzz-db itself.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…y clean

Fix all remaining quality gaps identified in the pre-Thufir review:

Clippy (FIX-11):
- Remove 17 needless borrows in e2e_nip37_draft.rs (auto-fixed)
- sort_by → sort_by_key in tie-break test (auto-fixed)
- while_let_loop → while let loop in removed-member fan-out test
- splitn(3, ':').next() → split(':').next() in ingest.rs NIP-09 guard

DB tests (CRITICAL-A-test, FIX-4, FIX-5):
- Add build_test_draft_at helper (explicit timestamp control)
- Add query_draft_head helper (reusable across tests)
- Expand draft_is_confined_to_its_community: full A/B lifecycle (insert →
  query → replace → tombstone) with scoped head assertions after each step;
  uses same d_tag in both communities to prove community_id is the real
  isolation boundary
- Add draft_channel_binding_is_immutable_across_sequential_calls: sequential
  rebind attempt on an already-bound address → DraftChannelMismatch; stored
  head still v1 after failed rebind
- Add post-race head query to concurrent_different_channel_drafts_one_wins_one_loses:
  assert exactly one live head bound to the winning channel after the race

E2E tests (CRITICAL-B-test, FIX-6, FIX-7, FIX-8, FIX-9, FIX-10):
- Add test_nip09_a_tag_deletion_of_draft_is_rejected: kind:5 a-tag targeting
  31234:<pubkey>:<d> must be rejected; draft must still be live head after
- FIX-7: Expand workflow tripwire to evaluate the actual dispatch predicate
  (is_workflow_execution_kind && is_command_kind && AUTHOR_ONLY_KINDS) for
  kind:31234 (→ false) and kind:9 (→ true, positive control)
- FIX-8: DM test — replace silent return with panic! on missing channel_id;
  use strictly increasing timestamps (base-2, base-1, base) to guarantee
  deterministic ordering across v1/v2/tombstone
- FIX-6: test_draft_not_returned_in_kindless_channel_query — rewrite to use
  attacker (not owner) as requester; the author-only gate strips drafts from
  non-author queries, not from the author's own channel queries
- FIX-9: Removed-member live fan-out — use author(owner) subscription so the
  probe event (owner draft) actually matches the filter and exercises the gate
- FIX-10: Rename test_draft_tenant_confinement_channel_from_different_community
  → inline note + pointer to the existing test_draft_rejected_nonexistent_channel_h_tag
  (the old name was misleading; true cross-tenant confinement is the DB test)

CI (FIX-CI):
- Wire buzz-db NIP-37 draft Postgres tests to backend-integration job

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…n p tag

EventBuilder silently drops p tags whose value equals the signer's own
pubkey (NIP self-tagging suppression, enabled by default). The test was
using owner.public_key().to_hex() as the p tag value, so the tag was
stripped before signing and the event arrived at the relay with no p tag
at all — the validator always saw a clean envelope and the rejection was
never exercised.

Fix: generate a separate Keys pair for the p tag value so it survives
EventBuilder's self-tag filter and reaches the relay's validator.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 marked this pull request as draft July 11, 2026 22:18
@wpfleger96 wpfleger96 force-pushed the duncan/nip37-relay-drafts branch 3 times, most recently from f84020b to d16805c Compare July 11, 2026 23:24
A kind:5 e-tag deletion that resolved to a kind:31234 event was
accepted by validate_standard_deletion_event (which only checked
authorship, not target kind). After soft-delete the live head row was
gone, allowing a second write with a different channel h-tag to bypass
the immutable-binding invariant. That path was fixed in the previous
commit on this branch.

A second bypass existed via kind:9005 (channel admin delete): the 9005
branch in validate_admin_event resolved the e-tag target and authorized
the actor (including channel admins who do not own the draft), then
returned Ok(()). Post-storage side effects would soft-delete the head
row, re-opening the cross-channel rebind window.

Fix: in the kind:9005 branch of validate_admin_event (side_effects.rs),
immediately after target resolution, reject pre-storage if the target
kind is KIND_DRAFT. Authorship and agent-owner actors receive the
tombstone-guidance error (mirror of the kind:5 wording). All other
actors — including channel admins — receive the generic
"target event not found" response, byte-identical to the missing-target
branch, so the validator cannot act as a draft-existence oracle.

Add two new E2E regressions:
- test_nip09_kind9005_deletion_of_draft_is_rejected_and_binding_holds:
  full bypass sequence (publish draft h=A -> kind:9005 e=draft rejected
  -> head still live -> h=B rebind rejected), deterministic base/base+1
  timestamps.
- test_nip09_kind9005_admin_deletion_of_draft_is_masked_as_not_found:
  channel admin submits kind:9005 targeting a draft; verifies response
  is exactly "target event not found" (oracle-masking tripwire).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 force-pushed the duncan/nip37-relay-drafts branch from d16805c to b56bf14 Compare July 11, 2026 23:37
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 6 commits July 12, 2026 13:09
The bridge top_level channel-window filter (handle_channel_window_filter)
passed all window rows to the response without an author-only guard. Since
next_cursor and has_more are computed at the DB layer before any in-memory
filtering, a bridge-level skip would still expose draft ids via the 39006
bounds overlay and leave has_more counts inflated by draft rows.

Fix: add an author_pubkey param to get_channel_window. When Some, the query
appends AND (e.kind NOT IN (30300, 31234) OR e.pubkey = $N), excluding
author-only kinds (KIND_DRAFT=31234, KIND_EVENT_REMINDER=30300) for rows
whose pubkey does not match the requester. This keeps the cursor, has_more,
and all overlay values computed against the already-restricted row set.

Pass Some(&pubkey_bytes) from handle_channel_window_filter via the new
pubkey_bytes parameter; internal / test callers pass None.

Tests: two new e2e tests in e2e_nip37_draft.rs:
- test_channel_window_draft_excluded_for_non_author: mixed kinds:[9,31234]
  query by a channel member who is not the author must return zero draft
  rows and zero draft ids anywhere in the response (rows, aux, overlays).
  kind:9 positive control row must still be present.
- test_channel_window_draft_visible_to_author: the author sees their own
  kind:31234 draft in the window, consistent with all other author-only
  read paths.

Closes the last unguarded author-only read surface identified in code review
of PR #1757.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Add actor_can_reference_target helper to mask author-only event ids
on all five write-path target-resolution sites (reaction channel
derivation, NIP-10 thread parent, stream-edit, forum-vote, kind:5
e-tag deletion), preventing non-authors from distinguishing a real
draft/reminder id from a random id via differential error responses.

Q2: DbError::DraftChannelRequired + channel_id=None DB-layer rejection
Q3: participates_in_thread_metadata split; outer e-tag rejection on drafts
Q4: reader_can_receive_event canonical read gate in buzz-core/filter.rs;
     wire into req.rs / count.rs / bridge.rs / search path
Q5: CI step for buzz-search FTS integration tests (ignored suite now runs)
Q6: HTTP catchall kindless privacy e2e test_draft_not_returned_in_kindless_channel_http_query
Q7: draft_kind_is_excluded_from_workflow_dispatch_by_author_only_guard
Q8: a-tag defensive-guard comment rewrite in side_effects.rs
Q9: e2e smoke for outer-e-tag rejection (test_draft_rejected_outer_e_tag)
Q10: fan-out author-side positive control (test_draft_live_fanout_reaches_author_own_subscription)
Q11: mixed-kinds /count test (test_draft_attacker_mixed_kinds_count_excludes_drafts)
Q12: derive exclusion list from AUTHOR_ONLY_KINDS in thread.rs
Q13: DM privacy recipient-side test (test_dm_draft_not_readable_by_dm_recipient)
Q14: migration 0007 deploy-window note
Q15: actor_can_reference_target post-lookup helper applied at reaction channel
     derivation (all actors rejected), NIP-10 thread parent (all actors
     rejected), stream-edit / forum-vote (non-author masked, author falls
     through), kind:5 e-tag deletion (non-author masked before authz,
     false comment corrected); generalizes over AUTHOR_ONLY_KINDS so
     kind:30300 reminders are also protected; unit tests confirm membership
Q16: channel-window cursor-boundary e2e (test_channel_window_cursor_boundary_excludes_draft)
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… guards

The previous Q15 unit tests only asserted AUTHOR_ONLY_KINDS membership;
deleting the guard from derive_reaction_channel or
resolve_nip10_thread_meta left every one green.  Replace the vacuous
coverage with four `#[ignore]` e2e tests that exercise the live relay:

- test_draft_target_reaction_oracle_closed: attacker submits kind:7
  reaction to (A) real draft id, (B) random nonexistent id; asserts
  byte-identical error string and zero kind:7 rows stored.
- test_draft_target_thread_parent_oracle_closed: attacker submits kind:9
  reply with draft id as NIP-10 parent vs random parent; asserts
  byte-identical error, zero stored replies, and no 39005 fan-out.
- test_draft_target_public_reference_author_also_rejected: draft author
  reacts to and replies-to their own draft; both must be rejected with the
  masking not-found errors (public-reference paths reject everyone).
- test_draft_target_kind5_oracle_closed: attacker submits kind:5 e-tag
  deletion targeting (A) real draft id, (B) random id; asserts
  byte-identical masking error and draft still readable by author.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
R1 (IMPORTANT): Extract should_dispatch_workflow helper from inline if
condition in dispatch_persistent_event_inner.  The Q7 test previously
duplicated the production predicate as a local closure; deleting the
AUTHOR_ONLY_KINDS guard from the real if block would leave it green.
Now dispatch_persistent_event_inner calls should_dispatch_workflow, and
the test calls the same function — no copy-paste divergence is possible.
Test gains kind:30300 + is_relay_workflow_msg=true controls.

R2 (IMPORTANT): Add test_reminder_target_reaction_oracle_closed e2e to
prove the author-only mask in derive_reaction_channel generalizes to
kind:30300 reminders (not only kind:31234 drafts).  Corrects the
overclaiming comment at ingest.rs:4149-4154 which asserted the unit
tests exercised actor_can_reference_target directly; they only assert
constant membership, and the comment now says so explicitly with a
pointer to the e2e tests that provide behavioral coverage.

R3 (MINOR): Move draft timestamp to base+3 in
test_channel_window_cursor_boundary_excludes_draft so the draft is
inside the raw first page (raw DESC: msg2@+4, draft@+3, msg1@+2,
msg0@+0 — a filter-after-pagination regression would cursor off draft).
Replace the optional if-let cursor branch with cursor.expect(), forcing
the test to fail if has_more=false or no cursor is returned.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…fanout

D2: rewrite test_draft_not_returned_in_kindless_channel_http_query.
The HTTP bridge runs sensitive-kind gates unconditionally, so a kindless
h-tag filter triggers p_gated_filters_authorized and returns 403 for any
non-owner caller — this is fail-closed, pre-existing main behavior.  The
test now asserts 403 for the kindless case (i), then uses a mixed
kinds:[9,31234] h-tag filter as the attacker (passes all three HTTP
gates) and asserts kind:9 appears while the draft is absent (ii),
proving reader_can_receive_event is live on the bridge per-event path.

D3: add #h ch_id tag to subscription filter in
test_draft_live_fanout_reaches_author_own_subscription.  Drafts are
channel-bound events; fan_out_scoped's symmetric scoping invariant
means a global (no #h) sub never sees channel-scoped events by design.
Channel-scoping the filter routes the sub through channel_kind_index so
fan-out can match and filter_fanout_by_access passes the author through.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
fts_integration.rs setup() applies migration 0001, which contains
CREATE EXTENSION IF NOT EXISTS pgcrypto.  pg_extension is database-
global with a unique index on extname; IF NOT EXISTS is a check-then-
insert, not an atomic upsert.  When 18 tests run in parallel, the first
wave all see the extension as absent, all attempt the insert, and all
but one hit SQLSTATE 23505 (duplicate key on pg_extension_name_index).

Fix: take a session-level pg_advisory_lock on the admin connection
before the extension create, release it after.  This serializes the
install across all parallel test workers so exactly one does the real
CREATE; the rest treat it as a no-op on lock acquisition.  The advisory
lock is released automatically when the session ends.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
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