14 KiB
Plan: Event Fetching Strategy (Curated / Uncensored)
Status: implementation plan, not implemented. This document replaces the
earlier proposal of the same name; it keeps the verified background from it
and docs/backend-audit.md (authoritative for current behaviour).
Cross-checked against:
- rust-nostr at the revision from
Cargo.lock,b230cecf9dbb38e0228e6fff4544ed9d261326fc(local checkout~/.cargo/git/checkouts/nostr-619b808bb247a9ed/b230cec). - GitWorkshop at
420c0c3.
Goal
Mirror GitWorkshop's "Event Fetching Strategy" for the per-repository fetches
in RepoStore:
- Curated: only the relays declared in the repository announcement.
- Uncensored: the repository's declared relays plus every maintainer's NIP-65 relays.
Global discovery is unchanged in both modes: RepoListStore keeps syncing
announcements, states and deletions from BOOTSTRAP_RELAYS.
GitWorkshop reference
src/services/settings.ts:197-210:RelayCurationMode = "repo" | "outbox", persisted tolocalStorage, default"outbox"(Uncensored).src/pages/Settings.tsx:85-100: two selectable cards, "Curated" and "Uncensored".src/hooks/useResolvedRepository.ts:repoRelayGroupis the announcement'srelaystag.extraRelaysForMaintainerMailboxCoverageis a delta group of every maintainer's NIP-65 outbox + inbox relays, excluding relays already in the repo group, capped atMAX_MAILBOX_RELAYS_PER_USER = 3per direction (addMailboxRelaysToGroup). The pubkey set is the announcement chain'sdiscoveryPubkeys(maintainers, moderators, owner).- Gating:
src/hooks/useNip34Loaders.ts:447andsrc/pages/repo/RepoLayout.tsx:319-360only subscribe the item loaders to the maintainer group when the mode isoutbox. Curated never touches maintainer relays.
Current behaviour in signed
crates/signed_state/src/repo.rs:
subscribe_remote->Backend::subscribe_bootstrap: one-shot REQ ofrepo_filtersonBOOTSTRAP_RELAYS(manual target).connect_announced_relays->Backend::connect_repo_relays: add and connect the announcement'srelaystag, then negentropy-syncrepo_filters(manual target). Deduped by therepo_relaysset. Called fromnew,announce, and on every announcement change inrun_refresh.run_refreshalso fetches comments (filters::comments_for) and statuses (filters::statuses_for) for each newly seen root from bootstrap +repo_relays.- No fetch path uses NIP-65: the gossip store is configured but never
consulted (
docs/backend-audit.md, section 2).
Design
flowchart TD
A[RepoStore opens a repo] --> B{Event fetching strategy}
B -->|Curated| C[Manual sync to repo-declared relays]
B -->|Uncensored| D[Auto sync: SDK resolves maintainers' NIP-65 relays from authors and #p] --> E[Manual sync to repo-declared relays]
C --> F[(Local database)]
D --> F
E --> F
-
Curated stays exactly what the code does today: bootstrap REQ plus the manual announced-relay sync.
-
Uncensored adds one SDK Auto sync. No relay URLs are resolved, stored, or tracked, and no kind
10002events are fetched or parsed bysigned: the SDK's NIP-65 gossip targeting resolves the maintainers' relays per request (client.sync(filter)with no.with(..), verified innostr-sdk/src/client/api/sync.rs:151-166andapi/util.rs:12-29). The existing manual announced-relay sync stays for the repo's own relays in both modes. -
The filters must name the maintainers. Gossip resolution is driven by
Filter::extract_public_keys(nostr/src/filter/mod.rs:591), which reads onlyauthorsand the lowercase#ptag:Filter shape Auto target authorsonlyeach author's NIP-65 write relays #ponlyeach pubkey's NIP-65 read relays both union of read and write relays neither the pool's read relays Today's
repo_filtersresolve nothing for this purpose: the announcement/state filters carry only the owner inauthors, and the activity filter is#a-only, so it is classifiedOtherand falls back to the pool's read relays. Uncensored therefore sends a separate maintainer-shaped filter set to the Auto sync. -
Maintainer filter set (Uncensored only):
/// Filters the SDK resolves through NIP-65 gossip in Uncensored mode. /// /// Gossip reads pubkeys from `authors` and the lowercase `#p` tag only, /// so every filter names the owner and the maintainers. fn maintainer_filters(addr: &RepoAddr, maintainers: &[PublicKey]) -> Vec<Filter> { let mut pubkeys = maintainers.to_vec(); // NIP-34 events tag the announcement author, which may not be a // maintainer for subordinate forks. if !pubkeys.contains(&addr.public_key) { pubkeys.push(addr.public_key); } vec![ // Announcement and state events, including co-maintainer states, // resolved to write relays. Filter::new() .kinds([Kind::GitRepoAnnouncement, Kind::RepoState]) .authors(pubkeys.clone()) .identifier(addr.identifier.clone()), // Activity tagging a maintainer, resolved to their read relays. Filter::new() .kinds(filters::ACTIVITY_KINDS) .coordinate(addr) .pubkeys(pubkeys.clone()), // Activity authored by a maintainer, resolved to their write relays. Filter::new() .kinds(filters::ACTIVITY_KINDS) .coordinate(addr) .authors(pubkeys.clone()), // Deletions authored by a maintainer, resolved to write relays. Filter::new() .kinds([Kind::EventDeletion, Kind::RequestToVanish]) .authors(pubkeys), ] }maintainersisAnnouncement::effective_maintainers(), already computed byrun_refresh. Because the announcement/state filter names every maintainer, co-maintainer state events (kind30618) land in the local database. They are not displayed yet:run_refreshreads state through the owner-onlyfilters::state(docs/backend-audit.md, finding 3). -
The manual leg is unchanged:
subscribe_bootstrapplusconnect_announced_relayskeep covering bootstrap and repo-declared relays. Per-root follow-ups (comments_for,statuses_for) also stay as-is. -
Default: Uncensored, matching GitWorkshop (
DEFAULT_RELAY_CURATION_MODE = "outbox"). Flagged under "Decisions" because it changes what existing users fetch.
Coverage
- The
#pactivity filter finds roots and statuses that tag a maintainer (NIP-34 root events and statuses tag the owner) on the maintainers' read relays. - The
authorsactivity filter finds maintainer-authored events (issues, PRs, patches, comments, statuses) on their write relays.signed's comments and statuses carry theatag (comment_builder,set_status,publish_applied_status), so this filter does not drop them. - Comments whose parent author is not a maintainer are not matched by the
#pfilter, but they are published to the parent author's inbox, not to a maintainer's relays; maintainer-authored comments are still caught by theauthorsfilter. - Not covered: status events without an
atag published to a maintainer's outbox. Per-root#efilters carry no pubkeys, so they cannot resolve through gossip; GitWorkshop covers these with per-item supplemental queries (out of scope below).
Changes
1. Setting, crates/settings/src/settings.rs
Follow the bare-enum pattern of AppearanceMode:
/// Which relays `RepoStore` queries for a repository's activity.
#[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.
Curated,
/// Repository relays plus every maintainer's NIP-65 relays.
#[default]
Uncensored,
}
Add pub event_fetching: EventFetchingStrategy to Settings.
2. Settings UI, crates/workspace/src/views/sidebar/settings_dialog.rs
- Options:
SelectOption::new("curated", "Curated")andSelectOption::new("uncensored", "Uncensored"). - Add an
event_fetching: Entity<SelectState<Vec<SelectOption>>>field toSettingsControls, seeded from the persisted value, and aSelectEvent::Confirmsubscription that maps the value to the enum and callsstore.edit(|settings| settings.event_fetching = strategy, cx). - Add an
event_fetching_sectionnext toappearance_section, usingsetting_rowwith the title "Event Fetching Strategy" and a description of both modes, and insert it insettings_view.
3. Backend, crates/signed_state/src/backend.rs
New method next to connect_repo_relays:
/// Sync filters through the SDK's NIP-65 gossip targeting.
///
/// `client.sync(filter)` without `.with(..)` is an Auto request: the SDK
/// resolves each filter's `authors` and lowercase `#p` pubkeys to their
/// NIP-65 relays, connects them, and negentropy-syncs there.
pub fn sync_auto(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
let client = self.client.clone();
cx.spawn(async move |_this, _cx| {
for filter in filters {
if let Err(e) = client.sync(filter).await {
log::warn!("gossip relay fetch failed: {e}");
}
}
Ok::<(), Error>(())
})
.detach();
}
Errors stay log-only, like connect_repo_relays; BackendEvent::Synced is
deliberately not emitted (it would refresh RepoListStore for repo-level
traffic).
4. RepoStore, crates/signed_state/src/repo.rs
New field, initialized empty in new and new_local:
/// Maintainers already synced through gossip in Uncensored mode.
synced_maintainers: HashSet<PublicKey>,
New methods after connect_announced_relays: maintainer_filters (listed
under Design) and
/// In Uncensored mode, sync this repository's maintainer-shaped filters
/// through the SDK's NIP-65 gossip targeting.
fn sync_maintainer_relays(&mut self, maintainers: &[PublicKey], cx: &mut Context<Self>) {
let strategy = settings::SettingsStore::try_global(cx)
.map(|store| store.read(cx).settings().event_fetching)
.unwrap_or_default();
if strategy != EventFetchingStrategy::Uncensored {
return;
}
let Some(addr) = self.addr.clone() else {
return;
};
if !maintainers
.iter()
.any(|pk| !self.synced_maintainers.contains(pk))
{
return;
}
self.synced_maintainers.extend(maintainers.iter().copied());
let filters = Self::maintainer_filters(&addr, maintainers);
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| backend.sync_auto(filters, cx));
}
Hook into run_refresh, in the foreground update after
connect_announced_relays:
let maintainers = this
.announcement
.as_ref()
.map(Announcement::effective_maintainers)
.unwrap_or_default();
this.sync_maintainer_relays(&maintainers, cx);
Notes:
SettingsStore::try_globalkeeps wasm safe: the settings store is only installed by the desktop app (desktop/src/main.rs:21).- No new crate dependencies:
signed_statealready depends onsettingsandnostr_sdk.
Tests
crates/settings: extendjson_roundtrip_preserves_everythingandpartial_json_merges_with_defaultsforevent_fetching(snake_case values, default).crates/signed_state/src/repo.rs,mod tests: unit-testmaintainer_filters: every filter names the owner and maintainers viaauthorsor#p(the announcement/state filter included), activity filters carry the#acoordinate, and the owner is added for a subordinate fork.cargo test -p settings -p signed_stateandcargo check -p signed_workspacefor the UI.- Manual smoke: open a repository whose activity exists only on a maintainer's relays (not on the announced relays or bootstrap) in both modes.
Decisions
- Default. Uncensored, to match GitWorkshop. Curated preserves today's relay traffic; flipping the default is a one-line change.
- Bootstrap REQ stays in Curated. It is the index path that keeps repositories with unreachable announced relays usable. Strict GitWorkshop parity (repo relays only in Curated) is possible later but is a behaviour change unrelated to the option itself.
- Read and write coverage is approximated by two activity filters (
#pandauthors) rather than a resolved "all maintainer relays" set. The SDK resolves the relay sets per filter shape;signedstores no relay URLs. - The announcement/state Auto filter names every maintainer plus the owner. This also fetches co-maintainer state events into the local database; showing them is a separate display-side change.
- Runtime switching.
sync_maintainer_relaysreads the setting on everyrun_refresh, so switching to Uncensored applies at the next refresh without a restart. Switching back stops new Auto syncs but does not undo relays already resolved by the gossip pool. - Gossip pool growth. Auto requests add resolved relays with
RelayCapabilities::GOSSIPand never remove them for the session (docs/backend-audit.md); accepted, as the SDK is designed this way. - Errors stay log-only, consistent with existing background fetches.
Out of scope
- Status events without an
atag published to maintainer outboxes (GitWorkshop's per-item supplemental loader). - Per-item author inbox relays
(
src/services/nostr.ts:1042,1070,MAX_AUTHOR_INBOX_RELAYS = 3). - Co-maintainer state display: co-maintainer states are fetched (decision 4)
but
run_refreshstill reads state through the owner-onlyfilters::state(docs/backend-audit.md, finding 3). - Gating or replacing the bootstrap REQ in Curated mode.
Phasing
- Setting enum, field and settings tests. Done.
- Settings UI control.
Backend::sync_autoandRepoStore::sync_maintainer_relayswith the maintainer filter set, plus the filter unit test.cargo test/cargo check, then a manual smoke test.