Skip to main content

lash_sqlite_store/
process_registry.rs

1//! SQLite-backed [`ProcessRegistry`] (`SqliteProcessRegistry`).
2//!
3//! First-party SQLite implementation of the public async process-registry
4//! surface. Every DB body is a *synchronous* rusqlite closure handed
5//! to [`SqliteConnection::call`] (reads) or [`SqliteConnection::write_flow`]
6//! (read-then-write).
7//!
8//! Transactional methods use `write_flow` so logical [`lash_core::PluginError`]
9//! failures roll back instead of committing partial writes through rusqlite's
10//! error channel. The `*_conn` helpers are synchronous and accept
11//! `&rusqlite::Connection`, so they compose inside connection and transaction
12//! closures.
13
14use super::*;
15
16fn process_status_label(record: &ProcessRecord) -> &'static str {
17    record.status.label()
18}
19
20impl SqliteProcessRegistry {
21    pub async fn open(path: &Path) -> tokio_rusqlite::Result<Self> {
22        let conn = SqliteConnection::open(path).await?;
23        ensure_process_schema(&conn).await?;
24        apply_pragmas(&conn, StoreBacking::File).await?;
25        Ok(Self { conn })
26    }
27
28    pub async fn memory() -> tokio_rusqlite::Result<Self> {
29        let conn = SqliteConnection::open_in_memory().await?;
30        ensure_process_schema(&conn).await?;
31        apply_pragmas(&conn, StoreBacking::Memory).await?;
32        Ok(Self { conn })
33    }
34
35    pub(super) fn load_process_conn(
36        conn: &Connection,
37        process_id: &str,
38    ) -> Result<Option<ProcessRecord>, lash_core::PluginError> {
39        let json: Option<String> = conn
40            .query_row(
41                "SELECT record_json FROM processes WHERE process_id = ?1",
42                params![process_id],
43                |row| row.get(0),
44            )
45            .optional()
46            .map_err(process_sqlite_error)?;
47        json.map(|json| serde_json::from_str(&json).map_err(process_decode_error))
48            .transpose()
49    }
50
51    pub(super) fn save_process_conn(
52        conn: &Connection,
53        record: &ProcessRecord,
54    ) -> Result<(), lash_core::PluginError> {
55        let change_seq = Self::next_change_seq_conn(conn)?;
56        conn.execute(
57            "UPDATE processes
58             SET updated_at_ms = ?2, change_seq = ?3, status = ?4, record_json = ?5
59             WHERE process_id = ?1",
60            params![
61                record.id.as_str(),
62                record.updated_at_ms as i64,
63                change_seq as i64,
64                process_status_label(record),
65                process_encode_json(record)?
66            ],
67        )
68        .map_err(process_sqlite_error)?;
69        Ok(())
70    }
71
72    fn next_change_seq_conn(conn: &Connection) -> Result<u64, lash_core::PluginError> {
73        conn.execute(
74            "UPDATE process_change_clock
75             SET current_seq = current_seq + 1
76             WHERE singleton = 1",
77            [],
78        )
79        .map_err(process_sqlite_error)?;
80        conn.query_row(
81            "SELECT current_seq FROM process_change_clock WHERE singleton = 1",
82            [],
83            |row| row.get::<_, i64>(0),
84        )
85        .map(|seq| seq as u64)
86        .map_err(process_sqlite_error)
87    }
88
89    pub(super) fn load_event_by_key_conn(
90        conn: &Connection,
91        process_id: &str,
92        replay_key: &str,
93    ) -> Result<Option<(String, ProcessEvent)>, lash_core::PluginError> {
94        let row: Option<(String, String)> = conn
95            .query_row(
96                "SELECT payload_hash, event_json
97                 FROM process_events
98                 WHERE process_id = ?1 AND idempotency_key = ?2",
99                params![process_id, replay_key],
100                |row| Ok((row.get(0)?, row.get(1)?)),
101            )
102            .optional()
103            .map_err(process_sqlite_error)?;
104        row.map(|(hash, json)| {
105            serde_json::from_str(&json)
106                .map(|event| (hash, event))
107                .map_err(process_decode_error)
108        })
109        .transpose()
110    }
111
112    pub(super) fn load_process_lease_conn(
113        conn: &Connection,
114        process_id: &str,
115    ) -> Result<Option<ProcessLease>, lash_core::PluginError> {
116        conn.query_row(
117            "SELECT lease_owner_id, lease_token, lease_fencing_token,
118                    lease_claimed_at_ms, lease_expires_at_ms,
119                    lease_owner_incarnation_id, lease_owner_liveness_json
120             FROM process_leases
121             WHERE process_id = ?1",
122            params![process_id],
123            |row| {
124                let owner_id: Option<String> = row.get(0)?;
125                let lease_token: Option<String> = row.get(1)?;
126                let incarnation_id: Option<String> = row.get(5)?;
127                let liveness_json: Option<String> = row.get(6)?;
128                let (Some(owner_id), Some(lease_token)) = (owner_id, lease_token) else {
129                    return Ok(None);
130                };
131                Ok(Some(ProcessLease {
132                    schema_version: PROCESS_LEASE_SCHEMA_VERSION,
133                    process_id: process_id.to_string(),
134                    owner: process_lease_owner_from_columns(
135                        owner_id,
136                        incarnation_id,
137                        liveness_json,
138                    ),
139                    lease_token,
140                    fencing_token: row.get::<_, i64>(2)? as u64,
141                    claimed_at_epoch_ms: row.get::<_, i64>(3)? as u64,
142                    expires_at_epoch_ms: row.get::<_, i64>(4)? as u64,
143                }))
144            },
145        )
146        .optional()
147        .map(|lease| lease.flatten())
148        .map_err(process_sqlite_error)
149    }
150
151    /// Insert-or-replace the persisted lease row for `process_id` with a fresh
152    /// lease owned by `owner` at `fencing_token`.
153    fn acquire_process_lease_conn(
154        conn: &Connection,
155        process_id: &str,
156        owner: &LeaseOwnerIdentity,
157        fencing_token: u64,
158        now: u64,
159        lease_ttl_ms: u64,
160    ) -> Result<ProcessLease, lash_core::PluginError> {
161        let lease = ProcessLease {
162            schema_version: PROCESS_LEASE_SCHEMA_VERSION,
163            process_id: process_id.to_string(),
164            owner: owner.clone(),
165            lease_token: format!(
166                "{:x}",
167                Sha256::digest(
168                    format!(
169                        "{process_id}:{}:{}:{now}:{fencing_token}",
170                        owner.owner_id, owner.incarnation_id
171                    )
172                    .as_bytes()
173                )
174            ),
175            fencing_token,
176            claimed_at_epoch_ms: now,
177            expires_at_epoch_ms: now.saturating_add(lease_ttl_ms),
178        };
179        conn.execute(
180            "INSERT INTO process_leases (
181                process_id, lease_owner_id, lease_owner_incarnation_id,
182                lease_owner_liveness_json, lease_token, lease_fencing_token,
183                lease_claimed_at_ms, lease_expires_at_ms
184             )
185             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
186             ON CONFLICT(process_id) DO UPDATE SET
187                lease_owner_id = excluded.lease_owner_id,
188                lease_owner_incarnation_id = excluded.lease_owner_incarnation_id,
189                lease_owner_liveness_json = excluded.lease_owner_liveness_json,
190                lease_token = excluded.lease_token,
191                lease_fencing_token = excluded.lease_fencing_token,
192                lease_claimed_at_ms = excluded.lease_claimed_at_ms,
193                lease_expires_at_ms = excluded.lease_expires_at_ms",
194            params![
195                lease.process_id.as_str(),
196                lease.owner.owner_id.as_str(),
197                lease.owner.incarnation_id.as_str(),
198                encode_process_lease_liveness(&lease.owner.liveness)?,
199                lease.lease_token.as_str(),
200                lease.fencing_token as i64,
201                lease.claimed_at_epoch_ms as i64,
202                lease.expires_at_epoch_ms as i64,
203            ],
204        )
205        .map_err(process_sqlite_error)?;
206        Ok(lease)
207    }
208
209    fn list_grants_for_scope_conn(
210        conn: &Connection,
211        session_scope: &SessionScope,
212        live_only: bool,
213    ) -> Result<Vec<ProcessHandleGrantEntry>, lash_core::PluginError> {
214        let session_scope_id = session_scope.id();
215        let status_clause = if live_only {
216            "AND p.status = 'running'"
217        } else {
218            ""
219        };
220        let mut stmt = conn
221            .prepare(&format!(
222                "SELECT g.process_id, g.descriptor_json, p.record_json
223                 FROM process_handle_grants g
224                 JOIN processes p ON p.process_id = g.process_id
225                 WHERE g.scope_id = ?1 {status_clause}
226                 ORDER BY g.process_id ASC"
227            ))
228            .map_err(process_sqlite_error)?;
229        let rows = stmt
230            .query_map(params![session_scope_id.as_str()], |row| {
231                Ok((
232                    row.get::<_, String>(0)?,
233                    row.get::<_, String>(1)?,
234                    row.get::<_, String>(2)?,
235                ))
236            })
237            .map_err(process_sqlite_error)?;
238        let mut entries = Vec::new();
239        for row in rows {
240            let (process_id, descriptor_json, record_json) = row.map_err(process_sqlite_error)?;
241            let descriptor: ProcessHandleDescriptor =
242                serde_json::from_str(&descriptor_json).map_err(process_decode_error)?;
243            let record: ProcessRecord =
244                serde_json::from_str(&record_json).map_err(process_decode_error)?;
245            entries.push((
246                ProcessHandleGrant {
247                    session_id: session_scope.session_id.clone(),
248                    process_id,
249                    descriptor,
250                },
251                record,
252            ));
253        }
254        Ok(entries)
255    }
256}
257
258/// Map a `Result<T, PluginError>` produced by a synchronous transaction body to
259/// a [`TxOutcome`]: commit on success, roll back on logical error. Both arms
260/// carry the inner `Result` back so the caller recovers the value or the
261/// `PluginError` after the transaction resolves.
262pub(super) fn tx_outcome<T>(
263    result: Result<T, lash_core::PluginError>,
264) -> TxOutcome<Result<T, lash_core::PluginError>> {
265    match result {
266        Ok(value) => TxOutcome::Commit(Ok(value)),
267        Err(err) => TxOutcome::Rollback(Err(err)),
268    }
269}
270
271#[async_trait::async_trait]
272impl ProcessRegistry for SqliteProcessRegistry {
273    fn durability_tier(&self) -> DurabilityTier {
274        DurabilityTier::Durable
275    }
276
277    async fn register_process(
278        &self,
279        registration: ProcessRegistration,
280    ) -> Result<ProcessRecord, lash_core::PluginError> {
281        let (registration, registration_hash) = prepare_process_registration(registration)?;
282        let record = self
283            .conn
284            .write_flow(move |tx| {
285                Ok(tx_outcome((|| {
286                    if let Some(existing) = Self::load_process_conn(tx, &registration.id)? {
287                        if existing.registration_hash == registration_hash {
288                            return Ok(existing);
289                        }
290                        return Err(lash_core::PluginError::Session(format!(
291                            "process `{}` registration hash conflict: existing {}, new {}",
292                            registration.id, existing.registration_hash, registration_hash
293                        )));
294                    }
295                    let now = current_epoch_ms();
296                    let record = ProcessRecord::from_prepared_registration(
297                        registration,
298                        registration_hash,
299                        now,
300                    );
301                    let originator_scope_id = record.originator_scope_id();
302                    let change_seq = Self::next_change_seq_conn(tx)?;
303                    tx.execute(
304                        "INSERT INTO processes (
305                            process_id, registration_hash, owner_scope_id,
306                            created_at_ms, updated_at_ms, change_seq, status, record_json
307                         )
308                         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
309                        params![
310                            record.id.as_str(),
311                            record.registration_hash.as_str(),
312                            originator_scope_id.as_str(),
313                            record.created_at_ms as i64,
314                            record.updated_at_ms as i64,
315                            change_seq as i64,
316                            process_status_label(&record),
317                            process_encode_json(&record)?,
318                        ],
319                    )
320                    .map_err(process_sqlite_error)?;
321                    Ok(record)
322                })()))
323            })
324            .await
325            .map_err(process_sqlite_error)??;
326        Ok(record)
327    }
328
329    async fn set_external_ref(
330        &self,
331        process_id: &str,
332        external_ref: ProcessExternalRef,
333    ) -> Result<ProcessRecord, lash_core::PluginError> {
334        let process_id = process_id.to_string();
335        let (record, _changed) = self
336            .conn
337            .write_flow(move |tx| {
338                Ok(tx_outcome((|| {
339                    let mut record =
340                        Self::load_process_conn(tx, &process_id)?.ok_or_else(|| {
341                            lash_core::PluginError::Session(format!(
342                                "unknown process `{process_id}`"
343                            ))
344                        })?;
345                    if let Some(existing) = &record.external_ref {
346                        if existing == &external_ref {
347                            return Ok((record, false));
348                        }
349                        return Err(process_external_ref_conflict(
350                            &process_id,
351                            existing,
352                            &external_ref,
353                        ));
354                    }
355                    record.external_ref = Some(external_ref);
356                    record.updated_at_ms = current_epoch_ms();
357                    Self::save_process_conn(tx, &record)?;
358                    Ok((record, true))
359                })()))
360            })
361            .await
362            .map_err(process_sqlite_error)??;
363        Ok(record)
364    }
365
366    async fn grant_handle(
367        &self,
368        session_scope: &SessionScope,
369        process_id: &str,
370        descriptor: ProcessHandleDescriptor,
371    ) -> Result<ProcessHandleGrant, lash_core::PluginError> {
372        let session_scope = session_scope.clone();
373        let process_id = process_id.to_string();
374        self.conn
375            .write_flow(move |tx| {
376                Ok(tx_outcome((|| {
377                    let session_scope_id = session_scope.id();
378                    if Self::load_process_conn(tx, &process_id)?.is_none() {
379                        return Err(lash_core::PluginError::Session(format!(
380                            "unknown process `{process_id}`"
381                        )));
382                    }
383                    tx.execute(
384                        "INSERT INTO process_handle_grants (session_id, scope_id, process_id, descriptor_json)
385                         VALUES (?1, ?2, ?3, ?4)
386                         ON CONFLICT(scope_id, process_id) DO UPDATE SET
387                            session_id = excluded.session_id,
388                            descriptor_json = excluded.descriptor_json",
389                        params![
390                            session_scope.session_id.as_str(),
391                            session_scope_id.as_str(),
392                            process_id.as_str(),
393                            process_encode_json(&descriptor)?
394                        ],
395                    )
396                    .map_err(process_sqlite_error)?;
397                    Ok(ProcessHandleGrant {
398                        session_id: session_scope.session_id.clone(),
399                        process_id: process_id.clone(),
400                        descriptor,
401                    })
402                })()))
403            })
404            .await
405            .map_err(process_sqlite_error)?
406    }
407
408    async fn revoke_handle(
409        &self,
410        session_scope: &SessionScope,
411        process_id: &str,
412    ) -> Result<(), lash_core::PluginError> {
413        let session_scope_id = session_scope.id().as_str().to_string();
414        let process_id = process_id.to_string();
415        self.conn
416            .call(move |conn| {
417                conn.execute(
418                    "DELETE FROM process_handle_grants WHERE scope_id = ?1 AND process_id = ?2",
419                    params![session_scope_id, process_id],
420                )
421            })
422            .await
423            .map_err(process_sqlite_error)?;
424        Ok(())
425    }
426
427    async fn transfer_handle_grants(
428        &self,
429        from_scope: &SessionScope,
430        to_scope: &SessionScope,
431        process_ids: &[String],
432    ) -> Result<(), lash_core::PluginError> {
433        let from_scope = from_scope.clone();
434        let to_scope = to_scope.clone();
435        let process_ids = process_ids.to_vec();
436        self.conn
437            .write_flow(move |tx| {
438                Ok(tx_outcome((|| {
439                    let from_scope_id = from_scope.id();
440                    let to_scope_id = to_scope.id();
441                    for process_id in &process_ids {
442                        let descriptor_json: Option<String> = tx
443                            .query_row(
444                                "SELECT descriptor_json
445                                 FROM process_handle_grants
446                                 WHERE scope_id = ?1 AND process_id = ?2",
447                                params![from_scope_id.as_str(), process_id.as_str()],
448                                |row| row.get(0),
449                            )
450                            .optional()
451                            .map_err(process_sqlite_error)?;
452                        let Some(descriptor_json) = descriptor_json else {
453                            return Err(lash_core::PluginError::Session(format!(
454                                "process handle `{process_id}` is not granted to session `{}`",
455                                from_scope.session_id
456                            )));
457                        };
458                        tx.execute(
459                            "DELETE FROM process_handle_grants
460                             WHERE scope_id = ?1 AND process_id = ?2",
461                            params![from_scope_id.as_str(), process_id.as_str()],
462                        )
463                        .map_err(process_sqlite_error)?;
464                        tx.execute(
465                            "INSERT INTO process_handle_grants (session_id, scope_id, process_id, descriptor_json)
466                             VALUES (?1, ?2, ?3, ?4)
467                             ON CONFLICT(scope_id, process_id) DO UPDATE SET
468                                session_id = excluded.session_id,
469                                descriptor_json = excluded.descriptor_json",
470                            params![
471                                to_scope.session_id.as_str(),
472                                to_scope_id.as_str(),
473                                process_id.as_str(),
474                                descriptor_json
475                            ],
476                        )
477                        .map_err(process_sqlite_error)?;
478                    }
479                    Ok(())
480                })()))
481            })
482            .await
483            .map_err(process_sqlite_error)?
484    }
485
486    async fn list_handle_grants(
487        &self,
488        session_scope: &SessionScope,
489    ) -> Result<Vec<ProcessHandleGrantEntry>, lash_core::PluginError> {
490        let session_scope = session_scope.clone();
491        self.conn
492            .call(move |conn| {
493                Ok(Self::list_grants_for_scope_conn(
494                    conn,
495                    &session_scope,
496                    false,
497                ))
498            })
499            .await
500            .map_err(process_sqlite_error)?
501    }
502
503    async fn list_live_handle_grants(
504        &self,
505        session_scope: &SessionScope,
506    ) -> Result<Vec<ProcessHandleGrantEntry>, lash_core::PluginError> {
507        let session_scope = session_scope.clone();
508        self.conn
509            .call(move |conn| Ok(Self::list_grants_for_scope_conn(conn, &session_scope, true)))
510            .await
511            .map_err(process_sqlite_error)?
512    }
513
514    async fn has_handle_grant(
515        &self,
516        session_scope: &SessionScope,
517        process_id: &str,
518    ) -> Result<bool, lash_core::PluginError> {
519        let session_scope_id = session_scope.id().as_str().to_string();
520        let process_id = process_id.to_string();
521        self.conn
522            .call(move |conn| {
523                let exists = conn
524                    .query_row(
525                        "SELECT 1
526                         FROM process_handle_grants g
527                         JOIN processes p ON p.process_id = g.process_id
528                         WHERE g.scope_id = ?1 AND g.process_id = ?2
529                         LIMIT 1",
530                        params![session_scope_id, process_id],
531                        |_| Ok(()),
532                    )
533                    .optional()?
534                    .is_some();
535                Ok(exists)
536            })
537            .await
538            .map_err(process_sqlite_error)
539    }
540
541    async fn handle_grants_for_process(
542        &self,
543        process_id: &str,
544    ) -> Result<Vec<ProcessHandleGrant>, lash_core::PluginError> {
545        let process_id = process_id.to_string();
546        self.conn
547            .call(move |conn| {
548                Ok((|| {
549                    if Self::load_process_conn(conn, &process_id)?.is_none() {
550                        return Err(lash_core::PluginError::Session(format!(
551                            "unknown process `{process_id}`"
552                        )));
553                    }
554                    let mut stmt = conn
555                        .prepare(
556                            "SELECT session_id, descriptor_json
557                             FROM process_handle_grants
558                             WHERE process_id = ?1
559                             ORDER BY session_id ASC, scope_id ASC",
560                        )
561                        .map_err(process_sqlite_error)?;
562                    let rows = stmt
563                        .query_map(params![process_id], |row| {
564                            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
565                        })
566                        .map_err(process_sqlite_error)?;
567                    let mut grants = Vec::new();
568                    for row in rows {
569                        let (session_id, descriptor_json) = row.map_err(process_sqlite_error)?;
570                        let descriptor: ProcessHandleDescriptor =
571                            serde_json::from_str(&descriptor_json).map_err(process_decode_error)?;
572                        grants.push(ProcessHandleGrant {
573                            session_id,
574                            process_id: process_id.clone(),
575                            descriptor,
576                        });
577                    }
578                    Ok(grants)
579                })())
580            })
581            .await
582            .map_err(process_sqlite_error)?
583    }
584
585    async fn delete_session_process_state(
586        &self,
587        session_id: &str,
588    ) -> Result<lash_core::ProcessSessionDeleteReport, lash_core::PluginError> {
589        let session_id_owned = session_id.to_string();
590        let (
591            revoked_handle_count,
592            deleted_wake_count,
593            mut orphaned_process_ids,
594            mut preserved_process_ids,
595        ) = self
596            .conn
597            .write_flow(move |tx| {
598                Ok(tx_outcome((|| {
599                    let session_id = session_id_owned;
600                    let removed = {
601                        let mut stmt = tx
602                            .prepare(
603                                "SELECT g.process_id, p.record_json
604                                 FROM process_handle_grants g
605                                 JOIN processes p ON p.process_id = g.process_id
606                                 WHERE g.session_id = ?1
607                                 ORDER BY g.process_id ASC",
608                            )
609                            .map_err(process_sqlite_error)?;
610                        let rows = stmt
611                            .query_map(params![session_id], |row| {
612                                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
613                            })
614                            .map_err(process_sqlite_error)?;
615                        let mut removed = Vec::new();
616                        for row in rows {
617                            let (process_id, record_json) = row.map_err(process_sqlite_error)?;
618                            let record: ProcessRecord =
619                                serde_json::from_str(&record_json).map_err(process_decode_error)?;
620                            removed.push((process_id, record));
621                        }
622                        removed
623                    };
624
625                    // Wake acknowledgements are process-scoped consumed-event markers.
626                    // Session deletion removes materialized session-addressed deliveries
627                    // through the session store; clearing these rows would re-expose
628                    // already-consumed wakes to surviving grants or future host readers.
629                    let deleted_wake_count = 0;
630                    let revoked_handle_count = tx
631                        .execute(
632                            "DELETE FROM process_handle_grants WHERE session_id = ?1",
633                            params![session_id],
634                        )
635                        .map_err(process_sqlite_error)?;
636                    let mut orphaned_process_ids = Vec::new();
637                    let mut preserved_process_ids = Vec::new();
638                    for (process_id, record) in removed {
639                        if record.is_terminal() {
640                            continue;
641                        }
642                        let remaining_grants: i64 = tx
643                            .query_row(
644                                "SELECT COUNT(*) FROM process_handle_grants WHERE process_id = ?1",
645                                params![process_id],
646                                |row| row.get(0),
647                            )
648                            .map_err(process_sqlite_error)?;
649                        if remaining_grants == 0 {
650                            orphaned_process_ids.push(process_id);
651                        } else {
652                            preserved_process_ids.push(process_id);
653                        }
654                    }
655                    let wake_targeted = {
656                        let mut stmt = tx
657                            .prepare("SELECT process_id, record_json FROM processes ORDER BY process_id ASC")
658                            .map_err(process_sqlite_error)?;
659                        let rows = stmt
660                            .query_map([], |row| {
661                                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
662                            })
663                            .map_err(process_sqlite_error)?;
664                        let mut records = Vec::new();
665                        for row in rows {
666                            let (process_id, record_json) = row.map_err(process_sqlite_error)?;
667                            let record: ProcessRecord =
668                                serde_json::from_str(&record_json).map_err(process_decode_error)?;
669                            records.push((process_id, record));
670                        }
671                        records
672                    };
673                    for (_process_id, mut record) in wake_targeted {
674                        if record.clear_wake_target_for_session(&session_id) {
675                            Self::save_process_conn(tx, &record)?;
676                        }
677                    }
678                    Ok((
679                        revoked_handle_count,
680                        deleted_wake_count,
681                        orphaned_process_ids,
682                        preserved_process_ids,
683                    ))
684                })()))
685            })
686            .await
687            .map_err(process_sqlite_error)??;
688        orphaned_process_ids.sort();
689        orphaned_process_ids.dedup();
690        preserved_process_ids.sort();
691        preserved_process_ids.dedup();
692        Ok(lash_core::ProcessSessionDeleteReport {
693            session_id: session_id.to_string(),
694            revoked_handle_count,
695            deleted_wake_count,
696            orphaned_process_ids,
697            preserved_process_ids,
698        })
699    }
700
701    async fn append_event(
702        &self,
703        process_id: &str,
704        request: ProcessEventAppendRequest,
705    ) -> Result<ProcessEventAppendResult, lash_core::PluginError> {
706        let process_id = process_id.to_string();
707        let (result, _appended) = self
708            .conn
709            .write_flow(move |tx| {
710                Ok(tx_outcome((|| {
711                    let mut record =
712                        Self::load_process_conn(tx, &process_id)?.ok_or_else(|| {
713                            lash_core::PluginError::Session(format!(
714                                "unknown process `{process_id}`"
715                            ))
716                        })?;
717                    let replay_lookup = if let Some(replay_key) =
718                        request.replay.as_ref().map(|replay| replay.key.as_str())
719                    {
720                        Self::load_event_by_key_conn(tx, &process_id, replay_key)?
721                    } else {
722                        None
723                    };
724                    let sequence = tx
725                        .query_row(
726                            "SELECT COALESCE(MAX(sequence), 0) + 1 FROM process_events WHERE process_id = ?1",
727                            params![process_id],
728                            |row| row.get::<_, i64>(0),
729                        )
730                        .map_err(process_sqlite_error)? as u64;
731                    let occurred_at_ms = current_epoch_ms();
732                    let prepared = prepare_process_event_append(
733                        &record,
734                        request,
735                        sequence,
736                        replay_lookup,
737                        occurred_at_ms,
738                    )?;
739                    match prepared {
740                        lash_core::ProcessEventAppendPlan::Replay {
741                            event,
742                            repair_status,
743                            wake_delivery,
744                            occurred_at_ms,
745                        } => {
746                            let repaired = if let Some(status) = repair_status {
747                                lash_core::apply_process_status_projection(
748                                    &mut record,
749                                    status,
750                                    occurred_at_ms,
751                                );
752                                Self::save_process_conn(tx, &record)?;
753                                true
754                            } else {
755                                false
756                            };
757                            Ok((
758                                ProcessEventAppendResult {
759                                    event,
760                                    wake_delivery,
761                                },
762                                repaired,
763                            ))
764                        }
765                        lash_core::ProcessEventAppendPlan::Insert {
766                            event,
767                            payload_hash,
768                            status_update,
769                            wake_delivery,
770                            occurred_at_ms,
771                        } => {
772                            tx.execute(
773                                "INSERT INTO process_events (
774                                    process_id, sequence, event_type, payload_hash, idempotency_key,
775                                    occurred_at_ms, event_json
776                                 )
777                                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
778                                params![
779                                    process_id,
780                                    sequence as i64,
781                                    event.event_type.as_str(),
782                                    payload_hash.as_str(),
783                                    event.invocation.replay_key(),
784                                    occurred_at_ms as i64,
785                                    process_encode_json(&event)?,
786                                ],
787                            )
788                            .map_err(process_sqlite_error)?;
789                            if let Some(status) = status_update {
790                                lash_core::apply_process_status_projection(
791                                    &mut record,
792                                    status,
793                                    occurred_at_ms,
794                                );
795                            } else {
796                                record.updated_at_ms = occurred_at_ms;
797                            }
798                            Self::save_process_conn(tx, &record)?;
799                            Ok((
800                                ProcessEventAppendResult {
801                                    event,
802                                    wake_delivery,
803                                },
804                                true,
805                            ))
806                        }
807                    }
808                })()))
809            })
810            .await
811            .map_err(process_sqlite_error)??;
812        Ok(result)
813    }
814
815    async fn events_after(
816        &self,
817        process_id: &str,
818        after_sequence: u64,
819    ) -> Result<Vec<ProcessEvent>, lash_core::PluginError> {
820        let process_id = process_id.to_string();
821        self.conn
822            .call(move |conn| {
823                Ok((|| {
824                    if Self::load_process_conn(conn, &process_id)?.is_none() {
825                        return Err(lash_core::PluginError::Session(format!(
826                            "unknown process `{process_id}`"
827                        )));
828                    }
829                    let mut stmt = conn
830                        .prepare(
831                            "SELECT event_json FROM process_events
832                             WHERE process_id = ?1 AND sequence > ?2
833                             ORDER BY sequence ASC",
834                        )
835                        .map_err(process_sqlite_error)?;
836                    let rows = stmt
837                        .query_map(params![process_id, after_sequence as i64], |row| {
838                            row.get::<_, String>(0)
839                        })
840                        .map_err(process_sqlite_error)?;
841                    let mut events = Vec::new();
842                    for row in rows {
843                        events.push(
844                            serde_json::from_str(&row.map_err(process_sqlite_error)?)
845                                .map_err(process_decode_error)?,
846                        );
847                    }
848                    Ok(events)
849                })())
850            })
851            .await
852            .map_err(process_sqlite_error)?
853    }
854
855    async fn count_events_through(
856        &self,
857        process_id: &str,
858        event_type: &str,
859        up_to_sequence: u64,
860    ) -> Result<u64, lash_core::PluginError> {
861        let process_id = process_id.to_string();
862        let event_type = event_type.to_string();
863        self.conn
864            .call(move |conn| {
865                Ok((|| {
866                    if Self::load_process_conn(conn, &process_id)?.is_none() {
867                        return Err(lash_core::PluginError::Session(format!(
868                            "unknown process `{process_id}`"
869                        )));
870                    }
871                    conn.query_row(
872                        "SELECT COUNT(*) FROM process_events
873                         WHERE process_id = ?1 AND event_type = ?2 AND sequence <= ?3",
874                        params![process_id, event_type, up_to_sequence as i64],
875                        |row| row.get::<_, i64>(0),
876                    )
877                    .map(|count| count as u64)
878                    .map_err(process_sqlite_error)
879                })())
880            })
881            .await
882            .map_err(process_sqlite_error)?
883    }
884
885    async fn recent_events(
886        &self,
887        process_id: &str,
888        limit: usize,
889    ) -> Result<Vec<ProcessEvent>, lash_core::PluginError> {
890        let process_id = process_id.to_string();
891        self.conn
892            .call(move |conn| {
893                Ok((|| {
894                    if Self::load_process_conn(conn, &process_id)?.is_none() {
895                        return Err(lash_core::PluginError::Session(format!(
896                            "unknown process `{process_id}`"
897                        )));
898                    }
899                    let mut stmt = conn
900                        .prepare(
901                            "SELECT event_json FROM process_events
902                             WHERE process_id = ?1
903                             ORDER BY sequence DESC
904                             LIMIT ?2",
905                        )
906                        .map_err(process_sqlite_error)?;
907                    let rows = stmt
908                        .query_map(params![process_id, limit as i64], |row| {
909                            row.get::<_, String>(0)
910                        })
911                        .map_err(process_sqlite_error)?;
912                    let mut events: Vec<ProcessEvent> = Vec::new();
913                    for row in rows {
914                        events.push(
915                            serde_json::from_str(&row.map_err(process_sqlite_error)?)
916                                .map_err(process_decode_error)?,
917                        );
918                    }
919                    events.reverse();
920                    Ok(events)
921                })())
922            })
923            .await
924            .map_err(process_sqlite_error)?
925    }
926
927    async fn wake_events_after(
928        &self,
929        process_id: &str,
930        after_sequence: u64,
931    ) -> Result<Vec<ProcessEvent>, lash_core::PluginError> {
932        let acked: std::collections::HashSet<u64> = {
933            let process_id = process_id.to_string();
934            self.conn
935                .call(move |conn| {
936                    Ok(
937                        (|| -> Result<std::collections::HashSet<u64>, lash_core::PluginError> {
938                            let mut stmt = conn
939                                .prepare(
940                                    "SELECT sequence FROM process_wake_acks WHERE process_id = ?1",
941                                )
942                                .map_err(process_sqlite_error)?;
943                            let rows = stmt
944                                .query_map(params![process_id], |row| row.get::<_, i64>(0))
945                                .map_err(process_sqlite_error)?;
946                            let mut set = std::collections::HashSet::new();
947                            for row in rows {
948                                set.insert(row.map_err(process_sqlite_error)? as u64);
949                            }
950                            Ok(set)
951                        })(),
952                    )
953                })
954                .await
955                .map_err(process_sqlite_error)??
956        };
957        Ok(self
958            .events_after(process_id, after_sequence)
959            .await?
960            .into_iter()
961            .filter(|event| event.semantics.wake.is_some() && !acked.contains(&event.sequence))
962            .collect())
963    }
964
965    async fn complete_process(
966        &self,
967        process_id: &str,
968        await_output: ProcessAwaitOutput,
969        authority: lash_core::ProcessCompletionAuthority,
970    ) -> Result<ProcessRecord, lash_core::PluginError> {
971        // Load, validate the authority against the row's declared disposition,
972        // and append the terminal event as one atomic transaction, so a
973        // concurrent complete→prune→re-register cannot slip a different
974        // disposition between the validation and the append.
975        super::process_registry_completion::complete_process(
976            self,
977            process_id,
978            await_output,
979            authority,
980        )
981        .await
982    }
983
984    async fn complete_process_with_lease(
985        &self,
986        lease: &ProcessLease,
987        await_output: ProcessAwaitOutput,
988    ) -> Result<ProcessRecord, lash_core::PluginError> {
989        super::process_registry_completion::complete_process_with_lease(self, lease, await_output)
990            .await
991    }
992
993    async fn record_first_started(
994        &self,
995        process_id: &str,
996        started: ProcessStarted,
997    ) -> Result<ProcessRecord, lash_core::PluginError> {
998        let process_id = process_id.to_string();
999        self.conn
1000            .write_flow(move |tx| {
1001                Ok(tx_outcome((|| {
1002                    let mut record =
1003                        Self::load_process_conn(tx, &process_id)?.ok_or_else(|| {
1004                            lash_core::PluginError::Session(format!(
1005                                "unknown process `{process_id}`"
1006                            ))
1007                        })?;
1008                    // First-writer-wins: the started fact is immutable once written.
1009                    if record.first_started.is_none() {
1010                        record.first_started = Some(Box::new(started));
1011                        record.updated_at_ms = current_epoch_ms();
1012                        Self::save_process_conn(tx, &record)?;
1013                    }
1014                    Ok(record)
1015                })()))
1016            })
1017            .await
1018            .map_err(process_sqlite_error)?
1019    }
1020
1021    async fn request_process_abandon(
1022        &self,
1023        process_id: &str,
1024        request: AbandonRequest,
1025    ) -> Result<ProcessRecord, lash_core::PluginError> {
1026        let process_id = process_id.to_string();
1027        self.conn
1028            .write_flow(move |tx| {
1029                Ok(tx_outcome((|| {
1030                    let mut record =
1031                        Self::load_process_conn(tx, &process_id)?.ok_or_else(|| {
1032                            lash_core::PluginError::Session(format!(
1033                                "unknown process `{process_id}`"
1034                            ))
1035                        })?;
1036                    if record.is_terminal() {
1037                        return Err(lash_core::PluginError::Session(format!(
1038                            "terminal process `{process_id}` cannot accept an abandon request"
1039                        )));
1040                    }
1041                    // First-writer-wins: preserve the original recorded authorization.
1042                    if record.abandon_request.is_none() {
1043                        record.abandon_request = Some(Box::new(request));
1044                        record.updated_at_ms = current_epoch_ms();
1045                        Self::save_process_conn(tx, &record)?;
1046                    }
1047                    Ok(record)
1048                })()))
1049            })
1050            .await
1051            .map_err(process_sqlite_error)?
1052    }
1053
1054    async fn set_process_wait(
1055        &self,
1056        process_id: &str,
1057        wait: lash_core::WaitState,
1058    ) -> Result<ProcessRecord, lash_core::PluginError> {
1059        let process_id = process_id.to_string();
1060        self.conn
1061            .write_flow(move |tx| {
1062                Ok(tx_outcome((|| {
1063                    let mut record =
1064                        Self::load_process_conn(tx, &process_id)?.ok_or_else(|| {
1065                            lash_core::PluginError::Session(format!(
1066                                "unknown process `{process_id}`"
1067                            ))
1068                        })?;
1069                    if record.is_terminal() {
1070                        return Err(lash_core::PluginError::Session(format!(
1071                            "terminal process `{process_id}` cannot enter a wait state"
1072                        )));
1073                    }
1074                    record.wait = Some(wait);
1075                    record.updated_at_ms = current_epoch_ms();
1076                    Self::save_process_conn(tx, &record)?;
1077                    Ok(record)
1078                })()))
1079            })
1080            .await
1081            .map_err(process_sqlite_error)?
1082    }
1083
1084    async fn clear_process_wait(
1085        &self,
1086        process_id: &str,
1087    ) -> Result<ProcessRecord, lash_core::PluginError> {
1088        let process_id = process_id.to_string();
1089        self.conn
1090            .write_flow(move |tx| {
1091                Ok(tx_outcome((|| {
1092                    let mut record =
1093                        Self::load_process_conn(tx, &process_id)?.ok_or_else(|| {
1094                            lash_core::PluginError::Session(format!(
1095                                "unknown process `{process_id}`"
1096                            ))
1097                        })?;
1098                    record.wait = None;
1099                    record.updated_at_ms = current_epoch_ms();
1100                    Self::save_process_conn(tx, &record)?;
1101                    Ok(record)
1102                })()))
1103            })
1104            .await
1105            .map_err(process_sqlite_error)?
1106    }
1107
1108    async fn get_process(&self, process_id: &str) -> Option<ProcessRecord> {
1109        let process_id = process_id.to_string();
1110        self.conn
1111            .call(move |conn| Ok(Self::load_process_conn(conn, &process_id).ok().flatten()))
1112            .await
1113            .ok()
1114            .flatten()
1115    }
1116
1117    async fn list_processes(
1118        &self,
1119        filter: &lash_core::ProcessListFilter,
1120    ) -> Result<Vec<ProcessRecord>, lash_core::PluginError> {
1121        let filter = filter.clone();
1122        self.conn
1123            .call(move |conn| {
1124                Ok((|| {
1125                    let mut stmt = conn
1126                        .prepare(
1127                            "SELECT record_json FROM processes
1128                             ORDER BY process_id ASC",
1129                        )
1130                        .map_err(process_sqlite_error)?;
1131                    let rows = stmt
1132                        .query_map([], |row| row.get::<_, String>(0))
1133                        .map_err(process_sqlite_error)?;
1134                    let mut records = Vec::new();
1135                    for row in rows {
1136                        let record: ProcessRecord =
1137                            serde_json::from_str(&row.map_err(process_sqlite_error)?)
1138                                .map_err(process_decode_error)?;
1139                        if filter.matches_record(&record) {
1140                            records.push(record);
1141                        }
1142                    }
1143                    Ok(records)
1144                })())
1145            })
1146            .await
1147            .map_err(process_sqlite_error)?
1148    }
1149
1150    async fn processes_changed_since(
1151        &self,
1152        cursor: ProcessChangeCursor,
1153        limit: usize,
1154    ) -> Result<(Vec<ProcessRecord>, ProcessChangeCursor), lash_core::PluginError> {
1155        if limit == 0 {
1156            return Ok((Vec::new(), cursor));
1157        }
1158        self.conn
1159            .call(move |conn| {
1160                Ok(
1161                    crate::process_registry_change::processes_changed_since_conn(
1162                        conn, cursor, limit,
1163                    ),
1164                )
1165            })
1166            .await
1167            .map_err(process_sqlite_error)?
1168    }
1169
1170    async fn ack_wake(
1171        &self,
1172        process_id: &str,
1173        sequence: u64,
1174    ) -> Result<(), lash_core::PluginError> {
1175        let process_id = process_id.to_string();
1176        self.conn
1177            .call(move |conn| {
1178                Ok((|| {
1179                    if Self::load_process_conn(conn, &process_id)?.is_none() {
1180                        return Err(lash_core::PluginError::Session(format!(
1181                            "unknown process `{process_id}`"
1182                        )));
1183                    }
1184                    conn.execute(
1185                        "INSERT OR IGNORE INTO process_wake_acks (process_id, sequence) VALUES (?1, ?2)",
1186                        params![process_id, sequence as i64],
1187                    )
1188                    .map_err(process_sqlite_error)?;
1189                    Ok(())
1190                })())
1191            })
1192            .await
1193            .map_err(process_sqlite_error)?
1194    }
1195
1196    async fn list_non_terminal(&self) -> Result<Vec<ProcessRecord>, lash_core::PluginError> {
1197        self.conn
1198            .call(move |conn| {
1199                Ok((|| {
1200                    let mut stmt = conn
1201                        .prepare(
1202                            "SELECT record_json FROM processes
1203                             WHERE status = 'running'
1204                             ORDER BY process_id ASC",
1205                        )
1206                        .map_err(process_sqlite_error)?;
1207                    let rows = stmt
1208                        .query_map([], |row| row.get::<_, String>(0))
1209                        .map_err(process_sqlite_error)?;
1210                    let mut records = Vec::new();
1211                    for row in rows {
1212                        let record: ProcessRecord =
1213                            serde_json::from_str(&row.map_err(process_sqlite_error)?)
1214                                .map_err(process_decode_error)?;
1215                        records.push(record);
1216                    }
1217                    Ok(records)
1218                })())
1219            })
1220            .await
1221            .map_err(process_sqlite_error)?
1222    }
1223
1224    async fn live_reference_summary(
1225        &self,
1226    ) -> Result<Vec<ProcessLiveReferenceSummary>, lash_core::PluginError> {
1227        let records = self.list_non_terminal().await?;
1228        Ok(ProcessLiveReferenceSummary::from_records(records.iter()))
1229    }
1230
1231    async fn claim_process_lease(
1232        &self,
1233        process_id: &str,
1234        owner: &LeaseOwnerIdentity,
1235        lease_ttl_ms: u64,
1236    ) -> Result<ProcessLeaseClaimOutcome, lash_core::PluginError> {
1237        let process_id = process_id.to_string();
1238        let owner = owner.clone();
1239        self.conn
1240            .write_flow(move |tx| {
1241                Ok(tx_outcome((|| {
1242                    if Self::load_process_conn(tx, &process_id)?.is_none() {
1243                        return Err(lash_core::PluginError::Session(format!(
1244                            "unknown process `{process_id}`"
1245                        )));
1246                    }
1247                    let now = current_epoch_ms();
1248                    let current = Self::load_process_lease_conn(tx, &process_id)?;
1249                    if let Some(current) = current.as_ref()
1250                        && current.expires_at_epoch_ms > now
1251                    {
1252                        if current.owner.same_incarnation(&owner) {
1253                            // Same incarnation re-enters its own live lease:
1254                            // extend the expiry, keep token and fencing token.
1255                            let expires_at = now.saturating_add(lease_ttl_ms);
1256                            tx.execute(
1257                                "UPDATE process_leases
1258                                 SET lease_expires_at_ms = ?2
1259                                 WHERE process_id = ?1",
1260                                params![process_id, expires_at as i64],
1261                            )
1262                            .map_err(process_sqlite_error)?;
1263                            return Ok(ProcessLeaseClaimOutcome::Acquired(ProcessLease {
1264                                expires_at_epoch_ms: expires_at,
1265                                ..current.clone()
1266                            }));
1267                        }
1268                        return Ok(ProcessLeaseClaimOutcome::Busy {
1269                            holder: current.clone(),
1270                        });
1271                    }
1272                    // Read the raw fencing token directly: a completed/abandoned
1273                    // lease nulls the owner/token columns but retains the
1274                    // monotonically-increasing `lease_fencing_token`, so a
1275                    // re-claim never reuses a stale writer's token.
1276                    let fencing_token: u64 = tx
1277                        .query_row(
1278                            "SELECT lease_fencing_token FROM process_leases WHERE process_id = ?1",
1279                            params![process_id],
1280                            |row| row.get::<_, i64>(0),
1281                        )
1282                        .optional()
1283                        .map_err(process_sqlite_error)?
1284                        .unwrap_or(0) as u64
1285                        + 1;
1286                    Ok(ProcessLeaseClaimOutcome::Acquired(
1287                        Self::acquire_process_lease_conn(
1288                            tx,
1289                            &process_id,
1290                            &owner,
1291                            fencing_token,
1292                            now,
1293                            lease_ttl_ms,
1294                        )?,
1295                    ))
1296                })()))
1297            })
1298            .await
1299            .map_err(process_sqlite_error)?
1300    }
1301
1302    async fn reclaim_process_lease(
1303        &self,
1304        process_id: &str,
1305        owner: &LeaseOwnerIdentity,
1306        observed_holder: &ProcessLease,
1307        lease_ttl_ms: u64,
1308    ) -> Result<ProcessLeaseClaimOutcome, lash_core::PluginError> {
1309        let process_id = process_id.to_string();
1310        let owner = owner.clone();
1311        let observed_holder = observed_holder.clone();
1312        self.conn
1313            .write_flow(move |tx| {
1314                Ok(tx_outcome((|| {
1315                    if Self::load_process_conn(tx, &process_id)?.is_none() {
1316                        return Err(lash_core::PluginError::Session(format!(
1317                            "unknown process `{process_id}`"
1318                        )));
1319                    }
1320                    let now = current_epoch_ms();
1321                    let current = Self::load_process_lease_conn(tx, &process_id)?;
1322                    let Some(current) = current else {
1323                        // Free (or released) lease: acquire on the retained
1324                        // fencing token like a plain claim would.
1325                        let fencing_token: u64 = tx
1326                            .query_row(
1327                                "SELECT lease_fencing_token FROM process_leases WHERE process_id = ?1",
1328                                params![process_id],
1329                                |row| row.get::<_, i64>(0),
1330                            )
1331                            .optional()
1332                            .map_err(process_sqlite_error)?
1333                            .unwrap_or(0) as u64
1334                            + 1;
1335                        return Ok(ProcessLeaseClaimOutcome::Acquired(
1336                            Self::acquire_process_lease_conn(
1337                                tx,
1338                                &process_id,
1339                                &owner,
1340                                fencing_token,
1341                                now,
1342                                lease_ttl_ms,
1343                            )?,
1344                        ));
1345                    };
1346                    if current.expires_at_epoch_ms <= now {
1347                        return Ok(ProcessLeaseClaimOutcome::Acquired(
1348                            Self::acquire_process_lease_conn(
1349                                tx,
1350                                &process_id,
1351                                &owner,
1352                                current.fencing_token.saturating_add(1),
1353                                now,
1354                                lease_ttl_ms,
1355                            )?,
1356                        ));
1357                    }
1358                    // Fenced CAS on the observed holder: identity, token, and
1359                    // fencing token must all still match, and the holder must
1360                    // be definitely dead for this claimant.
1361                    if observed_holder.process_id == process_id
1362                        && current.owner.same_incarnation(&observed_holder.owner)
1363                        && current.lease_token == observed_holder.lease_token
1364                        && current.fencing_token == observed_holder.fencing_token
1365                        && current.owner.is_definitely_dead_for_claimant(&owner)
1366                    {
1367                        let fencing_token = current.fencing_token.saturating_add(1);
1368                        let lease = ProcessLease {
1369                            schema_version: PROCESS_LEASE_SCHEMA_VERSION,
1370                            process_id: process_id.clone(),
1371                            owner: owner.clone(),
1372                            lease_token: format!(
1373                                "{:x}",
1374                                Sha256::digest(
1375                                    format!(
1376                                        "{process_id}:{}:{}:{now}:{fencing_token}",
1377                                        owner.owner_id, owner.incarnation_id
1378                                    )
1379                                    .as_bytes()
1380                                )
1381                            ),
1382                            fencing_token,
1383                            claimed_at_epoch_ms: now,
1384                            expires_at_epoch_ms: now.saturating_add(lease_ttl_ms),
1385                        };
1386                        let changed = tx
1387                            .execute(
1388                                "UPDATE process_leases
1389                                 SET lease_owner_id = ?1,
1390                                     lease_owner_incarnation_id = ?2,
1391                                     lease_owner_liveness_json = ?3,
1392                                     lease_token = ?4,
1393                                     lease_fencing_token = ?5,
1394                                     lease_claimed_at_ms = ?6,
1395                                     lease_expires_at_ms = ?7
1396                                 WHERE process_id = ?8
1397                                   AND lease_owner_id = ?9
1398                                   AND lease_owner_incarnation_id = ?10
1399                                   AND lease_token = ?11
1400                                   AND lease_fencing_token = ?12",
1401                                params![
1402                                    lease.owner.owner_id,
1403                                    lease.owner.incarnation_id,
1404                                    encode_process_lease_liveness(&lease.owner.liveness)?,
1405                                    lease.lease_token,
1406                                    lease.fencing_token as i64,
1407                                    lease.claimed_at_epoch_ms as i64,
1408                                    lease.expires_at_epoch_ms as i64,
1409                                    process_id,
1410                                    observed_holder.owner.owner_id,
1411                                    observed_holder.owner.incarnation_id,
1412                                    observed_holder.lease_token,
1413                                    observed_holder.fencing_token as i64,
1414                                ],
1415                            )
1416                            .map_err(process_sqlite_error)?;
1417                        if changed == 1 {
1418                            return Ok(ProcessLeaseClaimOutcome::Acquired(lease));
1419                        }
1420                        // Lost the CAS race: re-read and report the winner.
1421                        if let Some(current) = Self::load_process_lease_conn(tx, &process_id)?
1422                            && current.expires_at_epoch_ms > now
1423                        {
1424                            return Ok(ProcessLeaseClaimOutcome::Busy { holder: current });
1425                        }
1426                        return Err(process_lease_expired(&process_id));
1427                    }
1428                    Ok(ProcessLeaseClaimOutcome::Busy { holder: current })
1429                })()))
1430            })
1431            .await
1432            .map_err(process_sqlite_error)?
1433    }
1434
1435    async fn renew_process_lease(
1436        &self,
1437        lease: &ProcessLease,
1438        lease_ttl_ms: u64,
1439    ) -> Result<ProcessLease, lash_core::PluginError> {
1440        let lease = lease.clone();
1441        self.conn
1442            .write_flow(move |tx| {
1443                Ok(tx_outcome((|| {
1444                    let now = current_epoch_ms();
1445                    let current = Self::load_process_lease_conn(tx, &lease.process_id)?;
1446                    if !guard_lease(current.as_ref(), &lease.lease_token, now)
1447                        || !current.as_ref().is_some_and(|current| {
1448                            current.owner.same_incarnation(&lease.owner)
1449                                && current.fencing_token == lease.fencing_token
1450                        })
1451                    {
1452                        return Err(process_lease_expired(&lease.process_id));
1453                    }
1454                    let renewed = ProcessLease {
1455                        expires_at_epoch_ms: now.saturating_add(lease_ttl_ms),
1456                        ..lease.clone()
1457                    };
1458                    tx.execute(
1459                        "UPDATE process_leases
1460                         SET lease_expires_at_ms = ?2
1461                         WHERE process_id = ?1 AND lease_token = ?3",
1462                        params![
1463                            renewed.process_id.as_str(),
1464                            renewed.expires_at_epoch_ms as i64,
1465                            renewed.lease_token.as_str(),
1466                        ],
1467                    )
1468                    .map_err(process_sqlite_error)?;
1469                    Ok(renewed)
1470                })()))
1471            })
1472            .await
1473            .map_err(process_sqlite_error)?
1474    }
1475
1476    async fn get_process_lease(
1477        &self,
1478        process_id: &str,
1479    ) -> Result<Option<ProcessLease>, lash_core::PluginError> {
1480        let process_id = process_id.to_string();
1481        self.conn
1482            .call(move |conn| Ok(Self::load_process_lease_conn(conn, &process_id)))
1483            .await
1484            .map_err(process_sqlite_error)?
1485    }
1486
1487    async fn complete_process_lease(
1488        &self,
1489        completion: &ProcessLeaseCompletion,
1490    ) -> Result<(), lash_core::PluginError> {
1491        let process_id = completion.process_id.clone();
1492        let lease_token = completion.lease_token.clone();
1493        self.conn
1494            .call(move |conn| {
1495                conn.execute(
1496                    "UPDATE process_leases
1497                     SET lease_owner_id = NULL,
1498                         lease_token = NULL,
1499                         lease_claimed_at_ms = 0,
1500                         lease_expires_at_ms = 0
1501                     WHERE process_id = ?1 AND lease_token = ?2",
1502                    params![process_id, lease_token],
1503                )
1504            })
1505            .await
1506            .map_err(process_sqlite_error)?;
1507        Ok(())
1508    }
1509
1510    async fn prune_terminal_processes(
1511        &self,
1512        cutoff_epoch_ms: u64,
1513        filter: Option<ProcessListFilter>,
1514        up_to_change_seq: Option<ProcessChangeCursor>,
1515    ) -> Result<ProcessPruneReport, lash_core::PluginError> {
1516        let cutoff = cutoff_epoch_ms as i64;
1517        let max_change_seq = up_to_change_seq.map(ProcessChangeCursor::store_sequence);
1518        self.conn
1519            .write_flow(move |tx| {
1520                Ok(tx_outcome(
1521                    crate::process_registry_change::prune_terminal_processes_conn(
1522                        tx,
1523                        cutoff,
1524                        filter,
1525                        max_change_seq,
1526                    ),
1527                ))
1528            })
1529            .await
1530            .map_err(process_sqlite_error)?
1531    }
1532}
1533
1534/// Loud, stable error for a superseded or expired process lease.
1535pub(super) fn process_lease_expired(process_id: &str) -> lash_core::PluginError {
1536    lash_core::PluginError::Session(format!(
1537        "process lease for `{process_id}` is missing or expired"
1538    ))
1539}
1540
1541fn process_lease_owner_from_columns(
1542    owner_id: String,
1543    incarnation_id: Option<String>,
1544    liveness_json: Option<String>,
1545) -> LeaseOwnerIdentity {
1546    LeaseOwnerIdentity {
1547        incarnation_id: incarnation_id.unwrap_or_else(|| owner_id.clone()),
1548        owner_id,
1549        liveness: liveness_json
1550            .as_deref()
1551            .and_then(|json| serde_json::from_str(json).ok())
1552            .unwrap_or(LeaseOwnerLiveness::Opaque),
1553    }
1554}
1555
1556fn encode_process_lease_liveness(
1557    liveness: &LeaseOwnerLiveness,
1558) -> Result<String, lash_core::PluginError> {
1559    serde_json::to_string(liveness).map_err(|err| {
1560        lash_core::PluginError::Session(format!("failed to encode process lease liveness: {err}"))
1561    })
1562}
1563
1564fn process_external_ref_conflict(
1565    process_id: &str,
1566    existing: &ProcessExternalRef,
1567    new: &ProcessExternalRef,
1568) -> lash_core::PluginError {
1569    lash_core::PluginError::Session(format!(
1570        "process `{process_id}` external ref conflict: existing {existing:?}, new {new:?}"
1571    ))
1572}