Skip to main content

coven_protocol/
circle_journal.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4
5use crate::circle::{
6    CircleId, CircleOperationId, CircleOperationKind, CircleOperationState,
7    PreparedCircleTransition,
8};
9use crate::objects::{ExactObjectRef, PreparedExactObject};
10use crate::store_commit::{StoreBatchCommit, StoreBatchCommitRef, StoreDeviceHead};
11
12/// A journal whose recorded state contradicts itself or the commit it
13/// describes. Produced by the journal's own validation; workflow errors wrap
14/// it at the operation boundary.
15#[derive(Debug, thiserror::Error)]
16pub enum CircleJournalError {
17    #[error("Circle operation journal: {0}")]
18    Invariant(String),
19    #[error("Circle operation journal protocol: {0}")]
20    Protocol(#[from] crate::store_commit::StoreProtocolError),
21    #[error("Circle operation journal remote object: {0}")]
22    RemoteObject(#[from] crate::remote_object::RemoteObjectRecordError),
23    #[error("{operation}: {source}")]
24    Json {
25        operation: &'static str,
26        #[source]
27        source: serde_json::Error,
28    },
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct CircleOperationPolicy {
34    pub head: StoreDeviceHead,
35    pub history_evidence: crate::store_commit::RetainedMergeCommitEvidence,
36}
37
38/// One Circle operation as prepared: everything the publication pipeline needs
39/// to upload its object graph, and nothing that changes while it does.
40///
41/// The objects themselves are named, not carried. Their stored bytes live in
42/// the payload spool under each reference's stored hash, written before the row
43/// that names them, so this value stays KB-scale however large the graph is —
44/// and the upload progress that does change per step lives in
45/// `circle_operation_uploads`, not here.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct PreparedCircleOperation {
49    pub creation: PreparedCircleTransition,
50    pub history: CircleTransitionHistory,
51    pub commit_bytes: Vec<u8>,
52    pub commit_ref: StoreBatchCommitRef,
53    pub prepared_objects: BTreeMap<String, ExactObjectRef>,
54    pub policy: CircleOperationPolicy,
55}
56
57impl PreparedCircleOperation {
58    /// Refuse a byte-carrying object map that is not this operation's own.
59    ///
60    /// The spool holds the bytes and this value holds the references; a caller
61    /// that supplies both is asserting they belong together, and the assertion
62    /// is checked rather than trusted.
63    pub fn require_prepared_objects(
64        &self,
65        prepared: &BTreeMap<String, PreparedExactObject>,
66    ) -> Result<(), CircleJournalError> {
67        if prepared.len() != self.prepared_objects.len()
68            || !prepared
69                .iter()
70                .all(|(step, object)| self.prepared_objects.get(step) == Some(object.reference()))
71        {
72            return Err(CircleJournalError::Invariant(
73                "Circle prepared object bytes name a different object graph than the operation"
74                    .to_string(),
75            ));
76        }
77        Ok(())
78    }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case", deny_unknown_fields)]
83pub enum CircleTransitionHistory {
84    Founder,
85    Successor(Box<crate::store_commit::CircleControlRef>),
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case", deny_unknown_fields)]
90pub enum CircleOperationIntent {
91    Create {
92        name: String,
93    },
94    Rename {
95        name: String,
96    },
97    AddMember {
98        member_pubkey: String,
99        role: crate::circle::CircleRole,
100    },
101    RemoveMember {
102        member_pubkey: String,
103    },
104    ResolveControl {
105        chosen: crate::circle::CircleControlCoord,
106    },
107    Delete,
108}
109
110/// Where one Circle operation stands. Persisted on its own, apart from the
111/// operation it describes: this is what a transition rewrites, and the prepared
112/// operation is what a transition leaves alone.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case", deny_unknown_fields)]
115pub enum CircleOperationProgress {
116    Ready,
117    WaitingForCloseResponses,
118    Finalizing,
119    Blocked {
120        block: crate::circle::CircleOperationBlock,
121        phase: CircleOperationPhase,
122    },
123    /// A verified nonactivation proof was accepted. The candidate's exclusive
124    /// objects are being exact-deleted and the durable row cleared in the
125    /// completing transaction. The retained operation identifies the candidate
126    /// graph so a restart resumes the exact same cleanup.
127    Discarding,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "snake_case")]
132pub enum CircleOperationPhase {
133    Initial,
134    Finalization,
135}
136
137/// One Circle operation as it stands right now: the identity and prepared
138/// operation held in `circle_operations`, the phase held beside them, and the
139/// upload steps already completed, joined from `circle_operation_uploads`.
140///
141/// The three parts have different lifetimes on disk, which is why they are
142/// stored apart: the operation is written once, the phase changes on
143/// transitions, and the upload steps accumulate one row at a time.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct CircleOperationJournal {
147    pub operation_id: CircleOperationId,
148    pub circle_id: CircleId,
149    pub intent: CircleOperationIntent,
150    pub operation: PreparedCircleOperation,
151    pub progress: CircleOperationProgress,
152    pub uploaded: BTreeSet<String>,
153}
154
155impl CircleOperationJournal {
156    /// A freshly prepared operation, with nothing uploaded yet.
157    pub fn ready(
158        operation_id: CircleOperationId,
159        circle_id: CircleId,
160        intent: CircleOperationIntent,
161        operation: PreparedCircleOperation,
162    ) -> Self {
163        Self {
164            operation_id,
165            circle_id,
166            intent,
167            operation,
168            progress: CircleOperationProgress::Ready,
169            uploaded: BTreeSet::new(),
170        }
171    }
172
173    pub fn circle_id(&self) -> CircleId {
174        self.circle_id
175    }
176
177    pub fn operation(&self) -> &PreparedCircleOperation {
178        &self.operation
179    }
180
181    pub fn operation_mut(&mut self) -> &mut PreparedCircleOperation {
182        &mut self.operation
183    }
184
185    /// Refuse an upload step that names no object in this operation. Every
186    /// completed step must name one, so a joined upload row that does not is a
187    /// journal that contradicts itself.
188    pub fn validate_uploaded(&self) -> Result<(), CircleJournalError> {
189        for step in &self.uploaded {
190            if !self.operation.prepared_objects.contains_key(step) {
191                return Err(CircleJournalError::Invariant(format!(
192                    "Circle upload marker {step} names no prepared object"
193                )));
194            }
195        }
196        Ok(())
197    }
198
199    /// The objects of this operation that `remote_objects` holds a record for:
200    /// its commit's candidate-exclusive graph, plus the commit and the Store
201    /// head published with it.
202    ///
203    /// The rest of an operation's objects — its control head, roster and
204    /// metadata — are shared Circle objects the candidate does not own
205    /// exclusively, so completing their upload step has no candidate record to
206    /// mark. This names that set so a caller dispatches on it rather than
207    /// discovering it by a lookup that comes back empty.
208    pub fn candidate_owned_objects(&self) -> Result<BTreeSet<ExactObjectRef>, CircleJournalError> {
209        let operation = self.operation();
210        let commit = self.commit()?;
211        operation.commit_ref.verify_commit(&commit)?;
212        let mut objects = crate::remote_object::CandidateObjectGraph::from_commit(&commit)?
213            .exact_objects()
214            .cloned()
215            .collect::<BTreeSet<_>>();
216        objects.insert(operation.commit_ref.object.clone());
217        objects.insert(
218            operation
219                .prepared_objects
220                .get("store-head")
221                .ok_or_else(|| {
222                    CircleJournalError::Invariant(
223                        "Circle operation lacks its prepared Store head".to_string(),
224                    )
225                })?
226                .clone(),
227        );
228        Ok(objects)
229    }
230
231    /// The candidate graph this operation would activate, closed over the
232    /// stored bytes of its objects.
233    ///
234    /// The bytes come from the caller because this value holds only references
235    /// to them: the durable copy is in the payload spool, and the caller that
236    /// has just written or read it supplies what it read.
237    pub fn closed_remote_objects(
238        &self,
239        prepared_objects: &BTreeMap<String, PreparedExactObject>,
240    ) -> Result<Vec<crate::remote_object::ClosedRemoteObject>, CircleJournalError> {
241        let operation = self.operation();
242        operation.require_prepared_objects(prepared_objects)?;
243        let commit: StoreBatchCommit =
244            serde_json::from_slice(&operation.commit_bytes).map_err(|source| {
245                CircleJournalError::Json {
246                    operation: "parse Circle commit",
247                    source,
248                }
249            })?;
250        operation.commit_ref.verify_commit(&commit)?;
251        let access_refs = commit
252            .circle_controls()
253            .iter()
254            .flat_map(|control| control.objects().access.iter())
255            .collect::<Vec<_>>();
256        if access_refs.len() != operation.creation.access.len() {
257            return Err(CircleJournalError::Invariant(
258                "Circle access material does not cover the signed candidate graph".to_string(),
259            ));
260        }
261        let prepared_for = |object: &ExactObjectRef| {
262            prepared_objects
263                .values()
264                .find(|prepared| prepared.reference() == object)
265                .ok_or_else(|| {
266                    CircleJournalError::Invariant(format!(
267                        "Circle candidate object {} has no prepared bytes",
268                        crate::remote_object::remote_object_id(object)
269                    ))
270                })
271        };
272        let mut materials = Vec::with_capacity(access_refs.len() * 3 + 1);
273        let [circle_reference] = commit.circle_controls() else {
274            return Err(CircleJournalError::Invariant(
275                "Circle operation commit must activate exactly one Circle control".to_string(),
276            ));
277        };
278        match (
279            &circle_reference.objects().close_intent,
280            &operation.creation.close_intent,
281        ) {
282            (Some(reference), Some(intent))
283                if reference.close_id == intent.close_id
284                    && reference.intent_hash == intent.intent_hash() =>
285            {
286                let prepared = prepared_for(&reference.object)?;
287                materials.push(crate::remote_object::CandidateObjectMaterial {
288                    object: reference.object.clone(),
289                    canonical_semantic_bytes: serde_json::to_vec(intent).map_err(|source| {
290                        CircleJournalError::Json {
291                            operation: "serialize Circle epoch-close intent",
292                            source,
293                        }
294                    })?,
295                    stored_bytes: prepared.stored_bytes().to_vec(),
296                });
297            }
298            (None, None) => {}
299            _ => {
300                return Err(CircleJournalError::Invariant(
301                    "Circle epoch-close intent does not match its signed candidate graph"
302                        .to_string(),
303                ));
304            }
305        }
306        match (
307            &circle_reference.objects().close_outcome,
308            &operation.creation.close_outcome,
309        ) {
310            (Some(reference), Some(outcome))
311                if reference.close_id == outcome.close_id
312                    && reference.outcome_hash == outcome.outcome_hash() =>
313            {
314                let prepared = prepared_for(&reference.object)?;
315                materials.push(crate::remote_object::CandidateObjectMaterial {
316                    object: reference.object.clone(),
317                    canonical_semantic_bytes: crate::circle::CircleEpochCloseSlotValue::Outcome(
318                        outcome.clone(),
319                    )
320                    .to_bytes(),
321                    stored_bytes: prepared.stored_bytes().to_vec(),
322                });
323            }
324            (None, None) => {}
325            _ => {
326                return Err(CircleJournalError::Invariant(
327                    "Circle epoch-close outcome does not match its signed candidate graph"
328                        .to_string(),
329                ));
330            }
331        }
332        match (
333            &circle_reference.objects().close_cancellation,
334            &operation.creation.close_cancellation,
335        ) {
336            (Some(reference), Some(cancellation))
337                if reference.close_id == cancellation.close_id
338                    && reference.cancellation_hash == cancellation.cancellation_hash() =>
339            {
340                let prepared = prepared_for(&reference.object)?;
341                materials.push(crate::remote_object::CandidateObjectMaterial {
342                    object: reference.object.clone(),
343                    canonical_semantic_bytes:
344                        crate::circle::CircleEpochCloseSlotValue::Cancellation(cancellation.clone())
345                            .to_bytes(),
346                    stored_bytes: prepared.stored_bytes().to_vec(),
347                });
348            }
349            (None, None) => {}
350            _ => {
351                return Err(CircleJournalError::Invariant(
352                    "Circle epoch-close cancellation does not match its signed candidate graph"
353                        .to_string(),
354                ));
355            }
356        }
357        let mut bootstrap_blobs = BTreeMap::new();
358        for (access, reference) in operation.creation.access.iter().zip(access_refs) {
359            let leaf = prepared_for(&reference.leaf.object)?;
360            materials.push(crate::remote_object::CandidateObjectMaterial {
361                object: reference.leaf.object.clone(),
362                canonical_semantic_bytes: serde_json::to_vec(&access.leaf.value).map_err(
363                    |source| CircleJournalError::Json {
364                        operation: "serialize Circle access leaf",
365                        source,
366                    },
367                )?,
368                stored_bytes: leaf.stored_bytes().to_vec(),
369            });
370            let envelope = prepared_for(&reference.envelope.object)?;
371            materials.push(crate::remote_object::CandidateObjectMaterial {
372                object: reference.envelope.object.clone(),
373                canonical_semantic_bytes: serde_json::to_vec(&access.envelope).map_err(
374                    |source| CircleJournalError::Json {
375                        operation: "serialize Circle access envelope",
376                        source,
377                    },
378                )?,
379                stored_bytes: envelope.stored_bytes().to_vec(),
380            });
381            if let Some(bootstrap) = &reference.bootstrap {
382                let image = prepared_for(&bootstrap.object)?;
383                materials.push(crate::remote_object::CandidateObjectMaterial {
384                    object: bootstrap.object.clone(),
385                    canonical_semantic_bytes: Vec::new(),
386                    stored_bytes: image.stored_bytes().to_vec(),
387                });
388            }
389            if let crate::circle::CircleAccessDisposition::Active {
390                bootstrap: Some(bootstrap),
391                ..
392            } = &access.leaf.value.disposition
393            {
394                for blob in &bootstrap.blobs {
395                    let stored = blob.stored().ok_or_else(|| {
396                        CircleJournalError::Invariant(
397                            "Circle bootstrap row blob has no exact stored locator".to_string(),
398                        )
399                    })?;
400                    let object_id = crate::remote_object::remote_object_id(stored.object());
401                    if bootstrap_blobs
402                        .insert(object_id, stored.clone())
403                        .is_some_and(|existing| existing != *stored)
404                    {
405                        return Err(CircleJournalError::Invariant(format!(
406                            "Circle bootstrap blob {object_id} has conflicting exact references"
407                        )));
408                    }
409                }
410            }
411        }
412        let mut remotes = crate::remote_object::CandidateObjectGraph::from_commit(&commit)
413            .and_then(|graph| graph.close(&commit, &operation.commit_ref, materials))?;
414        for blob in bootstrap_blobs.into_values() {
415            remotes.push(
416                crate::remote_object::RemoteObjectRecord::candidate_owned_blob(
417                    &blob,
418                    operation.commit_ref.clone(),
419                    true,
420                )?,
421            );
422        }
423        let commit_prepared = prepared_objects.get("store-commit").ok_or_else(|| {
424            CircleJournalError::Invariant(
425                "Circle operation lacks its prepared Store commit".to_string(),
426            )
427        })?;
428        remotes.push(crate::remote_object::RemoteObjectRecord::candidate_commit(
429            operation.commit_ref.clone(),
430            &operation.commit_bytes,
431            commit_prepared.stored_bytes(),
432        )?);
433        let prepared = prepared_objects.get("store-head").ok_or_else(|| {
434            CircleJournalError::Invariant(
435                "Circle operation lacks its prepared Store head".to_string(),
436            )
437        })?;
438        remotes.push(
439            crate::remote_object::RemoteObjectRecord::candidate_activated_store_head(
440                crate::store_commit::StoreDeviceHeadRef {
441                    head_hash: operation.policy.head.head_hash(),
442                    object: prepared.reference().clone(),
443                },
444                &operation.policy.head.to_bytes(),
445                prepared.stored_bytes(),
446                operation.commit_ref.clone(),
447            )?,
448        );
449        Ok(remotes)
450    }
451
452    pub fn state(&self) -> CircleOperationState {
453        match &self.progress {
454            CircleOperationProgress::Ready => CircleOperationState::Pending,
455            CircleOperationProgress::WaitingForCloseResponses => {
456                CircleOperationState::WaitingForCloseResponses
457            }
458            CircleOperationProgress::Finalizing => CircleOperationState::Finalizing,
459            CircleOperationProgress::Blocked { block, .. } => CircleOperationState::Blocked {
460                block: block.clone(),
461            },
462            CircleOperationProgress::Discarding => CircleOperationState::Discarding,
463        }
464    }
465
466    /// Enter cleanup after a verified nonactivation proof was accepted. Legal
467    /// from any state whose candidate has not activated — a ready or blocked
468    /// initial candidate, or a finalization candidate. A candidate that already
469    /// won its slot has no journal row in these states, so no path reaches here.
470    pub fn begin_discard(&mut self) -> Result<(), CircleJournalError> {
471        match &self.progress {
472            CircleOperationProgress::Ready
473            | CircleOperationProgress::Finalizing
474            | CircleOperationProgress::Blocked { .. } => {}
475            CircleOperationProgress::WaitingForCloseResponses
476            | CircleOperationProgress::Discarding => {
477                return Err(CircleJournalError::Invariant(format!(
478                    "Circle operation {} cannot enter discard from its current state",
479                    self.operation_id
480                )));
481            }
482        }
483        self.progress = CircleOperationProgress::Discarding;
484        Ok(())
485    }
486
487    pub fn is_discarding(&self) -> bool {
488        matches!(&self.progress, CircleOperationProgress::Discarding)
489    }
490
491    pub fn block(
492        &mut self,
493        block: crate::circle::CircleOperationBlock,
494    ) -> Result<(), CircleJournalError> {
495        let phase = match &self.progress {
496            CircleOperationProgress::Ready => CircleOperationPhase::Initial,
497            CircleOperationProgress::Finalizing => CircleOperationPhase::Finalization,
498            CircleOperationProgress::WaitingForCloseResponses
499            | CircleOperationProgress::Blocked { .. }
500            | CircleOperationProgress::Discarding => {
501                return Err(CircleJournalError::Invariant(format!(
502                    "Circle operation {} is not publishable",
503                    self.operation_id
504                )));
505            }
506        };
507        self.progress = CircleOperationProgress::Blocked { block, phase };
508        Ok(())
509    }
510
511    /// Return a blocked operation to the phase captured when it blocked, so it
512    /// re-enters the idempotent publish pipeline against its exact retained
513    /// operation.
514    pub fn unblock(&mut self) -> Result<(), CircleJournalError> {
515        let CircleOperationProgress::Blocked { phase, .. } = &self.progress else {
516            return Err(CircleJournalError::Invariant(format!(
517                "Circle operation {} is not blocked",
518                self.operation_id
519            )));
520        };
521        self.progress = match phase {
522            CircleOperationPhase::Initial => CircleOperationProgress::Ready,
523            CircleOperationPhase::Finalization => CircleOperationProgress::Finalizing,
524        };
525        Ok(())
526    }
527
528    pub fn wait_for_close_responses(&mut self) -> Result<(), CircleJournalError> {
529        if !matches!(&self.progress, CircleOperationProgress::Ready) {
530            return Err(CircleJournalError::Invariant(format!(
531                "Circle operation {} is not ready to enter close-response waiting",
532                self.operation_id
533            )));
534        }
535        self.progress = CircleOperationProgress::WaitingForCloseResponses;
536        Ok(())
537    }
538
539    /// Install the freshly prepared finalization operation, replacing the one
540    /// that reached its close.
541    ///
542    /// This is the one point in an operation's life where the prepared
543    /// operation changes: the finalization commit is a new candidate graph.
544    /// Its steps are named for the object kinds they carry, so they repeat the
545    /// names the superseded operation used — which is why the completed uploads
546    /// go with the operation they belonged to.
547    pub fn begin_finalization(
548        &mut self,
549        operation: PreparedCircleOperation,
550    ) -> Result<(), CircleJournalError> {
551        if !matches!(
552            &self.progress,
553            CircleOperationProgress::WaitingForCloseResponses
554        ) {
555            return Err(CircleJournalError::Invariant(format!(
556                "Circle operation {} is not waiting for close responses",
557                self.operation_id
558            )));
559        }
560        self.operation = operation;
561        self.uploaded.clear();
562        self.progress = CircleOperationProgress::Finalizing;
563        Ok(())
564    }
565
566    pub fn is_finalizing(&self) -> bool {
567        matches!(
568            &self.progress,
569            CircleOperationProgress::Finalizing
570                | CircleOperationProgress::Blocked {
571                    phase: CircleOperationPhase::Finalization,
572                    ..
573                }
574        )
575    }
576
577    pub fn is_publishable(&self) -> bool {
578        matches!(
579            &self.progress,
580            CircleOperationProgress::Ready | CircleOperationProgress::Finalizing
581        )
582    }
583
584    pub fn commit(&self) -> Result<StoreBatchCommit, CircleJournalError> {
585        serde_json::from_slice(&self.operation().commit_bytes).map_err(|source| {
586            CircleJournalError::Json {
587                operation: "parse Store commit",
588                source,
589            }
590        })
591    }
592
593    pub fn validate_identity(&self) -> Result<(), CircleJournalError> {
594        if self.operation().creation.circle_id != self.circle_id {
595            return Err(CircleJournalError::Invariant(format!(
596                "circle operation {} payload names circle {} but its operation names circle {}",
597                self.operation_id,
598                self.circle_id,
599                self.operation().creation.circle_id
600            )));
601        }
602        let commit = self.commit()?;
603        let expected_write_id = if self.is_finalizing() {
604            if self.operation().creation.close_cancellation.is_some() {
605                self.operation_id.cancellation_write_id()
606            } else {
607                self.operation_id.finalization_write_id()
608            }
609        } else {
610            crate::write::WriteId::from_generated(self.operation_id.as_str().to_string())
611        };
612        if commit.write_id != expected_write_id {
613            return Err(CircleJournalError::Invariant(format!(
614                "circle operation id {} differs from payload commit operation id {}",
615                self.operation_id, commit.write_id
616            )));
617        }
618        Ok(())
619    }
620
621    pub fn kind(&self) -> CircleOperationKind {
622        match self.intent {
623            CircleOperationIntent::Create { .. } => CircleOperationKind::Create,
624            CircleOperationIntent::Rename { .. } => CircleOperationKind::Rename,
625            CircleOperationIntent::AddMember { .. } => CircleOperationKind::AddMember,
626            CircleOperationIntent::RemoveMember { .. } => CircleOperationKind::RemoveMember,
627            CircleOperationIntent::ResolveControl { .. } => CircleOperationKind::ResolveControl,
628            CircleOperationIntent::Delete => CircleOperationKind::Delete,
629        }
630    }
631}