Skip to main content

coven_replication/sync/store/circles/
commands.rs

1use super::{
2    CircleAuthoringState, CircleOperationError, CircleOperationIntent, CircleTransitionHistory,
3};
4use coven_protocol::circle::{CircleCloseStatus, CircleId, CircleRole, CircleRosterChain};
5use coven_protocol::store_commit::CircleControlRef;
6use coven_storage::BlobPathScheme;
7
8pub struct StoreCircleCommands<'store> {
9    store: &'store super::Store,
10}
11
12impl<'store> StoreCircleCommands<'store> {
13    pub(crate) fn new(store: &'store super::Store) -> Self {
14        Self { store }
15    }
16
17    /// Every Circle mutation requires protected blob paths: a browsable layout
18    /// would leak Circle structure through object names.
19    fn require_protected_paths(&self) -> Result<(), CircleOperationError> {
20        if matches!(self.store.blob_path_scheme(), BlobPathScheme::Plain) {
21            return Err(CircleOperationError::BrowsableStorage);
22        }
23        Ok(())
24    }
25
26    async fn writer(
27        &self,
28    ) -> Result<
29        crate::sync::store::commit_publication::AuthorizedWriterOperation<'store>,
30        CircleOperationError,
31    > {
32        self.require_protected_paths()?;
33        self.store
34            .authorize_writer()
35            .await
36            .map_err(CircleOperationError::from)
37    }
38
39    /// The read-only settlement status of a Circle's in-flight epoch close: for
40    /// each participant device, whether its create-once response slot holds a
41    /// response, an Owner exclusion, or is still empty. Reports each slot's
42    /// declared settlement; the finalize path verifies each slot before acting on
43    /// it. A read, so it does not require Owner authorization — any participant
44    /// resolving the closing control can inspect it.
45    pub(crate) async fn circle_close_status(
46        &self,
47        circle_id: CircleId,
48    ) -> Result<CircleCloseStatus, CircleOperationError> {
49        self.store.circle_close_status(circle_id).await
50    }
51
52    pub(crate) async fn create_circle(
53        &self,
54        metadata_stamp: &str,
55        name: &str,
56    ) -> Result<CircleId, CircleOperationError> {
57        let mut writer = self.writer().await?;
58        writer.circles().create_circle(metadata_stamp, name).await
59    }
60
61    pub(crate) async fn rename_circle(
62        &self,
63        metadata_stamp: &str,
64        circle_id: CircleId,
65        name: &str,
66    ) -> Result<(), CircleOperationError> {
67        let mut writer = self.writer().await?;
68        writer
69            .circles()
70            .rename_circle(metadata_stamp, circle_id, name)
71            .await
72    }
73
74    pub(crate) async fn remove_circle_member(
75        &self,
76        circle_id: CircleId,
77        member_pubkey: String,
78    ) -> Result<coven_protocol::circle::CircleOperationId, CircleOperationError> {
79        let mut writer = self.writer().await?;
80        writer
81            .circles()
82            .remove_circle_member(circle_id, member_pubkey)
83            .await
84    }
85
86    /// Resolve a Circle whose control history forked into concurrent valid
87    /// successors by authoring a covering successor of the chosen branch. This
88    /// is callable on a conflicted Circle regardless of rotation state — it is
89    /// deliberately allowed during required rotation, because resolution is the
90    /// exit path out of the conflict and a conflicted Circle
91    /// has no single resolved roster to evaluate rotation against. A
92    /// rotation-required Circle re-derives that state from the resolved
93    /// successor and blocks new content afterward.
94    pub(crate) async fn resolve_circle_control(
95        &self,
96        circle_id: CircleId,
97        chosen: coven_protocol::circle::CircleControlCoord,
98    ) -> Result<(), CircleOperationError> {
99        let mut writer = self.writer().await?;
100        writer
101            .circles()
102            .resolve_circle_control(circle_id, chosen)
103            .await
104    }
105
106    /// Cancel the local device's in-flight epoch close by settling its one outcome
107    /// slot with an Owner-signed cancellation and activating a reopening control
108    /// that restores the frozen epoch. When concurrent controls have made the
109    /// Circle conflicted, the durable operation's close id selects its exact
110    /// retained branch; reopening that branch leaves the other branches visible
111    /// for an explicit control resolution. Refuses if no local close operation is
112    /// waiting for responses — a close whose outcome already won the slot has
113    /// moved out of the waiting state and cannot be cancelled.
114    pub(crate) async fn cancel_circle_epoch_close(
115        &self,
116        circle_id: CircleId,
117    ) -> Result<coven_protocol::circle::CircleOperationId, CircleOperationError> {
118        let mut writer = self.writer().await?;
119        writer.circles().cancel_circle_epoch_close(circle_id).await
120    }
121
122    /// Sign and publish an Owner exclusion of an unavailable participant device to
123    /// that device's create-once close-response slot, letting a stalled close reach
124    /// completion. Create-once decides the slot: if the device's own response
125    /// landed first, the exclusion is a no-op and that response is adopted.
126    pub(crate) async fn exclude_circle_close_device(
127        &self,
128        circle_id: CircleId,
129        excluded_device_id: coven_protocol::store_commit::StoreDeviceId,
130    ) -> Result<(), CircleOperationError> {
131        let mut writer = self.writer().await?;
132        writer
133            .circles()
134            .exclude_circle_close_device(circle_id, excluded_device_id)
135            .await
136    }
137
138    /// Return a blocked operation to its captured phase and re-enter the publish
139    /// pipeline, which revalidates against refreshed signed state. Initiator-driven
140    /// — the cycle never auto-unblocks. Refuses typed if the operation is not
141    /// blocked; retrying twice converges because publication is per-step
142    /// idempotent and re-blocks if authority is still absent.
143    pub(crate) async fn retry_circle_operation(
144        &self,
145        operation_id: &coven_protocol::circle::CircleOperationId,
146        routing_encryption: Option<&coven_keys::encryption::EncryptionService>,
147    ) -> Result<(), CircleOperationError> {
148        let mut writer = self.writer().await?;
149        writer
150            .circles()
151            .retry_circle_operation(operation_id, routing_encryption)
152            .await
153    }
154
155    /// Discard a durable Circle operation that can provably never activate,
156    /// exact-deleting its candidate-exclusive objects and clearing its journal
157    /// row. Legal only with one of the three direct nonactivation proofs — a
158    /// different verified winner already occupies the candidate's successor slot,
159    /// the author was permanently excluded, or a membership revocation forecloses
160    /// activation. Without proof it refuses typed: it never assumes an unseen
161    /// candidate failed to activate. Idempotent and restart-safe — a crash between
162    /// the recorded proof and the cleared row resumes the same cleanup from the
163    /// durable `Discarding` state.
164    pub(crate) async fn discard_circle_operation(
165        &self,
166        operation_id: &coven_protocol::circle::CircleOperationId,
167    ) -> Result<(), CircleOperationError> {
168        self.require_protected_paths()?;
169        let mut authorized = self
170            .store
171            .authorize()
172            .await
173            .map_err(CircleOperationError::from)?;
174        authorized.discard_circle_operation(operation_id).await
175    }
176
177    /// Author the terminal deletion of a Circle. It requires a resolved current
178    /// state — a conflicted Circle is refused until the Owner resolves it,
179    /// because the conflicting set may bury membership intent — and refuses a
180    /// Circle that is already deleted. It is not gated by the rotation-required
181    /// check: deletion distributes no key, so it is a terminal exit like member
182    /// removal.
183    pub(crate) async fn delete_circle(
184        &self,
185        circle_id: CircleId,
186    ) -> Result<(), CircleOperationError> {
187        let mut writer = self.writer().await?;
188        writer.circles().delete_circle(circle_id).await
189    }
190}
191
192pub(crate) struct CircleRenameRequest {
193    pub(super) circle_id: CircleId,
194    pub(super) name: String,
195    pub(super) metadata_stamp: String,
196    pub(super) current: CircleAuthoringState,
197    pub(super) previous_control: CircleControlRef,
198}
199
200pub(crate) struct CircleAddMemberRequest {
201    pub(super) circle_id: CircleId,
202    pub(super) member_pubkey: String,
203    pub(super) role: CircleRole,
204    bootstrap: crate::sync::store::SnapshotCut,
205    pub(super) current: CircleAuthoringState,
206    pub(super) previous_control: CircleControlRef,
207    pub(super) roster_chain: CircleRosterChain,
208}
209
210impl CircleAddMemberRequest {
211    pub(super) fn new(
212        circle_id: CircleId,
213        member_pubkey: String,
214        role: CircleRole,
215        bootstrap: crate::sync::store::SnapshotCut,
216        current: CircleAuthoringState,
217        previous_control: CircleControlRef,
218        roster_chain: CircleRosterChain,
219    ) -> Self {
220        Self {
221            circle_id,
222            member_pubkey,
223            role,
224            bootstrap,
225            current,
226            previous_control,
227            roster_chain,
228        }
229    }
230
231    pub(super) fn bootstrap_blobs(&self) -> &[coven_database::SnapshotBlobFact] {
232        self.bootstrap.blobs()
233    }
234
235    pub(super) async fn read_bootstrap_image(
236        &self,
237    ) -> Result<Vec<u8>, coven_database::SnapshotImageError> {
238        self.bootstrap.read_image().await
239    }
240
241    pub(super) fn bootstrap_coverage(&self) -> &coven_protocol::store_commit::CommitFrontier {
242        self.bootstrap.coverage()
243    }
244}
245
246pub(crate) struct CircleRemoveMemberRequest {
247    pub(super) circle_id: CircleId,
248    pub(super) member_pubkey: String,
249    pub(super) current: CircleAuthoringState,
250    pub(super) previous_control: CircleControlRef,
251    pub(super) roster_chain: CircleRosterChain,
252}
253
254pub(crate) struct CircleDeleteRequest {
255    pub(super) circle_id: CircleId,
256    pub(super) current: CircleAuthoringState,
257    pub(super) previous_control: CircleControlRef,
258}
259
260pub(crate) struct CircleResolveControlRequest {
261    pub(super) circle_id: CircleId,
262    pub(super) chosen: CircleAuthoringState,
263    pub(super) previous_control: CircleControlRef,
264    /// The retained branches other than `chosen`. The resolution merges each
265    /// one's control, metadata, and roster head frontiers into its own so no
266    /// author-stream head slot is re-allocated once the conflict collapses.
267    pub(super) losing_branches: Vec<CircleResolveLosingBranch>,
268    /// Every retained branch coordinate, in canonical order, as captured when
269    /// the command ran. Preparation verifies this still equals the currently
270    /// retained conflict set inside the journal transaction, so a branch
271    /// discovered between command and activation resurfaces as a new conflict
272    /// rather than being silently swallowed.
273    pub(super) conflicting_branches: Vec<coven_protocol::circle::CircleControlCoord>,
274}
275
276pub(crate) struct CircleResolveLosingBranch {
277    /// The losing branch's exact activation reference: its control head plus the
278    /// full activation objects (metadata and roster head frontiers and their
279    /// entries) the resolution covers.
280    pub(super) reference: CircleControlRef,
281    /// The metadata entry this branch selected — one input to the resolution's
282    /// deterministic name selection over the merged frontier.
283    pub(super) selected_metadata: coven_protocol::circle::CircleMetadata,
284}
285
286pub(crate) struct CircleFinalizeEpochCloseRequest {
287    pub(super) operation_id: coven_protocol::circle::CircleOperationId,
288    pub(super) circle_id: CircleId,
289    pub(super) member_pubkey: String,
290    pub(super) metadata_stamp: String,
291    pub(super) current: CircleAuthoringState,
292    pub(super) previous_control: CircleControlRef,
293    pub(super) roster_chain: CircleRosterChain,
294    pub(super) intent: coven_protocol::circle::CircleEpochCloseIntent,
295    pub(super) responses: Vec<coven_protocol::circle::CircleEpochCloseSettlement>,
296    bootstrap: crate::sync::store::SnapshotCut,
297}
298
299impl CircleFinalizeEpochCloseRequest {
300    #[allow(clippy::too_many_arguments)]
301    pub(super) fn new(
302        operation_id: coven_protocol::circle::CircleOperationId,
303        circle_id: CircleId,
304        member_pubkey: String,
305        metadata_stamp: String,
306        current: CircleAuthoringState,
307        previous_control: CircleControlRef,
308        roster_chain: CircleRosterChain,
309        intent: coven_protocol::circle::CircleEpochCloseIntent,
310        responses: Vec<coven_protocol::circle::CircleEpochCloseSettlement>,
311        bootstrap: crate::sync::store::SnapshotCut,
312    ) -> Self {
313        Self {
314            operation_id,
315            circle_id,
316            member_pubkey,
317            metadata_stamp,
318            current,
319            previous_control,
320            roster_chain,
321            intent,
322            responses,
323            bootstrap,
324        }
325    }
326
327    pub(super) fn bootstrap_blobs(&self) -> &[coven_database::SnapshotBlobFact] {
328        self.bootstrap.blobs()
329    }
330
331    pub(super) async fn read_bootstrap_image(
332        &self,
333    ) -> Result<Vec<u8>, coven_database::SnapshotImageError> {
334        self.bootstrap.read_image().await
335    }
336
337    pub(super) fn bootstrap_coverage(&self) -> &coven_protocol::store_commit::CommitFrontier {
338        self.bootstrap.coverage()
339    }
340}
341
342pub(crate) struct CircleCancelEpochCloseRequest {
343    pub(super) operation_id: coven_protocol::circle::CircleOperationId,
344    pub(super) circle_id: CircleId,
345    pub(super) member_pubkey: String,
346    pub(super) current: CircleAuthoringState,
347    pub(super) previous_control: CircleControlRef,
348}
349
350pub(crate) enum CircleOperationRequest {
351    Create {
352        name: String,
353        metadata_stamp: String,
354    },
355    Rename(Box<CircleRenameRequest>),
356    AddMember(Box<CircleAddMemberRequest>),
357    RemoveMember(Box<CircleRemoveMemberRequest>),
358    ResolveControl(Box<CircleResolveControlRequest>),
359    Delete(Box<CircleDeleteRequest>),
360    FinalizeEpochClose(Box<CircleFinalizeEpochCloseRequest>),
361    CancelEpochClose(Box<CircleCancelEpochCloseRequest>),
362}
363
364impl CircleOperationRequest {
365    pub(super) fn intent(&self) -> CircleOperationIntent {
366        match self {
367            Self::Create { name, .. } => CircleOperationIntent::Create { name: name.clone() },
368            Self::Rename(request) => CircleOperationIntent::Rename {
369                name: request.name.clone(),
370            },
371            Self::AddMember(request) => CircleOperationIntent::AddMember {
372                member_pubkey: request.member_pubkey.clone(),
373                role: request.role,
374            },
375            Self::RemoveMember(request) => CircleOperationIntent::RemoveMember {
376                member_pubkey: request.member_pubkey.clone(),
377            },
378            Self::ResolveControl(request) => CircleOperationIntent::ResolveControl {
379                chosen: request.chosen.control.coord.clone(),
380            },
381            Self::Delete(_) => CircleOperationIntent::Delete,
382            Self::FinalizeEpochClose(request) => CircleOperationIntent::RemoveMember {
383                member_pubkey: request.member_pubkey.clone(),
384            },
385            Self::CancelEpochClose(request) => CircleOperationIntent::RemoveMember {
386                member_pubkey: request.member_pubkey.clone(),
387            },
388        }
389    }
390
391    pub(super) fn history(&self) -> CircleTransitionHistory {
392        let previous_control = match self {
393            Self::Create { .. } => return CircleTransitionHistory::Founder,
394            Self::Rename(request) => &request.previous_control,
395            Self::AddMember(request) => &request.previous_control,
396            Self::RemoveMember(request) => &request.previous_control,
397            Self::ResolveControl(request) => &request.previous_control,
398            Self::Delete(request) => &request.previous_control,
399            Self::FinalizeEpochClose(request) => &request.previous_control,
400            Self::CancelEpochClose(request) => &request.previous_control,
401        };
402        CircleTransitionHistory::Successor(Box::new(previous_control.clone()))
403    }
404
405    /// The stable operation id and derived write identity for a close settlement.
406    /// Finalize and cancel settle the same durable operation but derive distinct
407    /// write identities, so a crashed settlement resumes as the kind it began as
408    /// rather than being re-derived into the other.
409    pub(super) fn settlement(
410        &self,
411    ) -> Option<(
412        coven_protocol::circle::CircleOperationId,
413        coven_protocol::write::WriteId,
414    )> {
415        match self {
416            Self::FinalizeEpochClose(request) => Some((
417                request.operation_id.clone(),
418                request.operation_id.finalization_write_id(),
419            )),
420            Self::CancelEpochClose(request) => Some((
421                request.operation_id.clone(),
422                request.operation_id.cancellation_write_id(),
423            )),
424            Self::Create { .. }
425            | Self::Rename(_)
426            | Self::AddMember(_)
427            | Self::RemoveMember(_)
428            | Self::ResolveControl(_)
429            | Self::Delete(_) => None,
430        }
431    }
432}