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