feat(protocol): define compact membership discovery (BUZZ-615) (#9117)

## Summary

Cold login currently downloads full channel rosters to learn the
viewer's memberships. This defines compact discovery in NIP-CL and
reserves relay-only kind 39008 for a signed, request-bound response.
Full kind-39002 rosters remain compatible.

The contract binds a complete snapshot to the community, viewer, NIP-98
request and requested channel types. It defines a primary database cut,
overflow errors, lazy-roster precedence, reconnect repair and safe
fallback across mixed relay versions. The next PR implements both query
modes. This PR does not enable either capability.

A separate `membership_public` capability uses relay-only kind 39009 for
lookups of any subject’s listed-open forum, stream and workflow
memberships. It preserves roles and archived state while excluding
private, unlisted, DM and system channels. Signed answers bind
requester, subject and public scope; they cannot grant membership or
sending permissions. The self-mode contract is unchanged.

### Related issue

[BUZZ-615](https://linear.app/squareup/issue/BUZZ-615). No duplicate
compact-membership implementation found. Related work:
[#8898](https://github.com/block/buzz/pull/8898) protects full-roster
signing; [#9060](https://github.com/block/buzz/pull/9060) caches
membership lookups.

### Testing

McKeon independently reviewed the contract and the fixes to request
binding, per-channel race fencing and mixed-version fallback. Content
checks parse every JSON example and resolve relative links. Paul’s
review fixes remove implementation details from the normative text and
move the existing 39009 registration and regression test into this PR.
Protocol behavior is unchanged. Runtime validation belongs to the
implementation PR. This description does not attest human testing.

### Implementation and operator notes

Both kinds are registered in `crates/buzz-core/src/kind.rs`, included in
`ALL_KINDS`, and rejected as client-submitted events by the relay-only
guard. The dependent implementation uses one primary SQL statement’s
MVCC snapshot over active `channel_members` joined to nondeleted
channels, supported by the partial `(community_id, pubkey) WHERE
removed_at IS NULL` index. Public requests share the existing
community/requester `ApiCalls` quota configured by
`human_api_calls_per_min`.

`BUZZ_MEMBERSHIP_SNAPSHOT_ENABLED` and `BUZZ_MEMBERSHIP_PUBLIC_ENABLED`
independently default to false. Deploy support across the fleet with
both flags off, then enable capabilities independently. Disable the
affected capability to roll back; client fallback and back-off behavior
remain specified in NIP-CL. Client opt-in and production-shaped
throttled validation follow separately.

Generated with Codex

---------

Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
This commit is contained in:
Brad Seiler
2026-11-05 18:52:07 -05:00
committed by GitHub
parent cec47c20a3
commit 66c3d6917d
2 changed files with 484 additions and 1 deletions

View File

@@ -492,6 +492,12 @@ pub const KIND_WINDOW_BOUNDS: u32 = 39006;
/// NIP-CW thread-mode query-time bounds, bound to a normalized newest-first thread request.
pub const KIND_THREAD_WINDOW_BOUNDS: u32 = 39007;
/// NIP-CL: request-bound compact viewer membership overlay; query-time only, never stored.
pub const KIND_MEMBERSHIP_SNAPSHOT: u32 = 39008;
/// NIP-CL: request-bound public membership overlay; query-time only, never stored.
pub const KIND_MEMBERSHIP_PUBLIC: u32 = 39009;
/// Workflow definition (parameterized replaceable, d=workflow_uuid).
pub const KIND_WORKFLOW_DEF: u32 = 30620;
@@ -757,6 +763,8 @@ pub const ALL_KINDS: &[u32] = &[
KIND_THREAD_SUMMARY,
KIND_WINDOW_BOUNDS,
KIND_THREAD_WINDOW_BOUNDS,
KIND_MEMBERSHIP_SNAPSHOT,
KIND_MEMBERSHIP_PUBLIC,
KIND_PRESENCE_UPDATE,
KIND_TYPING_INDICATOR,
KIND_AGENT_PRESENCE,
@@ -912,6 +920,8 @@ pub const fn is_relay_only_kind(kind: u32) -> bool {
| KIND_THREAD_SUMMARY
| KIND_WINDOW_BOUNDS
| KIND_THREAD_WINDOW_BOUNDS
| KIND_MEMBERSHIP_SNAPSHOT
| KIND_MEMBERSHIP_PUBLIC
)
}
@@ -996,6 +1006,15 @@ mod tests {
assert!(is_relay_only_kind(KIND_THREAD_WINDOW_BOUNDS));
}
#[test]
fn compact_membership_snapshot_is_relay_only() {
assert_eq!(KIND_MEMBERSHIP_SNAPSHOT, 39008);
assert!(is_relay_only_kind(KIND_MEMBERSHIP_SNAPSHOT));
assert_eq!(KIND_MEMBERSHIP_PUBLIC, 39009);
assert!(is_relay_only_kind(KIND_MEMBERSHIP_PUBLIC));
assert!(ALL_KINDS.contains(&KIND_MEMBERSHIP_PUBLIC));
}
#[test]
fn parameterized_replaceable_range() {
assert!(!is_parameterized_replaceable(29999));

View File

@@ -8,7 +8,8 @@ Channel Identity and Discovery
This NIP adds identity facts to the relay-signed NIP-29 channel-state events
(kinds 39000–39002). With these facts, a client can find channels with
ordinary NIP-01 filters. It needs no new filter key and no relay-specific API.
ordinary NIP-01 filters. The optional compact membership modes below use
explicit filter extensions on the existing `POST /query` bridge.
## Motivation
@@ -291,6 +292,469 @@ A relay without this NIP drops label tags on create and refuses
a system channel on a relay whose NIP-11 document does not contain `"nip-cl"`.
Lookups by label on such a relay find nothing, which is safe.
## Compact membership modes
These independent, optional query modes reserve distinct response kinds:
| Mode / NIP-11 capability | Kind | Subject | Scope |
| --- | --- | --- | --- |
| `membership_snapshot` v1 | 39008 | NIP-98 signer only | Own active memberships, including private channels and DMs as specified below. |
| `membership_public` v1 | 39009 | Any public key | Active memberships in listed, open forum/stream/workflow channels only. |
Kinds 39008 and 39009 are relay-only, request-bound projections. Support for
39008 does not imply support for 39009, or conversely. Each mode key MUST
appear only with its own kind; a mismatched mode key and kind returns 400.
## Compact viewer membership snapshot (version 1)
This optional capability replaces full-roster discovery for clients that
implement the reconciliation rules below. It does not change kinds
39000–39003 or their legacy query behavior.
### Capability and request
An enabled relay advertises `membership_snapshot` in its host-bound NIP-11
information document:
```json
{
"membership_snapshot": {
"version": 1,
"community_id": "<community UUID>",
"pubkey": "<relay signing public key>",
"kind": 39008,
"max_channels": 4096,
"max_response_bytes": 2097152
}
}
```
The operator switch for this capability is off by default.
Disabled relays omit the descriptor and return HTTP 404 with
`{"error":"membership_snapshot unsupported"}` for this query mode. Capability
advertisement alone is not authentication or membership authority. Clients
MUST obtain it from the community's TLS origin and pin the relay signing key
as they do for group-state events. An unresolved host never advertises it.
Send one exact filter to the existing `POST /query` bridge:
```json
[{
"kinds": [39008],
"#p": ["<authenticated viewer public key>"],
"#t": ["dm", "forum", "stream", "system", "workflow"],
"membership_snapshot": 1
}]
```
The nonempty type array MUST be sorted, unique, and contain only known types
(`dm`, `forum`, `stream`, `system`, `workflow` in version 1). A client MAY
request a subset. Including `system` is an explicit opt-in to system channels,
not a change to ordinary NIP-CL reads. Unknown types, versions, extra filter
keys, constraints, multiple filters or another viewer return 400. A relay
MUST NOT silently narrow the requested set. Future types need a reviewed
protocol update; unsupported clients do not discover or revoke them.
This mode requires a valid NIP-98 event whose `u` and `method` bind the
host-derived query URL and POST, and whose `payload` binds the exact body,
even when unsigned development bridge reads are enabled. The bridge's normal
admission, membership/ban policy, rate limit and replay guard apply. The viewer
MUST be the signer proven by NIP-98, never `token_owner` or the NIP-OA
owner used for admission. An agent asking for its owner's memberships gets
400, even when its auth tag names that owner. Use a
fresh NIP-98 event for every attempt (a random auth-event nonce tag avoids
same-second duplicates). No second nonce scheme is introduced.
### Signed response and verification
Success is an **object**, never an ordinary Nostr event array:
```json
{
"membership_snapshot": 1,
"event": {
"kind": 39008,
"pubkey": "<relay signing public key>",
"tags": [["d", "<NIP-98 event ID>"], ["p", "<viewer>"]],
"content": "<JSON object below>",
"id": "<event ID>",
"created_at": 1793899000,
"sig": "<signature>"
}
}
```
The signed content is:
```json
{
"version": 1,
"community_id": "<host-resolved community UUID>",
"host": "<canonical resolved request host>",
"viewer": "<authenticated viewer public key>",
"request_id": "<NIP-98 event ID>",
"types": ["dm", "forum", "stream", "system", "workflow"],
"complete": true,
"snapshot_id": "<SHA-256 content identity>",
"channels": [{
"id": "<channel UUID>",
"role": "guest",
"channel_type": "stream",
"visibility": "open",
"archived": false
}]
}
```
Like the 39005–39007 query overlays, kind 39008 occupies the addressable
range but is an on-demand projection: it is never persisted, replaced, broadcast or returned
by ordinary REQ, COUNT or `/query`. Clients cannot submit it through EVENT.
The `d` tag is a request coordinate, not a durable per-viewer list.
The client MUST verify the envelope version, full Nostr ID and signature,
expected kind and pinned relay key, both tags, and **all** signed binding
inputs: schema version, host-resolved community UUID, canonical host (the normalized host of the NIP-98 `u` URL used for this
request, not an alias inferred from another community URL), viewer,
outstanding NIP-98 event ID and exact requested type array. The request ID
MUST belong to the current viewer/community/session generation and be consumed
at most once. A canceled, superseded, timed-out or previous-session request
cannot establish current membership. `created_at` is informational wall clock;
it MUST NOT order responses, prove freshness or override a newer revocation.
The signature provides verifiable provenance when the result is cached or
exported. A saved response is display data, never fresh authorization. Only
matching the outstanding request proves an on-demand response; neither its
signature nor `snapshot_id` is proof of current membership on its own.
Arrays, including `[]`, malformed envelopes, missing/false completeness,
invalid signatures, binding mismatches, unknown fields needed for authority,
duplicate channels or invalid roles/types/visibility MUST fail validation.
Never interpret these as an empty complete list. An empty **valid signed**
`channels` list is authoritative only for its requested types and snapshot cut.
### Canonical selection and bounds
The relay MUST derive the complete response from one consistent read of
committed state that reflects every change committed before the relay
received the request. Select only the viewer's active memberships in nondeleted
channels in the same community, ordered by channel UUID. Channel UUID,
role, type, visibility and archive state MUST reflect that same read.
The projection MUST NOT include full rosters, other members' keys or profiles.
There is one entry per active membership in the requested type set:
- Roles retain `owner`, `admin`, `member`, `bot`, and `guest`. A guest remains
read-only under existing send-time policy; membership is not a write grant.
- Open, private, unlisted and explicitly requested system memberships are
included. Open channels where the viewer is not a member are excluded.
- Archived channels are included with `archived:true`; deleted channels and
removed memberships are excluded.
- Hidden DMs are included, as in kind-39002 discovery. NIP-DV kind 30622 owns
the separate per-viewer hide overlay; hiding is not leaving a DM.
- Names, avatars, labels, creation identity, join policy and other channel
metadata remain in kind 39000. No member list is needed to use this result.
The snapshot identity is SHA-256 of the UTF-8 compact JSON array
`["nip-cl-membership-state-v1", community_id, viewer, types, channels]`, with
UUIDs/pubkeys in lowercase and channel object keys in the order shown above.
Clients MUST recompute this identity from the verified signed fields; it
describes content, not a monotone revision, transaction ID or live cursor.
A role change, removal or channel-state transition committed after the cut is
visible on a subsequent refresh, even if legacy roster re-signing fails.
Version 1 permits at most 4096 channels and 2097152 encoded response bytes.
Selection probes one extra row. Either overflow returns 413 for the whole
request, with no success envelope or partial list. Database/signing failure
returns an error, never complete empty state. There are no pages, tokens,
server snapshot caches, or resume semantics in v1. Retry the whole projection
with fresh auth; a larger-account paging design requires a new version with
an immutable cut, authorization, expiry and resource bounds.
### Authority and snapshot-to-live handoff
A snapshot describes one committed cut; it does **not** fence live delivery.
The supported client path MUST keep viewer membership and role authority
separate from full-roster availability:
1. Establish the authenticated viewer's live 44100/44101 subscription for all
requested types before issuing the snapshot. Treat these notices as hints,
never an ordered or lossless delta log. Observe connection gaps, explicit
denials and changes to projected archive/visibility/type/deletion state as
invalidations too. Names or avatars alone do not dirty this projection.
2. Fence the request by account, tenant and session generation. Increment an
invalidation generation for every relevant hint received after dispatch.
Buffer these hints while loading. A changed generation makes the result
dirty: for every channel named by a hint after dispatch, ignore that
result's entry entirely (presence, absence and role). Keep prior state
marked stale, including any denial already applied; suspend actions that
depend on an unconfirmed role. For unhinted channels a dirty result may
only add memberships, never alter existing roles or remove by omission.
A global invalidation discards the whole result. Coalesce a follow-up
refresh after processing the hints. A clean valid
snapshot may reconcile omissions only within its exact requested type set.
3. Serialize refreshes per viewer/community and fence delayed completions.
Never order snapshots by timestamps or hashes. Local access denials or
removal/deletion hints immediately invalidate subscriptions, dependent UI
and roster caches; a delayed response cannot undo them. A later clean
request may establish a real re-add. Duplicate/out-of-order hints may cause
redundant invalidation, never grant authority.
4. Lazy kind-39002 responses serve other-member features. They MUST NOT add,
remove or change the viewer's own membership or role on this path, even
when their timestamp is newer. Contradictions invalidate and schedule a
compact refresh. Preserve existing send-time permission checks; a roster
or snapshot cannot override a relay denial.
5. Refresh after every reconnect/gap before claiming freshness. Reconnect costs
one compact projection per viewer, never one roster read per channel. On
resume, reconnect or capability changes, recheck the host-bound descriptor.
Coalesce notices, jitter dispatch and exponential failure backoff, and cap
immediate repair to three attempts per recovery episode. Continuous churn
then leaves explicit stale/incomplete state until the next bounded episode;
it MUST NOT start an endless roster reload or certify dirty omissions.
6. Because notifications are post-commit and may be lost, refresh on foreground
resume and use a jittered 15-minute backstop while active, with no overlapping
reads. This bounds **attempts**, not successful convergence during outage or
churn. Successful quiescent refresh repairs missed roles, grants, revocations,
deletion and metadata transitions. Display stale state when repair fails;
the relay still authorizes every operation against current access.
The relay continues to enforce current read/write authorization independently.
This response is discovery authority, never a bearer capability. Relay-wide
ban/removal invalidates the session even if channel rows remain active.
### Compatibility
A client MUST require the descriptor before selecting this mode. An explicit
absent/disabled capability uses legacy full-roster discovery. If a pod returns
an ordinary array or the exact disabled 404 error after another pod advertised
support, mark compact discovery unsupported for that generation, recheck the
descriptor and back off capability probes. Explicitly select a new legacy
authority/session generation and run full legacy validation; the array is
never a complete compact snapshot. This handles mixed-version fleets without
revocation by empty response. An auth error,
invalid signature, malformed envelope or transient failure MUST NOT silently
switch authority models. Old relays may ignore unknown filter fields and
return an ordinary array; this is not a valid compact response. Rollback may
explicitly select legacy discovery with a new authority/session generation,
without deleting saved display/user data. New relays keep legacy 39002 events,
query shape, type opt-ins and signatures intact. Disabling the capability
requires clients to recheck support and explicitly select legacy authority
as above.
## Compact public membership lookup (version 1)
This independent mode answers “which public channels is this person in?”
without loading their peers' roster entries. It does not relax the self-mode
rule that kind 39008's `#p` MUST equal the NIP-98 signer. In this section,
**requester** means the authenticated signer and **subject** means the public
key whose memberships are requested. “Public” means listed and open within
the host-resolved community, not accessible across community boundaries.
### Capability and request
An enabled relay advertises this separate descriptor in its host-bound
NIP-11 information document:
```json
{
"membership_public": {
"version": 1,
"community_id": "<community UUID>",
"pubkey": "<relay signing public key>",
"kind": 39009,
"max_channels": 4096,
"max_response_bytes": 2097152
}
}
```
The operator switch for public lookup is off by default and independent of
the self-snapshot switch. Disabled relays omit this descriptor and
return HTTP 404 with `{"error":"membership_public unsupported"}` for this
mode. An unresolved host never advertises it. Clients MUST obtain and pin
this descriptor using the TLS-origin and relay-key rules of self mode.
Send exactly one filter to `POST /query`:
```json
[{
"kinds": [39009],
"#p": ["<subject public key>"],
"#t": ["forum", "stream", "workflow"],
"membership_public": 1
}]
```
`#p` MUST contain exactly one lowercase 64-character hex public key. The
nonempty `#t` array MUST be sorted, unique, and contain only `forum`,
`stream`, or `workflow`; a subset is allowed. Unknown types (including `dm`
and `system`), versions, extra keys or constraints, multiple filters, mixed
kinds, or mixing `membership_public` with `membership_snapshot` return 400.
A relay MUST NOT silently narrow the requested set.
A valid fresh NIP-98 event MUST bind the exact body, canonical host-derived
query URL and POST method, even when unsigned development reads are enabled.
The normal bridge admission, community membership/ban policy and replay
guard apply to the requester. Any requester admitted by those rules MAY
query any subject, including themselves or their owner. The requester is
always the signer, never `token_owner` or the NIP-OA owner. No relationship
between requester and subject expands the public scope or grants private
access. A subject need not have a profile or current community membership;
selection uses their active channel memberships in this community only.
Every attempt MUST consume the requester's existing per-community API-call
quota before selection. Changing subjects MUST NOT
reset that quota. Exhaustion returns 429 using the bridge's existing
`rate-limited: quota exceeded; retry in <seconds>s` error convention; shared
admission unavailability returns 503 and fails closed. Clients MUST respect
that retry delay, back off and jitter further attempts; no new per-subject
quota or auth nonce scheme is introduced.
### Signed response and verification
Success is an object with its own version marker and response kind:
```json
{
"membership_public": 1,
"event": {
"kind": 39009,
"pubkey": "<relay signing public key>",
"tags": [["d", "<NIP-98 event ID>"], ["p", "<subject>"]],
"content": "<JSON object below>",
"id": "<event ID>",
"created_at": 1793899000,
"sig": "<signature>"
}
}
```
The signed content is:
```json
{
"version": 1,
"scope": "public",
"community_id": "<host-resolved community UUID>",
"host": "<canonical resolved request host>",
"requester": "<NIP-98 signer public key>",
"subject": "<requested public key>",
"request_id": "<NIP-98 event ID>",
"types": ["forum", "stream", "workflow"],
"complete": true,
"snapshot_id": "<SHA-256 content identity>",
"channels": [{
"id": "<channel UUID>",
"role": "member",
"channel_type": "stream",
"visibility": "open",
"archived": false
}]
}
```
Kind 39009 is relay-only, query-time data: it MUST NOT be persisted,
replaced, broadcast, returned by ordinary REQ, COUNT or `/query`, or accepted
through client EVENT. Its `d` tag is a request coordinate, not a durable list.
The client MUST verify the envelope marker/version, full Nostr ID and
signature, kind 39009 and pinned relay key, exactly one `d` and one `p` tag
with the values shown, and every signed binding: schema version,
`scope:"public"`, community UUID, canonical host (the normalized host of the
NIP-98 `u` URL for this request), requester equal to the current signer,
subject equal to the requested `#p`, outstanding request ID and exact type
array. The request MUST belong to the current requester/community/session
and the currently selected subject; consume it at most once. Canceled,
superseded, timed-out or previous-session results MUST NOT replace current
discovery data. Neither timestamps nor hashes order responses.
Arrays (including `[]`), malformed envelopes, missing/false completeness,
invalid signatures, binding mismatches, unknown fields needed to interpret
the result, duplicate channel IDs, invalid roles/types, non-boolean archive
state or visibility other than `open` MUST fail validation. Failure is never
a complete empty list. A valid signed empty list means only that no matching
public memberships existed at that cut. A saved response is display data;
its signature and identity do not prove present visibility or authorization.
### Selection, completeness and bounds
The relay MUST derive the complete response from one consistent read of
committed state that reflects every change committed before the relay
received the request. Select the subject's active memberships in nondeleted
channels in the same community, restricted to requested types, open and not
unlisted, ordered by channel UUID. This is the listed-open discovery
predicate, with no shared-private-channel exception. All entry fields MUST
reflect that same read. The projection MUST NOT include full rosters,
peers' public keys or profiles.
There is one entry per matching membership. Preserve the subject's role
(`owner`, `admin`, `member`, `bot` or `guest`); include archived channels with
`archived:true`. Exclude removed memberships, deleted channels, private or
unlisted channels, DMs and system channels, even when the requester is a
member too. Channel names and other metadata remain in kind 39000 under
existing read rules.
`complete:true` means all of the subject's memberships satisfying this
public predicate and requested type set at that instant. It never means
the subject's full membership set. A channel absent on refresh may have
become private, unlisted or deleted, or its membership may have ended;
absence MUST NOT establish loss of membership.
The snapshot identity is SHA-256 of the UTF-8 compact JSON array
`["nip-cl-public-membership-v1", community_id, subject, types, channels]`,
with UUIDs/pubkeys in lowercase and channel object keys in the order shown.
Clients MUST recompute it from the verified signed fields. Requester and
request ID bind the signed response but are excluded from content identity;
identical public sets may have identical identities across requesters.
The identity is neither a revision nor a live cursor.
The same bounds as self mode apply: at most 4096 matching channels and
2097152 encoded response bytes, probing one extra matching row. Either
overflow returns 413 for the whole request, with no success envelope or
partial list. Database/signing failure returns an error, never complete
empty state. There are no pages, tokens, server snapshot caches or resume
semantics in v1. A retry requires fresh NIP-98 auth.
### Discovery authority and compatibility
A validated result MAY replace a cached public discovery list keyed by
community, subject and exact requested types. It MUST NOT populate or alter
the requester's own membership/role authority, canonical kind-39002 roster
caches, send permissions or the self-mode 44100/44101 snapshot-to-live
handoff, even when requester equals subject. Roles describe the subject;
they are not grants to the requester. The self-mode handoff does not apply.
Ordinary read/write authorization remains independently enforced, and
saved public data MUST NOT authorize a read after a visibility change.
Clients MUST require `membership_public` before selecting this mode. Absence
or the exact disabled 404 means this lookup is unsupported; an ordinary
array from an older relay is never a valid public result. If a pod returns
an ordinary array or the exact disabled 404 after another pod advertised
support, mark public lookup unsupported for that generation, recheck its
host-bound descriptor and back off capability probes. Keep any saved public
list explicitly stale; this does not switch the self authority model.
Auth failures, invalid signatures, malformed responses and transient errors
MUST NOT be accepted as an empty result or silently retried through self mode.
Public and self capabilities can be enabled and disabled independently.
Turning off public lookup stops its use without changing the
self-membership authority/session generation or deleting saved display data.
### Normative examples and rejection cases
In these examples A signs fresh body-bound NIP-98 auth and B is the subject:
| Request / response | Required outcome |
| --- | --- |
| A requests B's `forum,stream,workflow` memberships; B belongs to listed-open stream X, archived listed-open forum Y, private Z shared with A, an unlisted stream, a DM and a system channel. | Return X and Y only, preserving B's roles and marking Y archived. |
| A is B's agent and its admission auth tag names B as owner. | The same public-only X/Y result; no owner's private memberships. |
| A requests A with `membership_public:1`. | The same public predicate; never private/DM/system memberships or self authority. |
| A requests B with `membership_snapshot:1`, kind 39008. | 400; self mode still requires subject equal to signer. |
| Public request contains `#t:["dm","stream"]`, `#t:["system"]`, duplicate/unsorted types, two subjects, both mode keys, or an added `limit`. | 400; never silently drop forbidden constraints or types. |
| A receives a validly signed answer for requester C, subject C, another host/community, another type subset or another request ID. | Reject the binding mismatch. A's outstanding request is unchanged. |
| A receives kind 39008 or the self envelope for its public request, or kind 39009 for its self request. | Reject the cross-mode response, including when A equals B. |
| X becomes private or unlisted before a later lookup's cut. | Omit X from the new public list even if A still shares X; replacing the public cache is allowed, revoking membership from the omission is forbidden. |
| B has no matching public memberships, or has never had a community profile. | Return a valid signed complete empty list if selection is empty; do not disclose excluded memberships. |
| Selection or encoding overflows, selection/signing fails, or requester quota is exhausted. | Return the specified whole-request error; never a partial or complete-empty success. |
## Trust
A client that relies on these tags MUST check that the relay's key signed the