22//!
33//! The create and invite paths know the whole requested roster before they
44//! mutate MLS state. Resolve that set as a set: canonicalize aliases, collapse
5- //! duplicate account ids, reuse validated local/directory entries , and batch
6- //! cold relay work by compatible endpoint set. The legacy one-member resolver
7- //! remains the bounded fallback for relay/query shapes that do not support a
8- //! multi -author request .
5+ //! duplicate account ids, reuse discovery routes , and fetch current KeyPackages
6+ //! from relays before every invitation. Cached packages are discovery hints, not
7+ //! evidence that a recipient still owns the private bundle. Unsupported batch
8+ //! queries fall back to bounded single -author relay requests .
99
1010use std:: collections:: { BTreeMap , HashMap , HashSet , VecDeque } ;
1111use std:: time:: { Duration , Instant , SystemTime , UNIX_EPOCH } ;
@@ -22,7 +22,7 @@ use transport_nostr_adapter::{
2222
2323use crate :: key_package_records:: {
2424 fresh_or_cached_key_package, fresh_relay_list_status_from_records,
25- latest_fresh_key_package_from_records, merge_relay_list_status, validated_cached_key_package ,
25+ latest_fresh_key_package_from_records, merge_relay_list_status,
2626} ;
2727use crate :: relay_plane:: { DirectoryEventQuery , DirectoryFetchOutcome } ;
2828use crate :: { AccountRelayListStatus , AppError , FetchedKeyPackage , MarmotApp } ;
@@ -82,8 +82,22 @@ mod tests {
8282 assert ! ( !cache. order. contains( & newest) ) ;
8383 }
8484
85+ #[ test]
86+ fn package_refresh_does_not_renew_routes_that_expired_during_fetch ( ) {
87+ let mut cache = MemberKeyPackagePrewarmCache :: default ( ) ;
88+ let id = "01" . repeat ( 32 ) ;
89+ cache. insert ( fetched ( id. clone ( ) ) ) ;
90+ cache. entries . get_mut ( & id) . unwrap ( ) . inserted_at =
91+ Instant :: now ( ) - MEMBER_PREWARM_CACHE_TTL - Duration :: from_secs ( 1 ) ;
92+ cache. insert ( fetched ( id. clone ( ) ) ) ;
93+ assert ! (
94+ cache. get( & id) . is_none( ) ,
95+ "the next lookup must refresh expired routes"
96+ ) ;
97+ }
98+
8599 #[ tokio:: test]
86- async fn composition_prewarm_reuse_preserves_original_freshness_deadline ( ) {
100+ async fn composition_prewarm_route_reuse_preserves_original_freshness_deadline ( ) {
87101 let ( _directory, app, accounts, _fetcher) =
88102 crate :: tests:: member_resolution_fixture ( 1 , false ) . await ;
89103 let id = & accounts[ 0 ] . account_id_hex ;
@@ -102,6 +116,9 @@ mod tests {
102116 app. prewarm_group_member_key_packages ( & [ id. as_str ( ) ] )
103117 . await
104118 . unwrap ( ) ;
119+ app. resolve_member_key_packages ( & [ id. as_str ( ) ] )
120+ . await
121+ . unwrap ( ) ;
105122 }
106123 let mut cache = app. member_key_package_prewarm_cache . lock ( ) . unwrap ( ) ;
107124 assert_eq ! ( cache. entries. get( id) . unwrap( ) . inserted_at, original) ;
@@ -126,15 +143,23 @@ impl MemberKeyPackagePrewarmCache {
126143 }
127144
128145 fn insert ( & mut self , fetched : FetchedKeyPackage ) {
129- self . remove_expired ( ) ;
130146 let account_id_hex = fetched. account_id_hex . clone ( ) ;
147+ // Refreshing package bytes does not prove that reused relay routes are
148+ // fresh. Preserve their original deadline until the entry expires and
149+ // the resolver performs discovery again.
150+ let inserted_at = self
151+ . entries
152+ . get ( & account_id_hex)
153+ . map ( |entry| entry. inserted_at )
154+ . unwrap_or_else ( Instant :: now) ;
155+ self . remove_expired ( ) ;
131156 self . order . retain ( |existing| existing != & account_id_hex) ;
132157 self . order . push_back ( account_id_hex. clone ( ) ) ;
133158 self . entries . insert (
134159 account_id_hex,
135160 MemberKeyPackagePrewarmEntry {
136161 fetched,
137- inserted_at : Instant :: now ( ) ,
162+ inserted_at,
138163 } ,
139164 ) ;
140165 while self . entries . len ( ) > MEMBER_PREWARM_CACHE_LIMIT {
@@ -176,8 +201,8 @@ pub struct MemberKeyPackagePrewarmSummary {
176201 pub requested_members : u64 ,
177202 /// Canonical account ids after aliases and duplicates are collapsed.
178203 pub unique_members : u64 ,
179- /// Packages satisfied by validated local state, durable directory state,
180- /// or the process-local prewarm cache .
204+ /// Retained for API compatibility; always zero because packages must be
205+ /// fetched from relays. Discovery routes may still be reused .
181206 pub reused_members : u64 ,
182207 /// Packages that required relay resolution during this call.
183208 pub network_resolved_members : u64 ,
@@ -205,7 +230,6 @@ impl From<MemberKeyPackageResolutionStats> for MemberKeyPackagePrewarmSummary {
205230#[ derive( Clone ) ]
206231struct MemberTarget {
207232 account_id_hex : String ,
208- local_label : Option < String > ,
209233 relay_lists : AccountRelayListStatus ,
210234 cached_relay_lists : AccountRelayListStatus ,
211235 relay_records : Vec < crate :: relay_plane:: DirectoryRelayEventRecord > ,
@@ -226,9 +250,10 @@ impl MarmotApp {
226250 /// Resolve a roster as one deterministic set.
227251 ///
228252 /// Account aliases are canonicalized and duplicate account ids collapse to
229- /// their first input position. On error, the error belonging to the first
230- /// unresolved canonical member is returned even if later relay work
231- /// completed earlier.
253+ /// their first input position. Every package is fetched from relays; cached
254+ /// packages are never a fallback if that lookup fails. On error, the error
255+ /// belonging to the first unresolved canonical member is returned even if
256+ /// later relay work completed earlier.
232257 pub async fn resolve_member_key_packages (
233258 & self ,
234259 member_refs : & [ & str ] ,
@@ -263,9 +288,9 @@ impl MarmotApp {
263288 /// The roster must also resolve a safe Marmot inbox route for every member;
264289 /// missing routes return [`AppError::MissingMemberInboxRoute`]. Successfully
265290 /// fetched packages and relay metadata remain cached even when another
266- /// member fails readiness. A later create call re-reads and validates the
267- /// cached bytes, and the MLS mutation boundary still performs its ordinary
268- /// lifetime/single-use validation .
291+ /// member fails readiness. A later create call can reuse discovery routes,
292+ /// but fetches KeyPackages again because prewarmed material may have been
293+ /// consumed in the meantime .
269294 pub async fn prewarm_group_member_key_packages (
270295 & self ,
271296 member_refs : & [ & str ] ,
@@ -351,7 +376,6 @@ impl MarmotApp {
351376 . unwrap_or_else ( AccountRelayListStatus :: empty) ;
352377 targets. push ( MemberTarget {
353378 account_id_hex,
354- local_label : local. map ( |account| account. label ) ,
355379 relay_lists : relay_lists. clone ( ) ,
356380 cached_relay_lists : relay_lists,
357381 relay_records : Vec :: new ( ) ,
@@ -361,82 +385,44 @@ impl MarmotApp {
361385 let mut outcomes = ( 0 ..targets. len ( ) )
362386 . map ( |_| None )
363387 . collect :: < Vec < Option < Result < KeyPackage , AppError > > > > ( ) ;
364- let mut unresolved = Vec :: new ( ) ;
388+ // Even a valid, unexpired cached package may have been consumed on
389+ // another device. Every purpose, including composition prewarm, must
390+ // fetch current relay publications; only discovery routes are reused.
391+ let unresolved = ( 0 ..targets. len ( ) ) . collect :: < Vec < _ > > ( ) ;
365392 let mut fresh_prewarmed_routes = HashSet :: new ( ) ;
366- let mut reused_members = 0usize ;
393+ let reused_members = 0 ;
367394 for ( index, target) in targets. iter_mut ( ) . enumerate ( ) {
395+ // Recovery deliberately refreshes routes as well as packages.
368396 if purpose == MemberResolutionPurpose :: CommitFresh {
369- unresolved. push ( index) ;
370- continue ;
371- }
372- if let Some ( label) = & target. local_label
373- && let Some ( key_package) = self . validated_current_local_key_package ( label)
374- && let Ok ( key_package) =
375- self . validate_member_key_package_current ( & target. account_id_hex , key_package)
376- {
377- outcomes[ index] = Some ( Ok ( key_package) ) ;
378- reused_members += 1 ;
379- continue ;
380- }
381- let cached = self . directory_entry_for_account_id ( & target. account_id_hex ) ?;
382- if let Some ( key_package) = cached. and_then ( |entry| entry. key_package )
383- && let Ok ( key_package) =
384- validated_cached_key_package ( & target. account_id_hex , & key_package)
385- && let Ok ( key_package) =
386- self . validate_member_key_package_current ( & target. account_id_hex , key_package)
387- {
388- outcomes[ index] = Some ( Ok ( key_package) ) ;
389- reused_members += 1 ;
390397 continue ;
391398 }
392399 let prefetched = self
393400 . member_key_package_prewarm_cache
394401 . lock ( )
395402 . unwrap_or_else ( |poisoned| poisoned. into_inner ( ) )
396403 . get ( & target. account_id_hex ) ;
397- if let Some ( mut fetched) = prefetched {
398- target. relay_lists = merge_relay_list_status (
399- target. relay_lists . clone ( ) ,
400- fetched. relay_lists . clone ( ) ,
401- ) ;
404+ if let Some ( fetched) = prefetched {
405+ target. relay_lists =
406+ merge_relay_list_status ( target. relay_lists . clone ( ) , fetched. relay_lists ) ;
402407 target. cached_relay_lists = target. relay_lists . clone ( ) ;
403- fetched. relay_lists = target. relay_lists . clone ( ) ;
404- // Reuse must not restart the observation TTL. Only newly
405- // fetched packages enter through accept_prefetched_key_package.
406- let accepted = self . validate_member_key_package_current (
407- & fetched. account_id_hex ,
408- fetched. key_package . clone ( ) ,
409- ) ;
410- let accepted = accepted. and_then ( |key_package| {
411- if purpose == MemberResolutionPurpose :: Commit {
412- self . remember_directory_key_package ( & fetched) ?;
413- }
414- Ok ( key_package)
415- } ) ;
416- if let Ok ( key_package) = accepted {
417- if !target. relay_lists . nip65 . relays . is_empty ( )
418- && !self
419- . retain_safe_discovered_endpoints (
420- target
421- . relay_lists
422- . inbox
423- . relays
424- . iter ( )
425- . cloned ( )
426- . map ( TransportEndpoint )
427- . collect ( ) ,
428- "member prewarm inbox readiness" ,
429- )
430- . is_empty ( )
431- {
432- fresh_prewarmed_routes. insert ( index) ;
433- }
434- outcomes[ index] = Some ( Ok ( key_package) ) ;
435- reused_members += 1 ;
436- continue ;
408+ if !target. relay_lists . nip65 . relays . is_empty ( )
409+ && !self
410+ . retain_safe_discovered_endpoints (
411+ target
412+ . relay_lists
413+ . inbox
414+ . relays
415+ . iter ( )
416+ . cloned ( )
417+ . map ( TransportEndpoint )
418+ . collect ( ) ,
419+ "member prewarm inbox readiness" ,
420+ )
421+ . is_empty ( )
422+ {
423+ fresh_prewarmed_routes. insert ( index) ;
437424 }
438425 }
439- unresolved. push ( index) ;
440426 }
441427
442428 // Durable projections have unknown observation age and need a bounded
@@ -916,19 +902,12 @@ impl MarmotApp {
916902 . filter ( |record| record. event . pubkey == * account_id)
917903 . cloned ( )
918904 . collect :: < Vec < _ > > ( ) ;
919- let cached = if purpose == MemberResolutionPurpose :: CommitFresh {
920- None
921- } else {
922- self . directory_entry_for_account_id ( account_id)
923- . ok ( )
924- . flatten ( )
925- } ;
926905 let selected = latest_fresh_key_package_from_records (
927906 account_id,
928907 account_records,
929908 self . directory_freshness ( ) ,
930909 )
931- . and_then ( |selection| fresh_or_cached_key_package ( account_id, selection, cached ) ) ;
910+ . and_then ( |selection| fresh_or_cached_key_package ( account_id, selection, None ) ) ;
932911 match selected {
933912 Ok ( mut fetched) => {
934913 fetched. relay_lists = targets[ index] . relay_lists . clone ( ) ;
@@ -982,19 +961,14 @@ impl MarmotApp {
982961 . map_err ( |error| {
983962 AppError :: RelayDirectory ( format ! ( "fetch key packages: {error}" ) )
984963 } ) ?;
985- let cached = if purpose == MemberResolutionPurpose :: CommitFresh {
986- None
987- } else {
988- app. directory_entry_for_account_id ( & target. account_id_hex ) ?
989- } ;
990964 let mut fetched = fresh_or_cached_key_package (
991965 & target. account_id_hex ,
992966 latest_fresh_key_package_from_records (
993967 & target. account_id_hex ,
994968 records,
995969 app. directory_freshness ( ) ,
996970 ) ?,
997- cached ,
971+ None ,
998972 ) ?;
999973 fetched. relay_lists = target. relay_lists ;
1000974 Ok :: < _ , AppError > ( fetched)
0 commit comments