Skip to main content

coven_database/store/store_session/
candidate_records.rs

1use crate::*;
2use coven_protocol::objects::ExactObjectRef;
3use coven_protocol::remote_object::{remote_object_id, RemoteObjectRecord};
4use coven_protocol::store_commit::{
5    ObjectHash, StoreBatchCommit, StoreBatchCommitRef, StoreCommitCoord, StoreDeviceHead,
6    StoreDeviceRegistrationRef, StoreHistoryCut, VerifiedStoreBatchCommit,
7};
8use coven_protocol::write::WriteId;
9use rusqlite::Connection;
10use std::collections::{BTreeMap, BTreeSet};
11
12use super::publication_state::PreparedStoreWriteState;
13#[derive(Debug, Clone)]
14pub struct PreparedMergeCandidate {
15    pub commit: VerifiedStoreBatchCommit,
16    pub reference: StoreBatchCommitRef,
17    pub canonical_signed_bytes: Vec<u8>,
18    pub head: StoreDeviceHead,
19    pub head_object: ExactObjectRef,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct CandidateCleanupObject {
24    pub object: ExactObjectRef,
25}
26
27/// Record `nonactivation` against every object a losing candidate published, and
28/// return the ones whose durable state now names them for deletion. An object
29/// several candidates own stays until the last of them is nonactivated, so a
30/// caller can never delete an object another live candidate still needs. The
31/// candidate's own commit must reach a cleanup target: it is uploaded before the
32/// head that decides the position, so a candidate that lost that position always
33/// leaves it behind.
34pub(crate) fn begin_candidate_nonactivation_targets_on(
35    tx: &rusqlite::Transaction<'_>,
36    candidate: &StoreBatchCommitRef,
37    objects: &[ExactObjectRef],
38    nonactivation: &coven_protocol::remote_object::CandidateNonactivation,
39) -> Result<Vec<CandidateCleanupObject>, DbError> {
40    let mut unique = BTreeSet::new();
41    let mut cleanup = Vec::new();
42    for object in objects {
43        let object_id = remote_object_id(object);
44        if !unique.insert(object_id) {
45            return Err(DbError::Message(
46                "losing candidate repeats an exact owned object".to_string(),
47            ));
48        }
49        if let Some(target) =
50            begin_remote_candidate_nonactivation_on(tx, object_id, nonactivation.clone())?
51        {
52            cleanup.push(CandidateCleanupObject { object: target });
53        }
54    }
55    if !objects.contains(&candidate.object)
56        || !cleanup
57            .iter()
58            .any(|target| target.object == candidate.object)
59    {
60        return Err(DbError::Message(
61            "losing candidate has no exact commit cleanup target".to_string(),
62        ));
63    }
64    cleanup.sort_by(|left, right| left.object.cmp(&right.object));
65    Ok(cleanup)
66}
67
68/// The objects of an already-nonactivated candidate still awaiting deletion.
69/// Reading it again after each delete is what makes an interrupted cleanup
70/// resumable: every object has either a pending target or a completed cleanup,
71/// and anything else is a state this candidate never reached.
72pub(crate) fn candidate_cleanup_targets_on(
73    conn: &Connection,
74    candidate: &StoreBatchCommitRef,
75    objects: &[ExactObjectRef],
76) -> Result<Vec<CandidateCleanupObject>, DbError> {
77    let mut unique = BTreeSet::new();
78    let mut cleanup = Vec::new();
79    for object in objects {
80        let object_id = remote_object_id(object);
81        if !unique.insert(object_id) {
82            return Err(DbError::Message(
83                "candidate cleanup repeats an exact object".to_string(),
84            ));
85        }
86        let remote = load_remote_object_on(conn, object_id)?;
87        if let Some(target) = remote.cleanup_target() {
88            cleanup.push(CandidateCleanupObject {
89                object: target.clone(),
90            });
91        } else if !remote
92            .candidate_cleanup_complete(candidate)
93            .map_err(DbError::from)?
94        {
95            return Err(DbError::Message(format!(
96                "candidate object {object_id} has no cleanup decision"
97            )));
98        }
99    }
100    cleanup.sort_by(|left, right| left.object.cmp(&right.object));
101    Ok(cleanup)
102}
103
104pub(crate) fn require_candidate_cleanup_complete_on(
105    conn: &Connection,
106    candidate: &StoreBatchCommitRef,
107    objects: &[ExactObjectRef],
108    context: &str,
109) -> Result<(), DbError> {
110    if candidate_cleanup_targets_on(conn, candidate, objects)?.is_empty() {
111        Ok(())
112    } else {
113        Err(DbError::Message(context.to_string()))
114    }
115}
116
117pub(crate) fn delete_remote_objects_on(
118    tx: &rusqlite::Transaction<'_>,
119    object_ids: impl IntoIterator<Item = ObjectHash>,
120    context: &str,
121) -> Result<(), DbError> {
122    let mut unique = BTreeSet::new();
123    for object_id in object_ids {
124        if !unique.insert(object_id) {
125            return Err(DbError::Message(format!(
126                "{context} repeats remote object {object_id}"
127            )));
128        }
129        if !crate::remote_object_records::delete_remote_object_on(tx, object_id)? {
130            return Err(DbError::Message(format!(
131                "{context} object {object_id} disappeared during cleanup"
132            )));
133        }
134    }
135    Ok(())
136}
137
138pub(super) fn parse_prepared_merge_candidate_on(
139    records: crate::store::store_session::StoreRecords<'_>,
140    authority: &mut super::verified_store_authority::VerifiedStoreAuthority,
141    prepared: &PreparedStoreWriteState,
142) -> Result<PreparedMergeCandidate, DbError> {
143    authority.prepared_merge_candidate_on(records, prepared)
144}
145
146pub(super) fn prepared_merge_candidate_objects(
147    prepared: &PreparedStoreWriteState,
148) -> (
149    &crate::DurablePreparedProtocolObject,
150    &crate::DurablePreparedProtocolObject,
151) {
152    match prepared {
153        PreparedStoreWriteState::Publication { commit, head, .. } => (commit, head),
154        PreparedStoreWriteState::MergeAbandonment {
155            candidate_commit,
156            candidate_head,
157            ..
158        } => (candidate_commit, candidate_head),
159    }
160}
161
162/// Verify one candidate from the two objects that identify it.
163///
164/// Both objects arrive as their signed bytes plus the reference they are stored
165/// under; the upload representation is not needed to verify a candidate, only to
166/// create one, so it is not asked for.
167pub(super) fn parse_prepared_merge_candidate_parts_on(
168    records: crate::store::store_session::StoreRecords<'_>,
169    authority: &mut super::verified_store_authority::VerifiedStoreAuthority,
170    commit_bytes: &[u8],
171    commit_object: &ExactObjectRef,
172    head_bytes: &[u8],
173    head_object: &ExactObjectRef,
174) -> Result<PreparedMergeCandidate, DbError> {
175    authority.prepared_merge_candidate_parts_on(
176        records,
177        commit_bytes,
178        commit_object,
179        head_bytes,
180        head_object,
181    )
182}
183
184#[allow(clippy::too_many_arguments)]
185pub(super) fn verify_prepared_merge_candidate_parts(
186    root: &coven_protocol::store_commit::StoreRootRef,
187    unverified: StoreBatchCommit,
188    registration: &coven_protocol::store_commit::StoreDeviceRegistration,
189    commit_bytes: &[u8],
190    commit_object: &ExactObjectRef,
191    head_bytes: &[u8],
192    head_object: &ExactObjectRef,
193) -> Result<PreparedMergeCandidate, DbError> {
194    let coord = StoreCommitCoord {
195        stream_id: coven_protocol::store_commit::StreamActivation::device_authorized_stream_id(
196            root.store_root_hash,
197            &unverified.author_registration,
198            coven_protocol::store_commit::StreamAnchorDomain::StoreAnnouncements,
199        ),
200        sequence: unverified.seq(),
201    };
202    let value = VerifiedStoreBatchCommit::parse_prepared(
203        commit_bytes,
204        root.store_root_hash,
205        coord,
206        commit_object.clone(),
207        registration,
208    )
209    .map_err(|error| DbError::context("verify Merge candidate", error))?;
210    let reference = value.reference().clone();
211    let head_value =
212        StoreDeviceHead::parse_at(head_bytes, root.store_root_hash, registration, &reference)
213            .map_err(|error| DbError::context("verify Merge candidate head", error))?;
214    Ok(PreparedMergeCandidate {
215        commit: value,
216        reference,
217        canonical_signed_bytes: commit_bytes.to_vec(),
218        head: head_value,
219        head_object: head_object.clone(),
220    })
221}
222
223pub fn blocked_merge_candidate_from_prepared(
224    candidate: PreparedMergeCandidate,
225) -> BlockedMergeCandidate {
226    BlockedMergeCandidate {
227        commit: candidate.commit,
228        commit_bytes: candidate.canonical_signed_bytes,
229        commit_object: candidate.reference.object,
230        head: candidate.head,
231        head_object: candidate.head_object,
232    }
233}
234
235pub(super) fn parse_prepared_merge_publication_on(
236    records: crate::store::store_session::StoreRecords<'_>,
237    authority: &mut super::verified_store_authority::VerifiedStoreAuthority,
238    prepared: &PreparedStoreWriteState,
239) -> Result<PreparedMergeCandidate, DbError> {
240    match prepared {
241        PreparedStoreWriteState::Publication { commit, head, .. } => {
242            parse_prepared_merge_candidate_parts_on(
243                records,
244                authority,
245                commit.semantic_bytes(),
246                commit.prepared().reference(),
247                head.semantic_bytes(),
248                head.prepared().reference(),
249            )
250        }
251        PreparedStoreWriteState::MergeAbandonment {
252            authority_commit,
253            authority_head,
254            ..
255        } => parse_prepared_merge_candidate_parts_on(
256            records,
257            authority,
258            authority_commit.semantic_bytes(),
259            authority_commit.prepared().reference(),
260            authority_head.semantic_bytes(),
261            authority_head.prepared().reference(),
262        ),
263    }
264}
265
266pub enum MergeCandidateHeadEvidence<'a> {
267    OccupiedByProof,
268    Verified(&'a coven_protocol::remote_object::VerifiedCandidateHeadNonactivation),
269}
270
271pub(super) fn author_exclusion_activation_for_candidate_on(
272    records: crate::store::store_session::StoreRecords<'_>,
273    retained: &mut dyn super::verified_store_authority::VerifiedStoreLookup,
274    root: &coven_protocol::store_commit::StoreRootRef,
275    candidate: &StoreBatchCommitRef,
276    author: &StoreDeviceRegistrationRef,
277) -> Result<Option<AuthorExclusionActivationLocator>, DbError> {
278    let expected_stream =
279        coven_protocol::store_commit::StreamActivation::device_authorized_stream_id(
280            root.store_root_hash,
281            author,
282            coven_protocol::store_commit::StreamAnchorDomain::StoreAnnouncements,
283        );
284    let StoreCommitCoord {
285        stream_id,
286        sequence,
287    } = &candidate.coord;
288    if *stream_id != expected_stream {
289        return Err(DbError::Message(
290            "candidate stream differs from its exact author registration".to_string(),
291        ));
292    }
293    let state = records.current_store_device_state()?;
294    let Some(record) = state.devices.get(&author.device_id) else {
295        return Err(DbError::Message(
296            "candidate author is absent from the current device state".to_string(),
297        ));
298    };
299    if record.registration != *author {
300        return Err(DbError::Message(
301            "candidate author differs from the current device registration".to_string(),
302        ));
303    }
304    let coven_protocol::store_commit::StoreDeviceStatus::Inactive {
305        terminals,
306        accepted_cut: _,
307    } = &record.status
308    else {
309        return Ok(None);
310    };
311    select_author_exclusion_activation_locator(
312        terminals.as_slice(),
313        &expected_stream,
314        *sequence,
315        |exclusion| load_author_exclusion_activation_locator_on(records, retained, root, exclusion),
316    )
317}
318
319pub fn select_author_exclusion_activation_locator(
320    terminals: &[coven_protocol::store_commit::StoreDeviceExclusionRef],
321    expected_stream: &coven_protocol::causal_grants::AuthorStreamId,
322    sequence: u64,
323    mut load: impl FnMut(
324        &coven_protocol::store_commit::StoreDeviceExclusionRef,
325    ) -> Result<AuthorExclusionActivationLocator, DbError>,
326) -> Result<Option<AuthorExclusionActivationLocator>, DbError> {
327    for exclusion in terminals {
328        let locator = load(exclusion)?;
329        let excluded_by_this_terminal = match locator.accepted_cut().get(expected_stream) {
330            Some(reference) => sequence > reference.coord.sequence(),
331            None => true,
332        };
333        if excluded_by_this_terminal {
334            return Ok(Some(locator));
335        }
336    }
337    Ok(None)
338}
339
340pub(super) fn load_author_exclusion_activation_locator_on(
341    records: crate::store::store_session::StoreRecords<'_>,
342    retained: &mut dyn super::verified_store_authority::VerifiedStoreLookup,
343    root: &coven_protocol::store_commit::StoreRootRef,
344    exclusion: &coven_protocol::store_commit::StoreDeviceExclusionRef,
345) -> Result<AuthorExclusionActivationLocator, DbError> {
346    let exclusion_json = serde_json::to_string(exclusion)
347        .map_err(|error| DbError::context("serialize author exclusion reference", error))?;
348    let stored = records.author_exclusion_activation_row(&exclusion_json)?;
349    let Some((accepted_cut, activation_commit, activation_head)) = stored else {
350        return Err(DbError::Message(
351            "applied author exclusion has no exact activation locator".to_string(),
352        ));
353    };
354    let accepted_cut = serde_json::from_str(&accepted_cut)
355        .map_err(|error| DbError::context("parse author exclusion accepted cut", error))?;
356    let activation_commit = serde_json::from_str(&activation_commit)
357        .map_err(|error| DbError::context("parse author exclusion activation commit", error))?;
358    let activation_head = serde_json::from_str(&activation_head)
359        .map_err(|error| DbError::context("parse author exclusion activation head", error))?;
360    let locator = AuthorExclusionActivationLocator::verified(
361        exclusion.clone(),
362        accepted_cut,
363        activation_commit,
364        activation_head,
365    );
366    let retained =
367        retained.retained_materialization_by_ref_on(records, locator.activation_commit())?;
368    if retained.root() != root {
369        return Err(DbError::Message(
370            "author exclusion activation belongs to another Store root".to_string(),
371        ));
372    }
373    let accepted_cut = StoreHistoryCut(locator.accepted_cut().clone());
374    if retained.activation_head_object() != &locator.activation_head().object
375        || retained.activation_head().head_hash() != locator.activation_head().head_hash
376        || !retained
377            .device_operations()
378            .exclusions()
379            .any(|(candidate, cut)| candidate == exclusion && cut == &accepted_cut)
380    {
381        return Err(DbError::Message(
382            "author exclusion locator differs from its exact retained activation".to_string(),
383        ));
384    }
385    Ok(locator)
386}
387
388pub enum BlockedMergeCandidateNonactivation {
389    Merge(coven_protocol::remote_object::CandidateNonactivation),
390    Terminal {
391        durable: coven_protocol::remote_object::CandidateNonactivation,
392        head_nonactivation: coven_protocol::remote_object::VerifiedCandidateHeadNonactivation,
393    },
394}
395
396pub fn blocked_merge_candidate_nonactivation(
397    verified: coven_protocol::remote_object::VerifiedCandidateNonactivation,
398) -> Result<BlockedMergeCandidateNonactivation, DbError> {
399    if matches!(
400        verified.proof(),
401        coven_protocol::remote_object::CandidateNonactivationProof::AuthorExclusion { .. }
402            | coven_protocol::remote_object::CandidateNonactivationProof::MergeMembershipGrantRevocation { .. }
403            | coven_protocol::remote_object::CandidateNonactivationProof::MergeDependencyRetraction { .. }
404    ) {
405        let (durable, head_nonactivation) = verified
406            .into_terminal_head_nonactivation()
407            .map_err(DbError::from)?;
408        return Ok(BlockedMergeCandidateNonactivation::Terminal {
409            durable,
410            head_nonactivation,
411        });
412    }
413    verified.merge_winner_commit().map_err(DbError::from)?;
414    Ok(BlockedMergeCandidateNonactivation::Merge(
415        verified.into_durable(),
416    ))
417}
418
419pub(super) fn validate_terminal_candidate_authority_on(
420    records: crate::store::store_session::StoreRecords<'_>,
421    retained: &mut dyn super::verified_store_authority::VerifiedStoreLookup,
422    root: &coven_protocol::store_commit::StoreRootRef,
423    candidate: &PreparedMergeCandidate,
424    durable: &coven_protocol::remote_object::CandidateNonactivation,
425) -> Result<(), DbError> {
426    if durable.reference().map_err(DbError::from)? != candidate.reference {
427        return Err(DbError::Message(
428            "terminal candidate authority names another candidate".to_string(),
429        ));
430    }
431    validate_terminal_nonactivation_authority_on(records, retained, root, durable)
432}
433
434pub(super) fn validate_terminal_nonactivation_authority_on(
435    records: crate::store::store_session::StoreRecords<'_>,
436    retained: &mut dyn super::verified_store_authority::VerifiedStoreLookup,
437    root: &coven_protocol::store_commit::StoreRootRef,
438    durable: &coven_protocol::remote_object::CandidateNonactivation,
439) -> Result<(), DbError> {
440    match durable.proof() {
441        coven_protocol::remote_object::CandidateNonactivationProof::AuthorExclusion {
442            exclusion,
443            accepted_cut,
444            activation_head,
445        } => {
446            let commit: StoreBatchCommit = serde_json::from_slice(
447                &durable.candidate().canonical_signed_bytes,
448            )
449            .map_err(|error| DbError::context("terminal candidate commit", error))?;
450            let reference = durable
451                .reference()
452                .map_err(DbError::from)?;
453            let current = author_exclusion_activation_for_candidate_on(
454                records,
455                retained,
456                root,
457                &reference,
458                &commit.author_registration,
459            )?
460            .ok_or_else(|| {
461                DbError::Message(
462                    "candidate is no longer excluded by the selected terminal cutoff".to_string(),
463                )
464            })?;
465            if current.exclusion() != exclusion
466                || current.accepted_cut() != accepted_cut
467                || current.activation_head() != activation_head
468            {
469                return Err(DbError::Message(
470                    "author-exclusion activation changed after remote verification".to_string(),
471                ));
472            }
473        }
474        coven_protocol::remote_object::CandidateNonactivationProof::MergeMembershipGrantRevocation {
475            activation_commit,
476            ..
477        } => {
478            let StoreCommitCoord {
479                stream_id,
480                sequence,
481            } = &activation_commit.coord;
482            if records
483                .materialized_commit_ref(&stream_id.to_string(), *sequence)?
484            .as_ref()
485                != Some(activation_commit)
486            {
487                return Err(DbError::Message(
488                    "membership-grant revocation activation is no longer current accepted history"
489                        .to_string(),
490                ));
491            }
492        }
493        coven_protocol::remote_object::CandidateNonactivationProof::MergeDependencyRetraction {
494            dependency_nonactivation,
495            ..
496        } => {
497            validate_terminal_nonactivation_authority_on(
498                records,
499                retained,
500                root,
501                dependency_nonactivation,
502            )?;
503        }
504        coven_protocol::remote_object::CandidateNonactivationProof::MergeWinner { .. } => {
505            return Err(DbError::Message(
506                "terminal candidate authority received another proof family".to_string(),
507            ));
508        }
509    }
510    Ok(())
511}
512
513pub(crate) fn begin_merge_candidate_nonactivation_on(
514    conn: &rusqlite::Transaction<'_>,
515    write_id: &WriteId,
516    candidate: &PreparedMergeCandidate,
517    nonactivation: &coven_protocol::remote_object::CandidateNonactivation,
518    include_indexed_blobs: bool,
519    extra_objects: &[ExactObjectRef],
520) -> Result<(), DbError> {
521    begin_merge_candidate_nonactivation_with_head_evidence_on(
522        conn,
523        write_id,
524        candidate,
525        nonactivation,
526        include_indexed_blobs,
527        extra_objects,
528        MergeCandidateHeadEvidence::OccupiedByProof,
529    )
530}
531
532pub(crate) fn begin_merge_candidate_nonactivation_with_verified_head_on(
533    conn: &rusqlite::Transaction<'_>,
534    write_id: &WriteId,
535    candidate: &PreparedMergeCandidate,
536    nonactivation: &coven_protocol::remote_object::CandidateNonactivation,
537    include_indexed_blobs: bool,
538    extra_objects: &[ExactObjectRef],
539    head_nonactivation: &coven_protocol::remote_object::VerifiedCandidateHeadNonactivation,
540) -> Result<(), DbError> {
541    begin_merge_candidate_nonactivation_with_head_evidence_on(
542        conn,
543        write_id,
544        candidate,
545        nonactivation,
546        include_indexed_blobs,
547        extra_objects,
548        MergeCandidateHeadEvidence::Verified(head_nonactivation),
549    )
550}
551
552pub(crate) fn begin_merge_candidate_nonactivation_with_head_evidence_on(
553    conn: &rusqlite::Transaction<'_>,
554    write_id: &WriteId,
555    candidate: &PreparedMergeCandidate,
556    nonactivation: &coven_protocol::remote_object::CandidateNonactivation,
557    include_indexed_blobs: bool,
558    extra_objects: &[ExactObjectRef],
559    head_evidence: MergeCandidateHeadEvidence<'_>,
560) -> Result<(), DbError> {
561    if nonactivation.reference().map_err(DbError::from)? != candidate.reference
562        || nonactivation.candidate().canonical_signed_bytes != candidate.canonical_signed_bytes
563    {
564        return Err(DbError::Message(
565            "verified Merge nonactivation names another prepared candidate".to_string(),
566        ));
567    }
568    let mut object_ids = candidate_graph_exact_objects(&candidate.commit)?
569        .iter()
570        .map(|object| remote_object_id(object).to_string())
571        .collect::<Vec<_>>();
572    if include_indexed_blobs {
573        let mut statement = conn
574            .prepare(
575                "SELECT remote_object_id FROM store_write_blobs WHERE write_id = ?1
576                 ORDER BY remote_object_id",
577            )
578            .map_err(DbError::from)?;
579        let indexed = statement
580            .query_map([write_id.as_str()], |row| row.get::<_, String>(0))
581            .map_err(DbError::from)?
582            .collect::<Result<Vec<_>, _>>()
583            .map_err(DbError::from)?;
584        drop(statement);
585        object_ids.extend(indexed);
586    }
587    object_ids.extend(
588        extra_objects
589            .iter()
590            .map(|object| remote_object_id(object).to_string()),
591    );
592    for encoded in object_ids {
593        let object_id: ObjectHash = encoded
594            .parse()
595            .map_err(|error| DbError::context("Merge conflict remote object id", error))?;
596        let _cleanup_target =
597            begin_remote_candidate_nonactivation_on(conn, object_id, nonactivation.clone())?;
598    }
599    let head_object_id = remote_object_id(&candidate.head_object);
600    let _head_cleanup_target = match head_evidence {
601        MergeCandidateHeadEvidence::OccupiedByProof => {
602            begin_remote_candidate_nonactivation_on(conn, head_object_id, nonactivation.clone())?
603        }
604        MergeCandidateHeadEvidence::Verified(head_nonactivation) => {
605            begin_remote_candidate_nonactivation_with_verified_head_on(
606                conn,
607                head_object_id,
608                nonactivation.clone(),
609                head_nonactivation,
610            )?
611        }
612    };
613    let commit_object_id = remote_object_id(&candidate.reference.object);
614    let _cleanup_target =
615        begin_remote_candidate_nonactivation_on(conn, commit_object_id, nonactivation.clone())?;
616    Ok(())
617}
618
619/// Read a prepared candidate's durable nonactivation proof and lift it into the
620/// terminal cleanup authority it names. Returns `None` when the candidate has no
621/// proof yet or its proof is a non-terminal Merge-winner (whose head is cleaned
622/// by occupation, not terminal reconciliation). Shared by Merge cleanup and
623/// Circle-operation discard so both derive the authority identically.
624pub(super) fn terminal_candidate_verification_on(
625    records: crate::store::store_session::StoreRecords<'_>,
626    retained: &mut dyn super::verified_store_authority::VerifiedStoreLookup,
627    root: &coven_protocol::store_commit::StoreRootRef,
628    candidate: PreparedMergeCandidate,
629) -> Result<Option<TerminalCandidateCleanupVerification>, DbError> {
630    let remote = records.remote_object(remote_object_id(&candidate.reference.object))?;
631    let Some(proof) = remote
632        .candidate_nonactivation_proof(&candidate.reference)
633        .map_err(DbError::from)?
634    else {
635        return Ok(None);
636    };
637    let authority = match proof {
638        coven_protocol::remote_object::CandidateNonactivationProof::AuthorExclusion {
639            exclusion,
640            ..
641        } => TerminalCandidateAuthority::AuthorExclusion(
642            load_author_exclusion_activation_locator_on(records, retained, root, exclusion)?,
643        ),
644        coven_protocol::remote_object::CandidateNonactivationProof::MergeMembershipGrantRevocation {
645            grant_id,
646            membership,
647            activation_commit,
648            activation_head,
649        } => TerminalCandidateAuthority::MembershipGrantRevocation {
650            grant_id: grant_id.clone(),
651            membership: membership.clone(),
652            activation_commit: activation_commit.clone(),
653            activation_head: activation_head.clone(),
654        },
655        coven_protocol::remote_object::CandidateNonactivationProof::MergeDependencyRetraction { .. } => {
656            let durable = coven_protocol::remote_object::CandidateNonactivation::from_durable_parts(
657                &candidate.reference,
658                &candidate.commit,
659                proof.clone(),
660            )
661            .map_err(DbError::from)?;
662            validate_terminal_nonactivation_authority_on(records, retained, root, &durable)?;
663            TerminalCandidateAuthority::DependencyRetraction(
664                coven_protocol::remote_object::VerifiedDependencyRetractionAuthority::after_live_authority_check(durable)
665                    .map_err(DbError::from)?,
666            )
667        }
668        coven_protocol::remote_object::CandidateNonactivationProof::MergeWinner { .. } => {
669            return Ok(None)
670        }
671    };
672    Ok(Some(TerminalCandidateCleanupVerification {
673        authority,
674        candidate: blocked_merge_candidate_from_prepared(candidate),
675    }))
676}
677
678pub(crate) fn merge_candidate_cleanup_targets_on(
679    conn: &Connection,
680    write_id: &WriteId,
681    candidate: &PreparedMergeCandidate,
682    include_indexed_blobs: bool,
683    extra_objects: &[ExactObjectRef],
684) -> Result<Vec<CandidateCleanupObject>, DbError> {
685    let commit_remote = load_remote_object_on(conn, remote_object_id(&candidate.reference.object))?;
686    if !matches!(
687        &commit_remote,
688        RemoteObjectRecord::CandidateCommit(record)
689            if matches!(
690                &record.state,
691                coven_protocol::remote_object::CandidateCommitState::CleanupPending {
692                    proof: coven_protocol::remote_object::CandidateNonactivationProof::MergeWinner { .. }
693                        | coven_protocol::remote_object::CandidateNonactivationProof::AuthorExclusion { .. }
694                        | coven_protocol::remote_object::CandidateNonactivationProof::MergeMembershipGrantRevocation { .. }
695                } | coven_protocol::remote_object::CandidateCommitState::AbsentVerified {
696                    proof: coven_protocol::remote_object::CandidateNonactivationProof::MergeWinner { .. }
697                        | coven_protocol::remote_object::CandidateNonactivationProof::AuthorExclusion { .. }
698                        | coven_protocol::remote_object::CandidateNonactivationProof::MergeMembershipGrantRevocation { .. }
699                }
700            )
701    ) {
702        return Err(DbError::Message(
703            "Merge candidate has no durable nonactivation proof".to_string(),
704        ));
705    }
706    let mut cleanup = BTreeMap::new();
707    {
708        let mut encoded = candidate_graph_exact_objects(&candidate.commit)?
709            .iter()
710            .map(|object| remote_object_id(object).to_string())
711            .collect::<Vec<_>>();
712        if include_indexed_blobs {
713            let mut statement = conn
714                .prepare("SELECT remote_object_id FROM store_write_blobs WHERE write_id = ?1")
715                .map_err(DbError::from)?;
716            let indexed = statement
717                .query_map([write_id.as_str()], |row| row.get::<_, String>(0))
718                .map_err(DbError::from)?
719                .collect::<Result<Vec<_>, _>>()
720                .map_err(DbError::from)?;
721            drop(statement);
722            encoded.extend(indexed);
723        }
724        encoded.extend(
725            extra_objects
726                .iter()
727                .map(|object| remote_object_id(object).to_string()),
728        );
729        for encoded in encoded {
730            let object_id: ObjectHash = encoded
731                .parse()
732                .map_err(|error| DbError::context("Merge cleanup remote object id", error))?;
733            let remote = load_remote_object_on(conn, object_id)?;
734            if let Some(object) = remote.cleanup_target() {
735                cleanup.insert(
736                    object.clone(),
737                    CandidateCleanupObject {
738                        object: object.clone(),
739                    },
740                );
741            } else if !remote
742                .candidate_cleanup_complete(&candidate.reference)
743                .map_err(|error| DbError::context(format!("Merge cleanup {object_id}"), error))?
744            {
745                return Err(DbError::Message(format!(
746                    "Merge candidate object {object_id} has no cleanup transition"
747                )));
748            }
749        }
750    }
751    let head_cleanup =
752        load_merge_candidate_head_cleanup_on(conn, &candidate.head_object, &candidate.reference)?;
753    if matches!(
754        head_cleanup,
755        MergeCandidateHeadCleanup::Remote { complete: false }
756    ) {
757        return Err(DbError::Message(
758            "Merge candidate head absence is not verified".to_string(),
759        ));
760    }
761    let mut targets = Vec::new();
762    for object in candidate_graph_exact_objects(&candidate.commit)?
763        .into_iter()
764        .chain(extra_objects.iter().cloned())
765    {
766        if let Some(target) = cleanup.remove(&object) {
767            targets.push(target);
768        }
769    }
770    if !cleanup.is_empty() {
771        return Err(DbError::Message(
772            "Merge cleanup contains an object outside the signed candidate manifest".to_string(),
773        ));
774    }
775    if let Some(object) = commit_remote.cleanup_target() {
776        targets.push(CandidateCleanupObject {
777            object: object.clone(),
778        });
779    } else if !commit_remote
780        .candidate_cleanup_complete(&candidate.reference)
781        .map_err(|error| DbError::context("Merge cleanup commit", error))?
782    {
783        return Err(DbError::Message(
784            "Merge candidate commit cleanup is incomplete".to_string(),
785        ));
786    }
787    Ok(targets)
788}
789
790pub(crate) fn finish_merge_retraction_cleanup_on(
791    tx: &rusqlite::Transaction<'_>,
792    candidate: &PreparedMergeCandidate,
793) -> Result<(), DbError> {
794    if !merge_candidate_cleanup_targets_on(tx, &candidate.commit.write_id, candidate, false, &[])?
795        .is_empty()
796    {
797        return Err(DbError::Message(
798            "Merge retraction cleanup still has remote targets".to_string(),
799        ));
800    }
801    let mut object_ids = candidate_graph_exact_objects(&candidate.commit)?
802        .iter()
803        .map(remote_object_id)
804        .collect::<BTreeSet<_>>();
805    object_ids.insert(remote_object_id(&candidate.reference.object));
806    for object_id in object_ids {
807        let remote = load_remote_object_on(tx, object_id)?;
808        if !remote
809            .candidate_cleanup_complete(&candidate.reference)
810            .map_err(|error| {
811                DbError::context(
812                    format!("finish Merge retraction cleanup for {object_id}"),
813                    error,
814                )
815            })?
816        {
817            return Err(DbError::Message(format!(
818                "Merge retraction object {object_id} is not terminal"
819            )));
820        }
821        if matches!(
822            remote,
823            RemoteObjectRecord::CandidateCommit(
824                coven_protocol::remote_object::CandidateCommitRecord {
825                    state: coven_protocol::remote_object::CandidateCommitState::AbsentVerified { .. },
826                    ..
827                }
828            ) | RemoteObjectRecord::CandidateExclusive(
829                coven_protocol::remote_object::CandidateObjectRecord {
830                    state: coven_protocol::remote_object::CandidateObjectState::AbsentVerified { .. },
831                    ..
832                }
833            )
834        ) && !crate::remote_object_records::delete_remote_object_on(tx, object_id)?
835        {
836            return Err(DbError::Message(format!(
837                "Merge retraction object {object_id} disappeared during finalization"
838            )));
839        }
840    }
841    let StoreCommitCoord {
842        stream_id,
843        sequence,
844    } = &candidate.reference.coord;
845    let deleted = tx
846        .execute(
847            "DELETE FROM merge_retraction_cleanups
848             WHERE device_id = ?1 AND seq = ?2 AND commit_ref = ?3",
849            rusqlite::params![
850                stream_id.to_string(),
851                Database::sequence_to_sqlite(&stream_id.to_string(), *sequence)?,
852                serde_json::to_string(&candidate.reference).map_err(|error| {
853                    DbError::context("serialize completed Merge retraction cleanup ref", error)
854                })?,
855            ],
856        )
857        .map_err(DbError::from)?;
858    if deleted != 1 {
859        return Err(DbError::Message(
860            "Merge retraction cleanup disappeared during finalization".to_string(),
861        ));
862    }
863    Ok(())
864}
865
866pub enum MergeCandidateHeadCleanup {
867    Remote { complete: bool },
868    ProtocolInert,
869}
870
871pub(crate) fn load_merge_candidate_head_cleanup_on(
872    conn: &Connection,
873    head: &ExactObjectRef,
874    candidate: &StoreBatchCommitRef,
875) -> Result<MergeCandidateHeadCleanup, DbError> {
876    let object_id = remote_object_id(head);
877    let (remote_exists, inert_exists): (bool, bool) = conn
878        .query_row(
879            "SELECT EXISTS(SELECT 1 FROM remote_objects WHERE object_id = ?1),
880                    EXISTS(SELECT 1 FROM protocol_inert_objects WHERE object_id = ?1)",
881            [object_id.to_string()],
882            |row| Ok((row.get(0)?, row.get(1)?)),
883        )
884        .map_err(DbError::from)?;
885    match (remote_exists, inert_exists) {
886        (true, false) => load_remote_object_on(conn, object_id)?
887            .candidate_cleanup_complete(candidate)
888            .map(|complete| MergeCandidateHeadCleanup::Remote { complete })
889            .map_err(|error| DbError::context("Merge cleanup head", error)),
890        (false, true) => {
891            let inert = load_protocol_inert_object_on(conn, object_id)?;
892            if !inert
893                .is_terminal_head_for(candidate, head)
894                .map_err(|error| DbError::context("Merge cleanup inert head", error))?
895            {
896                return Err(DbError::Message(format!(
897                    "protocol-inert Merge head {object_id} does not prove this excluded candidate"
898                )));
899            }
900            Ok(MergeCandidateHeadCleanup::ProtocolInert)
901        }
902        (false, false) => Err(DbError::Message(format!(
903            "Merge candidate head {object_id} is absent from durable remote state"
904        ))),
905        (true, true) => Err(DbError::Message(format!(
906            "Merge candidate head {object_id} is both active and protocol-inert"
907        ))),
908    }
909}
910
911pub(crate) fn remove_cleaned_merge_authority_on(
912    tx: &rusqlite::Transaction<'_>,
913    authority: &PreparedMergeCandidate,
914) -> Result<(), DbError> {
915    for object in [
916        authority.reference.object.clone(),
917        authority.head_object.clone(),
918    ] {
919        let object_id = remote_object_id(&object);
920        let remote = load_remote_object_on(tx, object_id)?;
921        if !remote
922            .candidate_cleanup_complete(&authority.reference)
923            .map_err(|error| {
924                DbError::context(
925                    format!("validate abandoned authority cleanup for {object_id}"),
926                    error,
927                )
928            })?
929        {
930            return Err(DbError::Message(
931                "losing Merge abandonment cleanup is incomplete".to_string(),
932            ));
933        }
934        if !crate::remote_object_records::delete_remote_object_on(tx, object_id)? {
935            return Err(DbError::Message(format!(
936                "abandoned authority object {object_id} disappeared during removal"
937            )));
938        }
939    }
940    Ok(())
941}
942
943pub(crate) fn remove_cleaned_author_excluded_merge_authority_on(
944    tx: &rusqlite::Transaction<'_>,
945    authority: &PreparedMergeCandidate,
946) -> Result<(), DbError> {
947    let commit_object_id = remote_object_id(&authority.reference.object);
948    let commit = load_remote_object_on(tx, commit_object_id)?;
949    if !commit
950        .candidate_cleanup_complete(&authority.reference)
951        .map_err(|error| {
952            DbError::context(
953                format!("validate excluded abandonment commit cleanup for {commit_object_id}"),
954                error,
955            )
956        })?
957    {
958        return Err(DbError::Message(
959            "excluded Merge abandonment commit cleanup is incomplete".to_string(),
960        ));
961    }
962    if !crate::remote_object_records::delete_remote_object_on(tx, commit_object_id)? {
963        return Err(DbError::Message(
964            "excluded Merge abandonment commit disappeared during removal".to_string(),
965        ));
966    }
967
968    let head = &authority.head_object;
969    if let MergeCandidateHeadCleanup::Remote { complete } =
970        load_merge_candidate_head_cleanup_on(tx, head, &authority.reference)?
971    {
972        if !complete {
973            return Err(DbError::Message(
974                "excluded Merge abandonment head cleanup is incomplete".to_string(),
975            ));
976        }
977        let head_object_id = remote_object_id(head);
978        if !crate::remote_object_records::delete_remote_object_on(tx, head_object_id)? {
979            return Err(DbError::Message(
980                "excluded Merge abandonment head disappeared during removal".to_string(),
981            ));
982        }
983    }
984    Ok(())
985}