Skip to content

feat(relay): add author-only-unless-shared read gate for kind 30175#2768

Merged
wpfleger96 merged 4 commits into
mainfrom
duncan/persona-shared-read-gate
Jul 25, 2026
Merged

feat(relay): add author-only-unless-shared read gate for kind 30175#2768
wpfleger96 merged 4 commits into
mainfrom
duncan/persona-shared-read-gate

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jul 24, 2026

Copy link
Copy Markdown
Member

Kind 30175 persona sync events carry plaintext system_prompt and respond_to_allowlist. This PR adds author-only-unless-shared read semantics: events without ["shared","true"] are visible only to the author; events with that tag are community-readable.

What changed

New read class (kind 30175)

Kind 30175 gets per-event gating at every relay read surface. The shared marker is a tag, not a content field, so content bytes (which double as the source_version drift basis) are not affected when toggling share state.

event_visible_to_reader helper (handlers/req.rs)

Centralizes the three per-event access predicates — is_author_only_event, is_unshared_persona_event, reader_authorized_for_event — into one pub(crate) fn callable from both WS and HTTP adapters. All result-visibility sites now call this single helper.

NIP-98 HTTP bridge (api/bridge.rs)

  • POST /query catchall: replaced the two-step author-only + result-gated checks with event_visible_to_reader (now also covers the persona shared-gate).
  • POST /count: added needs_persona_filtering to the fast-path guard (forces per-event fallback when filter can match kind:30175) and replaced both fallback loops' individual checks with event_visible_to_reader.
  • FTS /search bridge helper: replaced is_author_only_event with event_visible_to_reader as defense-in-depth (30175 is not in the FTS allowlist today; comment at site explains the future-proofing intent).

Ingest validation (handlers/ingest.rs)

validate_persona_envelope rejects malformed shared tags: wrong value, missing value, duplicates. Accepts exactly ["shared","true"] and tag-absent.

Kind helpers (buzz-core/src/kind.rs)

is_persona_shared_kind, is_unshared_persona_event, filter_can_match_persona_shared_kinds.

Tests (e2e_persona.rs)

8 unit tests in kind.rs, 6 in ingest.rs, 8 e2e tests total:

  • AC-1–6 covering the gated surfaces
  • test_persona_live_fanout_shared_gate: reworked with explicit monotonic created_at timestamps (t0 < t1 < t2) and per-step head assertions, eliminating the NIP-33 event-id tie-break race. Also asserts foreign live subscription receives nothing on shared→unshared transition.
  • test_persona_ingest_shared_tag_validation: added shared=x and missing-value wire-level rejection cases.
  • test_persona_mixed_kind_filter_does_not_leak: publishes a kind-9 event and asserts it IS returned; absence-only assertion no longer sufficient.
  • test_persona_http_query_cross_author_gate: NIP-98 /query cross-author gate (authors filter, kindless ids — both blocked; shared ids — passes).
  • test_persona_http_count_cross_author_gate: NIP-98 /count cross-author gate (foreign sees 1/shared, author sees all, wildcard checked).

NIP-AP.md

Replaced aspirational "every relay read chokepoint" wording with an enumerated list of gated surfaces including NIP-98 /query, /count, and FTS/search with their enforcement mechanism named. Added Non-goal note for side-band existence oracles (reaction/report/deletion target resolution).

Existing tests

All pre-existing e2e_persona tests use {ids:[event_id]} or {authors:[self]} filters — author self-reads bypass the gate and are unaffected.

Gates

just check ✅ | just test-unit ✅ | cargo test -p buzz-relay ✅ (749 passed, 1 pre-existing failure in demo_join_forwarded_arm_round_trips_echo — flaky on main, unrelated to this PR, verified red at origin/main before this branch)

Kind 30175 (persona) events carry a system prompt and respond_to_allowlist
that should not be visible to all community members as a side effect of
device sync. Add a shared-tag-gated read model:

