Skip to main content

coven_database/store/store_session/
circle_operations.rs

1use crate::query_mapped_rows;
2use crate::*;
3use coven_protocol::store_commit::StoreBatchCommitRef;
4use rusqlite::{Connection, OptionalExtension};
5
6use super::*;
7
8impl StoreSession<'_> {
9    fn circle_operations(
10        &self,
11    ) -> Result<Vec<coven_protocol::circle::CircleOperationInfo>, DbError> {
12        let conn = self.conn;
13        let rows = crate::query_mapped_rows(
14            conn,
15            "SELECT operation_id, circle_id, prepared, phase
16             FROM circle_operations
17             ORDER BY rowid",
18            [],
19            |row| {
20                Ok((
21                    row.get::<_, String>(0)?,
22                    row.get::<_, String>(1)?,
23                    row.get::<_, Vec<u8>>(2)?,
24                    row.get::<_, String>(3)?,
25                ))
26            },
27        )
28        .map_err(DbError::from)?;
29        rows.into_iter()
30            .map(|(operation_id, circle_id, prepared, phase)| {
31                let uploaded = crate::circle_operation_uploaded_steps_on(conn, &operation_id)?;
32                let journal = parse_circle_operation_row(
33                    &operation_id,
34                    &circle_id,
35                    &prepared,
36                    &phase,
37                    uploaded,
38                )?;
39                Ok(coven_protocol::circle::CircleOperationInfo {
40                    operation_id: journal.operation_id.clone(),
41                    circle_id: journal.circle_id(),
42                    kind: journal.kind(),
43                    state: journal.state(),
44                })
45            })
46            .collect()
47    }
48
49    fn circle_states(
50        &self,
51        identity_pubkey: &str,
52        active_store_members: &std::collections::BTreeSet<String>,
53    ) -> Result<Vec<coven_protocol::circle::Circle>, DbError> {
54        Ok(circle_current_states_on(self.conn)?
55            .into_iter()
56            .map(|state| {
57                let (name, role) = state.display(identity_pubkey);
58                coven_protocol::circle::Circle {
59                    id: state.circle_id(),
60                    name,
61                    role,
62                    state: state.derived_state(active_store_members),
63                }
64            })
65            .collect())
66    }
67
68    fn circle_members(
69        &self,
70        circle_id: coven_protocol::circle::CircleId,
71        identity_pubkey: &str,
72        store_members: &std::collections::BTreeSet<String>,
73    ) -> Result<Vec<coven_protocol::circle::CircleMemberInfo>, DbError> {
74        let state = circle_current_state_on(self.conn, circle_id)?
75            .ok_or_else(|| DbError::Message(format!("Circle {circle_id} has no current state")))?;
76        let Some((_current, access, roster, _metadata)) = state.active() else {
77            return Err(DbError::Message(format!(
78                "Circle {circle_id} has no active local state"
79            )));
80        };
81        if access.recipient_pubkey != identity_pubkey {
82            return Err(DbError::Message(format!(
83                "active Circle {circle_id} belongs to another local identity"
84            )));
85        }
86        Ok(roster
87            .members()
88            .into_iter()
89            .filter(|(pubkey, _)| store_members.contains(pubkey))
90            .map(|(pubkey, role)| coven_protocol::circle::CircleMemberInfo {
91                is_self: pubkey == identity_pubkey,
92                pubkey,
93                role,
94            })
95            .collect())
96    }
97
98    fn circle_signing_context(
99        &self,
100        circle_id: coven_protocol::circle::CircleId,
101        identity_pubkey: &str,
102        authoring: fn(
103            &coven_protocol::circle_activation::CircleCurrentState,
104        ) -> Option<coven_protocol::circle_activation::CircleAuthoringState>,
105        missing_authoring: fn(coven_protocol::circle::CircleId) -> String,
106        foreign_identity: fn(coven_protocol::circle::CircleId) -> String,
107    ) -> Result<
108        (
109            coven_protocol::circle_activation::CircleAuthoringState,
110            StoreBatchCommitRef,
111        ),
112        DbError,
113    > {
114        let state = circle_current_state_on(self.conn, circle_id)?
115            .ok_or_else(|| DbError::Message(format!("Circle {circle_id} has no current state")))?;
116        let authoring =
117            authoring(&state).ok_or_else(|| DbError::Message(missing_authoring(circle_id)))?;
118        if authoring.access.recipient_pubkey != identity_pubkey {
119            return Err(DbError::Message(foreign_identity(circle_id)));
120        }
121        let activated_commit = super::circle_authority::circle_activation_commit_ref_on(
122            self.conn,
123            circle_id,
124            &authoring.control.coord,
125        )?
126        .ok_or_else(|| {
127            DbError::Message(format!(
128                "Circle {circle_id} current control has no materialized activation"
129            ))
130        })?;
131        Ok((authoring, activated_commit))
132    }
133
134    fn circle_control_conflict_branches(
135        &self,
136        circle_id: coven_protocol::circle::CircleId,
137    ) -> Result<Option<Vec<coven_protocol::circle::CircleControlCoord>>, DbError> {
138        Ok(circle_current_state_on(self.conn, circle_id)?
139            .and_then(|state| state.conflict_branches()))
140    }
141
142    fn circle_is_deleted(
143        &self,
144        circle_id: coven_protocol::circle::CircleId,
145    ) -> Result<bool, DbError> {
146        Ok(circle_current_state_on(self.conn, circle_id)?.is_some_and(|state| state.is_deleted()))
147    }
148
149    fn current_circle_control(
150        &self,
151        circle_id: coven_protocol::circle::CircleId,
152    ) -> Result<Option<coven_protocol::circle::CircleControlCoord>, DbError> {
153        Ok(
154            circle_current_state_on(self.conn, circle_id)?.and_then(|state| {
155                state
156                    .authoring_state()
157                    .map(|authoring| authoring.control.coord.clone())
158            }),
159        )
160    }
161
162    fn closing_circle_controls(
163        &self,
164    ) -> Result<Vec<coven_protocol::circle::PreparedCircleControl>, DbError> {
165        Ok(circle_current_states_on(self.conn)?
166            .into_iter()
167            .filter_map(|state| state.closing_control().cloned())
168            .collect())
169    }
170
171    fn circle_publication_context(
172        &self,
173        circle_id: coven_protocol::circle::CircleId,
174        expected_control: &coven_protocol::circle::CircleControlCoord,
175    ) -> Result<coven_protocol::circle_activation::CircleEpochAccess, DbError> {
176        circle_publication_context_on(self.conn, circle_id, expected_control)
177    }
178
179    fn current_circle_partition_control(
180        &self,
181        circle_id: coven_protocol::circle::CircleId,
182    ) -> Result<crate::CirclePartitionControl, DbError> {
183        crate::active_circle_control(self.conn, circle_id).map_err(DbError::from)
184    }
185
186    fn circle_publication_rotation_block(
187        &self,
188        circle_id: coven_protocol::circle::CircleId,
189        active_store_members: &std::collections::BTreeSet<String>,
190    ) -> Result<Option<coven_protocol::circle::CirclePublicationBlocked>, DbError> {
191        let Some(state) = circle_current_state_on(self.conn, circle_id)? else {
192            return Ok(None);
193        };
194        Ok(state
195            .rotation_required(active_store_members)
196            .map(
197                |rotation| coven_protocol::circle::CirclePublicationBlocked::RotationRequired {
198                    circle_id,
199                    removed_members: rotation.removed_members,
200                },
201            ))
202    }
203
204    fn record_circle_close_exclusions(
205        &self,
206        exclusions: &[coven_protocol::circle_activation::LocalCircleExclusion],
207    ) -> Result<(), DbError> {
208        let tx = self.conn.unchecked_transaction().map_err(DbError::from)?;
209        for exclusion in exclusions {
210            record_circle_close_exclusion_on(&tx, exclusion)?;
211        }
212        tx.commit().map_err(DbError::from)
213    }
214
215    #[cfg(any(test, feature = "test-utils"))]
216    fn circles(
217        &self,
218        identity_pubkey: &str,
219        active_store_members: &std::collections::BTreeSet<String>,
220    ) -> Result<Vec<coven_protocol::circle::CircleInfo>, DbError> {
221        let mut circles = Vec::new();
222        for state in circle_current_states_on(self.conn)? {
223            let circle_id = state.circle_id();
224            if state.is_deleted() {
225                circles.push(coven_protocol::circle::CircleInfo::Deleted { id: circle_id });
226            } else if let Some(branches) = state.conflict_branches() {
227                circles.push(coven_protocol::circle::CircleInfo::Conflicted {
228                    id: circle_id,
229                    branches,
230                });
231            } else if let Some((_current, access, roster, metadata)) = state.active() {
232                if access.recipient_pubkey != identity_pubkey {
233                    return Err(DbError::Message(format!(
234                        "active circle {circle_id} belongs to another local identity"
235                    )));
236                }
237                let role = roster
238                    .members()
239                    .get(identity_pubkey)
240                    .copied()
241                    .ok_or_else(|| {
242                        DbError::Message(format!(
243                            "activated circle {circle_id} excludes the local identity"
244                        ))
245                    })?;
246                circles.push(coven_protocol::circle::CircleInfo::Active {
247                    id: circle_id,
248                    name: metadata.name.clone(),
249                    role,
250                    rotation_required: state.rotation_required(active_store_members).is_some(),
251                });
252            }
253        }
254        Ok(circles)
255    }
256}
257
258impl StoreDatabase {
259    pub async fn get_circle_operations(
260        &self,
261    ) -> Result<Vec<coven_protocol::circle::CircleOperationInfo>, DbError> {
262        self.call_store(|session| session.circle_operations()).await
263    }
264
265    /// Every Circle the local identity can see, with its public derived state. A
266    /// deleted or conflicted Circle stays visible (as `Deleted`/`ControlConflict`)
267    /// rather than silently disappearing; an inactive Circle the identity holds no
268    /// access to is `Inactive`.
269    pub async fn circle_states(
270        &self,
271        identity_pubkey: &str,
272        active_store_members: std::collections::BTreeSet<String>,
273    ) -> Result<Vec<coven_protocol::circle::Circle>, DbError> {
274        let identity_pubkey = identity_pubkey.to_string();
275        self.call_store(move |session| {
276            session.circle_states(&identity_pubkey, &active_store_members)
277        })
278        .await
279    }
280
281    pub async fn get_circle_members(
282        &self,
283        circle_id: coven_protocol::circle::CircleId,
284        identity_pubkey: &str,
285        store_members: std::collections::BTreeSet<String>,
286    ) -> Result<Vec<coven_protocol::circle::CircleMemberInfo>, DbError> {
287        let identity_pubkey = identity_pubkey.to_string();
288        self.call_store(move |session| {
289            session.circle_members(circle_id, &identity_pubkey, &store_members)
290        })
291        .await
292    }
293
294    pub async fn circle_authoring_context(
295        &self,
296        circle_id: coven_protocol::circle::CircleId,
297        identity_pubkey: &str,
298    ) -> Result<
299        (
300            coven_protocol::circle_activation::CircleAuthoringState,
301            StoreBatchCommitRef,
302        ),
303        DbError,
304    > {
305        self.circle_signing_context(
306            circle_id,
307            identity_pubkey,
308            |state| state.authoring_state(),
309            |circle_id| format!("Circle {circle_id} has no active authoring state"),
310            |circle_id| format!("active Circle {circle_id} belongs to another local identity"),
311        )
312        .await
313    }
314
315    /// The signing context shared by Circle commands: the current state's
316    /// authoring view selected by `authoring`, required to belong to the local
317    /// identity, plus the commit that activated the current control.
318    async fn circle_signing_context(
319        &self,
320        circle_id: coven_protocol::circle::CircleId,
321        identity_pubkey: &str,
322        authoring: fn(
323            &coven_protocol::circle_activation::CircleCurrentState,
324        ) -> Option<coven_protocol::circle_activation::CircleAuthoringState>,
325        missing_authoring: fn(coven_protocol::circle::CircleId) -> String,
326        foreign_identity: fn(coven_protocol::circle::CircleId) -> String,
327    ) -> Result<
328        (
329            coven_protocol::circle_activation::CircleAuthoringState,
330            StoreBatchCommitRef,
331        ),
332        DbError,
333    > {
334        let identity_pubkey = identity_pubkey.to_string();
335        self.call_store(move |session| {
336            session.circle_signing_context(
337                circle_id,
338                &identity_pubkey,
339                authoring,
340                missing_authoring,
341                foreign_identity,
342            )
343        })
344        .await
345    }
346
347    /// The authoring context a terminal deletion signs from, accepting any
348    /// resolved state whose local device holds owner access — `Active` or
349    /// `Closing`. Unlike `circle_authoring_context` (Active-only, for commands
350    /// that publish a new active epoch), deletion supersedes an in-flight close,
351    /// so it authors equally from a closing control's frozen epoch.
352    pub async fn circle_delete_context(
353        &self,
354        circle_id: coven_protocol::circle::CircleId,
355        identity_pubkey: &str,
356    ) -> Result<
357        (
358            coven_protocol::circle_activation::CircleAuthoringState,
359            StoreBatchCommitRef,
360        ),
361        DbError,
362    > {
363        self.circle_signing_context(
364            circle_id,
365            identity_pubkey,
366            |state| state.deletable_authoring_state(),
367            |circle_id| format!("Circle {circle_id} has no resolved authoring state to delete"),
368            |circle_id| format!("Circle {circle_id} belongs to another local identity"),
369        )
370        .await
371    }
372
373    /// The retained conflicting branch coordinates for a Circle whose control
374    /// history forked, in canonical order. `None` when the Circle has no
375    /// current state or its control is resolved.
376    pub async fn circle_control_conflict_branches(
377        &self,
378        circle_id: coven_protocol::circle::CircleId,
379    ) -> Result<Option<Vec<coven_protocol::circle::CircleControlCoord>>, DbError> {
380        self.call_store(move |session| session.circle_control_conflict_branches(circle_id))
381            .await
382    }
383
384    /// Whether the Circle's control history has terminated in a deletion.
385    pub async fn circle_is_deleted(
386        &self,
387        circle_id: coven_protocol::circle::CircleId,
388    ) -> Result<bool, DbError> {
389        self.call_store(move |session| session.circle_is_deleted(circle_id))
390            .await
391    }
392
393    /// The Circle's currently activated authoring control, or `None` when the
394    /// Circle is not in an active local state. Its retained keyring resolves
395    /// acknowledgements sealed under rotated-away epochs.
396    pub async fn current_circle_control(
397        &self,
398        circle_id: coven_protocol::circle::CircleId,
399    ) -> Result<Option<coven_protocol::circle::CircleControlCoord>, DbError> {
400        self.call_store(move |session| session.current_circle_control(circle_id))
401            .await
402    }
403
404    pub async fn closing_circle_controls(
405        &self,
406    ) -> Result<Vec<coven_protocol::circle::PreparedCircleControl>, DbError> {
407        self.call_store(|session| session.closing_circle_controls())
408            .await
409    }
410
411    pub async fn circle_closing_context(
412        &self,
413        circle_id: coven_protocol::circle::CircleId,
414        identity_pubkey: &str,
415    ) -> Result<
416        (
417            coven_protocol::circle_activation::CircleAuthoringState,
418            StoreBatchCommitRef,
419        ),
420        DbError,
421    > {
422        self.circle_signing_context(
423            circle_id,
424            identity_pubkey,
425            |state| state.closing_authoring_state(),
426            |circle_id| format!("Circle {circle_id} is not closing"),
427            |circle_id| format!("closing Circle {circle_id} belongs to another local identity"),
428        )
429        .await
430    }
431
432    pub async fn circle_publication_context(
433        &self,
434        circle_id: coven_protocol::circle::CircleId,
435        expected_control: coven_protocol::circle::CircleControlCoord,
436    ) -> Result<coven_protocol::circle_activation::CircleEpochAccess, DbError> {
437        self.call_store(move |session| {
438            session.circle_publication_context(circle_id, &expected_control)
439        })
440        .await
441    }
442
443    /// The Circle's current active control coordinate. A durable write captured
444    /// under an earlier control publishes under this one, so an epoch close that
445    /// retired the capture-time control does not strand the write: its rows
446    /// belong to whichever epoch is live when it publishes. Fails when the
447    /// Circle is not currently active.
448    pub async fn current_circle_partition_control(
449        &self,
450        circle_id: coven_protocol::circle::CircleId,
451    ) -> Result<crate::CirclePartitionControl, DbError> {
452        self.call_store(move |session| session.current_circle_partition_control(circle_id))
453            .await
454    }
455
456    /// Whether publishing new content into `circle_id` is blocked because the
457    /// Circle's resolved roster names Store identities that hold no active
458    /// membership grant at `active_store_members`. Derived from the current
459    /// materialized state, so activating a successor roster without those
460    /// identities clears it with no stored flag to reset.
461    pub async fn circle_publication_rotation_block(
462        &self,
463        circle_id: coven_protocol::circle::CircleId,
464        active_store_members: std::collections::BTreeSet<String>,
465    ) -> Result<Option<coven_protocol::circle::CirclePublicationBlocked>, DbError> {
466        self.call_store(move |session| {
467            session.circle_publication_rotation_block(circle_id, &active_store_members)
468        })
469        .await
470    }
471
472    pub async fn record_circle_close_exclusions(
473        &self,
474        exclusions: Vec<coven_protocol::circle_activation::LocalCircleExclusion>,
475    ) -> Result<(), DbError> {
476        self.call_store(move |session| session.record_circle_close_exclusions(&exclusions))
477            .await
478    }
479
480    #[cfg(any(test, feature = "test-utils"))]
481    pub async fn get_circles(
482        &self,
483        identity_pubkey: &str,
484        active_store_members: std::collections::BTreeSet<String>,
485    ) -> Result<Vec<coven_protocol::circle::CircleInfo>, DbError> {
486        let identity_pubkey = identity_pubkey.to_string();
487        self.call_store(move |session| session.circles(&identity_pubkey, &active_store_members))
488            .await
489    }
490}
491
492/// Record this device's own exclusion from a Circle epoch close, derived from
493/// the verified successor outcome at materialization. The row is keyed by
494/// Circle: a later close for the same Circle supersedes it. It is never
495/// deleted — the publication gate derives clear once the successor bootstrap's
496/// coverage records.
497pub(crate) fn record_circle_close_exclusion_on(
498    conn: &Connection,
499    exclusion: &coven_protocol::circle_activation::LocalCircleExclusion,
500) -> Result<(), DbError> {
501    let circle_id = exclusion.circle_id.to_string();
502    let close_id = serde_json::to_string(&exclusion.close_id)
503        .map_err(|error| DbError::context("serialize close exclusion id", error))?;
504    let excluded = serde_json::to_string(&exclusion.excluded)
505        .map_err(|error| DbError::context("serialize close exclusion registration", error))?;
506    let successor_control = serde_json::to_string(&exclusion.successor_control)
507        .map_err(|error| DbError::context("serialize close exclusion successor", error))?;
508    let activating_commit = serde_json::to_string(&exclusion.activating_commit)
509        .map_err(|error| DbError::context("serialize close exclusion activation", error))?;
510    conn.execute(
511        "INSERT INTO circle_close_exclusions
512         (circle_id, close_id, excluded_registration, successor_control, activating_commit)
513         VALUES (?1, ?2, ?3, ?4, ?5)
514         ON CONFLICT(circle_id) DO UPDATE SET
515           close_id = excluded.close_id,
516           excluded_registration = excluded.excluded_registration,
517           successor_control = excluded.successor_control,
518           activating_commit = excluded.activating_commit",
519        rusqlite::params![
520            circle_id,
521            close_id,
522            excluded,
523            successor_control,
524            activating_commit,
525        ],
526    )
527    .map_err(DbError::from)?;
528    Ok(())
529}
530
531/// Every stored Circle current state, ordered by Circle id. The rows are read
532/// and parsed up front so a caller can query or write through the same
533/// connection while it walks them.
534pub(crate) fn circle_current_states_on(
535    conn: &Connection,
536) -> Result<Vec<coven_protocol::circle_activation::CircleCurrentState>, DbError> {
537    let rows = query_mapped_rows(
538        conn,
539        "SELECT circle_id, state FROM circle_current_state ORDER BY circle_id",
540        [],
541        |row| Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?)),
542    )?;
543    rows.into_iter()
544        .map(|(stored_circle_id, payload)| parse_circle_current_state(&stored_circle_id, &payload))
545        .collect()
546}
547
548pub(crate) fn circle_current_state_on(
549    conn: &Connection,
550    circle_id: coven_protocol::circle::CircleId,
551) -> Result<Option<coven_protocol::circle_activation::CircleCurrentState>, DbError> {
552    let stored = conn
553        .query_row(
554            "SELECT circle_id, state FROM circle_current_state WHERE circle_id = ?1",
555            [circle_id.to_string()],
556            |row| Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?)),
557        )
558        .optional()
559        .map_err(DbError::from)?;
560    stored
561        .map(|(stored_circle_id, state)| parse_circle_current_state(&stored_circle_id, &state))
562        .transpose()
563}
564
565fn parse_circle_current_state(
566    stored_circle_id: &str,
567    payload: &[u8],
568) -> Result<coven_protocol::circle_activation::CircleCurrentState, DbError> {
569    let circle_id: coven_protocol::circle::CircleId = stored_circle_id
570        .parse()
571        .map_err(|error| DbError::context("parse current Circle id", error))?;
572    let state: coven_protocol::circle_activation::CircleCurrentState =
573        serde_json::from_slice(payload)
574            .map_err(|error| DbError::context("parse Circle current state", error))?;
575    if !state.verify() || state.circle_id() != circle_id {
576        return Err(DbError::Message(format!(
577            "Circle {circle_id} has invalid current state"
578        )));
579    }
580    Ok(state)
581}
582
583pub(crate) fn remove_local_circle_access_on(conn: &Connection) -> Result<(), DbError> {
584    for state in circle_current_states_on(conn)? {
585        let circle_id = state.circle_id().to_string();
586        let state = state.without_local_access();
587        let payload = serde_json::to_vec(&state)
588            .map_err(|error| DbError::context("serialize public Circle current state", error))?;
589        let changed = conn
590            .execute(
591                "UPDATE circle_current_state
592                 SET state = ?2
593                 WHERE circle_id = ?1",
594                rusqlite::params![circle_id, payload],
595            )
596            .map_err(DbError::from)?;
597        if changed != 1 {
598            return Err(DbError::Message(
599                "Circle current state changed while removing local access".to_string(),
600            ));
601        }
602    }
603    conn.execute_batch("DELETE FROM circle_access_cache;")
604        .map_err(DbError::from)?;
605    Ok(())
606}
607
608pub(crate) fn circle_publication_context_on(
609    conn: &Connection,
610    circle_id: coven_protocol::circle::CircleId,
611    expected_control: &coven_protocol::circle::CircleControlCoord,
612) -> Result<coven_protocol::circle_activation::CircleEpochAccess, DbError> {
613    // An exclusion blocks publication until this device's bootstrap coverage
614    // records the exact successor commit that excluded it. The gate derives
615    // clear from that coverage; no reset flag is mutated.
616    let exclusion: Option<(String, String)> = conn
617        .query_row(
618            "SELECT close_id, activating_commit FROM circle_close_exclusions
619                 WHERE circle_id = ?1",
620            [circle_id.to_string()],
621            |row| Ok((row.get(0)?, row.get(1)?)),
622        )
623        .optional()
624        .map_err(DbError::from)?;
625    if let Some((close_id, activating_commit)) = exclusion {
626        let coverage_commit: Option<String> = conn
627            .query_row(
628                "SELECT activation_commit FROM circle_bootstrap_coverage WHERE circle_id = ?1",
629                [circle_id.to_string()],
630                |row| row.get(0),
631            )
632            .optional()
633            .map_err(DbError::from)?;
634        if coverage_commit.as_deref() != Some(activating_commit.as_str()) {
635            let close_id = serde_json::from_str(&close_id).map_err(|error| {
636                DbError::context("parse pending Circle close exclusion id", error)
637            })?;
638            return Err(DbError::ExcludedDeviceMustReset {
639                circle_id,
640                close_id,
641            });
642        }
643    }
644    let state = circle_current_state_on(conn, circle_id)?
645        .ok_or_else(|| DbError::Message(format!("Circle {circle_id} has no current state")))?;
646    if state.is_deleted() {
647        return Err(DbError::Message(format!("Circle {circle_id} is deleted")));
648    }
649    let access = state
650        .epoch_access(expected_control)
651        .map_err(DbError::from)?
652        .ok_or_else(|| {
653            DbError::Message(format!("Circle {circle_id} has no active publication key"))
654        })?;
655    Ok(access)
656}
657
658#[cfg(test)]
659#[path = "circle_operations_test.rs"]
660mod tests;