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