- Events WITHOUT ["shared","true"] are author-only.
- Events WITH exactly ["shared","true"] are community-readable, enabling
  the opt-in agent catalog ({kinds:[30175]} all-authors).

Unlike AUTHOR_ONLY_KINDS (30300/30350), persona filters from foreign readers
are legitimate (that IS the catalog query), so enforcement is per-event at
every read chokepoint rather than at the filter layer:

- REQ historical delivery: is_unshared_persona_event gate in main loop and
  search lane silently omits foreign unshared events.
- Live fan-out: filter_fanout_by_access skips foreign connections when the
  event is unshared.
- COUNT: filter_can_match_persona_shared_kinds forces the per-event fallback
  path, preventing existence-leak via fast SQL count_events().
- ids-lookup: result-gated via the per-event check in the delivery loop.
- Ingest: validate_persona_envelope rejects malformed shared tags (wrong
  value, duplicates) so no ambiguous heads can be stored.

The shared tag is a tag (not a content field) so toggling sharing does not
change content bytes or the drift/source_version hash (persona_content_hash)
used by persona sync.

No data migration: existing 30175 heads have no tag -> become author-only
on deploy. Device sync is unaffected (usePersonaSync.ts already filters
authors:[self]).

Changes:
- kind.rs: is_persona_shared_kind, is_unshared_persona_event,
  persona_event_is_shared helpers + 8 unit tests; updated KIND_PERSONA doc
- ingest.rs: shared-tag validation in validate_persona_envelope + 6 tests
- req.rs: is_unshared_persona_event gate in REQ main loop + search lane;
  filter_can_match_persona_shared_kinds helper
- event.rs: persona shared-read gate in filter_fanout_by_access
- count.rs: needs_persona_filtering flag forces fallback at both count sites
- e2e_persona.rs: 6 acceptance-criteria tests covering foreign isolation,
  ids-lookup, COUNT fallback, live fan-out (including toggle), ingest
  rejection, and mixed-kind filter non-leak
- NIP-AP.md: Relay Behavior section updated with shared-tag-gated access
  model and updated Security considerations

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 requested a review from a team as a code owner July 24, 2026 21:29
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 3 commits July 24, 2026 18:05
Apply the author-only-unless-shared read gate to the NIP-98 HTTP bridge
(/query, /count, search) and centralize the per-event visibility decision
into a single event_visible_to_reader helper callable from both WS and
HTTP adapters.

Bridge parity (CRITICAL):
- /query catchall: replaced the two-step is_author_only_event +
  reader_authorized_for_event checks with event_visible_to_reader, which
  also covers is_unshared_persona_event. A foreign authenticated caller
  POSTing {kinds:[30175],authors:[victim]} or {ids:[unshared-id]} now
  receives nothing.
- /count: added needs_persona_filtering to the fast-path guard (forces
  fallback when the filter can match kind:30175) and replaced the two-step
  checks in both fallback loops with event_visible_to_reader. Existence
  leak via HTTP COUNT is closed on both the channel-scoped and unconstrained
  paths.
- FTS search helper: replaced is_author_only_event with event_visible_to_reader
  as defense-in-depth. Kind 30175 is not in the FTS allowlist today so the
  branch is not reachable, but the check prevents a future allowlist change
  from silently reopening the bypass (comment explains this at the site).

Structural centralization (IMPORTANT):
- Added event_visible_to_reader to handlers/req.rs, combining the three
  per-event access predicates (is_author_only_event, is_unshared_persona_event,
  reader_authorized_for_event) behind a single call. Both the WS and HTTP
  adapters now call this helper at their result-visibility sites.

Test gaps closed (IMPORTANT):
- Reworked test_persona_live_fanout_shared_gate with explicit monotonic
  created_at timestamps (t0 < t1 < t2) so NIP-33 head ordering is
  deterministic, not dependent on event-id tie-break. Added self-query
  assertions after each publish to verify the correct NIP-33 head is live,
  and added a live-fan-out assertion that foreign receives nothing when the
  shared head goes unshared.
