mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
Merge #4e732958: feat(sync): discover participant mailboxes
nostr:nevent1qgsx2lyl2e4zvfadwcvkd9fkrcwczj7mf858hy85mwqclwgut8wpg2spz3mhxue69uhhyetvv9ujumn8d96zuer9wcq3yamnwvaz7tm8d96xummnw3ezucm0d5q3kamnwvaz7tmwva5hgtnyv9hxxmmwwashjer9wchxxmmdqqsyuueftzc68d5a3x5d0pecs66y0nacv4pc87kkcfw499adyl5t3ycxqgtta PR-Author: DanConwayDev's Agent nostr:npub1v47f74n2ycn66asev62nv8sas99akj0g0wg0fkup37u3ckwuzs4q7cwtp0 CoverNote: ## What this adds Repository conversations can continue on relays used only by a non-root participant. For example, a reaction to an issue may be stored on one relay, while a reply to that reaction exists only on the reaction author's NIP-65 write relay. Root-author relay discovery and recursive reference queries cannot find that reply unless the participant's mailbox is also searched. This PR adds bounded, historic participant-mailbox coverage: 1. Preserve exact repository-root provenance while deriving the accepted recursive descendant frontier. 2. Treat authors of accepted replies, comments, reactions, zaps and other descendants as thread participants. 3. Accept kind `10002` relay lists only for repository owners, maintainers, root authors and those accepted participants. 4. Derive each participant's NIP-65 read, write and unmarked mailbox relays. 5. Query those mailboxes only for the repository roots and accepted descendant IDs associated with that participant. 6. Pass every returned event through the ordinary write policy, deletion/replacement rules and persistence pipeline. This discovers replies to reactions and other indirect descendants without fetching a participant's unrelated notes or trusting their relay list to bypass repository policy. ## Bounds and scheduling - The existing `NGIT_SYNC_RECURSIVE_DESCENDANT_LIMIT` bounds which indirect descendant IDs remain query roots. Each direct event that tags a repository root has its own bounded branch; the default is 500. - Once a branch reaches that bound, later indirect descendants cannot add more participant authors or query roots through that branch. - Exact root provenance prevents roots from one repository leaking into another participant query. - Participant mailbox coverage is historic only. It does not add permanent non-root participant subscriptions or expand the existing live-sync tier. - Filters use the existing byte-bounded grouping and paginated `RelayConnection::fetch_events` path, including request pacing, background priority, subscription-ledger capacity and EOSE/CLOSED handling. - At most one mailbox worker runs per relay, and at most one new due relay is started per maintenance pass. - Relays progress independently: there is no global mailbox lane, cross-relay success condition or shared completion counter. ## Restart and failure behavior - A probe starts only after both the WebSocket and the sync actor's connection lifecycle are ready. - Ready due relays are preferred, so old unreachable mailbox sources cannot starve connected work. - Completed mailbox workers are handled before new connection results, so a large startup connection queue cannot delay cursor progress or resource release. - Successful filters advance an in-memory, stable-sorted cursor immediately. Completing the last filter schedules the next historic rotation after 24 hours. - Failed filters release their relay worker and retry after five minutes without blocking other relays. - On restart, accepted roots, participants and kind `10002` ownership are reconstructed from LMDB. Filter cursors deliberately restart from the first current group; they are best-effort coverage, not durable exactly-once state. The implementation reuses the existing relay transport. It does not add a mailbox-specific protocol state machine, pending-batch purpose, CLOSE API, NIP-42 retry system, watchdog or connection-queue coordinator. ## Coverage and production evidence The audit motivating this change found 72 events visible through gitworkshop but absent from the pre-change production relay across the `gitworkshop` and `ngit` repositories. The persistent canary served 29 exact misses: six kind-1 notes, three reposts, ten reactions, six comments, two issues, one zap receipt and one repository-follow event. The exact candidate `de5fa6625aa5693afcde7335a572456680aef9df` activated on the isolated archive at 2026-08-14 19:23:49 UTC. Restart reconstruction found 4,291 accepted roots, 403 participant authors and 351 mailbox relays. During the recorded soak: - 63 mailbox filters started and all 63 reached terminal handling: 59 successes and four bounded failures. - Failures received the intended five-minute retry and did not block successful progress on other relays. - `relay.ngit.dev`, `nostr.land`, `nostr.mom`, `haven.danconwaydev.com/inbox` and other relays advanced independently. - `nostr.azzamo.net` completed all six filter groups; the final terminal reported `completed_cycle=true` and scheduled the next probe in 86,400 seconds. - The archive remained active with zero restarts and no process panic or fatal error. The temporary archive test override of `NGIT_SYNC_RECURSIVE_DESCENDANT_LIMIT=2` has been removed. Both the archive and public gitnostr.com have no override and therefore use the default of 500. Public gitnostr.com was not restarted or changed by the archive deployment. ## Validation and review state - Exact tested head: `de5fa6625aa5693afcde7335a572456680aef9df`. - One commit; 1,157 additions and 140 deletions across 11 files. - 767 library tests passed in the exact Nix release build locally and on the production host. - All three proactive Sync+ integration scenarios pass together, including a child found only through a participant reaction author's write mailbox. - Strict all-target Clippy, formatting, diff checks, Nix flake evaluation and a conflict-free merge-tree check against current `master` passed. - One remote build attempt hit the pre-existing `test_entries_expired_during_downtime` timing flake; its source is unchanged by this PR, and the unchanged candidate passed all 767 tests on retry before activation. Recommended for merge. The archive demonstrates successful and failed terminal paths, per-relay independence, restart reconstruction, cursor advancement and a complete historic-to-24-hour-refresh rotation under real startup load.
This commit is contained in:
@@ -109,9 +109,9 @@ Explanation documentation helps you **understand concepts** and design decisions
|
||||
---
|
||||
|
||||
### [GRASP-03 Proactive Sync Plus](grasp-03-proactive-sync-plus.md)
|
||||
**NIP-65 inbox discovery for accepted repository conversations**
|
||||
**NIP-65 inbox/outbox discovery for accepted repository conversations**
|
||||
|
||||
**Read when:** You want to understand how accepted conversations are recovered from root-author inboxes
|
||||
**Read when:** You want to understand how accepted conversations are recovered from participant mailboxes
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -594,8 +594,15 @@ For full design details, see [grasp-02-proactive-sync.md](grasp-02-proactive-syn
|
||||
|
||||
GRASP-03 Sync+ is a default-on mailbox-discovery overlay on this manager. Set
|
||||
`NGIT_SYNC_PLUS_ENABLED=false` to retain GRASP-02 sync without discovering
|
||||
root-author NIP-65 inboxes; NIP-11 advertises `GRASP-03` only while the overlay
|
||||
is enabled.
|
||||
accepted root and descendant participants' NIP-65 read/write mailboxes; NIP-11
|
||||
advertises `GRASP-03` only while the overlay is enabled. Exact thread
|
||||
provenance scopes each mailbox to its accepted roots. Accepted root authors'
|
||||
read/unmarked inboxes retain ordinary live/rotating GRASP-02 coverage. Wider
|
||||
participant mailboxes use independent, history-only `fetch_events` workers:
|
||||
at most one per relay and one new start per maintenance pass. They reuse the
|
||||
connection's ordinary pacing, subscription ledger, pagination and 30-second
|
||||
per-page terminal timeout without installing permanent participant
|
||||
subscriptions or coupling progress between relays.
|
||||
|
||||
### Rejected Events Index
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# GRASP-03 proactive sync plus
|
||||
|
||||
GRASP-03 extends repository-declared GRASP-02 coverage to the Nostr outbox
|
||||
model. An accepted issue, patch or pull request may have replies in the root
|
||||
author's inbox even when those events are absent from repository relays.
|
||||
model. An accepted issue, patch or pull request may have replies, reactions or
|
||||
zaps in a conversation participant's inbox or outbox even when those events
|
||||
are absent from repository relays.
|
||||
|
||||
The overlay is enabled by default and can be disabled with
|
||||
`NGIT_SYNC_PLUS_ENABLED=false`. Because it extends the proactive GRASP-02
|
||||
@@ -12,14 +13,19 @@ manager, it is effective only while that manager is running. NIP-11 advertises
|
||||
## Minimal approach
|
||||
|
||||
The implementation adds a narrow discovery control-plane, not a second sync
|
||||
engine: derive accepted root IDs and authors locally; ask a small existing
|
||||
index set for each author's latest profile kind `0` and NIP-65 kind `10002`;
|
||||
retain those replaceable events locally; treat `read` and unmarked relays as
|
||||
inboxes; and merge inbox-to-root mappings into ordinary per-relay sync targets.
|
||||
engine: derive accepted root IDs and participant authors locally; ask a small
|
||||
existing index set for each author's latest profile kind `0` and NIP-65 kind
|
||||
`10002`; retain those replaceable events locally; keep accepted root authors'
|
||||
`read` and unmarked inboxes in ordinary GRASP-02 coverage; and probe accepted
|
||||
participants' `read`, `write` and unmarked conversation mailboxes with
|
||||
repository and thread-reference filters.
|
||||
|
||||
GRASP-02 then owns all transport. Its current root tiers, historic and
|
||||
live/rotating coverage, grouping, subscription ledger, rate-limit recovery,
|
||||
pagination and per-relay connection lifecycle apply unchanged.
|
||||
Mailbox work reuses GRASP-02's connection safety, filter byte packing,
|
||||
pagination, subscription ledger, request pacing, event pipeline and write
|
||||
policy. The participant expansion is history-only: a non-root participant
|
||||
relay never becomes a permanent live source merely because an accepted author
|
||||
advertised it. Root-author inboxes retain the pre-existing live/rotating Sync+
|
||||
behavior.
|
||||
|
||||
Authors without an accepted stored relay list are queried through the configured
|
||||
user-index set plus the bootstrap relay. Once a list is retained, discovery
|
||||
@@ -28,21 +34,26 @@ to converge without making arbitrary repository relays identity sources.
|
||||
Discovery remains best effort and bounded to this operator-visible source graph.
|
||||
|
||||
If a successful user-index query returns no accepted kind `10002`, accepted
|
||||
roots by that author temporarily use the operator-configured Sync+ fallback
|
||||
relay set as inboxes. This is the same root-only overlay consumed by ordinary
|
||||
GRASP-02 historic, live and rotating coverage; it creates no separate
|
||||
subscription scheduler. An accepted relay list removes the author from desired
|
||||
fallback coverage and installs its declared inboxes. Existing shared live
|
||||
subscriptions still drain naturally, as with any other relay-list replacement.
|
||||
roots associated with that author temporarily use the operator-configured
|
||||
Sync+ fallback relay set as mailboxes. Root-author fallback roots continue
|
||||
through ordinary GRASP-02 historic, live and rotating coverage; non-root
|
||||
participant roots use the paced history probe. An accepted relay list removes
|
||||
the author from fallback coverage and schedules its declared relays instead.
|
||||
|
||||
The self-subscriber builds a compact root-candidate inventory during its
|
||||
existing startup load and maintains it incrementally as roots arrive. StateOnly
|
||||
candidates remain inert; promotion of their repository to Full makes them
|
||||
eligible without rescanning retained events. Eligible identity authors are the
|
||||
owners and declared maintainers of accepted Full announcements plus accepted
|
||||
root authors—not every author retained by the relay. A once-per-minute
|
||||
reconciliation derives sources from this index. Remote queries contain at most 100 authors and
|
||||
only one discovery batch may be in flight globally. Admission samples immediate
|
||||
eligible without rescanning retained root events. A bounded recursive scan of
|
||||
locally accepted root threads adds the authors of replies, reactions, zaps and
|
||||
other descendants. Root provenance is carried through event-ID and address
|
||||
references, so each participant is associated only with accepted threads in
|
||||
which their events occur, including indirect descendants that expose only an
|
||||
immediate parent. Eligible identity authors are
|
||||
the owners and declared maintainers of accepted Full announcements plus
|
||||
accepted root and descendant authors—not every author retained by the relay. A
|
||||
once-per-minute reconciliation derives sources from this index. Remote queries
|
||||
contain at most 100 authors and only one discovery batch may be in flight
|
||||
globally. Admission samples immediate
|
||||
transient capacity; if historic work wins the small race before the permit is
|
||||
acquired, that single batch may wait but discovery can never build a waiter
|
||||
queue. Its SDK-owned REQ draws from the same pacer and subscription ledger as
|
||||
@@ -51,7 +62,7 @@ reconnection machinery, but are a distinct control-plane role: connecting to a
|
||||
user index or outbox does not start ordinary announcement, repository, or
|
||||
descendant sync against it. At most one new discovery source is dialled per
|
||||
maintenance pass, and an exclusively discovery connection retires after its
|
||||
currently due author batches drain. A relay that independently becomes a
|
||||
currently due identity or mailbox work drains. A relay that independently becomes a
|
||||
repository source is promoted to the ordinary lifecycle without opening a
|
||||
duplicate connection. Authors returned by a successful query refresh after 24 hours;
|
||||
missing authors and failed queries retry after five minutes. Configured user
|
||||
@@ -61,25 +72,64 @@ write/unmarked outboxes are then followed additively for newer replacements;
|
||||
new outboxes discovered by a replacement join the same bounded round until no
|
||||
unvisited source remains. Identity events
|
||||
pass through the ordinary write policy, persistence and broadcast path. Only a
|
||||
stored, accepted kind `10002` can change inbox ownership. On startup, retained
|
||||
relay lists for eligible authors rebuild inbox ownership from the local
|
||||
stored, accepted kind `10002` can change mailbox ownership. On startup, retained
|
||||
relay lists for eligible authors rebuild mailbox ownership from the local
|
||||
database before any network refresh, so serving established coverage does not
|
||||
depend on an external index remaining available. Remote discovery then refreshes
|
||||
that retained state on the normal cadence.
|
||||
|
||||
Inbox replacement/removal changes desired ownership immediately. Additions are
|
||||
derived promptly. Removals prevent future rotating and historic work, but do
|
||||
not eagerly CLOSE live descendants or abort shared in-flight batches: existing
|
||||
coverage drains on its natural EOSE/CLOSED/disconnect or an ordinary later
|
||||
consolidation rebuild. This avoids interrupting unrelated roots and subscription
|
||||
churn merely because one author replaced a relay list. Root deletion follows
|
||||
the existing GRASP-02 root-index lifecycle and is reconstructed from retained
|
||||
accepted events on restart; this change does not add a second deletion graph.
|
||||
Root-author read/unmarked inboxes still use ordinary GRASP-02 coverage. Wider
|
||||
participant mailboxes use history-only workers. A maintenance pass starts at
|
||||
most one due relay, while each relay may have at most one worker in flight.
|
||||
Relays do not share a mailbox lane or terminal state: a slow or unavailable
|
||||
relay cannot prevent another relay from progressing on a later pass.
|
||||
|
||||
Each worker selects one stable-sorted, byte-bounded filter and delegates its
|
||||
REQ lifecycle to the existing `RelayConnection::fetch_events` path. That path
|
||||
owns per-relay request pacing, background priority, subscription-ledger
|
||||
capacity, EOSE/CLOSED handling and a 30-second timeout. The worker reuses the
|
||||
ordinary pagination state to continue through full historic pages until the
|
||||
filter is exhausted or a page makes no new progress. It then passes events
|
||||
through the normal write policy and persistence pipeline. No mailbox-specific
|
||||
pending-batch kind, EOSE hook, close API, watchdog or cross-relay coordinator
|
||||
is added.
|
||||
|
||||
The probe covers accepted repository coordinates, root IDs, bounded recursive
|
||||
descendant IDs, and descendant address coordinates using `a`/`A`/`q` and
|
||||
`e`/`E`/`q`. A numeric in-memory cursor gives each filter group a turn. A
|
||||
successful complete rotation refreshes after 24 hours; a failed group advances
|
||||
the cursor after a five-minute delay and is retried on a later rotation. This
|
||||
is deliberately best effort rather than a durable exactly-once schedule. A
|
||||
relay already needed for ordinary repository sync shares its connection; an
|
||||
exclusively control-plane connection retires when its identity and mailbox
|
||||
work is idle.
|
||||
|
||||
On restart, accepted roots and retained kind `10002` events rebuild participant
|
||||
and mailbox ownership from LMDB. The in-memory group cursor is intentionally
|
||||
not restored, so historic mailbox probing starts again from the first current
|
||||
filter and remains safe to repeat. Inventory changes keep the cursor modulo the
|
||||
new stable-sorted filter set; they do not coordinate with a worker on another
|
||||
relay.
|
||||
|
||||
The fixed-cardinality retained-state metric reports eligible authors, desired
|
||||
history-probe relays, allocated filter cursors, and active per-relay workers.
|
||||
This makes a stuck worker or unexpected inventory expansion visible without
|
||||
putting peer URLs or public keys into metric labels.
|
||||
|
||||
Mailbox replacement/removal changes desired ownership immediately. Root-author
|
||||
inbox additions use ordinary coverage, while removal-only changes let existing
|
||||
shared live subscriptions drain naturally. History-probe additions become due
|
||||
promptly; removals prevent future groups while an already in-flight
|
||||
`fetch_events` worker may finish naturally. Root deletion follows the existing
|
||||
GRASP-02 root-index lifecycle and is reconstructed from retained accepted
|
||||
events on restart; this change does not add a second deletion graph.
|
||||
|
||||
## Deliberately excluded
|
||||
|
||||
- the old recursive `SyncScope` graph and response-author fan-out;
|
||||
- mailbox expansion for authors who have not produced locally accepted
|
||||
repository-thread events;
|
||||
- maintainer mailbox expansion unrelated to an accepted root;
|
||||
- identity storage for authors other than accepted roots;
|
||||
- identity storage for authors outside accepted repositories and their
|
||||
locally accepted threads;
|
||||
- per-user fallback configuration knobs; and
|
||||
- a separate GRASP-03 subscription scheduler.
|
||||
- permanent non-root participant-mailbox live subscriptions.
|
||||
|
||||
@@ -39,7 +39,7 @@ larger retained set; it is not an excuse to copy arbitrary wire input.
|
||||
| Purgatory event maps and sync queue | write policy; promotion/cleanup owns removal | Time/external: entries are keyed by admitted event/repository identity, normally expire at 30 minutes, and soft-expired announcements at 24 hours. Queue entries deduplicate by identifier and disappear on completion or event expiry. | purgatory counts, queue and Git-process metrics |
|
||||
| Per-domain Git throttle queues | incomplete purgatory fetch; throttle manager owns drain | External: one entry per purgatory identifier/domain, merged on repeat. Completion, URL exhaustion, or purgatory expiry removes useful work. Request history is time-windowed. | domain/fetch logs and Git-process metrics |
|
||||
| Dependency retry attempts and temporary relays | rejected/purgatory dependency discovery; maintenance owns expiry | Time/external: event IDs are pruned against current purgatory input; temporary relays have explicit deadlines. Repeats overwrite timestamps. | retained dependency gauges |
|
||||
| NIP-65 discovery state/results | accepted root authors; discovery scheduler owns completion | External plus static work: author/source maps derive from accepted roots, one discovery query is in flight, and its result channel has capacity one. Missing results time out and clear in-flight state through result handling/disconnect refresh. | discovery logs and relay gauges |
|
||||
| NIP-65 discovery and mailbox probes | accepted root and descendant authors; ordinary root-inbox coverage plus discovery/probe scheduler own completion | External/session work: root-author read inboxes remain ordinary derived sync targets, while participant maps derive from accepted root threads. Identity discovery is globally single-flight. Mailbox history has at most one ordinary `fetch_events` worker per accepted relay; each page consumes that relay's pacing and ledger capacity and has a 30-second timeout. Starts are paced one per maintenance pass, progress on different relays is independent, and exclusive connections retire when idle. Numeric filter cursors and due times are in memory and bounded by desired mailbox relays. | discovery/probe logs and relay gauges |
|
||||
| Deferred consolidation | capacity refusal; final batch/reset/disconnect owns removal | External and deduplicated by relay. Final batch completion processes the set directly; no self-addressed notification queue remains. | retained deferred-consolidation gauge |
|
||||
| Descendant rotations and auxiliary live coverage | accepted root coverage; EOSE/CLOSED/disconnect/daily reset own transition | Session/external: one state object per derived relay and at most one rotating request in flight per relay. Coverage IDs consume ledger slots. | retained rotation gauge and terminal logs |
|
||||
| Health and naughty-list entries | connection failures; health checker owns recovery/expiry | External/time: one entry per canonical target, ordinary failures back off, persistent entries expire after 12 hours. Metrics expose only three fixed categories. | health gauges and aggregate naughty metrics |
|
||||
|
||||
+11
-9
@@ -28,7 +28,8 @@ use crate::nostr::persistence::{EventPersistence, SaveContext};
|
||||
use crate::nostr::policy::{
|
||||
accepted_purgatory, duplicate, reject_error, reject_invalid, reject_restricted,
|
||||
AnnouncementPolicy, AnnouncementResult, IdentityAdmission, PolicyContext, PrEventPolicy,
|
||||
ReferenceResult, RelatedEventPolicy, SharedProactiveRootAuthorIndex, StatePolicy, StateResult,
|
||||
ReferenceResult, RelatedEventPolicy, SharedProactiveParticipantAuthorIndex, StatePolicy,
|
||||
StateResult,
|
||||
};
|
||||
use crate::nostr::SharedDatabase;
|
||||
use crate::purgatory::promotion_hooks::NostrPurgatoryPromotionHooks;
|
||||
@@ -84,7 +85,7 @@ pub struct Nip34WritePolicy {
|
||||
state_policy: StatePolicy,
|
||||
pr_event_policy: PrEventPolicy,
|
||||
related_event_policy: RelatedEventPolicy,
|
||||
proactive_root_authors: SharedProactiveRootAuthorIndex,
|
||||
proactive_participant_authors: SharedProactiveParticipantAuthorIndex,
|
||||
deletion: DeletionService,
|
||||
rejected_events_index: Arc<RwLock<Option<Arc<RejectedEventsIndex>>>>,
|
||||
}
|
||||
@@ -133,23 +134,24 @@ impl Nip34WritePolicy {
|
||||
state_policy: StatePolicy::new(ctx.clone()),
|
||||
pr_event_policy: PrEventPolicy::new(ctx.clone(), repo_init_locks),
|
||||
related_event_policy: RelatedEventPolicy::new(ctx.clone()),
|
||||
proactive_root_authors: crate::nostr::policy::ProactiveRootAuthorIndex::shared(),
|
||||
proactive_participant_authors:
|
||||
crate::nostr::policy::ProactiveParticipantAuthorIndex::shared(),
|
||||
deletion: DeletionService::new(deletion_ctx),
|
||||
rejected_events_index: Arc::new(RwLock::new(None)),
|
||||
ctx,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn proactive_root_authors(&self) -> SharedProactiveRootAuthorIndex {
|
||||
self.proactive_root_authors.clone()
|
||||
pub fn proactive_participant_authors(&self) -> SharedProactiveParticipantAuthorIndex {
|
||||
self.proactive_participant_authors.clone()
|
||||
}
|
||||
|
||||
async fn handle_proactive_identity(&self, event: &Event) -> WritePolicyResult {
|
||||
match self.proactive_root_authors.admit(event).await {
|
||||
match self.proactive_participant_authors.admit(event).await {
|
||||
IdentityAdmission::Accept => WritePolicyResult::Accept,
|
||||
IdentityAdmission::IrrelevantAuthor => {
|
||||
reject_restricted("Kind 0/10002 author must own an accepted repository root")
|
||||
}
|
||||
IdentityAdmission::IrrelevantAuthor => reject_restricted(
|
||||
"Kind 0/10002 author must participate in an accepted repository thread",
|
||||
),
|
||||
IdentityAdmission::NotIdentity => {
|
||||
reject_invalid("Proactive identity policy received a non-identity event")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Narrow GRASP-03 identity admission for accepted root authors.
|
||||
//! Narrow GRASP-03 identity admission for accepted repository participants.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
@@ -6,7 +6,7 @@ use std::sync::Arc;
|
||||
use nostr_sdk::prelude::{Event, Kind, PublicKey};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
pub type SharedProactiveRootAuthorIndex = Arc<ProactiveRootAuthorIndex>;
|
||||
pub type SharedProactiveParticipantAuthorIndex = Arc<ProactiveParticipantAuthorIndex>;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum IdentityAdmission {
|
||||
@@ -15,15 +15,15 @@ pub enum IdentityAdmission {
|
||||
NotIdentity,
|
||||
}
|
||||
|
||||
/// Derived admission set. Accepted repository roots remain authoritative; an
|
||||
/// identity event can never add its own author here.
|
||||
/// Derived admission set. Accepted repository threads remain authoritative;
|
||||
/// an identity event can never add its own author here.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ProactiveRootAuthorIndex {
|
||||
pub struct ProactiveParticipantAuthorIndex {
|
||||
authors: RwLock<HashSet<PublicKey>>,
|
||||
}
|
||||
|
||||
impl ProactiveRootAuthorIndex {
|
||||
pub fn shared() -> SharedProactiveRootAuthorIndex {
|
||||
impl ProactiveParticipantAuthorIndex {
|
||||
pub fn shared() -> SharedProactiveParticipantAuthorIndex {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ mod tests {
|
||||
async fn identity_cannot_make_its_own_author_relevant() {
|
||||
let author = Keys::generate();
|
||||
let other = Keys::generate();
|
||||
let index = ProactiveRootAuthorIndex::shared();
|
||||
let index = ProactiveParticipantAuthorIndex::shared();
|
||||
let metadata = EventBuilder::new(Kind::Metadata, "{}")
|
||||
.finalize(&author)
|
||||
.unwrap();
|
||||
|
||||
@@ -18,7 +18,9 @@ pub(crate) use result::{
|
||||
};
|
||||
|
||||
pub use announcement::{AnnouncementPolicy, AnnouncementResult};
|
||||
pub use identity::{IdentityAdmission, ProactiveRootAuthorIndex, SharedProactiveRootAuthorIndex};
|
||||
pub use identity::{
|
||||
IdentityAdmission, ProactiveParticipantAuthorIndex, SharedProactiveParticipantAuthorIndex,
|
||||
};
|
||||
pub use pr_event::PrEventPolicy;
|
||||
pub use related::{ReferenceResult, RelatedEventPolicy};
|
||||
pub use state::{StatePolicy, StateResult};
|
||||
|
||||
@@ -175,6 +175,25 @@ pub fn accepted_repository_authors<'a>(
|
||||
authors
|
||||
}
|
||||
|
||||
/// Associate each accepted root author with their own roots and each accepted
|
||||
/// descendant author with the root provenance derived for their events.
|
||||
pub fn mailbox_author_roots(
|
||||
roots: &[AcceptedRoot],
|
||||
participant_roots: impl IntoIterator<Item = (PublicKey, HashSet<EventId>)>,
|
||||
) -> HashMap<PublicKey, HashSet<EventId>> {
|
||||
let mut author_roots: HashMap<PublicKey, HashSet<EventId>> = HashMap::new();
|
||||
for root in roots {
|
||||
author_roots.entry(root.author).or_default().insert(root.id);
|
||||
}
|
||||
for (author, accepted_roots) in participant_roots {
|
||||
author_roots
|
||||
.entry(author)
|
||||
.or_default()
|
||||
.extend(accepted_roots);
|
||||
}
|
||||
author_roots
|
||||
}
|
||||
|
||||
pub fn merge_inbox_roots(
|
||||
targets: &mut HashMap<String, RelaySyncNeeds>,
|
||||
inbox_roots: &HashMap<String, HashSet<EventId>>,
|
||||
@@ -220,6 +239,15 @@ pub fn build_inbox_root_overlay(
|
||||
overlay
|
||||
}
|
||||
|
||||
/// Relays on which an author may receive or publish conversation events.
|
||||
/// Unmarked entries serve both roles under NIP-65.
|
||||
pub fn mailbox_relays(event: &Event) -> HashSet<String> {
|
||||
inbox_relays(event)
|
||||
.into_iter()
|
||||
.chain(outbox_relays(event))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -312,6 +340,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_list_maps_read_write_and_unmarked_participant_mailboxes() {
|
||||
let keys = Keys::generate();
|
||||
let relay_list = event(
|
||||
&keys,
|
||||
Kind::RelayList,
|
||||
vec![
|
||||
Tag::custom("r", ["wss://read.example", "read"]),
|
||||
Tag::custom("r", ["wss://write.example", "write"]),
|
||||
Tag::custom("r", ["wss://both.example"]),
|
||||
],
|
||||
1,
|
||||
);
|
||||
assert_eq!(
|
||||
mailbox_relays(&relay_list),
|
||||
HashSet::from([
|
||||
"wss://read.example".to_string(),
|
||||
"wss://write.example".to_string(),
|
||||
"wss://both.example".to_string(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_authors_are_bounded_to_full_repo_owners_and_maintainers() {
|
||||
let owner = Keys::generate();
|
||||
@@ -356,6 +407,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descendant_authors_receive_only_their_derived_root_provenance() {
|
||||
let first_root_author = Keys::generate().public_key();
|
||||
let second_root_author = Keys::generate().public_key();
|
||||
let participant = Keys::generate().public_key();
|
||||
let first_root = EventId::from_byte_array([12; 32]);
|
||||
let second_root = EventId::from_byte_array([13; 32]);
|
||||
let roots = vec![
|
||||
AcceptedRoot {
|
||||
id: first_root,
|
||||
author: first_root_author,
|
||||
repository: "30617:owner:first".to_string(),
|
||||
},
|
||||
AcceptedRoot {
|
||||
id: second_root,
|
||||
author: second_root_author,
|
||||
repository: "30617:owner:second".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let author_roots =
|
||||
mailbox_author_roots(&roots, [(participant, HashSet::from([second_root]))]);
|
||||
|
||||
assert_eq!(
|
||||
author_roots[&first_root_author],
|
||||
HashSet::from([first_root])
|
||||
);
|
||||
assert_eq!(
|
||||
author_roots[&second_root_author],
|
||||
HashSet::from([second_root])
|
||||
);
|
||||
assert_eq!(author_roots[&participant], HashSet::from([second_root]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_relay_list_uses_nip01_replacement_order() {
|
||||
let keys = Keys::generate();
|
||||
|
||||
+843
-72
File diff suppressed because it is too large
Load Diff
@@ -670,11 +670,9 @@ impl SelfSubscriber {
|
||||
// state, avoiding an O(all repositories × all relays) rebuild for
|
||||
// every historic batch.
|
||||
for relay_url in dirty_relays {
|
||||
// Skip our own relay URL (we're subscribed to ourselves via self-subscription)
|
||||
if relay_url.contains(&self.relay_domain) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keep our own relay's dirty signal: SyncManager rejects it as a
|
||||
// sync target, but uses the signal to refresh participant mailbox
|
||||
// inventory after a newly accepted local root arrives.
|
||||
let action = AddFilters {
|
||||
relay_url: relay_url.clone(),
|
||||
items: crate::sync::PendingItems::default(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Minimal GRASP-03 mailbox discovery scenario.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use nostr_sdk::prelude::*;
|
||||
@@ -9,6 +10,23 @@ use crate::common::{
|
||||
setup_announcement_on_relay, wait_for_event_on_relay, MockRelay, TestClient, TestRelay,
|
||||
};
|
||||
|
||||
async fn wait_for_log_line<F>(path: &Path, timeout: Duration, predicate: F) -> bool
|
||||
where
|
||||
F: Fn(&str) -> bool,
|
||||
{
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
let log = std::fs::read_to_string(path).unwrap_or_default();
|
||||
if log.lines().any(&predicate) {
|
||||
return true;
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_author_inbox_reuses_existing_root_sync_pipeline() {
|
||||
let index = MockRelay::start().await;
|
||||
@@ -207,6 +225,91 @@ async fn root_author_inbox_reuses_existing_root_sync_pipeline() {
|
||||
inbox.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn participant_write_mailbox_fetches_child_of_direct_reaction() {
|
||||
let index = MockRelay::start().await;
|
||||
let mailbox = MockRelay::start().await;
|
||||
let owner = Keys::generate();
|
||||
let root_author = Keys::generate();
|
||||
let participant = Keys::generate();
|
||||
let child_author = Keys::generate();
|
||||
let identifier = "participant-write-mailbox";
|
||||
|
||||
let relay_list = EventBuilder::new(Kind::RelayList, "")
|
||||
.tag(Tag::custom("r", vec![mailbox.url(), "write"]))
|
||||
.finalize(&participant)
|
||||
.expect("build participant relay list");
|
||||
send_to_relay_url(index.url(), &relay_list)
|
||||
.await
|
||||
.expect("seed participant relay list");
|
||||
|
||||
let syncing_git_dir = tempfile::tempdir().expect("create persistent git directory");
|
||||
let syncing_relay_dir = tempfile::tempdir().expect("create persistent relay directory");
|
||||
let syncing = TestRelay::start_on_reservation_persistent_sync(
|
||||
reserve_port(),
|
||||
Some(index.url().to_string()),
|
||||
false,
|
||||
syncing_git_dir.path().to_path_buf(),
|
||||
syncing_relay_dir.path().to_path_buf(),
|
||||
)
|
||||
.await;
|
||||
let syncing_domain = syncing.domain();
|
||||
let (_announcement, _git_dir) =
|
||||
setup_announcement_on_relay(&syncing, &owner, &[&syncing_domain], identifier).await;
|
||||
let issue = build_layer2_issue_event(
|
||||
&root_author,
|
||||
&repo_coord(&owner, identifier),
|
||||
"root with a reaction whose child is mailbox-only",
|
||||
)
|
||||
.expect("build accepted root");
|
||||
let root_client = TestClient::new(syncing.url(), root_author)
|
||||
.await
|
||||
.expect("connect root author");
|
||||
root_client.send_event(&issue).await.expect("publish root");
|
||||
|
||||
let reaction = EventBuilder::new(Kind::Reaction, "+")
|
||||
.tag(Tag::custom("e", vec![issue.id.to_hex()]))
|
||||
.finalize(&participant)
|
||||
.expect("build direct participant reaction");
|
||||
let participant_client = TestClient::new(syncing.url(), participant)
|
||||
.await
|
||||
.expect("connect participant");
|
||||
participant_client
|
||||
.send_event(&reaction)
|
||||
.await
|
||||
.expect("publish direct reaction");
|
||||
|
||||
let child = EventBuilder::new(Kind::TextNote, "reply to a reaction")
|
||||
.tag(Tag::custom("e", vec![reaction.id.to_hex()]))
|
||||
.finalize(&child_author)
|
||||
.expect("build mailbox-only child");
|
||||
send_to_relay_url(mailbox.url(), &child)
|
||||
.await
|
||||
.expect("seed participant write mailbox");
|
||||
|
||||
assert!(
|
||||
wait_for_event_on_relay(
|
||||
syncing.url(),
|
||||
Filter::new().id(child.id),
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.await,
|
||||
"history probing should fetch a child that only names the participant's direct reaction"
|
||||
);
|
||||
let sync_log = std::fs::read_to_string(syncing.log_path()).expect("read syncing relay log");
|
||||
assert!(
|
||||
sync_log.lines().any(|line| {
|
||||
line.contains("Started bounded participant mailbox fetch")
|
||||
&& line.contains(mailbox.url())
|
||||
}),
|
||||
"participant mailbox transport should remain observable"
|
||||
);
|
||||
|
||||
syncing.stop().await;
|
||||
mailbox.stop().await;
|
||||
index.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_relay_list_uses_bounded_fallback_coverage() {
|
||||
let index = MockRelay::start().await;
|
||||
@@ -259,7 +362,7 @@ async fn missing_relay_list_uses_bounded_fallback_coverage() {
|
||||
let sync_log = std::fs::read_to_string(syncing.log_path()).expect("read syncing relay log");
|
||||
assert!(
|
||||
sync_log.lines().any(|line| {
|
||||
line.contains("Updated proactive inbox coverage from NIP-65")
|
||||
line.contains("Updated proactive participant mailbox coverage from NIP-65")
|
||||
&& line.contains("fallback_authors=1")
|
||||
}),
|
||||
"fallback activation should remain observable"
|
||||
@@ -288,12 +391,11 @@ async fn missing_relay_list_uses_bounded_fallback_coverage() {
|
||||
.await,
|
||||
"a later accepted relay list should replace desired fallback coverage"
|
||||
);
|
||||
let sync_log = std::fs::read_to_string(syncing.log_path()).expect("read updated relay log");
|
||||
assert!(
|
||||
sync_log.lines().any(|line| {
|
||||
line.contains("Updated proactive inbox coverage from NIP-65")
|
||||
&& line.contains("fallback_authors=0")
|
||||
}),
|
||||
wait_for_log_line(&syncing.log_path(), Duration::from_secs(20), |line| {
|
||||
line.contains("proactive participant mailbox") && line.contains("fallback_authors=0")
|
||||
})
|
||||
.await,
|
||||
"accepted NIP-65 ownership should retire the author from desired fallback coverage"
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user