Skip to main content

coven/
circles.rs

1//! The `coven.circles()` application surface: create, lifecycle, inspection, and
2//! typed public errors. A [`Circles`] is a borrowed namespace over a
3//! [`CovenHandle`](crate::CovenHandle) with no state of its own; every method
4//! delegates to the sync manager and maps internal refusals to [`CircleError`].
5
6use coven_core::{
7    Circle, CircleCloseStatus, CircleControlCoord, CircleId, CircleMemberInfo,
8    CircleOperationBlock, CircleOperationId, CircleOperationInfo, CircleRole, StoreDeviceId,
9};
10
11use crate::handle::CovenHandle;
12use crate::sync::store::CircleOperationError;
13use crate::sync::sync_manager::SyncError;
14
15/// Why a Circle command or query failed. Maps the internal typed refusals 1:1 with
16/// stable identifiers and carries the ids a caller needs to display or retry.
17/// Write-path outcomes (a durable write's local/published/blocked/conflicted
18/// status) are not here — those stay on
19/// [`WriteStatus`](crate::WriteStatus)/[`WriteBlock`](crate::WriteBlock).
20#[derive(Debug, thiserror::Error)]
21pub enum CircleError {
22    /// No sync provider is configured, so there is no Store to command.
23    #[error("sync is not configured")]
24    NotConfigured,
25    /// The sync loop is not running, so a Circle write cannot be dispatched.
26    #[error("the sync loop is not running")]
27    LoopNotRunning,
28    /// Circles require opaque object storage; a browsable provider cannot hold
29    /// them.
30    #[error("circles require opaque (non-browsable) cloud storage")]
31    BrowsableStorage,
32    /// The Circle's resolved roster names Store identities that are no longer
33    /// active Store members. New content is refused until an Owner closes the
34    /// epoch and activates a successor roster without them.
35    #[error("circle {circle_id} requires rotation: its roster names removed Store members {removed_members:?}")]
36    RotationRequired {
37        circle_id: CircleId,
38        removed_members: Vec<String>,
39    },
40    /// The Circle's control history has forked and awaits Owner resolution.
41    #[error("circle {circle_id} has an unresolved control conflict")]
42    Conflicted { circle_id: CircleId },
43    /// The Circle's control history terminated in an Owner-signed deletion.
44    #[error("circle {circle_id} is deleted")]
45    Deleted { circle_id: CircleId },
46    /// Resolution was requested for a Circle that holds no retained control
47    /// conflict.
48    #[error("circle {circle_id} has no retained control conflict to resolve")]
49    NotConflicted { circle_id: CircleId },
50    /// The resolution's chosen branch is not among the Circle's retained
51    /// conflicting branches.
52    #[error("circle {circle_id} control conflict does not retain the chosen branch")]
53    ChosenBranchNotRetained { circle_id: CircleId },
54    /// Cancellation was requested for a Circle with no in-flight epoch close.
55    #[error("circle {circle_id} has no in-flight epoch close to cancel")]
56    NoCloseToCancel { circle_id: CircleId },
57    /// Device exclusion was requested for a Circle with no in-flight epoch close.
58    #[error("circle {circle_id} has no in-flight epoch close for device exclusion")]
59    NoCloseToExclude { circle_id: CircleId },
60    /// The named device is not a participant in the Circle's in-flight epoch
61    /// close.
62    #[error("device {device_id} is not a participant in circle {circle_id}'s epoch close")]
63    DeviceNotACloseParticipant {
64        circle_id: CircleId,
65        device_id: StoreDeviceId,
66    },
67    /// Retry was requested for a durable operation that is not blocked.
68    #[error("circle operation {operation_id} is not blocked")]
69    NotBlocked { operation_id: CircleOperationId },
70    /// Discard was requested without proof the candidate can never activate. The
71    /// operation stays durable; it never assumes an unseen candidate failed to
72    /// activate.
73    #[error("circle operation {operation_id} discard requires verified permanent nonactivation")]
74    DiscardRequiresNonactivation { operation_id: CircleOperationId },
75    /// A durable operation cannot publish because its author lost signed
76    /// authority; the initiator may retry it once authority is restored.
77    #[error("circle operation for {circle_id} is blocked: {block}")]
78    Blocked {
79        circle_id: CircleId,
80        block: CircleOperationBlock,
81    },
82    /// The local signing identity is not established.
83    #[error("the local identity is not established: {0}")]
84    Identity(String),
85    /// An internal protocol or database failure with no distinct public category.
86    #[error("circle protocol error: {0}")]
87    Protocol(String),
88}
89
90impl From<CircleOperationError> for CircleError {
91    fn from(error: CircleOperationError) -> Self {
92        match error {
93            CircleOperationError::BrowsableStorage => Self::BrowsableStorage,
94            CircleOperationError::RotationRequired {
95                circle_id,
96                removed_members,
97            } => Self::RotationRequired {
98                circle_id,
99                removed_members,
100            },
101            CircleOperationError::Conflicted { circle_id } => Self::Conflicted { circle_id },
102            CircleOperationError::Deleted { circle_id } => Self::Deleted { circle_id },
103            CircleOperationError::NotConflicted { circle_id } => Self::NotConflicted { circle_id },
104            CircleOperationError::ChosenBranchNotRetained { circle_id } => {
105                Self::ChosenBranchNotRetained { circle_id }
106            }
107            CircleOperationError::NoCloseToCancel { circle_id } => {
108                Self::NoCloseToCancel { circle_id }
109            }
110            CircleOperationError::NoCloseToExclude { circle_id } => {
111                Self::NoCloseToExclude { circle_id }
112            }
113            CircleOperationError::DeviceNotACloseParticipant {
114                circle_id,
115                device_id,
116            } => Self::DeviceNotACloseParticipant {
117                circle_id,
118                device_id,
119            },
120            CircleOperationError::NotBlocked { operation_id } => Self::NotBlocked { operation_id },
121            CircleOperationError::DiscardRequiresNonactivation { operation_id } => {
122                Self::DiscardRequiresNonactivation { operation_id }
123            }
124            CircleOperationError::Blocked { circle_id, block } => {
125                Self::Blocked { circle_id, block }
126            }
127            CircleOperationError::CommandChannelClosed
128            | CircleOperationError::ReplyChannelClosed => Self::LoopNotRunning,
129            // Internal protocol and database failures carry no distinct public
130            // category; surface their message under the catch-all.
131            other => Self::Protocol(other.to_string()),
132        }
133    }
134}
135
136impl From<SyncError> for CircleError {
137    fn from(error: SyncError) -> Self {
138        match error {
139            SyncError::NotConfigured => Self::NotConfigured,
140            SyncError::LoopNotRunning => Self::LoopNotRunning,
141            SyncError::Circle(error) => error.into(),
142            SyncError::Key(error) => Self::Identity(error.to_string()),
143            other => Self::Protocol(other.to_string()),
144        }
145    }
146}
147
148/// The `coven.circles()` namespace. Borrowed from a [`CovenHandle`]; holds no
149/// state of its own.
150pub struct Circles<'a> {
151    handle: &'a CovenHandle,
152}
153
154impl<'a> Circles<'a> {
155    pub(crate) fn new(handle: &'a CovenHandle) -> Self {
156        Self { handle }
157    }
158
159    fn manager(
160        &self,
161    ) -> Result<std::sync::Arc<crate::sync::sync_manager::SyncManager>, CircleError> {
162        self.handle.sync_manager().ok_or(CircleError::NotConfigured)
163    }
164
165    /// Create and activate a Circle whose founder is this Store identity. Returns
166    /// only after the signed roster, metadata, access set, control, Store commit,
167    /// activation head, and local materialization are durable.
168    pub async fn create(&self, name: &str) -> Result<CircleId, CircleError> {
169        self.manager()?.create_circle(name).await
170    }
171
172    /// Rename a Circle without changing its epoch key, membership, rows, or
173    /// package history.
174    pub async fn rename(&self, circle_id: CircleId, name: &str) -> Result<(), CircleError> {
175        self.manager()?.rename_circle(circle_id, name).await
176    }
177
178    /// Add (or re-add) a Store identity to the Circle's roster, sealing it a fresh
179    /// active access leaf and current bootstrap.
180    pub async fn add_member(
181        &self,
182        circle_id: CircleId,
183        member_pubkey: &str,
184    ) -> Result<(), CircleError> {
185        self.manager()?
186            .add_circle_member(circle_id, member_pubkey.to_string(), CircleRole::Member)
187            .await
188    }
189
190    /// Remove a Store identity from the Circle, closing the old epoch. Returns the
191    /// durable operation id tracking the close.
192    pub async fn remove_member(
193        &self,
194        circle_id: CircleId,
195        member_pubkey: &str,
196    ) -> Result<CircleOperationId, CircleError> {
197        self.manager()?
198            .remove_circle_member(circle_id, member_pubkey.to_string())
199            .await
200    }
201
202    /// Resolve a forked Circle control by authoring a successor of the chosen
203    /// branch. Callable regardless of rotation state — it is the exit path out of
204    /// the conflict.
205    pub async fn resolve(
206        &self,
207        circle_id: CircleId,
208        chosen: CircleControlCoord,
209    ) -> Result<(), CircleError> {
210        self.manager()?
211            .resolve_circle_control(circle_id, chosen)
212            .await
213    }
214
215    /// Cancel an in-flight epoch close, restoring the frozen epoch. Returns the
216    /// durable operation id the cancellation settles.
217    pub async fn cancel_close(
218        &self,
219        circle_id: CircleId,
220    ) -> Result<CircleOperationId, CircleError> {
221        self.manager()?.cancel_circle_epoch_close(circle_id).await
222    }
223
224    /// Exclude an unavailable participant device from the Circle's in-flight epoch
225    /// close. The excluded device must reset from the successor bootstrap before it
226    /// can write or acknowledge again.
227    pub async fn exclude_close_device(
228        &self,
229        circle_id: CircleId,
230        device_id: StoreDeviceId,
231    ) -> Result<(), CircleError> {
232        self.manager()?
233            .exclude_circle_close_device(circle_id, device_id)
234            .await
235    }
236
237    /// Delete a Circle with an Owner-signed terminal control transition.
238    pub async fn delete(&self, circle_id: CircleId) -> Result<(), CircleError> {
239        self.manager()?.delete_circle(circle_id).await
240    }
241
242    /// Retry a blocked durable operation from its captured phase, idempotently.
243    pub async fn retry_operation(
244        &self,
245        operation_id: CircleOperationId,
246    ) -> Result<(), CircleError> {
247        self.manager()?.retry_circle_operation(operation_id).await
248    }
249
250    /// Discard a durable operation that can provably never activate, deleting its
251    /// candidate-exclusive objects and clearing its journal row. Refused typed
252    /// without a verified permanent-nonactivation proof.
253    pub async fn discard_operation(
254        &self,
255        operation_id: CircleOperationId,
256    ) -> Result<(), CircleError> {
257        self.manager()?.discard_circle_operation(operation_id).await
258    }
259
260    /// Every Circle the local identity can see, with its derived
261    /// [`CircleState`](crate::CircleState).
262    pub async fn list(&self) -> Result<Vec<Circle>, CircleError> {
263        self.manager()?.list_circles().await
264    }
265
266    /// The Circle's current members who remain current Store members, with roles.
267    pub async fn members(&self, circle_id: CircleId) -> Result<Vec<CircleMemberInfo>, CircleError> {
268        self.manager()?.circle_members(circle_id).await
269    }
270
271    /// Every durable Circle operation that has not activated, with its typed
272    /// progress and block reason.
273    pub async fn operations(&self) -> Result<Vec<CircleOperationInfo>, CircleError> {
274        self.manager()?.circle_operations().await
275    }
276
277    /// The read-only settlement status of a Circle's in-flight epoch close.
278    pub async fn close_status(
279        &self,
280        circle_id: CircleId,
281    ) -> Result<CircleCloseStatus, CircleError> {
282        self.manager()?.circle_close_status(circle_id).await
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    fn circle_id(byte: u8) -> CircleId {
291        CircleId::from_bytes([byte; 16])
292    }
293
294    fn device_id(byte: u8) -> StoreDeviceId {
295        format!("{byte:02x}")
296            .repeat(32)
297            .parse()
298            .expect("a 64-character hexadecimal device id")
299    }
300
301    /// Each internal refusal maps to its public variant, carrying the identifiers a
302    /// caller needs. Covers the named refusal set: rename-on-deleted,
303    /// resolve-on-nonconflicted, cancel-without-close, exclude-non-participant, and
304    /// delete-on-conflicted, plus the browsable-storage and rotation refusals.
305    #[test]
306    fn internal_refusals_map_to_public_variants() {
307        let circle = circle_id(1);
308
309        let deleted: CircleError = CircleOperationError::Deleted { circle_id: circle }.into();
310        assert!(matches!(deleted, CircleError::Deleted { circle_id } if circle_id == circle));
311
312        let not_conflicted: CircleError =
313            CircleOperationError::NotConflicted { circle_id: circle }.into();
314        assert!(
315            matches!(not_conflicted, CircleError::NotConflicted { circle_id } if circle_id == circle)
316        );
317
318        let no_close: CircleError =
319            CircleOperationError::NoCloseToCancel { circle_id: circle }.into();
320        assert!(
321            matches!(no_close, CircleError::NoCloseToCancel { circle_id } if circle_id == circle)
322        );
323
324        let device = device_id(7);
325        let not_participant: CircleError = CircleOperationError::DeviceNotACloseParticipant {
326            circle_id: circle,
327            device_id: device,
328        }
329        .into();
330        assert!(matches!(
331            not_participant,
332            CircleError::DeviceNotACloseParticipant { circle_id, device_id }
333                if circle_id == circle && device_id == device
334        ));
335
336        let conflicted: CircleError = CircleOperationError::Conflicted { circle_id: circle }.into();
337        assert!(matches!(conflicted, CircleError::Conflicted { circle_id } if circle_id == circle));
338
339        let browsable: CircleError = CircleOperationError::BrowsableStorage.into();
340        assert!(matches!(browsable, CircleError::BrowsableStorage));
341
342        let rotation: CircleError = CircleOperationError::RotationRequired {
343            circle_id: circle,
344            removed_members: vec!["pk".to_string()],
345        }
346        .into();
347        assert!(matches!(
348            rotation,
349            CircleError::RotationRequired { circle_id, removed_members }
350                if circle_id == circle && removed_members == vec!["pk".to_string()]
351        ));
352
353        let no_exclude: CircleError =
354            CircleOperationError::NoCloseToExclude { circle_id: circle }.into();
355        assert!(
356            matches!(no_exclude, CircleError::NoCloseToExclude { circle_id } if circle_id == circle)
357        );
358
359        let chosen: CircleError =
360            CircleOperationError::ChosenBranchNotRetained { circle_id: circle }.into();
361        assert!(
362            matches!(chosen, CircleError::ChosenBranchNotRetained { circle_id } if circle_id == circle)
363        );
364
365        let operation_id = CircleOperationId::placeholder("discard-map-seed");
366        let discard: CircleError = CircleOperationError::DiscardRequiresNonactivation {
367            operation_id: operation_id.clone(),
368        }
369        .into();
370        assert!(matches!(
371            discard,
372            CircleError::DiscardRequiresNonactivation { operation_id: mapped }
373                if mapped == operation_id
374        ));
375
376        // The channel-closed plumbing variants collapse to LoopNotRunning; other
377        // internal failures collapse to the Protocol catch-all.
378        let closed: CircleError = CircleOperationError::CommandChannelClosed.into();
379        assert!(matches!(closed, CircleError::LoopNotRunning));
380        let internal: CircleError = CircleOperationError::InvalidState("bad".to_string()).into();
381        assert!(matches!(internal, CircleError::Protocol(_)));
382    }
383
384    /// No public Circle error's `Display` names a removed coordinated-protocol
385    /// shape. The vocabulary of the deleted protocol must never surface to a host.
386    #[test]
387    fn no_public_error_display_names_removed_protocol_vocabulary() {
388        let circle = circle_id(2);
389        let displays = [
390            CircleError::NotConfigured.to_string(),
391            CircleError::LoopNotRunning.to_string(),
392            CircleError::BrowsableStorage.to_string(),
393            CircleError::RotationRequired {
394                circle_id: circle,
395                removed_members: vec!["pk".to_string()],
396            }
397            .to_string(),
398            CircleError::Conflicted { circle_id: circle }.to_string(),
399            CircleError::Deleted { circle_id: circle }.to_string(),
400            CircleError::NotConflicted { circle_id: circle }.to_string(),
401            CircleError::ChosenBranchNotRetained { circle_id: circle }.to_string(),
402            CircleError::NoCloseToCancel { circle_id: circle }.to_string(),
403            CircleError::NoCloseToExclude { circle_id: circle }.to_string(),
404            CircleError::DeviceNotACloseParticipant {
405                circle_id: circle,
406                device_id: device_id(3),
407            }
408            .to_string(),
409            CircleError::Identity("locked".to_string()).to_string(),
410            CircleError::Protocol("state invalid".to_string()).to_string(),
411        ];
412        for display in displays {
413            let lowered = display.to_lowercase();
414            for forbidden in ["serial", "policy", "engine", "coordination"] {
415                assert!(
416                    !lowered.contains(forbidden),
417                    "public Circle error names removed protocol vocabulary {forbidden:?}: {display}"
418                );
419            }
420        }
421    }
422}