- Added shared=x and missing-value wire-level rejection cases to
  test_persona_ingest_shared_tag_validation (AC-5; previously only
  shared=false and duplicate were tested at the wire level).
- Fixed test_persona_mixed_kind_filter_does_not_leak (AC-6): now publishes
  a kind:9 event from the same author and asserts it IS returned while the
  unshared persona is omitted. Previously the absence-only assertion would
  pass if the whole mixed filter were dropped.
- Added two new HTTP bridge e2e tests:
  test_persona_http_query_cross_author_gate: verifies /query returns only
  shared persona to foreign, returns nothing for {ids:[unshared-id]}, and
  returns the shared event for {ids:[shared-id]}.
  test_persona_http_count_cross_author_gate: verifies /count returns 1
  (shared only) to foreign and 3 (all) to author, on author-scoped and
  wildcard filters.

NIP-AP.md (doc):
- Replaced aspirational every relay read chokepoint wording with an
  enumerated list of gated surfaces including NIP-98 /query, /count, and
  FTS/search with their exact enforcement mechanism named.
- Added Non-goal: side-band existence oracles note documenting the
  adjudicated decision not to gate reaction/report/deletion target
  resolution (leaks one bit of existence, never content; requires a known
  event id unshared personas never hand out through any gated path).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Task 1 — SQL visibility before LIMIT: add `persona_reader: Option<Vec<u8>>`
to `EventQuery`. When set, `query_events` appends
`AND (kind != 30175 OR pubkey = $reader OR tags @> '[["shared","true"]]')`
before ORDER/LIMIT, preventing newer private personas from starving older
shared ones off the candidate page. Wired from WS REQ, bridge /query, and
both COUNT fallback paths (conditioned on filter_can_match_persona_shared_kinds
to avoid the clause on non-persona queries). Two E2E tests added (WS REQ +
/query limit-gate scenario) that fail on 312014d.

Task 2 — Exact tag shape: tighten validate_persona_envelope to require
parts.len() == 2 (was >= 2) so ["shared","true","extra"] is rejected at
ingest. persona_event_is_shared independently fails closed on any non-exact
shape (returns false for 3-element tags). Docstring updated. Unit tests in
kind.rs (3-element and 1-element variants) and ingest.rs (3-element wire
rejection). Wire-level E2E test added.

Task 3 — Fix 3 deterministic E2E reds:
- test_persona_live_fanout_shared_gate: replace t0=1_700_000_000 with
  now-relative timestamps (now-2, now-1, now) so ingest skew check passes.
- test_persona_mixed_kind_filter_does_not_leak: add create_test_channel helper
  in e2e_persona.rs; kind:9 control event now carries h tag on an open-
  visibility channel so the foreign reader can access it.
- test_persona_count_excludes_foreign_unshared: add RelayMessage::Count variant
  to buzz-ws-client parse_relay_message (new typed path replaces raw-string
  JSON parse that always fell into UnexpectedMessage).

Task 4 — Single decision point: refactor event_visible_to_reader to take
&[u8] only, deriving hex internally (no more caller-supplied inconsistent
byte/hex pair). Replace the inlined 3-predicate sequences in WS REQ main loop,
WS REQ search lane, and both WS COUNT fallback loops with the single helper.
Remove now-unused viewer_hex variable and reader_pubkey_hex parameter from
handle_search_req.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Parallel persona tests all publish kind:30175 events. The foreign live
subscription in test_persona_live_fanout_shared_gate used an unscoped
kind-only filter, so any concurrent test's 30175 tripped the leak-panic
(false positive in the parallel Relay E2E job, CI run 30133257310).

Scope the subscription to author_keys.public_key() — still a valid
cross-author live fan-out test; scoping isolates it from the parallel suite.

Also include event id and author pubkey in both leak-panic messages so any
future failure identifies what was actually received.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 merged commit ab3af82 into main Jul 25, 2026
32 checks passed
@wpfleger96
wpfleger96 deleted the duncan/persona-shared-read-gate branch July 25, 2026 03:17
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.

2 participants