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#[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#[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 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 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#[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 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#[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 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 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 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 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 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 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 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}