diff options
Diffstat (limited to 'docs/explanation/announcements-purgatory-implementation.md')
| -rw-r--r-- | docs/explanation/announcements-purgatory-implementation.md | 296 |
1 files changed, 0 insertions, 296 deletions
diff --git a/docs/explanation/announcements-purgatory-implementation.md b/docs/explanation/announcements-purgatory-implementation.md deleted file mode 100644 index 263c253..0000000 --- a/docs/explanation/announcements-purgatory-implementation.md +++ /dev/null | |||
| @@ -1,296 +0,0 @@ | |||
| 1 | # Announcements Purgatory Implementation Details | ||
| 2 | |||
| 3 | This document provides detailed implementation notes for the [Announcements Purgatory Design](./announcements-purgatory-design.md). | ||
| 4 | |||
| 5 | ## Sync Integration | ||
| 6 | |||
| 7 | ### Current Sync Architecture | ||
| 8 | |||
| 9 | The sync system uses a two-index approach: | ||
| 10 | |||
| 11 | ```rust | ||
| 12 | // What we WANT to sync - source of truth from self-subscription | ||
| 13 | // Key: repo addressable ref (30617:pubkey:identifier) | ||
| 14 | pub type RepoSyncIndex = Arc<RwLock<HashMap<String, RepoSyncNeeds>>>; | ||
| 15 | |||
| 16 | pub struct RepoSyncNeeds { | ||
| 17 | pub relays: HashSet<String>, // Relay URLs from announcement | ||
| 18 | pub root_events: HashSet<EventId>, // 1617/1618/1621 event IDs | ||
| 19 | } | ||
| 20 | |||
| 21 | // What we have CONFIRMED syncing + connection state | ||
| 22 | // Key: relay URL | ||
| 23 | pub type RelaySyncIndex = Arc<RwLock<HashMap<String, RelayState>>>; | ||
| 24 | ``` | ||
| 25 | |||
| 26 | **Three-Layer Sync Strategy:** | ||
| 27 | 1. **Layer 1:** Announcements (kinds 30617, 10317) | ||
| 28 | 2. **Layer 2:** Repo-tagging events (events with `a`/`A`/`q` tags + kind 30618 by identifier) | ||
| 29 | 3. **Layer 3:** Root-event-tagging events (events with `e`/`E`/`q` tags) | ||
| 30 | |||
| 31 | ### Adding SyncLevel | ||
| 32 | |||
| 33 | Add a `sync_level` field to distinguish purgatory from promoted repos: | ||
| 34 | |||
| 35 | ```rust | ||
| 36 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | ||
| 37 | pub enum SyncLevel { | ||
| 38 | #[default] | ||
| 39 | Full, // L2 + L3 (promoted repos) | ||
| 40 | StateOnly, // Only state events (purgatory announcements) | ||
| 41 | } | ||
| 42 | |||
| 43 | pub struct RepoSyncNeeds { | ||
| 44 | pub relays: HashSet<String>, | ||
| 45 | pub root_events: HashSet<EventId>, | ||
| 46 | pub sync_level: SyncLevel, // NEW | ||
| 47 | } | ||
| 48 | ``` | ||
| 49 | |||
| 50 | ### Filter Building Changes | ||
| 51 | |||
| 52 | In `src/sync/filters.rs`, modify filter building to respect sync level: | ||
| 53 | |||
| 54 | ```rust | ||
| 55 | // For StateOnly repos, only build state event filters | ||
| 56 | pub fn build_layer2_and_layer3_filters( | ||
| 57 | repos: &HashMap<String, RepoSyncNeeds>, | ||
| 58 | // ... | ||
| 59 | ) -> Vec<Filter> { | ||
| 60 | let (full_repos, state_only_repos): (Vec<_>, Vec<_>) = repos | ||
| 61 | .iter() | ||
| 62 | .partition(|(_, needs)| needs.sync_level == SyncLevel::Full); | ||
| 63 | |||
| 64 | let mut filters = Vec::new(); | ||
| 65 | |||
| 66 | // Full repos get all L2/L3 filters | ||
| 67 | if !full_repos.is_empty() { | ||
| 68 | filters.extend(tagged_one_of_our_repo_event_filters(&full_repos)); | ||
| 69 | filters.extend(state_event_filters_for_our_repos(&full_repos)); | ||
| 70 | filters.extend(tagged_one_of_our_root_event_filters(&full_repos)); | ||
| 71 | } | ||
| 72 | |||
| 73 | // StateOnly repos get only state event filters | ||
| 74 | if !state_only_repos.is_empty() { | ||
| 75 | filters.extend(state_event_filters_for_our_repos(&state_only_repos)); | ||
| 76 | } | ||
| 77 | |||
| 78 | filters | ||
| 79 | } | ||
| 80 | ``` | ||
| 81 | |||
| 82 | The existing `state_event_filters_for_our_repos()` function already builds kind 30618 filters with `#d` tags, which is exactly what we need. | ||
| 83 | |||
| 84 | ### Self-Subscriber Changes | ||
| 85 | |||
| 86 | In `src/sync/self_subscriber.rs`, add purgatory announcements to the sync index: | ||
| 87 | |||
| 88 | ```rust | ||
| 89 | // When announcement enters purgatory | ||
| 90 | fn on_announcement_to_purgatory( | ||
| 91 | &self, | ||
| 92 | event: &Event, | ||
| 93 | identifier: &str, | ||
| 94 | relays: HashSet<String>, | ||
| 95 | ) { | ||
| 96 | let key = format!("30617:{}:{}", event.pubkey, identifier); | ||
| 97 | let mut index = self.repo_sync_index.write().unwrap(); | ||
| 98 | index.insert(key, RepoSyncNeeds { | ||
| 99 | relays, | ||
| 100 | root_events: HashSet::new(), | ||
| 101 | sync_level: SyncLevel::StateOnly, | ||
| 102 | }); | ||
| 103 | } | ||
| 104 | |||
| 105 | // When announcement promotes to database | ||
| 106 | fn on_announcement_promoted( | ||
| 107 | &self, | ||
| 108 | event: &Event, | ||
| 109 | identifier: &str, | ||
| 110 | ) { | ||
| 111 | let key = format!("30617:{}:{}", event.pubkey, identifier); | ||
| 112 | let mut index = self.repo_sync_index.write().unwrap(); | ||
| 113 | if let Some(needs) = index.get_mut(&key) { | ||
| 114 | needs.sync_level = SyncLevel::Full; | ||
| 115 | } | ||
| 116 | } | ||
| 117 | ``` | ||
| 118 | |||
| 119 | ### Algorithm Changes | ||
| 120 | |||
| 121 | In `src/sync/algorithms.rs`, preserve sync level when inverting repo->relay: | ||
| 122 | |||
| 123 | ```rust | ||
| 124 | pub fn derive_relay_targets( | ||
| 125 | repo_index: &RepoSyncIndex, | ||
| 126 | ) -> HashMap<String, RelaySyncNeeds> { | ||
| 127 | // ... existing inversion logic ... | ||
| 128 | // Ensure sync_level is preserved/aggregated per relay | ||
| 129 | // A relay gets Full if ANY of its repos are Full | ||
| 130 | } | ||
| 131 | ``` | ||
| 132 | |||
| 133 | ## Authorization Integration | ||
| 134 | |||
| 135 | ### Current Authorization Flow | ||
| 136 | |||
| 137 | Authorization lookups happen in `src/git/authorization.rs`: | ||
| 138 | |||
| 139 | | Function | Purpose | Currently Queries | | ||
| 140 | |----------|---------|-------------------| | ||
| 141 | | `fetch_repository_data()` | Get announcements + states by identifier | DB only | | ||
| 142 | | `collect_authorized_maintainers()` | Build maintainer set from announcements | DB only | | ||
| 143 | | `pubkey_authorised_for_repo_owners()` | Check if pubkey authorized | DB only | | ||
| 144 | |||
| 145 | ### Required Changes | ||
| 146 | |||
| 147 | Modify `fetch_repository_data()` to also query purgatory: | ||
| 148 | |||
| 149 | ```rust | ||
| 150 | pub async fn fetch_repository_data( | ||
| 151 | db: &Database, | ||
| 152 | purgatory: &Purgatory, // NEW parameter | ||
| 153 | identifier: &str, | ||
| 154 | ) -> Result<RepositoryData> { | ||
| 155 | // Existing DB query | ||
| 156 | let db_events = db.query(/* kind 30617, 30618 by identifier */).await?; | ||
| 157 | |||
| 158 | // NEW: Also check purgatory for announcements | ||
| 159 | let purgatory_announcements = purgatory | ||
| 160 | .get_announcements_by_identifier(identifier); | ||
| 161 | |||
| 162 | // Merge results | ||
| 163 | let mut announcements = parse_announcements(db_events); | ||
| 164 | announcements.extend(purgatory_announcements); | ||
| 165 | |||
| 166 | // ... rest of function | ||
| 167 | } | ||
| 168 | ``` | ||
| 169 | |||
| 170 | This affects: | ||
| 171 | - `StatePolicy::process_state_event()` - state event validation | ||
| 172 | - `get_state_authorization_for_specific_owner_repo()` - git push authorization | ||
| 173 | - `AnnouncementPolicy::is_maintainer_in_any_announcement()` - maintainer exception | ||
| 174 | |||
| 175 | ## Purgatory Store Changes | ||
| 176 | |||
| 177 | ### New Fields | ||
| 178 | |||
| 179 | ```rust | ||
| 180 | pub struct AnnouncementPurgatoryEntry { | ||
| 181 | pub event: Event, | ||
| 182 | pub identifier: String, | ||
| 183 | pub owner: PublicKey, | ||
| 184 | pub repo_path: PathBuf, | ||
| 185 | pub relays: HashSet<String>, // For sync registration | ||
| 186 | pub created_at: Instant, | ||
| 187 | pub expires_at: Instant, | ||
| 188 | pub soft_expired: bool, // Bare repo deleted, event retained | ||
| 189 | } | ||
| 190 | ``` | ||
| 191 | |||
| 192 | ### New Methods | ||
| 193 | |||
| 194 | ```rust | ||
| 195 | impl Purgatory { | ||
| 196 | /// Get announcements by identifier (for authorization) | ||
| 197 | pub fn get_announcements_by_identifier( | ||
| 198 | &self, | ||
| 199 | identifier: &str, | ||
| 200 | ) -> Vec<&AnnouncementPurgatoryEntry> { | ||
| 201 | self.announcement_purgatory | ||
| 202 | .iter() | ||
| 203 | .filter(|entry| entry.identifier == identifier) | ||
| 204 | .collect() | ||
| 205 | } | ||
| 206 | |||
| 207 | /// Transition to soft-expired state (protocol's 30min expiry reached) | ||
| 208 | pub fn soft_expire_announcement( | ||
| 209 | &self, | ||
| 210 | key: &(PublicKey, String), | ||
| 211 | ) -> Option<PathBuf> { | ||
| 212 | if let Some(mut entry) = self.announcement_purgatory.get_mut(key) { | ||
| 213 | entry.soft_expired = true; | ||
| 214 | entry.expires_at = Instant::now() + SOFT_EXPIRY_DURATION; // e.g., 24h extended retention | ||
| 215 | Some(entry.repo_path.clone()) // Return path for bare repo deletion | ||
| 216 | } else { | ||
| 217 | None | ||
| 218 | } | ||
| 219 | } | ||
| 220 | |||
| 221 | /// Revive soft-expired announcement when state event arrives | ||
| 222 | /// (caller must recreate bare repo) | ||
| 223 | pub fn revive_announcement( | ||
| 224 | &self, | ||
| 225 | key: &(PublicKey, String), | ||
| 226 | ) -> Option<PathBuf> { | ||
| 227 | if let Some(mut entry) = self.announcement_purgatory.get_mut(key) { | ||
| 228 | if entry.soft_expired { | ||
| 229 | entry.soft_expired = false; | ||
| 230 | entry.expires_at = Instant::now() + ACTIVE_EXPIRY_DURATION; // Reset 30min protocol timer | ||
| 231 | return Some(entry.repo_path.clone()); // Caller recreates bare repo | ||
| 232 | } | ||
| 233 | } | ||
| 234 | None | ||
| 235 | } | ||
| 236 | } | ||
| 237 | ``` | ||
| 238 | |||
| 239 | ## Expiry Cleanup Task | ||
| 240 | |||
| 241 | The existing cleanup task needs to handle the two-phase expiry: | ||
| 242 | |||
| 243 | ```rust | ||
| 244 | async fn cleanup_expired_announcements(&self) { | ||
| 245 | let now = Instant::now(); | ||
| 246 | |||
| 247 | for entry in self.announcement_purgatory.iter() { | ||
| 248 | if entry.expires_at <= now { | ||
| 249 | let key = (entry.owner.clone(), entry.identifier.clone()); | ||
| 250 | |||
| 251 | if entry.soft_expired { | ||
| 252 | // Fully expired - remove entirely | ||
| 253 | self.announcement_purgatory.remove(&key); | ||
| 254 | self.unregister_from_sync(&key); | ||
| 255 | } else { | ||
| 256 | // First expiry - transition to soft-expired | ||
| 257 | if let Some(repo_path) = self.soft_expire_announcement(&key) { | ||
| 258 | delete_bare_repo(&repo_path).await; | ||
| 259 | } | ||
| 260 | // Note: stays in sync index with StateOnly level | ||
| 261 | } | ||
| 262 | } | ||
| 263 | } | ||
| 264 | } | ||
| 265 | ``` | ||
| 266 | |||
| 267 | ## State Event Revival Flow | ||
| 268 | |||
| 269 | When a state event arrives for a soft-expired announcement, the state policy must: | ||
| 270 | |||
| 271 | 1. Check purgatory for a matching announcement (in addition to DB) | ||
| 272 | 2. Validate authorization against the purgatory announcement | ||
| 273 | 3. If soft-expired, call `revive_announcement()` and recreate the bare repo | ||
| 274 | 4. Extend the announcement's expiry (reset the 30-minute protocol timer) | ||
| 275 | 5. Route the state event to state purgatory | ||
| 276 | |||
| 277 | **Why revival is necessary:** Without soft expiry + revival, late-arriving state events would either be permanently rejected (if we added the announcement to `failed_events`) or cause constant re-syncing of the announcement event. Revival allows us to respect the protocol's 30-minute expiry while still handling delayed state events gracefully. | ||
| 278 | |||
| 279 | The exact integration will depend on the current structure of `StatePolicy::process_state_event()` - see implementation phase for details. | ||
| 280 | |||
| 281 | ## File Change Summary | ||
| 282 | |||
| 283 | | File | Estimated Lines | Changes | | ||
| 284 | |------|-----------------|---------| | ||
| 285 | | `src/sync/mod.rs` | ~10 | Add `SyncLevel` enum, field to `RepoSyncNeeds` | | ||
| 286 | | `src/sync/filters.rs` | ~20 | Partition repos by sync level, build appropriate filters | | ||
| 287 | | `src/sync/algorithms.rs` | ~15 | Preserve sync level in relay target derivation | | ||
| 288 | | `src/sync/self_subscriber.rs` | ~40 | Register purgatory announcements, handle promotion | | ||
| 289 | | `src/purgatory/mod.rs` | ~80 | Add announcement store, soft expiry methods | | ||
| 290 | | `src/purgatory/types.rs` | ~20 | Add `AnnouncementPurgatoryEntry` | | ||
| 291 | | `src/git/authorization.rs` | ~30 | Query purgatory in `fetch_repository_data()` | | ||
| 292 | | `src/nostr/policy/state.rs` | ~40 | Handle soft-expired revival | | ||
| 293 | | `src/nostr/policy/announcement.rs` | ~30 | Route to purgatory, check for replacements | | ||
| 294 | | `src/git/receive.rs` | ~20 | Trigger promotion on git data | | ||
| 295 | |||
| 296 | **Total: ~305 lines of changes** | ||