Skip to main content

coven_database/
remote_object_records.rs

1use coven_foundation::store_dir::StoreDir;
2use coven_protocol::remote_object::{ClosedRemoteObject, SemanticPayload};
3
4use crate::blob_records::remote_audience_to_db;
5use crate::store_reclaim_records::store_reclaim_journal_error;
6
7use super::*;
8
9pub fn candidate_graph_exact_objects(
10    commit: &StoreBatchCommit,
11) -> Result<Vec<ExactObjectRef>, DbError> {
12    coven_protocol::remote_object::CandidateObjectGraph::from_commit(commit)
13        .map(|graph| graph.exact_objects().cloned().collect())
14        .map_err(|error| DbError::context("closed candidate object graph", error))
15}
16
17/// Refuse an indexed remote object that is not the one the index names.
18///
19/// The comparison is by identity: the exact object, and the hash the record's
20/// plaintext is filed under. Payload files are named for the digest of their
21/// own contents, so a record whose semantic hash is the digest of these bytes
22/// names these bytes.
23pub(crate) fn validate_remote_object_on(
24    conn: &Connection,
25    object_id: ObjectHash,
26    expected_object: &ExactObjectRef,
27    expected_semantic_bytes: &[u8],
28) -> Result<(), DbError> {
29    let remote = load_remote_object_on(conn, object_id)?;
30    let semantic_matches = match remote.semantic_payload() {
31        SemanticPayload::Carried(carried) => carried == expected_semantic_bytes,
32        SemanticPayload::Spooled(hash) => hash == ObjectHash::digest(expected_semantic_bytes),
33        SemanticPayload::Absent => false,
34    };
35    if remote.object() != expected_object || !semantic_matches {
36        return Err(DbError::Message(format!(
37            "prepared remote object {object_id} differs from its semantic index"
38        )));
39    }
40    Ok(())
41}
42
43pub(crate) fn load_remote_object_on(
44    conn: &Connection,
45    object_id: ObjectHash,
46) -> Result<RemoteObjectRecord, DbError> {
47    let state: String = conn
48        .query_row(
49            "SELECT state FROM remote_objects WHERE object_id = ?1",
50            [object_id.to_string()],
51            |row| row.get(0),
52        )
53        .map_err(|error| match error {
54            rusqlite::Error::QueryReturnedNoRows => {
55                DbError::Message(format!("prepared remote object {object_id} is absent"))
56            }
57            error => DbError::from(error),
58        })?;
59    let remote: RemoteObjectRecord = serde_json::from_str(&state).map_err(|error| {
60        DbError::context(
61            format!("prepared remote object {object_id} has invalid closed state"),
62            error,
63        )
64    })?;
65    remote
66        .validate()
67        .map_err(|error| DbError::context(format!("prepared remote object {object_id}"), error))?;
68    let actual = remote_object_id(remote.object());
69    if actual != object_id {
70        return Err(DbError::Message(format!(
71            "prepared remote object key is {object_id}, exact reference hashes to {actual}"
72        )));
73    }
74    let indexed = indexed_retained_replay_owners_on(conn, object_id)?;
75    let embedded = remote
76        .retained_replay_owners()
77        .cloned()
78        .collect::<BTreeSet<_>>();
79    if embedded != indexed {
80        return Err(DbError::Message(format!(
81            "prepared remote object {object_id} differs from its retained-replay ownership index"
82        )));
83    }
84    Ok(remote)
85}
86
87/// Load one record together with the payloads it claims.
88///
89/// The row and the stored bytes are one record; flows that upload or re-encrypt an
90/// object need both halves, and reading them here keeps "the row's claims and
91/// the bytes agree" a single check rather than a per-caller convention.
92pub(crate) fn reopen_remote_object_on(
93    conn: &Connection,
94    store_dir: &StoreDir,
95    object_id: ObjectHash,
96) -> Result<coven_protocol::remote_object::ClosedRemoteObject, DbError> {
97    let remote = load_remote_object_on(conn, object_id)?;
98    let mut payloads = std::collections::BTreeMap::new();
99    for hash in remote.payload_claims() {
100        let bytes = crate::payload_store::read_payload_blocking(conn, store_dir, hash)
101            .map_err(DbError::from)?;
102        payloads.insert(hash, bytes);
103    }
104    coven_protocol::remote_object::ClosedRemoteObject::with_payloads(remote, payloads)
105        .map_err(|error| DbError::context(format!("remote object {object_id} payloads"), error))
106}
107
108pub(crate) fn indexed_retained_replay_owners_on(
109    conn: &Connection,
110    object_id: ObjectHash,
111) -> Result<BTreeSet<RetainedReplayOwner>, DbError> {
112    let mut statement = conn
113        .prepare(
114            "SELECT device_id, seq, commit_ref, input_hash
115             FROM retained_replay_objects WHERE object_id = ?1
116             ORDER BY device_id, seq",
117        )
118        .map_err(DbError::from)?;
119    let rows = statement
120        .query_map([object_id.to_string()], |row| {
121            Ok((
122                row.get::<_, String>(0)?,
123                row.get::<_, i64>(1)?,
124                row.get::<_, String>(2)?,
125                row.get::<_, String>(3)?,
126            ))
127        })
128        .map_err(DbError::from)?;
129    let mut owners = BTreeSet::new();
130    for row in rows {
131        let (device_id, sequence, encoded_commit, encoded_input_hash) =
132            row.map_err(DbError::from)?;
133        let commit: StoreBatchCommitRef =
134            serde_json::from_str(&encoded_commit).map_err(|error| {
135                DbError::context(
136                    format!("retained replay object {object_id} commit ref"),
137                    error,
138                )
139            })?;
140        let input_hash = encoded_input_hash.parse().map_err(|error| {
141            DbError::context(
142                format!("retained replay object {object_id} input hash"),
143                error,
144            )
145        })?;
146        let StoreCommitCoord {
147            stream_id,
148            sequence: commit_sequence,
149        } = &commit.coord;
150        let sequence = u64::try_from(sequence).map_err(|_| {
151            DbError::Message(format!(
152                "retained replay object {object_id} has an invalid sequence"
153            ))
154        })?;
155        if stream_id.to_string() != device_id || *commit_sequence != sequence {
156            return Err(DbError::Message(format!(
157                "retained replay object {object_id} index differs from its commit coordinate"
158            )));
159        }
160        if !owners.insert(RetainedReplayOwner::Commit { commit, input_hash }) {
161            return Err(DbError::Message(format!(
162                "retained replay object {object_id} repeats an owner"
163            )));
164        }
165    }
166    Ok(owners)
167}
168
169pub(crate) fn index_retained_replay_owner_on(
170    conn: &rusqlite::Transaction<'_>,
171    object_id: ObjectHash,
172    owner: &RetainedReplayOwner,
173) -> Result<(), DbError> {
174    let RetainedReplayOwner::Commit { commit, input_hash } = owner;
175    let StoreCommitCoord {
176        stream_id,
177        sequence,
178    } = &commit.coord;
179    let device_id = stream_id.to_string();
180    let sequence = Database::sequence_to_sqlite(&device_id, *sequence)?;
181    let commit_ref = serde_json::to_string(commit)
182        .map_err(|error| DbError::context("serialize retained replay commit ref", error))?;
183    let input_hash = input_hash.to_string();
184    conn.execute(
185        "INSERT INTO retained_replay_objects
186         (device_id, seq, commit_ref, input_hash, object_id)
187         VALUES (?1, ?2, ?3, ?4, ?5)
188         ON CONFLICT(device_id, seq, object_id) DO NOTHING",
189        rusqlite::params![
190            &device_id,
191            sequence,
192            &commit_ref,
193            &input_hash,
194            object_id.to_string()
195        ],
196    )
197    .map_err(DbError::from)?;
198    let stored: (String, String) = conn
199        .query_row(
200            "SELECT commit_ref, input_hash FROM retained_replay_objects
201             WHERE device_id = ?1 AND seq = ?2 AND object_id = ?3",
202            rusqlite::params![device_id, sequence, object_id.to_string()],
203            |row| Ok((row.get(0)?, row.get(1)?)),
204        )
205        .map_err(DbError::from)?;
206    if stored != (commit_ref, input_hash) {
207        return Err(DbError::Message(format!(
208            "retained replay object {object_id} already has different exact ownership"
209        )));
210    }
211    Ok(())
212}
213
214pub(crate) fn load_protocol_inert_object_on(
215    conn: &Connection,
216    object_id: ObjectHash,
217) -> Result<coven_protocol::remote_object::ProtocolInertObject, DbError> {
218    let state: String = conn
219        .query_row(
220            "SELECT state FROM protocol_inert_objects WHERE object_id = ?1",
221            [object_id.to_string()],
222            |row| row.get(0),
223        )
224        .map_err(DbError::from)?;
225    let inert: coven_protocol::remote_object::ProtocolInertObject = serde_json::from_str(&state)
226        .map_err(|error| {
227            DbError::context(
228                format!("protocol-inert object {object_id} has invalid closed state"),
229                error,
230            )
231        })?;
232    inert
233        .validate()
234        .map_err(|error| DbError::context(format!("protocol-inert object {object_id}"), error))?;
235    if inert.object_id() != object_id {
236        return Err(DbError::Message(format!(
237            "protocol-inert object key is {object_id}, exact reference hashes to {}",
238            inert.object_id()
239        )));
240    }
241    Ok(inert)
242}
243
244pub(crate) fn load_reclaimed_store_package_on(
245    conn: &Connection,
246    object_id: ObjectHash,
247) -> Result<Option<ReclaimedStorePackage>, DbError> {
248    let stored: Option<(String, String)> = conn
249        .query_row(
250            "SELECT authorization_hash, state FROM reclaimed_store_packages WHERE object_id = ?1",
251            [object_id.to_string()],
252            |row| Ok((row.get(0)?, row.get(1)?)),
253        )
254        .optional()
255        .map_err(DbError::from)?;
256    let Some((authorization_hash, state)) = stored else {
257        return Ok(None);
258    };
259    let authorization_hash = authorization_hash.parse::<ObjectHash>().map_err(|error| {
260        DbError::context(
261            format!("reclaimed Store package {object_id} has invalid authorization hash"),
262            error,
263        )
264    })?;
265    let reclaimed: ReclaimedStorePackage = serde_json::from_str(&state).map_err(|error| {
266        DbError::context(
267            format!("reclaimed Store package {object_id} has invalid closed state"),
268            error,
269        )
270    })?;
271    reclaimed.validate().map_err(store_reclaim_journal_error)?;
272    if reclaimed.object_id() != object_id
273        || reclaimed.authorization().authorization_hash != authorization_hash
274    {
275        return Err(DbError::Message(format!(
276            "reclaimed Store package {object_id} differs from its indexed identity"
277        )));
278    }
279    Ok(Some(reclaimed))
280}
281
282pub(crate) fn record_reclaimed_store_package_on(
283    conn: &Connection,
284    snapshot_root_hash: Option<ObjectHash>,
285    reclaimed: &ReclaimedStorePackage,
286) -> Result<(), DbError> {
287    reclaimed.validate().map_err(store_reclaim_journal_error)?;
288    let object_id = reclaimed.object_id();
289    if let Some(existing) = load_reclaimed_store_package_on(conn, object_id)? {
290        if existing == *reclaimed {
291            return Ok(());
292        }
293        if !matches!(
294            (&existing, reclaimed),
295            (
296                ReclaimedStorePackage::AbsentVerified {
297                    authorization: existing_authorization,
298                    authorization_activation: existing_activation,
299                },
300                ReclaimedStorePackage::Receipted {
301                    authorization,
302                    authorization_activation,
303                    ..
304                }
305            ) if existing_authorization == authorization
306                && existing_activation == authorization_activation
307        ) {
308            return Err(DbError::Message(format!(
309                "reclaimed Store package {object_id} has conflicting closed authority"
310            )));
311        }
312        let state = serde_json::to_string(reclaimed)
313            .map_err(|error| DbError::context("serialize reclaimed Store package", error))?;
314        let updated = conn
315            .execute(
316                "UPDATE reclaimed_store_packages SET state = ?2 WHERE object_id = ?1",
317                (object_id.to_string(), state),
318            )
319            .map_err(DbError::from)?;
320        if updated != 1 {
321            return Err(DbError::Message(format!(
322                "reclaimed Store package {object_id} disappeared during receipt closure"
323            )));
324        }
325        return Ok(());
326    }
327
328    let remote_exists: bool = conn
329        .query_row(
330            "SELECT EXISTS(SELECT 1 FROM remote_objects WHERE object_id = ?1)",
331            [object_id.to_string()],
332            |row| row.get(0),
333        )
334        .map_err(DbError::from)?;
335    if remote_exists {
336        let remote = load_remote_object_on(conn, object_id)?;
337        match reclaimed.authorization().target() {
338            coven_protocol::reclaim::ReclaimTarget::StorePackage(target) => {
339                remote.validate_reclaimable_store_package(&target.package, &target.activation)
340            }
341            coven_protocol::reclaim::ReclaimTarget::CirclePackage(target) => {
342                remote.validate_reclaimable_circle_package(&target.package, &target.activation)
343            }
344            coven_protocol::reclaim::ReclaimTarget::CircleBootstrapImage(target) => remote
345                .validate_reclaimable_circle_bootstrap_image(
346                    &target.coverage.bootstrap.image,
347                    &target.coverage.activation_commit,
348                ),
349            coven_protocol::reclaim::ReclaimTarget::CircleSnapshotImage(target) => {
350                let root_hash = snapshot_root_hash.ok_or_else(|| {
351                    DbError::Message(
352                        "Circle snapshot reclaim closure has no verified Store root".to_string(),
353                    )
354                })?;
355                let owner = target.snapshot_owner(root_hash).map_err(DbError::from)?;
356                remote.validate_reclaimable_snapshot_image(&target.image, &owner)
357            }
358            coven_protocol::reclaim::ReclaimTarget::StoreMembershipRollup(target) => remote
359                .validate_reclaimable_membership_rollup(&target.rollup, &target.snapshot_owner()),
360            coven_protocol::reclaim::ReclaimTarget::AudienceBlob(target) => {
361                remote.validate_reclaimable_stored_blob(&target.blob)
362            }
363        }
364        .map_err(|error| DbError::context(format!("close reclaimed package {object_id}"), error))?;
365        // A stored blob is referenced by a chain: row bindings name its locator row,
366        // which names its remote object. All three leave in this transaction or none
367        // does. The bindings that remain here are stale by construction — the reclaim
368        // verified no live row resolves to this blob — and they are what the foreign
369        // keys would otherwise hold the locator row against.
370        conn.execute(
371            "DELETE FROM row_blob_locators WHERE remote_object_id = ?1",
372            [object_id.to_string()],
373        )
374        .map_err(DbError::from)?;
375        conn.execute(
376            "DELETE FROM blob_locators WHERE remote_object_id = ?1",
377            [object_id.to_string()],
378        )
379        .map_err(DbError::from)?;
380        if !delete_remote_object_on(conn, object_id)? {
381            return Err(DbError::Message(format!(
382                "Store package {object_id} disappeared during reclaim closure"
383            )));
384        }
385    }
386    let inert_exists: bool = conn
387        .query_row(
388            "SELECT EXISTS(SELECT 1 FROM protocol_inert_objects WHERE object_id = ?1)",
389            [object_id.to_string()],
390            |row| row.get(0),
391        )
392        .map_err(DbError::from)?;
393    if inert_exists {
394        return Err(DbError::Message(format!(
395            "reclaimed Store package {object_id} is protocol-inert"
396        )));
397    }
398    let state = serde_json::to_string(reclaimed)
399        .map_err(|error| DbError::context("serialize reclaimed Store package", error))?;
400    let inserted = conn
401        .execute(
402            "INSERT INTO reclaimed_store_packages (object_id, authorization_hash, state) VALUES (?1, ?2, ?3)",
403            (
404                object_id.to_string(),
405                reclaimed.authorization().authorization_hash.to_string(),
406                state,
407            ),
408        )
409        .map_err(DbError::from)?;
410    if inserted != 1 {
411        return Err(DbError::Message(format!(
412            "reclaimed Store package {object_id} was not inserted"
413        )));
414    }
415    Ok(())
416}
417
418/// Install one record's payloads and record its claim on them, in the
419/// transaction that writes the row naming them.
420///
421/// The bytes land before the row commits and the claim commits with the row, so
422/// a row that exists names storage that exists. Installing them here keeps that
423/// a fact of one function instead of a per-caller convention.
424fn install_record_payloads_on(
425    conn: &Connection,
426    store_dir: &StoreDir,
427    closed: &ClosedRemoteObject,
428) -> Result<(), DbError> {
429    for (hash, bytes) in closed.payload_bytes() {
430        let written = crate::payload_store::write_payload_blocking(conn, store_dir, bytes)
431            .map_err(DbError::from)?;
432        if written != *hash {
433            return Err(DbError::Message(format!(
434                "remote object payload stored under {written}, named as {hash}"
435            )));
436        }
437    }
438    crate::payload_store::set_payload_owner_claims_on(
439        conn,
440        &crate::payload_store::remote_object_owner_key(closed.record().object_id()),
441        &closed.payload_bytes().keys().copied().collect(),
442    )
443}
444
445/// Let go of the payloads one remote object claimed, and remove its row.
446///
447/// Every deletion of a `remote_objects` row goes through here, so a payload no
448/// row names any more is owed its deletion by the same commit. Never used
449/// against a projected snapshot copy: those rows describe another device's
450/// payload storage, and deleting them must not touch this one's.
451pub(crate) fn delete_remote_object_on(
452    conn: &Connection,
453    object_id: ObjectHash,
454) -> Result<bool, DbError> {
455    crate::payload_store::release_payload_owner_on(
456        conn,
457        &crate::payload_store::remote_object_owner_key(object_id),
458    )?;
459    let removed = conn
460        .execute(
461            "DELETE FROM remote_objects WHERE object_id = ?1",
462            [object_id.to_string()],
463        )
464        .map_err(DbError::from)?;
465    Ok(removed == 1)
466}
467
468pub(crate) fn persist_exact_remote_object_on(
469    conn: &Connection,
470    store_dir: &StoreDir,
471    closed: &ClosedRemoteObject,
472    domain: &str,
473) -> Result<(), DbError> {
474    let remote = closed.record();
475    remote
476        .validate()
477        .map_err(|error| DbError::context(format!("prepared {domain}"), error))?;
478    let object_id = remote.object_id();
479    ensure_remote_object_is_writable_on(conn, object_id, domain)?;
480    let existing = conn
481        .query_row(
482            "SELECT state FROM remote_objects WHERE object_id = ?1",
483            [object_id.to_string()],
484            |row| row.get::<_, String>(0),
485        )
486        .optional()
487        .map_err(DbError::from)?;
488    if let Some(existing) = existing {
489        let existing: RemoteObjectRecord = serde_json::from_str(&existing).map_err(|error| {
490            DbError::context(
491                format!("prepared {domain} {object_id} has invalid closed state"),
492                error,
493            )
494        })?;
495        if existing != *remote {
496            return Err(DbError::Message(format!(
497                "prepared {domain} {object_id} already has different closed state"
498            )));
499        }
500        return install_record_payloads_on(conn, store_dir, closed);
501    }
502    install_record_payloads_on(conn, store_dir, closed)?;
503    let state = serde_json::to_string(remote)
504        .map_err(|error| DbError::context(format!("serialize prepared {domain}"), error))?;
505    conn.execute(
506        "INSERT INTO remote_objects (object_id, state) VALUES (?1, ?2)",
507        (object_id.to_string(), state),
508    )
509    .map_err(DbError::from)?;
510    Ok(())
511}
512
513fn ensure_remote_object_is_writable_on(
514    conn: &Connection,
515    object_id: ObjectHash,
516    domain: &str,
517) -> Result<(), DbError> {
518    if load_reclaimed_store_package_on(conn, object_id)?.is_some() {
519        return Err(DbError::Message(format!(
520            "prepared {domain} {object_id} is a reclaimed Store package"
521        )));
522    }
523    let inert_exists: bool = conn
524        .query_row(
525            "SELECT EXISTS(
526                 SELECT 1 FROM protocol_inert_objects WHERE object_id = ?1
527             )",
528            [object_id.to_string()],
529            |row| row.get(0),
530        )
531        .map_err(DbError::from)?;
532    if inert_exists {
533        return Err(DbError::Message(format!(
534            "prepared {domain} {object_id} is already protocol-inert"
535        )));
536    }
537    Ok(())
538}
539
540pub(crate) fn persist_prepared_remote_object_on(
541    conn: &Connection,
542    store_dir: &StoreDir,
543    closed: &ClosedRemoteObject,
544    owner: &StoreBatchCommitRef,
545    domain: &str,
546) -> Result<(), DbError> {
547    let remote = closed.record();
548    remote
549        .validate()
550        .map_err(|error| DbError::context(format!("prepared {domain}"), error))?;
551    let object_id = remote.object_id();
552    ensure_remote_object_is_writable_on(conn, object_id, domain)?;
553    let exists = conn
554        .query_row(
555            "SELECT EXISTS(SELECT 1 FROM remote_objects WHERE object_id = ?1)",
556            [object_id.to_string()],
557            |row| row.get::<_, bool>(0),
558        )
559        .map_err(DbError::from)?;
560    if !exists {
561        return persist_exact_remote_object_on(conn, store_dir, closed, domain);
562    }
563    let existing = load_remote_object_on(conn, object_id)?;
564    let merged = merge_prepared_remote_object(existing, remote, owner)?;
565    install_record_payloads_on(conn, store_dir, closed)?;
566    update_remote_object_on(conn, object_id, &merged)
567}
568
569pub(crate) fn update_remote_object_on(
570    conn: &Connection,
571    object_id: ObjectHash,
572    remote: &RemoteObjectRecord,
573) -> Result<(), DbError> {
574    remote
575        .validate()
576        .map_err(|error| DbError::context(format!("remote object {object_id}"), error))?;
577    if remote.object_id() != object_id {
578        return Err(DbError::Message(format!(
579            "remote object {object_id} changed its exact identity"
580        )));
581    }
582    let state = serde_json::to_string(remote)
583        .map_err(|error| DbError::context("serialize remote object", error))?;
584    let updated = conn
585        .execute(
586            "UPDATE remote_objects SET state = ?2 WHERE object_id = ?1",
587            (object_id.to_string(), state),
588        )
589        .map_err(DbError::from)?;
590    if updated != 1 {
591        return Err(DbError::Message(format!(
592            "remote object {object_id} disappeared during state transition"
593        )));
594    }
595    Ok(())
596}
597
598pub(crate) fn begin_remote_candidate_nonactivation_on(
599    conn: &rusqlite::Transaction<'_>,
600    object_id: ObjectHash,
601    nonactivation: coven_protocol::remote_object::CandidateNonactivation,
602) -> Result<Option<ExactObjectRef>, DbError> {
603    let mut remote = load_remote_object_on(conn, object_id)?;
604    let inert = remote
605        .begin_candidate_nonactivation(nonactivation)
606        .map_err(|error| {
607            DbError::context(
608                format!("record candidate nonactivation for {object_id}"),
609                error,
610            )
611        })?;
612    finish_remote_candidate_nonactivation_on(conn, object_id, remote, inert)
613}
614
615pub(crate) fn begin_remote_candidate_nonactivation_with_verified_head_on(
616    conn: &rusqlite::Transaction<'_>,
617    object_id: ObjectHash,
618    nonactivation: coven_protocol::remote_object::CandidateNonactivation,
619    head_nonactivation: &coven_protocol::remote_object::VerifiedCandidateHeadNonactivation,
620) -> Result<Option<ExactObjectRef>, DbError> {
621    let mut remote = load_remote_object_on(conn, object_id)?;
622    let inert = remote
623        .begin_candidate_nonactivation_with_verified_head_nonactivation(
624            nonactivation,
625            head_nonactivation,
626        )
627        .map_err(|error| {
628            DbError::context(
629                format!("record candidate nonactivation for {object_id}"),
630                error,
631            )
632        })?;
633    finish_remote_candidate_nonactivation_on(conn, object_id, remote, inert)
634}
635
636pub(crate) fn finish_remote_candidate_nonactivation_on(
637    conn: &rusqlite::Transaction<'_>,
638    object_id: ObjectHash,
639    remote: RemoteObjectRecord,
640    inert: Option<coven_protocol::remote_object::ProtocolInertObject>,
641) -> Result<Option<ExactObjectRef>, DbError> {
642    let Some(inert) = inert else {
643        let cleanup = remote.cleanup_target().cloned();
644        update_remote_object_on(conn, object_id, &remote)?;
645        return Ok(cleanup);
646    };
647    if inert.object_id() != object_id {
648        return Err(DbError::Message(format!(
649            "protocol-inert object {object_id} changed its exact identity"
650        )));
651    }
652    inert
653        .validate()
654        .map_err(|error| DbError::context(format!("protocol-inert object {object_id}"), error))?;
655    let encoded = serde_json::to_string(&inert)
656        .map_err(|error| DbError::context("serialize protocol-inert object", error))?;
657    if !delete_remote_object_on(conn, object_id)? {
658        return Err(DbError::Message(format!(
659            "remote object {object_id} disappeared during protocol-inert transition"
660        )));
661    }
662    let inserted = conn
663        .execute(
664            "INSERT INTO protocol_inert_objects (object_id, state) VALUES (?1, ?2)",
665            (object_id.to_string(), encoded),
666        )
667        .map_err(DbError::from)?;
668    if inserted != 1 {
669        return Err(DbError::Message(format!(
670            "protocol-inert object {object_id} was not inserted"
671        )));
672    }
673    Ok(None)
674}
675
676pub(crate) fn replace_prepared_merge_head_remote_on(
677    conn: &Connection,
678    store_dir: &StoreDir,
679    current: &ExactObjectRef,
680    winner: &StoreDeviceHead,
681    winner_object: &ExactObjectRef,
682    candidate: &StoreBatchCommitRef,
683) -> Result<(), DbError> {
684    // A Store head is signed plaintext, so the object it is published as names
685    // the digest of the head's own canonical bytes.
686    let winner_bytes = winner.to_bytes();
687    if winner_object.verify(&winner_bytes).is_err()
688        || winner_object.slot() != current.slot()
689        || winner_object == current
690        || winner.commit != *candidate
691    {
692        return Err(DbError::Message(
693            "alternate Merge head does not replace the prepared activation slot".to_string(),
694        ));
695    }
696    let old_object_id = remote_object_id(current);
697    let old_remote = load_remote_object_on(conn, old_object_id)?;
698    if !matches!(
699        &old_remote,
700        RemoteObjectRecord::RetainedAuthority(record)
701            if matches!(
702                &record.identity.domain,
703                coven_protocol::remote_object::RetainedAuthorityObjectDomain::DeviceHead { .. }
704            ) && matches!(
705                &record.state,
706                coven_protocol::remote_object::RetainedAuthorityObjectState::Prepared { ownership }
707                    if ownership.pending == BTreeSet::from([candidate.clone()])
708            )
709    ) {
710        return Err(DbError::Message(
711            "prepared Merge head lost its candidate ownership".to_string(),
712        ));
713    }
714    if !delete_remote_object_on(conn, old_object_id)? {
715        return Err(DbError::Message(
716            "prepared Merge head disappeared during replacement".to_string(),
717        ));
718    }
719    let winner_ref = coven_protocol::store_commit::StoreDeviceHeadRef {
720        head_hash: winner.head_hash(),
721        object: winner_object.clone(),
722    };
723    let winner_closed = RemoteObjectRecord::candidate_activated_store_head(
724        winner_ref,
725        &winner_bytes,
726        &winner_bytes,
727        candidate.clone(),
728    )
729    .map_err(|error| DbError::context("alternate Merge head", error))?;
730    let winner_closed = winner_closed
731        .map_record(|mut record| {
732            record.mark_uploaded_verified()?;
733            Ok(record)
734        })
735        .map_err(|error| DbError::context("mark alternate Merge head uploaded", error))?;
736    persist_exact_remote_object_on(conn, store_dir, &winner_closed, "alternate Merge head")
737}
738
739pub(crate) fn mark_remote_object_uploaded_on(
740    conn: &Connection,
741    expected: RemoteObjectRecord,
742) -> Result<RemoteObjectRecord, DbError> {
743    let object_id = expected.object_id();
744    let current = load_remote_object_on(conn, object_id)?;
745    if let (
746        RemoteObjectRecord::SharedLiveSet(current_record),
747        RemoteObjectRecord::CandidateExclusive(expected_record),
748    ) = (&current, &expected)
749    {
750        let expected_owner = match &expected_record.state {
751            coven_protocol::remote_object::CandidateObjectState::Prepared { ownership }
752            | coven_protocol::remote_object::CandidateObjectState::UploadedVerified { ownership } => {
753                ownership.pending.iter().next()
754            }
755            coven_protocol::remote_object::CandidateObjectState::CleanupPending { .. }
756            | coven_protocol::remote_object::CandidateObjectState::AbsentVerified { .. } => None,
757        };
758        if expected_record.identity.domain.shared_destination()
759            == Some(current_record.identity.domain.clone())
760            && expected_record.identity.semantic_hash == current_record.identity.semantic_hash
761            && expected_record.identity.object == current_record.identity.object
762            && expected_record.payloads == current_record.payloads
763            && expected_owner.is_some_and(|owner| {
764                matches!(
765                    &current_record.state,
766                    coven_protocol::remote_object::OwnedObjectState::UploadedVerified { ownership }
767                        if ownership.pending.contains(owner)
768                            || ownership.activated.contains(
769                                &coven_protocol::remote_object::SharedObjectOwner::StoreCommit(
770                                    owner.clone(),
771                                ),
772                            )
773                )
774            })
775        {
776            return Ok(current);
777        }
778    }
779    if let (
780        RemoteObjectRecord::RetainedAuthority(current_record),
781        RemoteObjectRecord::CandidateExclusive(expected_record),
782    ) = (&current, &expected)
783    {
784        let expected_owner = match &expected_record.state {
785            coven_protocol::remote_object::CandidateObjectState::Prepared { ownership }
786            | coven_protocol::remote_object::CandidateObjectState::UploadedVerified { ownership } => {
787                ownership.pending.iter().next()
788            }
789            coven_protocol::remote_object::CandidateObjectState::CleanupPending { .. }
790            | coven_protocol::remote_object::CandidateObjectState::AbsentVerified { .. } => None,
791        };
792        if expected_record.identity.domain.retained_destination()
793            == Some(current_record.identity.domain.clone())
794            && expected_record.identity.semantic_hash == current_record.identity.semantic_hash
795            && expected_record.identity.object == current_record.identity.object
796            && expected_record.payloads == current_record.payloads
797            && expected_owner.is_some_and(|owner| {
798                matches!(
799                    &current_record.state,
800                    coven_protocol::remote_object::RetainedAuthorityObjectState::UploadedVerified {
801                        ownership
802                    } if ownership.pending.contains(owner) || ownership.activated.contains(owner)
803                )
804            })
805        {
806            return Ok(current);
807        }
808    }
809    let mut uploaded = expected.clone();
810    uploaded.mark_uploaded_verified().map_err(|error| {
811        DbError::context(format!("mark remote object {object_id} uploaded"), error)
812    })?;
813    if current == uploaded {
814        return Ok(current);
815    }
816    if current != expected {
817        return Err(DbError::Message(format!(
818            "remote object {object_id} changed before upload completion"
819        )));
820    }
821    let expected_json = serde_json::to_string(&expected)
822        .map_err(|error| DbError::context("serialize expected remote object", error))?;
823    let uploaded_json = serde_json::to_string(&uploaded)
824        .map_err(|error| DbError::context("serialize uploaded remote object", error))?;
825    let updated = conn
826        .execute(
827            "UPDATE remote_objects SET state = ?3
828             WHERE object_id = ?1 AND state = ?2",
829            (object_id.to_string(), expected_json, uploaded_json),
830        )
831        .map_err(DbError::from)?;
832    if updated != 1 {
833        return Err(DbError::Message(format!(
834            "remote object {object_id} lost upload ownership"
835        )));
836    }
837    Ok(uploaded)
838}
839
840pub(crate) fn mark_reusable_retained_authority_uploaded_on(
841    conn: &Connection,
842    expected: RemoteObjectRecord,
843) -> Result<RemoteObjectRecord, DbError> {
844    let object_id = expected.object_id();
845    let RemoteObjectRecord::RetainedAuthority(expected_record) = &expected else {
846        return Err(DbError::Message(format!(
847            "reusable remote object {object_id} is not retained authority"
848        )));
849    };
850    let coven_protocol::remote_object::RetainedAuthorityObjectState::Prepared {
851        ownership: expected_ownership,
852    } = &expected_record.state
853    else {
854        return Err(DbError::Message(format!(
855            "reusable retained authority {object_id} is not prepared"
856        )));
857    };
858    if expected_ownership.pending.len() != 1 || !expected_ownership.nonactivated.is_empty() {
859        return Err(DbError::Message(format!(
860            "reusable retained authority {object_id} has ambiguous expected ownership"
861        )));
862    }
863    let candidate = expected_ownership
864        .pending
865        .iter()
866        .next()
867        .expect("validated one expected candidate");
868    let mut current = load_remote_object_on(conn, object_id)?;
869    let RemoteObjectRecord::RetainedAuthority(current_record) = &current else {
870        return Err(DbError::Message(format!(
871            "reusable retained authority {object_id} changed domain"
872        )));
873    };
874    if current_record.identity != expected_record.identity
875        || current_record.payloads != expected_record.payloads
876    {
877        return Err(DbError::Message(format!(
878            "reusable retained authority {object_id} changed exact identity or bytes"
879        )));
880    }
881    let owns_candidate = match &current_record.state {
882        coven_protocol::remote_object::RetainedAuthorityObjectState::Prepared { ownership } => {
883            ownership.pending.contains(candidate)
884        }
885        coven_protocol::remote_object::RetainedAuthorityObjectState::UploadedVerified {
886            ownership,
887        } => ownership.pending.contains(candidate) || ownership.activated.contains(candidate),
888        coven_protocol::remote_object::RetainedAuthorityObjectState::CleanupPending { .. }
889        | coven_protocol::remote_object::RetainedAuthorityObjectState::AbsentVerified { .. }
890        | coven_protocol::remote_object::RetainedAuthorityObjectState::UncreatedVerified {
891            ..
892        } => false,
893    };
894    if !owns_candidate {
895        return Err(DbError::Message(format!(
896            "reusable retained authority {object_id} does not belong to its upload candidate"
897        )));
898    }
899    let before = current.clone();
900    current.mark_uploaded_verified().map_err(|error| {
901        DbError::context(
902            format!("mark reusable retained authority {object_id} uploaded"),
903            error,
904        )
905    })?;
906    if current != before {
907        update_remote_object_on(conn, object_id, &current)?;
908    }
909    Ok(current)
910}
911
912pub(crate) fn merge_prepared_remote_object(
913    existing: RemoteObjectRecord,
914    proposed: &RemoteObjectRecord,
915    owner: &StoreBatchCommitRef,
916) -> Result<RemoteObjectRecord, DbError> {
917    use coven_protocol::remote_object::{OwnedObjectState, SharedLiveSetObjectDomain};
918
919    if &existing == proposed {
920        return Ok(existing);
921    }
922    if let (
923        RemoteObjectRecord::SharedLiveSet(existing_record),
924        RemoteObjectRecord::CandidateExclusive(proposed_record),
925    ) = (&existing, proposed)
926    {
927        let proposed_owner = match &proposed_record.state {
928            coven_protocol::remote_object::CandidateObjectState::Prepared { ownership }
929            | coven_protocol::remote_object::CandidateObjectState::UploadedVerified { ownership } => {
930                ownership.pending.contains(owner)
931            }
932            coven_protocol::remote_object::CandidateObjectState::CleanupPending { .. }
933            | coven_protocol::remote_object::CandidateObjectState::AbsentVerified { .. } => false,
934        };
935        if proposed_record.identity.domain.shared_destination()
936            != Some(existing_record.identity.domain.clone())
937            || proposed_record.identity.semantic_hash != existing_record.identity.semantic_hash
938            || proposed_record.identity.object != existing_record.identity.object
939            || proposed_record.payloads != existing_record.payloads
940            || !proposed_owner
941        {
942            return Err(DbError::Message(format!(
943                "shared candidate object {} already has different identity, bytes, or ownership",
944                proposed.object_id()
945            )));
946        }
947        let mut merged = existing.clone();
948        let RemoteObjectRecord::SharedLiveSet(record) = &mut merged else {
949            unreachable!("matched shared live-set object")
950        };
951        match &mut record.state {
952            OwnedObjectState::Prepared { ownership } => {
953                ownership.pending.insert(owner.clone());
954            }
955            OwnedObjectState::UploadedVerified { ownership } => {
956                ownership.pending.insert(owner.clone());
957            }
958            OwnedObjectState::RetirementPending { former_candidates } => {
959                record.state = OwnedObjectState::UploadedVerified {
960                    ownership: coven_protocol::remote_object::SharedObjectOwnership {
961                        pending: BTreeSet::from([owner.clone()]),
962                        activated: BTreeSet::new(),
963                        nonactivated: former_candidates.clone(),
964                    },
965                };
966            }
967        }
968        merged.validate().map_err(|error| {
969            DbError::context(
970                format!("merge shared candidate object {}", proposed.object_id()),
971                error,
972            )
973        })?;
974        return Ok(merged);
975    }
976    if let (
977        RemoteObjectRecord::RetainedAuthority(existing_record),
978        RemoteObjectRecord::CandidateExclusive(proposed_record),
979    ) = (&existing, proposed)
980    {
981        if proposed_record.identity.domain.retained_destination()
982            != Some(existing_record.identity.domain.clone())
983            || proposed_record.identity.semantic_hash != existing_record.identity.semantic_hash
984            || proposed_record.identity.object != existing_record.identity.object
985            || proposed_record.payloads != existing_record.payloads
986        {
987            return Err(DbError::Message(format!(
988                "retained candidate object {} already has different identity or bytes",
989                proposed.object_id()
990            )));
991        }
992        let proposed_owner = match &proposed_record.state {
993            coven_protocol::remote_object::CandidateObjectState::Prepared { ownership }
994            | coven_protocol::remote_object::CandidateObjectState::UploadedVerified { ownership } => {
995                ownership.pending.contains(owner)
996            }
997            coven_protocol::remote_object::CandidateObjectState::CleanupPending { .. }
998            | coven_protocol::remote_object::CandidateObjectState::AbsentVerified { .. } => false,
999        };
1000        if !proposed_owner {
1001            return Err(DbError::Message(format!(
1002                "retained candidate object {} does not name its preparing commit",
1003                proposed.object_id()
1004            )));
1005        }
1006        let mut merged = existing.clone();
1007        merged
1008            .add_retained_authority_candidate(owner.clone())
1009            .map_err(|error| {
1010                DbError::context(
1011                    format!("merge retained candidate object {}", proposed.object_id()),
1012                    error,
1013                )
1014            })?;
1015        return Ok(merged);
1016    }
1017    let (
1018        RemoteObjectRecord::SharedLiveSet(mut existing),
1019        RemoteObjectRecord::SharedLiveSet(proposed),
1020    ) = (existing, proposed)
1021    else {
1022        return Err(DbError::Message(format!(
1023            "remote object {} already has different closed state",
1024            proposed.object_id()
1025        )));
1026    };
1027    if existing.identity.domain != SharedLiveSetObjectDomain::StoredBlob
1028        || proposed.identity.domain != SharedLiveSetObjectDomain::StoredBlob
1029        || existing.identity != proposed.identity
1030        || existing.payloads != proposed.payloads
1031    {
1032        return Err(DbError::Message(format!(
1033            "stored blob object {} already has different identity or bytes",
1034            remote_object_id(&proposed.identity.object)
1035        )));
1036    }
1037    let proposed_has_owner = match &proposed.state {
1038        OwnedObjectState::Prepared { ownership } => ownership.pending.contains(owner),
1039        OwnedObjectState::UploadedVerified { ownership } => ownership.pending.contains(owner),
1040        OwnedObjectState::RetirementPending { .. } => false,
1041    };
1042    if !proposed_has_owner {
1043        return Err(DbError::Message(format!(
1044            "stored blob object {} does not name its preparing commit",
1045            remote_object_id(&proposed.identity.object)
1046        )));
1047    }
1048    let proposed_uploaded = matches!(&proposed.state, OwnedObjectState::UploadedVerified { .. });
1049    match &mut existing.state {
1050        OwnedObjectState::Prepared { ownership } => {
1051            ownership.pending.insert(owner.clone());
1052            if proposed_uploaded {
1053                existing.state = OwnedObjectState::UploadedVerified {
1054                    ownership: coven_protocol::remote_object::SharedObjectOwnership {
1055                        pending: ownership.pending.clone(),
1056                        activated: std::collections::BTreeSet::new(),
1057                        nonactivated: ownership.nonactivated.clone(),
1058                    },
1059                };
1060            }
1061        }
1062        OwnedObjectState::UploadedVerified { ownership } => {
1063            ownership.pending.insert(owner.clone());
1064        }
1065        OwnedObjectState::RetirementPending { former_candidates } => {
1066            let ownership = coven_protocol::remote_object::PendingCandidateOwnership {
1067                pending: std::collections::BTreeSet::from([owner.clone()]),
1068                nonactivated: former_candidates.clone(),
1069            };
1070            existing.state = if proposed_uploaded {
1071                OwnedObjectState::UploadedVerified {
1072                    ownership: coven_protocol::remote_object::SharedObjectOwnership {
1073                        pending: ownership.pending,
1074                        activated: std::collections::BTreeSet::new(),
1075                        nonactivated: ownership.nonactivated,
1076                    },
1077                }
1078            } else {
1079                OwnedObjectState::Prepared { ownership }
1080            };
1081        }
1082    }
1083    let merged = RemoteObjectRecord::SharedLiveSet(existing);
1084    merged.validate().map_err(|error| {
1085        DbError::context(
1086            format!("merged stored blob object {}", merged.object_id()),
1087            error,
1088        )
1089    })?;
1090    Ok(merged)
1091}
1092
1093pub(crate) fn validate_prepared_package_on(
1094    conn: &Connection,
1095    store_dir: &StoreDir,
1096    write_id: &WriteId,
1097    expected: &PreparedAudiencePackage,
1098) -> Result<(), DbError> {
1099    let audience = expected.package().audience().remote_audience();
1100    let remote_object_id: String = conn
1101        .query_row(
1102            "SELECT remote_object_id
1103             FROM store_write_packages
1104             WHERE write_id = ?1 AND audience = ?2",
1105            rusqlite::params![write_id.as_str(), remote_audience_to_db(&audience)],
1106            |row| row.get(0),
1107        )
1108        .map_err(DbError::from)?;
1109    let remote_object_id = remote_object_id
1110        .parse()
1111        .map_err(|error| DbError::context("stored prepared remote object id is invalid", error))?;
1112    let actual = PreparedAudiencePackage::from_remote(
1113        conn,
1114        store_dir,
1115        load_remote_object_on(conn, remote_object_id)?,
1116    )?;
1117    if actual.package() != expected.package()
1118        || actual.semantic_bytes() != expected.semantic_bytes()
1119        || actual.stored_bytes() != expected.stored_bytes()
1120        || actual.object() != expected.object()
1121        || actual.remote_object_id() != expected.remote_object_id()
1122    {
1123        return Err(DbError::Message(format!(
1124            "write {write_id} audience {audience:?} already has different prepared package bytes"
1125        )));
1126    }
1127    Ok(())
1128}
1129
1130pub(crate) fn validate_prepared_blob_on(
1131    conn: &Connection,
1132    write_id: &WriteId,
1133    expected: &PreparedAudienceBlob,
1134) -> Result<(), DbError> {
1135    let locator_hash = expected.blob().locator().locator_hash();
1136    let remote_object_id = expected.remote_object_id();
1137    let (stored_locator_hash, spool_path): (String, Option<String>) = conn
1138        .query_row(
1139            "SELECT locator_hash, spool_path
1140             FROM store_write_blobs
1141             WHERE write_id = ?1 AND audience = ?2 AND remote_object_id = ?3",
1142            rusqlite::params![
1143                write_id.as_str(),
1144                remote_audience_to_db(expected.audience()),
1145                remote_object_id.to_string(),
1146            ],
1147            |row| Ok((row.get(0)?, row.get(1)?)),
1148        )
1149        .map_err(DbError::from)?;
1150    if stored_locator_hash != locator_hash.to_string() {
1151        return Err(DbError::Message(format!(
1152            "write {write_id} audience {:?} exact object {remote_object_id} is indexed under locator {stored_locator_hash}, expected {locator_hash}",
1153            expected.audience()
1154        )));
1155    }
1156    let actual = PreparedAudienceBlob::from_remote(
1157        expected.audience().clone(),
1158        &locator_hash.to_string(),
1159        load_remote_object_on(conn, remote_object_id)?,
1160        spool_path.map(PathBuf::from),
1161    )?;
1162    if actual.blob() != expected.blob()
1163        || actual.spool_path() != expected.spool_path()
1164        || actual.remote_object_id() != expected.remote_object_id()
1165    {
1166        return Err(DbError::Message(format!(
1167            "write {write_id} audience {:?} exact object {remote_object_id} already has different prepared blob bytes",
1168            expected.audience()
1169        )));
1170    }
1171    Ok(())
1172}