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 crate::{
7    Circle, CircleCloseStatus, CircleControlCoord, CircleEpochCloseId, CircleId, CircleMemberInfo,
8    CircleOperationBlock, CircleOperationId, CircleOperationInfo, CircleRole, StoreDeviceId,
9};
10
11use crate::store_circles::StoreCircles;
12use crate::store_sync::SyncError;
13use coven_replication::sync::store::CircleOperationError;
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    /// The chosen control branch starts an epoch close. Resolve the conflict to
68    /// an active branch before starting a close.
69    #[error("circle {circle_id} control conflict must resolve to an active branch")]
70    ResolveToClosingBranch { circle_id: CircleId },
71    /// This device was excluded from an epoch close and must reset from a
72    /// successor bootstrap before it can continue.
73    #[error("device was excluded from circle {circle_id} close {close_id} and must reset")]
74    ExcludedDeviceMustReset {
75        circle_id: CircleId,
76        close_id: CircleEpochCloseId,
77    },
78    /// Retry was requested for a durable operation that is not blocked.
79    #[error("circle operation {operation_id} is not blocked")]
80    NotBlocked { operation_id: CircleOperationId },
81    /// Discard was requested without proof the candidate can never activate. The
82    /// operation stays durable; it never assumes an unseen candidate failed to
83    /// activate.
84    #[error("circle operation {operation_id} discard requires verified permanent nonactivation")]
85    DiscardRequiresNonactivation { operation_id: CircleOperationId },
86    /// A durable operation cannot publish. The typed block says whether the
87    /// initiator may retry after restoring authority or must discard and re-issue
88    /// an operation whose immutable stream position was taken.
89    #[error("circle operation for {circle_id} is blocked: {block}")]
90    Blocked {
91        circle_id: CircleId,
92        block: CircleOperationBlock,
93    },
94    /// The local signing identity is not established.
95    #[error("the local identity is not established: {0}")]
96    Identity(#[from] coven_keys::keys::KeyError),
97    #[error("circle operation failed: {0}")]
98    Operation(#[source] Box<CircleOperationError>),
99    #[error("circle sync failed: {0}")]
100    Sync(#[source] Box<SyncError>),
101    #[error("circle database query failed: {0}")]
102    Database(#[from] coven_database::DbError),
103}
104
105impl From<CircleOperationError> for CircleError {
106    fn from(error: CircleOperationError) -> Self {
107        match error {
108            CircleOperationError::BrowsableStorage => Self::BrowsableStorage,
109            CircleOperationError::RotationRequired {
110                circle_id,
111                removed_members,
112            } => Self::RotationRequired {
113                circle_id,
114                removed_members,
115            },
116            CircleOperationError::Conflicted { circle_id } => Self::Conflicted { circle_id },
117            CircleOperationError::Deleted { circle_id } => Self::Deleted { circle_id },
118            CircleOperationError::NotConflicted { circle_id } => Self::NotConflicted { circle_id },
119            CircleOperationError::ChosenBranchNotRetained { circle_id } => {
120                Self::ChosenBranchNotRetained { circle_id }
121            }
122            CircleOperationError::NoCloseToCancel { circle_id } => {
123                Self::NoCloseToCancel { circle_id }
124            }
125            CircleOperationError::NoCloseToExclude { circle_id } => {
126                Self::NoCloseToExclude { circle_id }
127            }
128            CircleOperationError::DeviceNotACloseParticipant {
129                circle_id,
130                device_id,
131            } => Self::DeviceNotACloseParticipant {
132                circle_id,
133                device_id,
134            },
135            CircleOperationError::ResolveToClosingBranch { circle_id } => {
136                Self::ResolveToClosingBranch { circle_id }
137            }
138            CircleOperationError::ExcludedDeviceMustReset {
139                circle_id,
140                close_id,
141            } => Self::ExcludedDeviceMustReset {
142                circle_id,
143                close_id,
144            },
145            CircleOperationError::NotBlocked { operation_id } => Self::NotBlocked { operation_id },
146            CircleOperationError::DiscardRequiresNonactivation { operation_id } => {
147                Self::DiscardRequiresNonactivation { operation_id }
148            }
149            CircleOperationError::Blocked { circle_id, block } => {
150                Self::Blocked { circle_id, block }
151            }
152            CircleOperationError::CommandChannelClosed
153            | CircleOperationError::ReplyChannelClosed => Self::LoopNotRunning,
154            other => Self::Operation(Box::new(other)),
155        }
156    }
157}
158
159impl From<SyncError> for CircleError {
160    fn from(error: SyncError) -> Self {
161        match error {
162            SyncError::NotConfigured => Self::NotConfigured,
163            SyncError::LoopNotRunning => Self::LoopNotRunning,
164            SyncError::Circle(error) => (*error).into(),
165            SyncError::Key(error) => Self::Identity(error),
166            other => Self::Sync(Box::new(other)),
167        }
168    }
169}
170
171/// The `coven.circles()` namespace. Borrowed from its Store Circle owner.
172pub struct Circles<'a> {
173    owner: &'a StoreCircles,
174}
175
176impl<'a> Circles<'a> {
177    pub(crate) fn new(owner: &'a StoreCircles) -> Self {
178        Self { owner }
179    }
180
181    /// Create and activate a Circle whose founder is this Store identity. Returns
182    /// only after the signed roster, metadata, access set, control, Store commit,
183    /// activation head, and local materialization are durable.
184    pub async fn create(&self, name: &str) -> Result<CircleId, CircleError> {
185        self.owner.create(name).await
186    }
187
188    /// Rename a Circle without changing its epoch key, membership, rows, or
189    /// package history.
190    pub async fn rename(&self, circle_id: CircleId, name: &str) -> Result<(), CircleError> {
191        self.owner.rename(circle_id, name).await
192    }
193
194    /// Add (or re-add) a Store identity to the Circle's roster, sealing it a fresh
195    /// active access leaf and current bootstrap.
196    pub async fn add_member(
197        &self,
198        circle_id: CircleId,
199        member_pubkey: &str,
200    ) -> Result<(), CircleError> {
201        self.owner
202            .add_member(circle_id, member_pubkey.to_string(), CircleRole::Member)
203            .await
204    }
205
206    /// Remove a Store identity from the Circle, closing the old epoch. Returns the
207    /// durable operation id tracking the close.
208    pub async fn remove_member(
209        &self,
210        circle_id: CircleId,
211        member_pubkey: &str,
212    ) -> Result<CircleOperationId, CircleError> {
213        self.owner
214            .remove_member(circle_id, member_pubkey.to_string())
215            .await
216    }
217
218    /// Resolve a forked Circle control by authoring a successor of the chosen
219    /// branch. Callable regardless of rotation state — it is the exit path out of
220    /// the conflict.
221    pub async fn resolve(
222        &self,
223        circle_id: CircleId,
224        chosen: CircleControlCoord,
225    ) -> Result<(), CircleError> {
226        self.owner.resolve(circle_id, chosen).await
227    }
228
229    /// Cancel an in-flight epoch close, restoring the frozen epoch. Returns the
230    /// durable operation id the cancellation settles.
231    pub async fn cancel_close(
232        &self,
233        circle_id: CircleId,
234    ) -> Result<CircleOperationId, CircleError> {
235        self.owner.cancel_close(circle_id).await
236    }
237
238    /// Exclude an unavailable participant device from the Circle's in-flight epoch
239    /// close. The excluded device must reset from the successor bootstrap before it
240    /// can write or acknowledge again.
241    pub async fn exclude_close_device(
242        &self,
243        circle_id: CircleId,
244        device_id: StoreDeviceId,
245    ) -> Result<(), CircleError> {
246        self.owner.exclude_close_device(circle_id, device_id).await
247    }
248
249    /// Delete a Circle with an Owner-signed terminal control transition.
250    pub async fn delete(&self, circle_id: CircleId) -> Result<(), CircleError> {
251        self.owner.delete(circle_id).await
252    }
253
254    /// Retry a blocked durable operation from its captured phase, idempotently.
255    pub async fn retry_operation(
256        &self,
257        operation_id: CircleOperationId,
258    ) -> Result<(), CircleError> {
259        self.owner.retry(operation_id).await
260    }
261
262    /// Discard a durable operation that can provably never activate, deleting its
263    /// candidate-exclusive objects and clearing its journal row. Refused typed
264    /// without a verified permanent-nonactivation proof.
265    pub async fn discard_operation(
266        &self,
267        operation_id: CircleOperationId,
268    ) -> Result<(), CircleError> {
269        self.owner.discard(operation_id).await
270    }
271
272    /// Every Circle the local identity can see, with its derived
273    /// [`CircleState`](crate::CircleState).
274    pub async fn list(&self) -> Result<Vec<Circle>, CircleError> {
275        self.owner.list().await
276    }
277
278    /// The Circle's current members who remain current Store members, with roles.
279    pub async fn members(&self, circle_id: CircleId) -> Result<Vec<CircleMemberInfo>, CircleError> {
280        self.owner.members(circle_id).await
281    }
282
283    /// Every durable Circle operation that has not activated, with its typed
284    /// progress and block reason.
285    pub async fn operations(&self) -> Result<Vec<CircleOperationInfo>, CircleError> {
286        self.owner.operations().await
287    }
288
289    /// The read-only settlement status of a Circle's in-flight epoch close.
290    pub async fn close_status(
291        &self,
292        circle_id: CircleId,
293    ) -> Result<CircleCloseStatus, CircleError> {
294        self.owner.close_status(circle_id).await
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    fn circle_id(byte: u8) -> CircleId {
303        CircleId::from_bytes([byte; 16])
304    }
305
306    fn device_id(byte: u8) -> StoreDeviceId {
307        format!("{byte:02x}")
308            .repeat(32)
309            .parse()
310            .expect("a 64-character hexadecimal device id")
311    }
312
313    fn close_id(byte: u8) -> CircleEpochCloseId {
314        serde_json::from_str(&format!("\"{}\"", format!("{byte:02x}").repeat(32)))
315            .expect("a 64-character hexadecimal close id")
316    }
317
318    fn authority_lost_block() -> CircleOperationBlock {
319        serde_json::from_str(&format!(
320            r#"{{"authority_lost":{{"grant_id":"{}"}}}}"#,
321            "cd".repeat(32)
322        ))
323        .expect("an authority-lost block with a 64-character hexadecimal grant id")
324    }
325
326    /// Each internal refusal maps to its public variant, carrying the identifiers a
327    /// caller needs. Covers the named refusal set: rename-on-deleted,
328    /// resolve-on-nonconflicted, cancel-without-close, exclude-non-participant, and
329    /// delete-on-conflicted, plus the browsable-storage and rotation refusals.
330    #[test]
331    fn internal_refusals_map_to_public_variants() {
332        let circle = circle_id(1);
333
334        let deleted: CircleError = CircleOperationError::Deleted { circle_id: circle }.into();
335        assert!(matches!(deleted, CircleError::Deleted { circle_id } if circle_id == circle));
336
337        let not_conflicted: CircleError =
338            CircleOperationError::NotConflicted { circle_id: circle }.into();
339        assert!(
340            matches!(not_conflicted, CircleError::NotConflicted { circle_id } if circle_id == circle)
341        );
342
343        let no_close: CircleError =
344            CircleOperationError::NoCloseToCancel { circle_id: circle }.into();
345        assert!(
346            matches!(no_close, CircleError::NoCloseToCancel { circle_id } if circle_id == circle)
347        );
348
349        let device = device_id(7);
350        let not_participant: CircleError = CircleOperationError::DeviceNotACloseParticipant {
351            circle_id: circle,
352            device_id: device,
353        }
354        .into();
355        assert!(matches!(
356            not_participant,
357            CircleError::DeviceNotACloseParticipant { circle_id, device_id }
358                if circle_id == circle && device_id == device
359        ));
360
361        let conflicted: CircleError = CircleOperationError::Conflicted { circle_id: circle }.into();
362        assert!(matches!(conflicted, CircleError::Conflicted { circle_id } if circle_id == circle));
363
364        let browsable: CircleError = CircleOperationError::BrowsableStorage.into();
365        assert!(matches!(browsable, CircleError::BrowsableStorage));
366
367        let rotation: CircleError = CircleOperationError::RotationRequired {
368            circle_id: circle,
369            removed_members: vec!["pk".to_string()],
370        }
371        .into();
372        assert!(matches!(
373            rotation,
374            CircleError::RotationRequired { circle_id, removed_members }
375                if circle_id == circle && removed_members == vec!["pk".to_string()]
376        ));
377
378        let no_exclude: CircleError =
379            CircleOperationError::NoCloseToExclude { circle_id: circle }.into();
380        assert!(
381            matches!(no_exclude, CircleError::NoCloseToExclude { circle_id } if circle_id == circle)
382        );
383
384        let chosen: CircleError =
385            CircleOperationError::ChosenBranchNotRetained { circle_id: circle }.into();
386        assert!(
387            matches!(chosen, CircleError::ChosenBranchNotRetained { circle_id } if circle_id == circle)
388        );
389
390        let operation_id = CircleOperationId::placeholder("discard-map-seed");
391        let not_blocked: CircleError = CircleOperationError::NotBlocked {
392            operation_id: operation_id.clone(),
393        }
394        .into();
395        assert!(matches!(
396            not_blocked,
397            CircleError::NotBlocked {
398                operation_id: mapped
399            } if mapped == operation_id
400        ));
401
402        let discard: CircleError = CircleOperationError::DiscardRequiresNonactivation {
403            operation_id: operation_id.clone(),
404        }
405        .into();
406        assert!(matches!(
407            discard,
408            CircleError::DiscardRequiresNonactivation { operation_id: mapped }
409                if mapped == operation_id
410        ));
411
412        let block = authority_lost_block();
413        let blocked: CircleError = CircleOperationError::Blocked {
414            circle_id: circle,
415            block: block.clone(),
416        }
417        .into();
418        assert!(matches!(
419            blocked,
420            CircleError::Blocked {
421                circle_id,
422                block: mapped
423            } if circle_id == circle && mapped == block
424        ));
425
426        let close_id = close_id(0xab);
427        let excluded_reset: CircleError = CircleOperationError::ExcludedDeviceMustReset {
428            circle_id: circle,
429            close_id,
430        }
431        .into();
432        assert!(matches!(
433            excluded_reset,
434            CircleError::ExcludedDeviceMustReset {
435                circle_id,
436                close_id: mapped
437            } if circle_id == circle && mapped == close_id
438        ));
439
440        let closing_resolution: CircleError =
441            CircleOperationError::ResolveToClosingBranch { circle_id: circle }.into();
442        assert!(matches!(
443            closing_resolution,
444            CircleError::ResolveToClosingBranch { circle_id } if circle_id == circle
445        ));
446
447        // The channel-closed plumbing variants collapse to LoopNotRunning; other
448        // internal failures retain the operation error.
449        let closed: CircleError = CircleOperationError::CommandChannelClosed.into();
450        assert!(matches!(closed, CircleError::LoopNotRunning));
451        let internal: CircleError = CircleOperationError::InvalidState("bad".to_string()).into();
452        assert!(matches!(internal, CircleError::Operation(_)));
453    }
454
455    /// No public Circle error's `Display` names a removed coordinated-protocol
456    /// shape. The vocabulary of the deleted protocol must never surface to a host.
457    #[test]
458    fn no_public_error_display_names_removed_protocol_vocabulary() {
459        let circle = circle_id(2);
460        let close_id = close_id(0xab);
461        let displays = [
462            CircleError::NotConfigured.to_string(),
463            CircleError::LoopNotRunning.to_string(),
464            CircleError::BrowsableStorage.to_string(),
465            CircleError::RotationRequired {
466                circle_id: circle,
467                removed_members: vec!["pk".to_string()],
468            }
469            .to_string(),
470            CircleError::Conflicted { circle_id: circle }.to_string(),
471            CircleError::Deleted { circle_id: circle }.to_string(),
472            CircleError::NotConflicted { circle_id: circle }.to_string(),
473            CircleError::ChosenBranchNotRetained { circle_id: circle }.to_string(),
474            CircleError::NoCloseToCancel { circle_id: circle }.to_string(),
475            CircleError::NoCloseToExclude { circle_id: circle }.to_string(),
476            CircleError::DeviceNotACloseParticipant {
477                circle_id: circle,
478                device_id: device_id(3),
479            }
480            .to_string(),
481            CircleError::ResolveToClosingBranch { circle_id: circle }.to_string(),
482            CircleError::ExcludedDeviceMustReset {
483                circle_id: circle,
484                close_id,
485            }
486            .to_string(),
487            CircleError::NotBlocked {
488                operation_id: CircleOperationId::placeholder("not-blocked-display"),
489            }
490            .to_string(),
491            CircleError::DiscardRequiresNonactivation {
492                operation_id: CircleOperationId::placeholder("discard-display"),
493            }
494            .to_string(),
495            CircleError::Blocked {
496                circle_id: circle,
497                block: authority_lost_block(),
498            }
499            .to_string(),
500            CircleError::Operation(Box::new(CircleOperationError::InvalidState(
501                "state invalid".to_string(),
502            )))
503            .to_string(),
504        ];
505        for display in displays {
506            let lowered = display.to_lowercase();
507            for forbidden in ["serial", "policy", "engine", "coordination"] {
508                assert!(
509                    !lowered.contains(forbidden),
510                    "public Circle error names removed protocol vocabulary {forbidden:?}: {display}"
511                );
512            }
513        }
514    }
515}