Skip to main content

coven_database/store/store_session/
circle_operation_discard.rs

1//! Journal-sourced durable state for discarding a Circle operation whose
2//! candidate has a verified permanent-nonactivation proof. The object-graph
3//! machine is shared verbatim with Merge candidate abandonment
4//! ([`candidate_records`]); only the durable home differs — a Merge candidate
5//! lives in `store_writes.prepared`, a Circle operation in the
6//! `circle_operations` journal — so these methods build the same
7//! [`PreparedMergeCandidate`] from the journal payload and clear the journal row
8//! (instead of the write row) when cleanup completes.
9
10use super::candidate_records::{
11    blocked_merge_candidate_from_prepared, blocked_merge_candidate_nonactivation,
12    merge_candidate_cleanup_targets_on, parse_prepared_merge_candidate_parts_on,
13    terminal_candidate_verification_on, CandidateCleanupObject, PreparedMergeCandidate,
14};
15use super::{StoreDatabase, StoreSession};
16use crate::{
17    candidate_graph_exact_objects, finish_remote_candidate_nonactivation_on,
18    load_protocol_inert_object_on, load_remote_object_on, TerminalCandidateCleanupVerification,
19};
20use coven_protocol::circle::{CircleAccessDisposition, CircleOperationId};
21use coven_protocol::circle_journal::{CircleOperationJournal, PreparedCircleOperation};
22use coven_protocol::objects::ExactObjectRef;
23use coven_protocol::remote_object::remote_object_id;
24use rusqlite::Connection;
25
26/// The candidate a Circle operation would activate, plus the shared bootstrap
27/// blob objects it owns. The blobs are ownership-tracked (`SharedLiveSet`), so
28/// they ride the same nonactivation pass Merge indexed blobs take: the candidate
29/// is removed from their ownership, and any that end up sole-owned retire by the
30/// shared path rather than as candidate-exclusive deletions.
31struct CircleOperationCandidate {
32    candidate: PreparedMergeCandidate,
33    bootstrap_blobs: Vec<ExactObjectRef>,
34}
35
36impl crate::store::store_session::StoreTransaction<'_, '_> {
37    fn circle_operation_candidate(
38        self,
39        authority: &mut super::VerifiedStoreAuthority,
40        operation: &PreparedCircleOperation,
41    ) -> Result<CircleOperationCandidate, crate::DbError> {
42        circle_operation_candidate_on(
43            crate::store::store_session::StoreRecords::new(self.transaction, self.store_dir),
44            authority,
45            operation,
46        )
47    }
48}
49
50pub struct CircleOperationDiscardCandidate {
51    pub candidate: crate::BlockedMergeCandidate,
52    pub revoked_grant: Option<coven_protocol::membership::MembershipGrantId>,
53}
54
55fn circle_operation_candidate_on(
56    records: crate::store::store_session::StoreRecords<'_>,
57    authority: &mut super::VerifiedStoreAuthority,
58    operation: &PreparedCircleOperation,
59) -> Result<CircleOperationCandidate, crate::DbError> {
60    let commit_object = operation
61        .prepared_objects
62        .get("store-commit")
63        .ok_or_else(|| {
64            crate::DbError::Message("Circle operation lacks its prepared Store commit".to_string())
65        })?;
66    let head_object = operation
67        .prepared_objects
68        .get("store-head")
69        .ok_or_else(|| {
70            crate::DbError::Message("Circle operation lacks its prepared Store head".to_string())
71        })?;
72    let candidate = parse_prepared_merge_candidate_parts_on(
73        records,
74        authority,
75        &operation.commit_bytes,
76        commit_object,
77        &operation.policy.head.to_bytes(),
78        head_object,
79    )?;
80    if candidate.reference != operation.commit_ref {
81        return Err(crate::DbError::Message(
82            "Circle operation candidate differs from its durable commit reference".to_string(),
83        ));
84    }
85    let mut bootstrap_blobs = Vec::new();
86    for access in &operation.creation.access {
87        if let CircleAccessDisposition::Active {
88            bootstrap: Some(bootstrap),
89            ..
90        } = &access.leaf.value.disposition
91        {
92            for blob in &bootstrap.blobs {
93                let stored = blob.stored().ok_or_else(|| {
94                    crate::DbError::Message(
95                        "Circle bootstrap row blob has no exact stored locator".to_string(),
96                    )
97                })?;
98                bootstrap_blobs.push(stored.object().clone());
99            }
100        }
101    }
102    Ok(CircleOperationCandidate {
103        candidate,
104        bootstrap_blobs,
105    })
106}
107
108impl StoreSession<'_> {
109    fn circle_operation_discard_candidate(
110        &mut self,
111        operation_id: String,
112    ) -> Result<CircleOperationDiscardCandidate, crate::DbError> {
113        let records = crate::store::store_session::StoreRecords::new(self.conn, self.store_dir);
114        let journal = load_discardable_operation_on(self.conn, &operation_id)?;
115        let candidate = circle_operation_candidate_on(
116            records,
117            self.verified_store_authority,
118            journal.operation(),
119        )?;
120        let revoked_grant = match journal.state() {
121            coven_protocol::circle::CircleOperationState::Blocked {
122                block: coven_protocol::circle::CircleOperationBlock::AuthorityLost { grant_id },
123            } => Some(grant_id),
124            coven_protocol::circle::CircleOperationState::Blocked {
125                block: coven_protocol::circle::CircleOperationBlock::PositionLost { .. },
126            }
127            | coven_protocol::circle::CircleOperationState::Pending
128            | coven_protocol::circle::CircleOperationState::WaitingForCloseResponses
129            | coven_protocol::circle::CircleOperationState::Finalizing
130            | coven_protocol::circle::CircleOperationState::Discarding => None,
131        };
132        Ok(CircleOperationDiscardCandidate {
133            candidate: blocked_merge_candidate_from_prepared(candidate.candidate),
134            revoked_grant,
135        })
136    }
137
138    fn begin_circle_operation_discard(
139        &mut self,
140        root: coven_protocol::store_commit::StoreRootRef,
141        operation_id: String,
142        nonactivation: coven_protocol::remote_object::VerifiedCandidateNonactivation,
143    ) -> Result<(), crate::DbError> {
144        let nonactivation = blocked_merge_candidate_nonactivation(nonactivation)?;
145        let tx = self
146            .conn
147            .unchecked_transaction()
148            .map_err(crate::DbError::from)?;
149        let mut journal = load_discardable_operation_on(&tx, &operation_id)?;
150        let CircleOperationCandidate {
151            candidate,
152            bootstrap_blobs,
153        } = crate::store::store_session::StoreTransaction::new(&tx, self.store_dir)
154            .circle_operation_candidate(self.verified_store_authority, journal.operation())?;
155        crate::store::store_session::StoreTransaction::new(&tx, self.store_dir)
156            .begin_blocked_merge_candidate_nonactivation(
157                self.verified_store_authority,
158                &root,
159                &candidate.commit.write_id,
160                &candidate,
161                &nonactivation,
162                false,
163                &bootstrap_blobs,
164            )?;
165        journal.begin_discard().map_err(crate::DbError::from)?;
166        super::circle_controls::update_circle_operation_phase_on(&tx, &journal)?;
167        tx.commit().map_err(crate::DbError::from)
168    }
169
170    fn circle_operation_discard_terminal_verifications(
171        &mut self,
172        root: coven_protocol::store_commit::StoreRootRef,
173        operation_id: String,
174    ) -> Result<Vec<TerminalCandidateCleanupVerification>, crate::DbError> {
175        let records = crate::store::store_session::StoreRecords::new(self.conn, self.store_dir);
176        let journal = load_discarding_operation_on(self.conn, &operation_id)?;
177        let candidate = circle_operation_candidate_on(
178            records,
179            self.verified_store_authority,
180            journal.operation(),
181        )?;
182        Ok(terminal_candidate_verification_on(
183            records,
184            self.verified_store_authority,
185            &root,
186            candidate.candidate,
187        )?
188        .into_iter()
189        .collect())
190    }
191
192    fn reconcile_circle_operation_terminal_head(
193        &mut self,
194        root: coven_protocol::store_commit::StoreRootRef,
195        operation_id: String,
196        durable: coven_protocol::remote_object::CandidateNonactivation,
197        head_nonactivation: coven_protocol::remote_object::VerifiedCandidateHeadNonactivation,
198    ) -> Result<(), crate::DbError> {
199        let tx = self
200            .conn
201            .unchecked_transaction()
202            .map_err(crate::DbError::from)?;
203        let journal = load_discarding_operation_on(&tx, &operation_id)?;
204        let store_transaction =
205            crate::store::store_session::StoreTransaction::new(&tx, self.store_dir);
206        let candidate = store_transaction
207            .circle_operation_candidate(self.verified_store_authority, journal.operation())?
208            .candidate;
209        let reference = durable.reference().map_err(crate::DbError::from)?;
210        if reference != candidate.reference {
211            return Err(crate::DbError::Message(
212                "fresh excluded-author head evidence names another candidate".to_string(),
213            ));
214        }
215        store_transaction.validate_terminal_candidate_authority(
216            self.verified_store_authority,
217            &root,
218            &candidate,
219            &durable,
220        )?;
221        let object_id = remote_object_id(&candidate.head_object);
222        let remote_exists: bool = tx
223            .query_row(
224                "SELECT EXISTS(SELECT 1 FROM remote_objects WHERE object_id = ?1)",
225                [object_id.to_string()],
226                |row| row.get(0),
227            )
228            .map_err(crate::DbError::from)?;
229        if !remote_exists {
230            let inert = load_protocol_inert_object_on(&tx, object_id)?;
231            if inert
232                .candidate_nonactivation_proof(&candidate.reference)
233                .map_err(crate::DbError::from)?
234                != Some(durable.proof())
235            {
236                return Err(crate::DbError::Message(
237                    "protocol-inert candidate head carries another proof".to_string(),
238                ));
239            }
240            return tx.commit().map_err(crate::DbError::from);
241        }
242        let mut remote = load_remote_object_on(&tx, object_id)?;
243        let inert = remote
244            .begin_candidate_nonactivation_with_verified_head_nonactivation(
245                durable,
246                &head_nonactivation,
247            )
248            .map_err(|error| {
249                crate::DbError::context(
250                    format!("reconcile excluded-author head {object_id}"),
251                    error,
252                )
253            })?;
254        finish_remote_candidate_nonactivation_on(&tx, object_id, remote, inert)?;
255        tx.commit().map_err(crate::DbError::from)
256    }
257
258    fn circle_operation_discard_cleanup_targets(
259        &mut self,
260        operation_id: String,
261    ) -> Result<Vec<CandidateCleanupObject>, crate::DbError> {
262        let records = crate::store::store_session::StoreRecords::new(self.conn, self.store_dir);
263        let journal = load_discarding_operation_on(self.conn, &operation_id)?;
264        let CircleOperationCandidate {
265            candidate,
266            bootstrap_blobs,
267        } = circle_operation_candidate_on(
268            records,
269            self.verified_store_authority,
270            journal.operation(),
271        )?;
272        merge_candidate_cleanup_targets_on(
273            self.conn,
274            &candidate.commit.write_id,
275            &candidate,
276            false,
277            &bootstrap_blobs,
278        )
279    }
280
281    fn discarding_circle_operations(&mut self) -> Result<Vec<CircleOperationId>, crate::DbError> {
282        let conn = self.conn;
283        crate::circle_operation_ids_in_phase_on(conn, |progress| {
284            matches!(
285                progress,
286                coven_protocol::circle_journal::CircleOperationProgress::Discarding
287            )
288        })?
289        .into_iter()
290        .map(|operation_id| Ok(load_discarding_operation_on(conn, &operation_id)?.operation_id))
291        .collect()
292    }
293
294    fn finish_circle_operation_discard(
295        &mut self,
296        operation_id: String,
297    ) -> Result<(), crate::DbError> {
298        let tx = self
299            .conn
300            .unchecked_transaction()
301            .map_err(crate::DbError::from)?;
302        let journal = load_discarding_operation_on(&tx, &operation_id)?;
303        let CircleOperationCandidate {
304            candidate,
305            bootstrap_blobs,
306        } = crate::store::store_session::StoreTransaction::new(&tx, self.store_dir)
307            .circle_operation_candidate(self.verified_store_authority, journal.operation())?;
308        if !merge_candidate_cleanup_targets_on(
309            &tx,
310            &candidate.commit.write_id,
311            &candidate,
312            false,
313            &bootstrap_blobs,
314        )?
315        .is_empty()
316        {
317            return Err(crate::DbError::Message(
318                "Circle operation discard still has remote cleanup targets".to_string(),
319            ));
320        }
321        let mut object_ids = candidate_graph_exact_objects(&candidate.commit)?
322            .iter()
323            .map(remote_object_id)
324            .collect::<std::collections::BTreeSet<_>>();
325        object_ids.insert(remote_object_id(&candidate.reference.object));
326        for object_id in object_ids {
327            let remote = load_remote_object_on(&tx, object_id)?;
328            if !remote
329                .candidate_cleanup_complete(&candidate.reference)
330                .map_err(|error| {
331                    crate::DbError::context(
332                        format!("finish Circle operation discard for {object_id}"),
333                        error,
334                    )
335                })?
336            {
337                return Err(crate::DbError::Message(format!(
338                    "Circle discard object {object_id} is not terminal"
339                )));
340            }
341            if matches!(
342                remote,
343                coven_protocol::remote_object::RemoteObjectRecord::CandidateCommit(
344                    coven_protocol::remote_object::CandidateCommitRecord {
345                        state:
346                            coven_protocol::remote_object::CandidateCommitState::AbsentVerified { .. },
347                        ..
348                    }
349                ) | coven_protocol::remote_object::RemoteObjectRecord::CandidateExclusive(
350                    coven_protocol::remote_object::CandidateObjectRecord {
351                        state:
352                            coven_protocol::remote_object::CandidateObjectState::AbsentVerified { .. },
353                        ..
354                    }
355                )
356            ) && !crate::remote_object_records::delete_remote_object_on(&tx, object_id)?
357            {
358                return Err(crate::DbError::Message(format!(
359                    "Circle discard object {object_id} disappeared during finalization"
360                )));
361            }
362        }
363        super::circle_controls::release_operation_payloads_on(&tx, &journal.operation_id)?;
364        let deleted = tx
365            .execute(
366                "DELETE FROM circle_operations WHERE operation_id = ?1",
367                [operation_id.as_str()],
368            )
369            .map_err(crate::DbError::from)?;
370        if deleted != 1 {
371            return Err(crate::DbError::Message(
372                "discarded Circle operation disappeared during finalization".to_string(),
373            ));
374        }
375        tx.commit().map_err(crate::DbError::from)
376    }
377}
378
379impl StoreDatabase {
380    /// The candidate a Circle operation would activate, plus the exact grant
381    /// named by its durable authority-loss block when one exists.
382    pub async fn circle_operation_discard_candidate(
383        &self,
384        operation_id: &CircleOperationId,
385    ) -> Result<CircleOperationDiscardCandidate, crate::DbError> {
386        let operation_id = operation_id.as_str().to_string();
387        self.call_store(move |session| session.circle_operation_discard_candidate(operation_id))
388            .await
389    }
390
391    /// Record verified nonactivation and move the journal into discarding in one
392    /// transaction.
393    pub async fn begin_circle_operation_discard(
394        &self,
395        root: coven_protocol::store_commit::StoreRootRef,
396        operation_id: &CircleOperationId,
397        nonactivation: coven_protocol::remote_object::VerifiedCandidateNonactivation,
398    ) -> Result<(), crate::DbError> {
399        let operation_id = operation_id.as_str().to_string();
400        self.call_store(move |session| {
401            session.begin_circle_operation_discard(root, operation_id, nonactivation)
402        })
403        .await
404    }
405
406    /// Return the terminal authorities that require fresh head evidence.
407    pub async fn circle_operation_discard_terminal_verifications(
408        &self,
409        root: coven_protocol::store_commit::StoreRootRef,
410        operation_id: &CircleOperationId,
411    ) -> Result<Vec<TerminalCandidateCleanupVerification>, crate::DbError> {
412        let operation_id = operation_id.as_str().to_string();
413        self.call_store(move |session| {
414            session.circle_operation_discard_terminal_verifications(root, operation_id)
415        })
416        .await
417    }
418
419    /// Reconcile an activation head against fresh excluded-author evidence.
420    pub async fn reconcile_circle_operation_terminal_head(
421        &self,
422        root: coven_protocol::store_commit::StoreRootRef,
423        operation_id: &CircleOperationId,
424        verified: coven_protocol::remote_object::VerifiedCandidateNonactivation,
425    ) -> Result<(), crate::DbError> {
426        if !matches!(
427            verified.proof(),
428            coven_protocol::remote_object::CandidateNonactivationProof::AuthorExclusion { .. }
429                | coven_protocol::remote_object::CandidateNonactivationProof::MergeMembershipGrantRevocation { .. }
430        ) {
431            return Err(crate::DbError::Message(
432                "terminal head reconciliation received another proof family".to_string(),
433            ));
434        }
435        let (durable, head_nonactivation) = verified
436            .into_terminal_head_nonactivation()
437            .map_err(crate::DbError::from)?;
438        let operation_id = operation_id.as_str().to_string();
439        self.call_store(move |session| {
440            session.reconcile_circle_operation_terminal_head(
441                root,
442                operation_id,
443                durable,
444                head_nonactivation,
445            )
446        })
447        .await
448    }
449
450    /// Return candidate-exclusive cloud objects still awaiting cleanup.
451    pub async fn circle_operation_discard_cleanup_targets(
452        &self,
453        operation_id: &CircleOperationId,
454    ) -> Result<Vec<CandidateCleanupObject>, crate::DbError> {
455        let operation_id = operation_id.as_str().to_string();
456        self.call_store(move |session| {
457            session.circle_operation_discard_cleanup_targets(operation_id)
458        })
459        .await
460    }
461
462    /// Return every Circle operation durably in the discarding state.
463    pub async fn discarding_circle_operations(
464        &self,
465    ) -> Result<Vec<CircleOperationId>, crate::DbError> {
466        self.call_store(|session| session.discarding_circle_operations())
467            .await
468    }
469
470    /// Assert terminal cleanup, remove terminal candidate rows, and clear the
471    /// journal in one transaction.
472    pub async fn finish_circle_operation_discard(
473        &self,
474        operation_id: &CircleOperationId,
475    ) -> Result<(), crate::DbError> {
476        let operation_id = operation_id.as_str().to_string();
477        self.call_store(move |session| session.finish_circle_operation_discard(operation_id))
478            .await
479    }
480}
481
482/// A Circle operation whose candidate may still activate — a ready, blocked, or
483/// finalization candidate. Refuses a row that already entered discard or waits on
484/// close responses (its candidate already won its slot).
485fn load_discardable_operation_on(
486    conn: &Connection,
487    operation_id: &str,
488) -> Result<CircleOperationJournal, crate::DbError> {
489    let journal = crate::load_circle_operation_on(conn, operation_id)?.ok_or_else(|| {
490        crate::DbError::Message(format!("circle operation {operation_id} is absent"))
491    })?;
492    if journal.is_discarding() {
493        return Err(crate::DbError::Message(format!(
494            "circle operation {operation_id} is already discarding"
495        )));
496    }
497    Ok(journal)
498}
499
500fn load_discarding_operation_on(
501    conn: &Connection,
502    operation_id: &str,
503) -> Result<CircleOperationJournal, crate::DbError> {
504    let journal = crate::load_circle_operation_on(conn, operation_id)?.ok_or_else(|| {
505        crate::DbError::Message(format!("circle operation {operation_id} is absent"))
506    })?;
507    if !journal.is_discarding() {
508        return Err(crate::DbError::Message(format!(
509            "circle operation {operation_id} is not discarding"
510        )));
511    }
512    Ok(journal)
513}