feat(sync): make Sync+ operator-controllable

Add a default-on Sync+ switch across CLI/environment, NixOS, examples, and reference documentation. The switch gates only the GRASP-03 NIP-65 mailbox overlay, preserving ordinary GRASP-02 proactive sync when operators opt out.

Advertise GRASP-03 through the established NIP-11 supported_grasps extension only when the overlay is enabled. Focused configuration and relay-information tests cover the default and opt-out behavior.

This deliberately does not change descendant sync algorithms, add missing-list fallbacks, or introduce a separate proactive-sync lifecycle.
This commit is contained in:
DanConwayDev
2026-08-12 16:01:38 +00:00
parent e4d51692f2
commit fef2c70853
9 changed files with 106 additions and 7 deletions
+5
View File
@@ -120,6 +120,11 @@
# Default: (none - relay discovery from stored announcements only)
# NGIT_SYNC_BOOTSTRAP_RELAY_URL=wss://relay.example.com
# Enable GRASP-03 Sync+ mailbox discovery on top of proactive GRASP-02 sync
# CLI: --sync-plus-enabled <true|false>
# Default: true
# NGIT_SYNC_PLUS_ENABLED=true
# Relays used to discover eligible accepted repository participants' NIP-65 relay lists
# CLI: --user-index-relays <comma-separated-websocket-urls>
# Default: wss://purplepag.es,wss://index.hzrd149.com,wss://indexer.coracle.social
+2
View File
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Add a default-on `NGIT_SYNC_PLUS_ENABLED` opt-out and advertise GRASP-03 in
NIP-11 only when the Sync+ overlay is effective.
- Recover repository-event descendants which reference a direct thread member
but omit the repository and root-event tags. When the per-connection ledger
can retain complete descendant coverage while preserving control and
+5
View File
@@ -580,6 +580,11 @@ The ngit-grasp relay implements **Proactive Sync of Nostr Events**, which synchr
For full design details, see [grasp-02-proactive-sync.md](grasp-02-proactive-sync.md).
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.
### Rejected Events Index
The rejected events index solves two critical problems during sync:
@@ -4,6 +4,11 @@ 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.
The overlay is enabled by default and can be disabled with
`NGIT_SYNC_PLUS_ENABLED=false`. Because it extends the proactive GRASP-02
manager, it is effective only while that manager is running. NIP-11 advertises
`GRASP-03` only when the overlay is enabled.
## Minimal approach
The implementation adds a narrow discovery control-plane, not a second sync
+19
View File
@@ -290,6 +290,25 @@ NGIT_DATABASE_BACKEND=memory
These options configure the proactive sync feature that synchronizes events from other relays.
#### `NGIT_SYNC_PLUS_ENABLED`
**Description:** Enable GRASP-03 Sync+ mailbox discovery on top of proactive GRASP-02 sync
**Type:** Boolean
**Default:** `true`
**Required:** No
```bash
# Opt out of mailbox discovery while retaining ordinary proactive sync
NGIT_SYNC_PLUS_ENABLED=false
```
The corresponding NixOS option is `syncPlusEnabled`. The setting is effective
only while the relay service's proactive sync manager is running. When false,
the relay retains GRASP-02 sync but does not discover NIP-65 inboxes and omits
`GRASP-03` from its NIP-11 `supported_grasps` list.
---
#### `NGIT_SYNC_BOOTSTRAP_RELAY_URL`
**Description:** URL of the bootstrap relay to initially sync events from
+11
View File
@@ -103,6 +103,16 @@ let
description = "Bootstrap relay URL to sync from on startup (optional)";
};
syncPlusEnabled = mkOption {
type = types.bool;
default = true;
description = ''
Enable GRASP-03 Sync+ mailbox discovery on top of proactive
GRASP-02 sync. This has no effect unless the relay service, and
therefore its proactive sync manager, is enabled.
'';
};
userIndexRelays = mkOption {
type = types.listOf types.str;
default = [
@@ -486,6 +496,7 @@ let
toString cfg.metricsConnectionPerIpAbuseThreshold;
NGIT_METRICS_TOP_N_REPOS = toString cfg.metricsTopNRepos;
NGIT_SYNC_MAX_BACKOFF_SECS = toString cfg.syncMaxBackoffSecs;
NGIT_SYNC_PLUS_ENABLED = if cfg.syncPlusEnabled then "true" else "false";
NGIT_SYNC_DISCONNECT_CHECK_INTERVAL_SECS =
toString cfg.syncDisconnectCheckIntervalSecs;
NGIT_SYNC_BASE_BACKOFF_SECS = toString cfg.syncBaseBackoffSecs;
+26
View File
@@ -389,6 +389,15 @@ pub struct Config {
#[arg(long, env = "NGIT_SYNC_BOOTSTRAP_RELAY_URL")]
pub sync_bootstrap_relay_url: Option<String>,
/// Enable GRASP-03 Sync+ mailbox discovery on top of proactive GRASP-02 sync.
#[arg(
long,
env = "NGIT_SYNC_PLUS_ENABLED",
default_value_t = true,
action = clap::ArgAction::Set
)]
pub sync_plus_enabled: bool,
/// Comma-separated relays used to discover eligible accepted repository participants'
/// NIP-65 relay lists.
#[arg(
@@ -1101,6 +1110,7 @@ impl Config {
metrics_connection_per_ip_abuse_threshold: 10,
metrics_top_n_repos: 10,
sync_bootstrap_relay_url: None,
sync_plus_enabled: true,
user_index_relays: DEFAULT_USER_INDEX_RELAYS.to_string(),
sync_max_backoff_secs: 3600,
sync_disconnect_check_interval_secs: 60,
@@ -1220,6 +1230,22 @@ mod tests {
);
}
#[test]
fn sync_plus_is_enabled_by_default_and_can_be_disabled() {
let default = Config::try_parse_from(["ngit-grasp", "--domain", "example.com"])
.expect("Sync+ default should parse");
assert!(default.sync_plus_enabled);
let disabled = Config::try_parse_from([
"ngit-grasp",
"--domain",
"example.com",
"--sync-plus-enabled=false",
])
.expect("Sync+ opt-out should parse");
assert!(!disabled.sync_plus_enabled);
}
#[test]
fn user_index_relays_parse_cli_list_and_ignore_empty_entries() {
let config = Config::try_parse_from([
+26 -7
View File
@@ -80,6 +80,9 @@ impl RelayInformationDocument {
supported_grasps.push("GRASP-05".to_string());
}
supported_grasps.push("GRASP-02".to_string());
if config.sync_plus_enabled {
supported_grasps.push("GRASP-03".to_string());
}
if config.grasp06_enable {
supported_grasps.push("GRASP-06".to_string());
}
@@ -197,8 +200,10 @@ mod tests {
// (disrespector off).
assert!(doc.supported_nips.contains(&9));
assert!(doc.supported_nips.contains(&62));
// Without archive mode, only GRASP-01 and GRASP-02
assert_eq!(doc.supported_grasps, vec!["GRASP-01", "GRASP-02"]);
assert_eq!(
doc.supported_grasps,
vec!["GRASP-01", "GRASP-02", "GRASP-03"]
);
assert!(doc.repo_acceptance_criteria.contains("None"));
assert!(doc.curation.is_none());
assert_eq!(
@@ -236,6 +241,7 @@ mod tests {
assert_eq!(parsed["name"], "Test Relay");
assert_eq!(parsed["supported_grasps"][0], "GRASP-01");
assert_eq!(parsed["supported_grasps"][1], "GRASP-02");
assert_eq!(parsed["supported_grasps"][2], "GRASP-03");
assert_eq!(parsed["icon"], "https://relay.example.com/icon.png");
assert_eq!(parsed["limitation"]["max_subscriptions"], 500);
assert_eq!(parsed["limitation"]["max_limit"], 500);
@@ -268,7 +274,7 @@ mod tests {
// Archive mode enabled: should include GRASP-05
assert_eq!(
doc.supported_grasps,
vec!["GRASP-01", "GRASP-05", "GRASP-02"]
vec!["GRASP-01", "GRASP-05", "GRASP-02", "GRASP-03"]
);
// Archive read-only: should have curation field
assert!(doc.curation.is_some());
@@ -291,7 +297,7 @@ mod tests {
// Archive whitelist enabled: should include GRASP-05
assert_eq!(
doc.supported_grasps,
vec!["GRASP-01", "GRASP-05", "GRASP-02"]
vec!["GRASP-01", "GRASP-05", "GRASP-02", "GRASP-03"]
);
// Archive read-only defaults to true: should have curation field
assert!(doc.curation.is_some());
@@ -312,7 +318,10 @@ mod tests {
let doc = RelayInformationDocument::from_config(&config);
// Repository whitelist doesn't enable GRASP-05
assert_eq!(doc.supported_grasps, vec!["GRASP-01", "GRASP-02"]);
assert_eq!(
doc.supported_grasps,
vec!["GRASP-01", "GRASP-02", "GRASP-03"]
);
// Should have curation field for repository whitelist
assert!(doc.curation.is_some());
assert!(doc
@@ -336,7 +345,7 @@ mod tests {
// Should have GRASP-05 enabled due to archive whitelist
assert_eq!(
doc.supported_grasps,
vec!["GRASP-01", "GRASP-05", "GRASP-02"]
vec!["GRASP-01", "GRASP-05", "GRASP-02", "GRASP-03"]
);
// Should have curation field reflecting BOTH archive and repository whitelist
assert!(doc.curation.is_some());
@@ -366,10 +375,20 @@ mod tests {
// to match the insertion order in from_config().
assert_eq!(
doc.supported_grasps,
vec!["GRASP-01", "GRASP-02", "GRASP-06"]
vec!["GRASP-01", "GRASP-02", "GRASP-03", "GRASP-06"]
);
}
#[test]
fn test_nip11_omits_grasp_03_when_sync_plus_is_disabled() {
let mut config = Config::for_testing();
config.sync_plus_enabled = false;
let doc = RelayInformationDocument::from_config(&config);
assert_eq!(doc.supported_grasps, vec!["GRASP-01", "GRASP-02"]);
}
#[test]
fn test_nip11_advertises_deletion_by_default() {
let config = Config::for_testing();
+7
View File
@@ -4816,6 +4816,13 @@ impl SyncManager {
}
async fn schedule_nip65_discovery(&mut self) {
// GRASP-03 is an optional overlay on the always-running GRASP-02
// manager. When disabled, do not inventory authors, retain identity
// authority, open discovery connections, or add inbox roots.
if !self.config.sync_plus_enabled {
return;
}
let now = Instant::now();
if self
.nip65_discovery