14 KiB
Proposal: Event Fetching Strategy (Curated / Uncensored)
Status: proposal, not implemented. Verified against docs/backend-audit.md;
read that document first.
References studied:
- GitWorkshop
main@420c0c3(git clone nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr//gitworkshop). - rust-nostr @
b230cecf9dbb38e0228e6fff4544ed9d261326fcfromCargo.lock. Everynostr*crate, includingnostr-gossipandnostr-gossip-memory, resolves to that single revision.
Goal
Add a global setting controlling which relays RepoStore queries for a
repository's activity, mirroring GitWorkshop's "Event Fetching Strategy":
- Curated (
repo): only the relays declared in the repository announcement. - Uncensored (
outbox): the repository's declared relays plus every maintainer's relays, resolved through the NIP-65 outbox model.
GitWorkshop reference
Source paths are relative to the cloned repository.
src/services/settings.ts:RelayCurationMode = "repo" | "outbox", persisted tolocalStorage, default"outbox"(the adjacent doc comment wrongly saysrepo; the constant is authoritative).src/pages/Settings.tsx(RelayCurationSection,CURATION_OPTIONS): two selectable cards, "Curated" and "Uncensored".src/hooks/useResolvedRepository.ts(layers 3-4):repoRelayGroup(RepositoryRelayGroup): the repo announcement'srelaystag. The Curated frontier.extraRelaysForMaintainerMailboxCoverage: a delta relay group built from every maintainer's NIP-65 outbox + inbox relays, excluding relays already inrepoRelayGroup. The Uncensored addition.
src/services/nostr.ts:resolveMailboxes(pubkey)reads NIP-65 kind10002with a 3 s timeout and caps toMAX_RESOLVED_RELAYS = 5per direction.nip34RepoLoader/nip34SupplementalRelayLoadersubscribe to the base and extra groups inoutboxmode, and additionally query each discovered item's author inbox relays (MAX_AUTHOR_INBOX_RELAYS = 3).- Deletions come from base + extra relays in
outboxmode.
How the pinned Nostr SDK handles NIP-65
The SDK ships a full NIP-65 outbox model. signed configures it but, as of
the audit, no code path exercises it: every fetch uses an explicit relay list.
crates/signed_nostr/src/backend.rsbuilds the client with.gossip(NostrGossipMemory::unbounded())and.gossip_config(GossipConfig::default().no_background_refresh()).nostr-sdk/src/client/builder.rs:GossipConfig { limits, allowed, sync/fetch timeouts, fetch_chunks, background_refresh }.GossipRelayLimitsdefaults: read 3, write 3, hint 1, most-used 1, NIP-17 3, per user.GossipAllowedRelaysgates onion/local/plain-TLS, not read/write.no_background_refresh()disables the periodic refresher that re-fetches NIP-65 lists for tracked and DB-seen keys.
nostr-sdk/src/client/api/req_target.rs:ReqTarget::auto(from aFilter,Vec<Filter>, or[Filter; N]) vsReqTarget::manual(from a relay map or(relay, filters)pairs).nostr-sdk/src/client/api/util.rs::build_targetsandnostr-sdk/src/client/api/sync.rs:- Auto target + gossip configured -> filters are broken down by NIP-65.
- Manual target (e.g.
client.sync(f).with(urls), or an explicitHashMap<RelayUrl, Vec<Filter>>), or gossip unset -> no breakdown.
nostr-sdk/src/client/gossip/updater.rs::gossip_break_down_filter:- Extracts pubkeys via
Filter::extract_public_keys(nostr/src/filter/mod.rs), which reads onlyauthorsand#p. - Ensures those pubkeys' NIP-65 lists are fresh (
ensure_gossip_public_keys_freshsyncs kind10002overDISCOVERY | READrelays), then breaks the filter down (gossip/resolver.rs::break_down_filter):authorsonly -> each author's write relays (+ hints, + most-received).#ponly -> each pubkey's read relays (+ hints, + most-received).- both -> union of read + write relays.
- neither (
Other) or no relay found (Orphan) -> the pool's read relays (pool/mod.rs::read_relay_urls).
- Resolved gossip relays are added with
RelayCapabilities::GOSSIP(relay/capabilities.rs), which does not includeREAD.
- Extracts pubkeys via
gossip/nostr-gossip/src/lib.rsexposes the publicNostrGossiptrait:get_best_relays(pubkey, BestRelaySelection, GossipAllowedRelays), plusprocess.NostrGossipMemory(re-exported vianostr_gossip_memory::prelude) implements it, so a retained handle can resolve a pubkey's relays directly. It is a pure store read: it does not fetch the kind10002itself. The store is primed by the freshness step of an Auto request (or by any kind10002that arrives for another reason), and marks a key outdated 24 h after its last fetch attempt.client.send_event(event)without.broadcast()is itself NIP-65 aware: with gossip configured it resolves the author's outbox and theptags' inboxes.signedcalls.broadcast()everywhere, so publishing goes to the pool's write relays and is unaffected by this setting.
What this means for repository filters
RepoStore::repo_filters mixes filter shapes. It is the filter (not the
event) that extract_public_keys reads, so filter tags decide what the SDK can
resolve:
- announcement + state:
.author(owner).identifier(id)-> hasauthors, so an Auto request resolves the owner's write relays. activity(addr):.coordinate(addr)only (#a). The events do carry the repo owner in a lowercaseptag, per NIP-34 - issues1621, patches1617, PRs1618, PR updates1619, statuses1630..=1633(see the SDK'snostr/src/nips/nip34.rsbuilders andRepoStore::set_status/publish_patch_series) - but the filter never asks forp, soextract_public_keysis empty and the filter is classifiedOther.deletions_for_repo(addr): one.author(owner)filter (owner write relays) and one.coordinate(addr)filter (Other).
Adding .pubkey(owner) (or all effective_maintainers()) to the activity
filter makes an Auto request resolve those pubkeys' read relays (the
#p-only branch, plus hints and most-received). Two caveats:
Filter::pubkeyANDs with.coordinate. Root kinds and statuses carry the owner'sp, per the SDK'snostr/src/nips/nip34.rsbuilders andRepoStore::set_status/publish_patch_series, but kind-1111 comments setpto the parent author (nostr/src/nips/nip22.rs::as_vecemits the root as uppercaseE/K/Pand the parent as lowercasee/k/p), which for a top-level comment is the issue/PR author, not the repository owner. A single#a+#pfilter can drop comments. Keep them as two filters (a#afilter plus a#pfilter, unioned in the database) or accept the loss.#pyields the read (inbox) relays only. GitWorkshop's extra group is outbox and inbox, so covering the write side still needs an explicit target.
Current behaviour in signed
Global discovery is unchanged: RepoListStore::subscribe_remote
(crates/signed_state/src/repos.rs) syncs all_announcements, all_states,
and deletions from BOOTSTRAP_RELAYS.
Per-repository fetching in crates/signed_state/src/repo.rs:
RepoStore::subscribe_remote->Backend::subscribe_bootstrap, a one-shot REQ ofrepo_filtersonBOOTSTRAP_RELAYS. It passes an explicitHashMap<&str, Vec<Filter>>, i.e. a Manual target, so gossip is skipped.RepoStore::connect_announced_relays->Backend::connect_repo_relays, which connects the announcement'srelaystag and negentropy-syncsrepo_filterswith.with(relays.iter()), again a Manual target.RepoStore::run_refreshreads results back from the local database.
Net effect: a repository's activity and per-repo deletions are fetched from the
global bootstrap relays and its announced relays. Although signed configures a
gossip store, every current fetch path uses manual targets and bypasses the
SDK's NIP-65 handling.
Proposed behaviour
flowchart TD
A[RepoStore opens repo] --> B{Event fetching strategy}
B -->|Curated| C[Repo-declared relays\nmanual targets]
B -->|Uncensored| D[Resolve maintainers' relays\nvia NostrGossip]
D --> E[Repo-declared + maintainer relays]
C --> F[fetch repo_filters]
E --> F
F --> G[Local database]
G --> H[run_refresh]
- Curated: fetch
repo_filtersfrom the announcement'srelaystag only. - Uncensored: additionally fetch the same filters from every
Announcement::effective_maintainers()'s NIP-65 write + read relays.
Announcements, state events, and global deletions keep arriving from the global
RepoListStore bootstrap sync in both modes.
Recommended: an Auto request plus a Manual request
Both can run on the same Client. They are independent - the pool supports
concurrent subscriptions and syncs, and events deduplicate in the database. Use
each for what it is good at:
- Auto: pass filters straight to
client.subscribe(filters)/client.sync(filter)(no.with(...)). Broadening the author-scoped filters (announcement, state, author-scoped deletions) toeffective_maintainers()resolves each maintainer's NIP-65 write relays; adding.pubkey(..)to the activity filter (see above) resolves their read relays. This request also drivesensure_gossip_public_keys_fresh, populating the gossip store. That side effect is what makes step 2 possible at all, so the Auto request must run before resolution; resolution is a pure store read. - Manual: the activity filter cannot reach maintainers' write relays
through the Auto path (that branch needs
authors), so cover the outbox side explicitly: resolve each maintainer's relays from the retained gossip handle viaget_best_relays(pk, BestRelaySelection::All { .. }, ...), then negentropy-syncthrough the existingBackend::connect_repo_relays.Allreturns the union of read, write, hint and most-received relays, i.e. GitWorkshop's outbox and inbox. Because this leg targets the whole filter, it also covers comments that an#a+#pfilter would drop.
Retain the gossip store handle so step 2 can resolve relays:
let gossip = Arc::new(NostrGossipMemory::unbounded());
let client = ClientBuilder::default().gossip(gossip.clone()) /* ... */ .build();
Store gossip (as Arc<NostrGossipMemory>) on Backend, then call
gossip.get_best_relays(..) per maintainer, union the results, drop relays
already in repo_relays, and track the rest in a new
mailbox_relays: HashSet<RelayUrl>.
Prefer splitting by filter shape (author-scoped -> Auto, coordinate-scoped ->
Manual) to avoid duplicate REQs. Sending the full repo_filters through both is
also valid; the database dedups events, at the cost of re-querying overlapping
relays.
Variant: Auto only
Drop the manual maintainer request and pass repo_filters directly to
client.subscribe(filters) / client.sync(filter), adding .pubkey(..) to the
activity filter. Simpler, but activity then reaches only maintainers' read
relays (plus hints/most-received); their write relays never receive it.
Recommendation: the Auto + Manual combination for Uncensored, matching
GitWorkshop's base + extra relay groups.
Setting and UI
Add to crates/settings/src/settings.rs, following the bare-enum pattern of
AppearanceMode:
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventFetchingStrategy {
/// Only the relays declared in the repository announcement.
#[default]
Curated,
/// Repository relays plus every maintainer's NIP-65 relays.
Uncensored,
}
Add pub event_fetching: EventFetchingStrategy to Settings, and a section to
crates/workspace/src/views/sidebar/settings_dialog.rs rendered from
settings_view, reusing the Select used by appearance_section (or
setting_block from signed_ui for a two-card layout closer to GitWorkshop).
Default
Recommend Uncensored, matching GitWorkshop. Curated remains the spam-resistant choice. Note the default changes what existing users fetch.
Files to change
crates/settings/src/settings.rs- enum andSettingsfield.crates/workspace/src/views/sidebar/settings_dialog.rs- new section.crates/signed_nostr/src/backend.rs- retain theNostrGossipMemoryhandle; optionally reconsiderno_background_refresh.crates/signed_state/src/backend.rs- store the gossip handle; expose a maintainer-relay resolver usingget_best_relays; reuseconnect_repo_relays.crates/signed_state/src/repo.rs- strategy-awaresubscribe_remote/connect_announced_relays, newconnect_maintainer_relays, newmailbox_relaysfield.crates/signed_core/src/filters.rs-relay_list(public_keys)filter for the freshness trigger. Noteensure_gossip_public_keys_freshis private to the client; an Auto request naming the pubkeys is the public way to trigger it, so this helper is only useful as part of an Auto filter list.
Open questions / decisions
- Activity
#pshaping. Add a second.pubkey(maintainers)activity filter so the Auto leg reaches maintainer inboxes, or keep the activity filter coordinate-only? The manual leg already reaches read and write relays for the whole filter, so comments are covered either way; this only decides how much the Auto leg contributes. - Bootstrap REQ in Curated mode. Recommended: drop it for this repository
(global
RepoListStorestill covers announcements, state, deletions). Alternative: keep it and make the setting purely additive. - Gossip freshness. Trigger kind
10002on demand per repo, or re-enable the SDK background refresher? Background refresh is currently disabled insigned_nostr/src/backend.rs. - Runtime changes. Re-fetch on
SettingsStoreedit (subscribeRepoStore), or apply on next open? - Per-item author inbox relays. GitWorkshop also queries each discovered
item author's inbox relays in
outboxmode. Larger change; propose a phase 2.
Testing
- Unit-test maintainer-relay resolution: given maintainers and a gossip store
seeded with kind
10002, assert the resolved, deduped relay set reachesconnect_repo_relays. - Assert Curated uses only the announced relays.
- Keep the
settingsround-trip tests updated for the new field.
Phasing
- Setting, UI, and gossip-handle plumbing.
- Curated wiring (bootstrap REQ gating) + Uncensored maintainer relay fetch.
- Runtime re-fetch on setting change.
- (Optional) per-item author inbox relays.