@@ -50,13 +50,16 @@ pub async fn prepare_desktop_capabilities(
5050 let facts =
5151 project ( super :: agent_discovery:: discover_acp_providers ( app. clone ( ) , Some ( false ) ) . await ?) ?;
5252 let scope = scope ( & app, & state, & owner, & community) ?;
53- Ok ( json ! ( { "event" : prepare_report( & mut open_retention_db( & scope. db_path) ?, & scope, facts) ? } ) )
53+ Ok (
54+ json ! ( { "event" : prepare_report( & mut open_retention_db( & scope. db_path) ?, & scope, facts, nostr:: Timestamp :: now) ? } ) ,
55+ )
5456}
5557
5658fn prepare_report (
5759 conn : & mut Connection ,
5860 scope : & RetentionScope ,
5961 facts : Vec < RuntimeFact > ,
62+ clock : impl FnOnce ( ) -> nostr:: Timestamp ,
6063) -> Result < Event , String > {
6164 let saved = prepare ( conn, scope) ?;
6265 let profile: Event =
@@ -90,8 +93,16 @@ fn prepare_report(
9093 . unwrap_or ( false ) ;
9194 let event = match previous {
9295 Some ( event) if unchanged => event,
93- _ => {
94- let event = report. sign ( & scope. owner_keys ) ?;
96+ previous => {
97+ let now = clock ( ) ;
98+ // Keep the prior retry record until real time advances. Signing tied
99+ // ciphertext can lose NIP-33's lower-ID tie; never cache that loss or
100+ // future-date a replacement. The existing pulse/reconnect/Refresh
101+ // retries discovery, not a captured projection, without waiting here.
102+ if previous. as_ref ( ) . is_some_and ( |e| now <= e. created_at ) {
103+ return Err ( "Desktop capability facts deferred until the clock advances" . into ( ) ) ;
104+ }
105+ let event = report. sign_at ( & scope. owner_keys , now) ?;
95106 tx. execute (
96107 "INSERT OR REPLACE INTO desktop_capabilities VALUES (1, ?1)" ,
97108 [ event. as_json ( ) ] ,
@@ -123,7 +134,7 @@ pub fn read_desktop_capabilities(
123134mod tests {
124135 use super :: * ;
125136 #[ test]
126- fn unchanged_facts_reopen_exact_bytes_changed_facts_replace_atomically ( ) {
137+ fn changed_facts_defer_until_real_clock_advances_then_win_signed_order ( ) {
127138 let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
128139 let scope = RetentionScope {
129140 db_path : dir. path ( ) . join ( "report.db" ) ,
@@ -134,26 +145,80 @@ mod tests {
134145 & mut open_retention_db ( & scope. db_path ) . unwrap ( ) ,
135146 & scope,
136147 vec ! [ ] ,
148+ || nostr:: Timestamp :: from ( 1000 ) ,
137149 )
138150 . unwrap ( ) ;
139151 let mut reopened = open_retention_db ( & scope. db_path ) . unwrap ( ) ;
140152 assert_eq ! (
141- prepare_report( & mut reopened, & scope, vec![ ] ) . unwrap( ) ,
153+ prepare_report( & mut reopened, & scope, vec![ ] , || panic!(
154+ "unchanged must not sign"
155+ ) )
156+ . unwrap( ) ,
142157 first
143158 ) ;
144159 assert_eq ! ( reopened. total_changes( ) , 0 ) ;
145- let facts = vec ! [ RuntimeFact {
160+ let mut facts = vec ! [ RuntimeFact {
146161 id: "goose" . into( ) ,
147162 availability: "available" . into( ) ,
148163 requires_external_cli: true ,
149164 max_parallelism: None ,
150165 } ] ;
151- let changed = prepare_report ( & mut reopened, & scope, facts) . unwrap ( ) ;
152- assert_ne ! ( changed. id, first. id) ;
166+ for now in [ 1000 , 990 , 999 , 1000 ] {
167+ let error = prepare_report ( & mut reopened, & scope, facts. clone ( ) , || {
168+ nostr:: Timestamp :: from ( now)
169+ } )
170+ . unwrap_err ( ) ;
171+ assert ! ( error. contains( "clock advances" ) ) ;
172+ assert_eq ! ( reopened. total_changes( ) , 0 , "deferral must not persist" ) ;
173+ // Returning to old facts cancels the proposed change, even after a
174+ // restart/rollback: no deferred payload or timestamp renewal survives.
175+ assert_eq ! (
176+ prepare_report( & mut reopened, & scope, vec![ ] , || panic!( "exact retry" ) ) . unwrap( ) ,
177+ first
178+ ) ;
179+ reopened = open_retention_db ( & scope. db_path ) . unwrap ( ) ;
180+ }
181+ // The retry observes today's facts, not the projection first deferred.
182+ facts[ 0 ] . availability = "cli_missing" . into ( ) ;
183+ let changed = prepare_report ( & mut reopened, & scope, facts. clone ( ) , || {
184+ nostr:: Timestamp :: from ( 1001 )
185+ } )
186+ . unwrap ( ) ;
187+ first. verify ( ) . unwrap ( ) ;
188+ changed. verify ( ) . unwrap ( ) ;
189+ assert_eq ! ( changed. created_at. as_secs( ) , 1001 , "no future timestamp" ) ;
190+ assert ! ( changed. created_at > first. created_at) ;
153191 assert_eq ! ( changed. tags, first. tags) ;
192+ for events in [
193+ vec ! [ first. clone( ) , changed. clone( ) ] ,
194+ vec ! [ changed. clone( ) , first] ,
195+ ] {
196+ let rows =
197+ DesktopCapabilities :: read_latest ( events, & scope. owner_keys , & scope. relay_url )
198+ . unwrap ( ) ;
199+ assert_eq ! ( rows. len( ) , 1 ) ;
200+ assert_eq ! ( rows[ 0 ] . 0 . runtimes, facts) ;
201+ assert_eq ! ( rows[ 0 ] . 1 , 1001 ) ;
202+ }
203+ let mut reopened = open_retention_db ( & scope. db_path ) . unwrap ( ) ;
204+ let mut invalid = facts. clone ( ) ;
205+ invalid[ 0 ] . max_parallelism = Some ( 0 ) ;
206+ assert ! ( prepare_report( & mut reopened, & scope, invalid, || {
207+ nostr:: Timestamp :: from( 1002 )
208+ } )
209+ . is_err( ) ) ;
210+ assert_eq ! (
211+ prepare_report( & mut reopened, & scope, facts, || panic!( "exact retry" ) ) . unwrap( ) ,
212+ changed
213+ ) ;
214+ assert_eq ! (
215+ reopened. total_changes( ) ,
216+ 0 ,
217+ "failed signing must not persist"
218+ ) ;
154219 reopened
155220 . execute ( "UPDATE desktop_capabilities SET raw = 'corrupt'" , [ ] )
156221 . unwrap ( ) ;
157- assert ! ( prepare_report( & mut reopened, & scope, vec![ ] ) . is_err( ) ) ;
222+ assert ! ( prepare_report( & mut reopened, & scope, vec![ ] , nostr :: Timestamp :: now ) . is_err( ) ) ;
158223 }
159224}
0 commit comments