Skip to main content

coven_database/store/store_session/
circle_authority.rs

1use crate::store::store_session::StoreRecords;
2use crate::*;
3use coven_keys::encryption::EncryptionService;
4use coven_protocol::store_commit::StoreBatchCommitRef;
5use rusqlite::{Connection, OptionalExtension};
6
7use super::*;
8
9/// The three states a Circle control's activating commit can be in when resolved
10/// from the retained authority: not an activation at all, a known activation whose
11/// materialization has been reclaimed, or a retained activation with its commit.
12enum CircleActivationCommitLookup {
13    Absent,
14    Reclaimed { stream_id: String, sequence: u64 },
15    Retained(StoreBatchCommitRef),
16}
17
18/// Resolve a Circle control's activating commit reference from retained
19/// authority. A known activation whose materialization was reclaimed is an
20/// error because strict callers require the current control to stay retained.
21pub(crate) fn circle_activation_commit_ref_on(
22    conn: &Connection,
23    circle_id: coven_protocol::circle::CircleId,
24    control: &coven_protocol::circle::CircleControlCoord,
25) -> Result<Option<StoreBatchCommitRef>, DbError> {
26    match circle_activation_commit_lookup_on(conn, circle_id, control)? {
27        CircleActivationCommitLookup::Absent => Ok(None),
28        CircleActivationCommitLookup::Reclaimed {
29            stream_id,
30            sequence,
31        } => Err(DbError::Message(format!(
32            "Circle {circle_id} activation commit {stream_id}/{sequence} is not retained"
33        ))),
34        CircleActivationCommitLookup::Retained(reference) => Ok(Some(reference)),
35    }
36}
37
38/// Resolve a Circle control's activating commit, reading a reclaimed
39/// materialization as absence because its standalone snapshot is superseded.
40pub(crate) fn retained_circle_activation_commit_ref_on(
41    conn: &Connection,
42    circle_id: coven_protocol::circle::CircleId,
43    control: &coven_protocol::circle::CircleControlCoord,
44) -> Result<Option<StoreBatchCommitRef>, DbError> {
45    Ok(
46        match circle_activation_commit_lookup_on(conn, circle_id, control)? {
47            CircleActivationCommitLookup::Retained(reference) => Some(reference),
48            CircleActivationCommitLookup::Absent
49            | CircleActivationCommitLookup::Reclaimed { .. } => None,
50        },
51    )
52}
53
54fn circle_activation_commit_lookup_on(
55    conn: &Connection,
56    circle_id: coven_protocol::circle::CircleId,
57    control: &coven_protocol::circle::CircleControlCoord,
58) -> Result<CircleActivationCommitLookup, DbError> {
59    let control_coord = serde_json::to_string(control)
60        .map_err(|error| DbError::context("serialize Circle control coordinate", error))?;
61    let stored = conn
62        .query_row(
63            "SELECT stream_id, seq, commit_hash
64             FROM circle_control_activations
65             WHERE circle_id = ?1 AND control_coord = ?2",
66            rusqlite::params![circle_id.to_string(), control_coord],
67            |row| {
68                Ok((
69                    row.get::<_, String>(0)?,
70                    row.get::<_, i64>(1)?,
71                    row.get::<_, String>(2)?,
72                ))
73            },
74        )
75        .optional()
76        .map_err(DbError::from)?;
77    let Some((stream_id, sequence_sql, commit_hash)) = stored else {
78        return Ok(CircleActivationCommitLookup::Absent);
79    };
80    let sequence = Database::sequence_from_sqlite(&stream_id, sequence_sql)?;
81    let stored_ref: Option<String> = conn
82        .query_row(
83            "SELECT commit_ref FROM retained_merge_materializations
84             WHERE device_id = ?1 AND seq = ?2",
85            rusqlite::params![&stream_id, sequence_sql],
86            |row| row.get(0),
87        )
88        .optional()
89        .map_err(DbError::from)?;
90    let Some(stored_ref) = stored_ref else {
91        return Ok(CircleActivationCommitLookup::Reclaimed {
92            stream_id,
93            sequence,
94        });
95    };
96    let reference = crate::store::materialized_commit_index::parse_stored_commit_ref(
97        &stream_id,
98        sequence,
99        &stored_ref,
100    )?;
101    if reference.commit_hash.to_string() != commit_hash {
102        return Err(DbError::Message(format!(
103            "Circle {circle_id} activation index differs from its retained commit"
104        )));
105    }
106    Ok(CircleActivationCommitLookup::Retained(reference))
107}
108
109impl StoreSession<'_> {
110    fn circle_control_covers_strictly(
111        &mut self,
112        root: &coven_protocol::store_commit::StoreRootRef,
113        circle_id: coven_protocol::circle::CircleId,
114        covering: &coven_protocol::circle::CircleControlCoord,
115        covered: &coven_protocol::circle::CircleControlCoord,
116    ) -> Result<bool, DbError> {
117        let Some(covering_reference) = StoreDatabase::verified_circle_activation_on(
118            crate::store::store_session::StoreRecords::new(self.conn, self.store_dir),
119            self.verified_store_authority,
120            root,
121            circle_id,
122            covering,
123        )?
124        else {
125            return Ok(false);
126        };
127        StoreDatabase::verified_circle_control_covers_on(
128            crate::store::store_session::StoreRecords::new(self.conn, self.store_dir),
129            self.verified_store_authority,
130            root,
131            circle_id,
132            &covering_reference.control,
133            covered,
134        )
135    }
136
137    fn circle_epoch_access(
138        &mut self,
139        root: &coven_protocol::store_commit::StoreRootRef,
140        circle_id: coven_protocol::circle::CircleId,
141        expected_control: &coven_protocol::circle::CircleControlCoord,
142    ) -> Result<Option<coven_protocol::circle_activation::CircleEpochAccess>, DbError> {
143        self.verified_store_authority.retained_replay_inputs_on(
144            crate::store::store_session::StoreRecords::new(self.conn, self.store_dir),
145            root,
146        )?;
147        let Some(activation) = self
148            .verified_store_authority
149            .verified_circle_activation_on(
150                crate::store::store_session::StoreRecords::new(self.conn, self.store_dir),
151                circle_id,
152                expected_control,
153            )?
154        else {
155            return Ok(None);
156        };
157        activation.epoch_access().map_err(DbError::from)
158    }
159
160    fn circle_historical_package_keyring(
161        &mut self,
162        root: &coven_protocol::store_commit::StoreRootRef,
163        circle_id: coven_protocol::circle::CircleId,
164        expected_control: &coven_protocol::circle::CircleControlCoord,
165        expected_key_fingerprint: coven_keys::encryption::KeyFingerprint,
166    ) -> Result<Option<String>, DbError> {
167        let Some(state) = super::circle_operations::circle_current_state_on(self.conn, circle_id)?
168        else {
169            return Ok(None);
170        };
171        let Some(current) = state
172            .authoring_state()
173            .or_else(|| state.closing_authoring_state())
174        else {
175            return Ok(None);
176        };
177        let Some(historical) = StoreDatabase::verified_circle_activation_on(
178            crate::store::store_session::StoreRecords::new(self.conn, self.store_dir),
179            self.verified_store_authority,
180            root,
181            circle_id,
182            expected_control,
183        )?
184        else {
185            return Ok(None);
186        };
187        if !StoreDatabase::verified_circle_control_covers_on(
188            crate::store::store_session::StoreRecords::new(self.conn, self.store_dir),
189            self.verified_store_authority,
190            root,
191            circle_id,
192            &current.control,
193            expected_control,
194        )? || current.control.value.epoch_id() != historical.control.value.epoch_id()
195            || current.control.value.key_fingerprint() != expected_key_fingerprint
196            || historical.control.value.key_fingerprint() != expected_key_fingerprint
197        {
198            return Ok(None);
199        }
200        let coven_protocol::circle::CircleAccessDisposition::Active { keyring, .. } =
201            &current.access.disposition
202        else {
203            return Ok(None);
204        };
205        let parsed =
206            coven_keys::encryption::MasterKeyring::from_serialized(keyring).map_err(|error| {
207                DbError::context(
208                    format!("parse Circle {circle_id} historical package keyring"),
209                    error,
210                )
211            })?;
212        let encryption = EncryptionService::from(parsed);
213        if encryption
214            .service_for_fingerprint(expected_key_fingerprint.as_bytes())
215            .is_err()
216        {
217            return Ok(None);
218        }
219        Ok(Some(keyring.clone()))
220    }
221
222    fn verified_circle_activation_context(
223        &mut self,
224        root: &coven_protocol::store_commit::StoreRootRef,
225        circle_id: coven_protocol::circle::CircleId,
226        control: &coven_protocol::circle::CircleControlCoord,
227    ) -> Result<
228        Option<(
229            coven_protocol::circle_activation::VerifiedCircleReference,
230            StoreBatchCommitRef,
231        )>,
232        DbError,
233    > {
234        let Some(commit) = circle_activation_commit_ref_on(self.conn, circle_id, control)? else {
235            return Ok(None);
236        };
237        let activation = StoreDatabase::verified_circle_activation_on(
238            crate::store::store_session::StoreRecords::new(self.conn, self.store_dir),
239            self.verified_store_authority,
240            root,
241            circle_id,
242            control,
243        )?
244        .ok_or_else(|| {
245            DbError::Message(format!(
246                "Circle {circle_id} activation context lost control {control:?}"
247            ))
248        })?;
249        Ok(Some((activation, commit)))
250    }
251
252    fn circle_blob_opening_protection(
253        &mut self,
254        root: &coven_protocol::store_commit::StoreRootRef,
255        circle_id: coven_protocol::circle::CircleId,
256        expected_control: &coven_protocol::circle::CircleControlCoord,
257        expected_key_fingerprint: coven_keys::encryption::KeyFingerprint,
258    ) -> Result<coven_protocol::objects::BlobSpoolProtection, DbError> {
259        circle_blob_opening_protection_on(
260            crate::store::store_session::StoreRecords::new(self.conn, self.store_dir),
261            self.verified_store_authority,
262            root,
263            circle_id,
264            expected_control,
265            expected_key_fingerprint,
266        )
267    }
268
269    fn verified_circle_activation(
270        &mut self,
271        root: &coven_protocol::store_commit::StoreRootRef,
272        circle_id: coven_protocol::circle::CircleId,
273        control: &coven_protocol::circle::CircleControlCoord,
274    ) -> Result<Option<coven_protocol::circle_activation::VerifiedCircleReference>, DbError> {
275        StoreDatabase::verified_circle_activation_on(
276            crate::store::store_session::StoreRecords::new(self.conn, self.store_dir),
277            self.verified_store_authority,
278            root,
279            circle_id,
280            control,
281        )
282    }
283
284    fn circle_restore_head(
285        &mut self,
286        root: &coven_protocol::store_commit::StoreRootRef,
287        circle_id: coven_protocol::circle::CircleId,
288        controls: &[coven_protocol::circle::CircleControlCoord],
289    ) -> Result<
290        Option<(
291            coven_protocol::circle::CircleControlCoord,
292            StoreBatchCommitRef,
293        )>,
294        DbError,
295    > {
296        let Some(head) = StoreDatabase::head_circle_control_on(
297            crate::store::store_session::StoreRecords::new(self.conn, self.store_dir),
298            self.verified_store_authority,
299            root,
300            circle_id,
301            controls,
302        )?
303        else {
304            return Ok(None);
305        };
306        let commit =
307            circle_activation_commit_ref_on(self.conn, circle_id, &head)?.ok_or_else(|| {
308                DbError::Message(format!(
309                    "Circle {circle_id} head control has no activating commit"
310                ))
311            })?;
312        Ok(Some((head, commit)))
313    }
314
315    fn retained_circle_activation_commit_ref(
316        &self,
317        circle_id: coven_protocol::circle::CircleId,
318        control: &coven_protocol::circle::CircleControlCoord,
319    ) -> Result<Option<StoreBatchCommitRef>, DbError> {
320        retained_circle_activation_commit_ref_on(self.conn, circle_id, control)
321    }
322
323    fn verified_circle_control_coord_covers(
324        &mut self,
325        root: &coven_protocol::store_commit::StoreRootRef,
326        circle_id: coven_protocol::circle::CircleId,
327        covering: &coven_protocol::circle::CircleControlCoord,
328        covered: &coven_protocol::circle::CircleControlCoord,
329    ) -> Result<bool, DbError> {
330        let Some(reference) = StoreDatabase::verified_circle_activation_on(
331            crate::store::store_session::StoreRecords::new(self.conn, self.store_dir),
332            self.verified_store_authority,
333            root,
334            circle_id,
335            covering,
336        )?
337        else {
338            return Ok(false);
339        };
340        StoreDatabase::verified_circle_control_covers_on(
341            crate::store::store_session::StoreRecords::new(self.conn, self.store_dir),
342            self.verified_store_authority,
343            root,
344            circle_id,
345            &reference.control,
346            covered,
347        )
348    }
349
350    fn verified_circle_control_covers(
351        &mut self,
352        root: &coven_protocol::store_commit::StoreRootRef,
353        circle_id: coven_protocol::circle::CircleId,
354        current: &coven_protocol::circle::PreparedCircleControl,
355        prior: &coven_protocol::circle::CircleControlCoord,
356    ) -> Result<bool, DbError> {
357        StoreDatabase::verified_circle_control_covers_on(
358            crate::store::store_session::StoreRecords::new(self.conn, self.store_dir),
359            self.verified_store_authority,
360            root,
361            circle_id,
362            current,
363            prior,
364        )
365    }
366}
367
368impl StoreDatabase {
369    /// Whether one activated Circle control strictly covers another in the retained
370    /// control lineage — `covering` is a proper successor of `covered`. Bootstrap
371    /// reclamation uses this to prove a removed recipient lost authority under a
372    /// successor control that supersedes its seed's control. `false` when the
373    /// controls are equal or `covering` is not retained.
374    pub async fn circle_control_covers_strictly(
375        &self,
376        root: coven_protocol::store_commit::StoreRootRef,
377        circle_id: coven_protocol::circle::CircleId,
378        covering: &coven_protocol::circle::CircleControlCoord,
379        covered: &coven_protocol::circle::CircleControlCoord,
380    ) -> Result<bool, DbError> {
381        if covering == covered {
382            return Ok(false);
383        }
384        let covering = covering.clone();
385        let covered = covered.clone();
386        self.call_store(move |session| {
387            session.circle_control_covers_strictly(&root, circle_id, &covering, &covered)
388        })
389        .await
390    }
391
392    pub async fn circle_epoch_access(
393        &self,
394        root: coven_protocol::store_commit::StoreRootRef,
395        circle_id: coven_protocol::circle::CircleId,
396        expected_control: coven_protocol::circle::CircleControlCoord,
397    ) -> Result<Option<coven_protocol::circle_activation::CircleEpochAccess>, DbError> {
398        self.call_store(move |session| {
399            session.circle_epoch_access(&root, circle_id, &expected_control)
400        })
401        .await
402    }
403
404    pub async fn circle_historical_package_keyring(
405        &self,
406        root: coven_protocol::store_commit::StoreRootRef,
407        circle_id: coven_protocol::circle::CircleId,
408        expected_control: coven_protocol::circle::CircleControlCoord,
409        expected_key_fingerprint: coven_keys::encryption::KeyFingerprint,
410    ) -> Result<Option<String>, DbError> {
411        self.call_store(move |session| {
412            session.circle_historical_package_keyring(
413                &root,
414                circle_id,
415                &expected_control,
416                expected_key_fingerprint,
417            )
418        })
419        .await
420    }
421
422    pub async fn verified_circle_activation_context(
423        &self,
424        root: coven_protocol::store_commit::StoreRootRef,
425        circle_id: coven_protocol::circle::CircleId,
426        control: coven_protocol::circle::CircleControlCoord,
427    ) -> Result<
428        Option<(
429            coven_protocol::circle_activation::VerifiedCircleReference,
430            StoreBatchCommitRef,
431        )>,
432        DbError,
433    > {
434        self.call_store(move |session| {
435            session.verified_circle_activation_context(&root, circle_id, &control)
436        })
437        .await
438    }
439
440    pub async fn circle_blob_opening_protection(
441        &self,
442        root: coven_protocol::store_commit::StoreRootRef,
443        circle_id: coven_protocol::circle::CircleId,
444        expected_control: coven_protocol::circle::CircleControlCoord,
445        expected_key_fingerprint: coven_keys::encryption::KeyFingerprint,
446    ) -> Result<coven_protocol::objects::BlobSpoolProtection, DbError> {
447        self.call_store(move |session| {
448            session.circle_blob_opening_protection(
449                &root,
450                circle_id,
451                &expected_control,
452                expected_key_fingerprint,
453            )
454        })
455        .await
456    }
457
458    pub async fn verified_circle_activation(
459        &self,
460        root: coven_protocol::store_commit::StoreRootRef,
461        circle_id: coven_protocol::circle::CircleId,
462        control: coven_protocol::circle::CircleControlCoord,
463    ) -> Result<Option<coven_protocol::circle_activation::VerifiedCircleReference>, DbError> {
464        self.call_store(move |session| {
465            session.verified_circle_activation(&root, circle_id, &control)
466        })
467        .await
468    }
469
470    pub async fn circle_restore_head(
471        &self,
472        root: coven_protocol::store_commit::StoreRootRef,
473        circle_id: coven_protocol::circle::CircleId,
474        controls: Vec<coven_protocol::circle::CircleControlCoord>,
475    ) -> Result<
476        Option<(
477            coven_protocol::circle::CircleControlCoord,
478            StoreBatchCommitRef,
479        )>,
480        DbError,
481    > {
482        self.call_store(move |session| session.circle_restore_head(&root, circle_id, &controls))
483            .await
484    }
485
486    pub async fn retained_circle_activation_commit_ref(
487        &self,
488        circle_id: coven_protocol::circle::CircleId,
489        control: coven_protocol::circle::CircleControlCoord,
490    ) -> Result<Option<StoreBatchCommitRef>, DbError> {
491        self.call_store(move |session| {
492            session.retained_circle_activation_commit_ref(circle_id, &control)
493        })
494        .await
495    }
496
497    pub async fn verified_circle_control_coord_covers(
498        &self,
499        root: coven_protocol::store_commit::StoreRootRef,
500        circle_id: coven_protocol::circle::CircleId,
501        covering: coven_protocol::circle::CircleControlCoord,
502        covered: coven_protocol::circle::CircleControlCoord,
503    ) -> Result<bool, DbError> {
504        self.call_store(move |session| {
505            session.verified_circle_control_coord_covers(&root, circle_id, &covering, &covered)
506        })
507        .await
508    }
509
510    /// The head control of a Circle: the retained control whose lineage no other
511    /// retained control covers. Restore resolves the restoring identity's current
512    /// access at the head control's activating commit, so a member removed by a
513    /// later epoch close resolves against the successor control that excludes them
514    /// — never against a stale predecessor that still lists them active. A Circle
515    /// with two uncovered controls is a forked lineage and fails loud.
516    pub(super) fn head_circle_control_on(
517        records: StoreRecords<'_>,
518        authority: &mut dyn super::verified_store_authority::VerifiedStoreLookup,
519        root: &coven_protocol::store_commit::StoreRootRef,
520        circle_id: coven_protocol::circle::CircleId,
521        controls: &[coven_protocol::circle::CircleControlCoord],
522    ) -> Result<Option<coven_protocol::circle::CircleControlCoord>, DbError> {
523        // A control whose activating commit was reclaimed is superseded by a later
524        // epoch and cannot be head; keep only controls whose commit is retained.
525        let mut retained: Vec<(
526            coven_protocol::circle::CircleControlCoord,
527            coven_protocol::circle::PreparedCircleControl,
528        )> = Vec::new();
529        for coord in controls {
530            let Some(activation_commit) =
531                records.retained_circle_activation_commit_ref(circle_id, coord)?
532            else {
533                continue;
534            };
535            let materialization = Self::load_retained_merge_materialization_by_ref_on(
536                records,
537                root,
538                authority,
539                &activation_commit,
540            )?;
541            let reference = materialization.circle_activation(circle_id, coord)?;
542            retained.push((coord.clone(), reference.control));
543        }
544        let mut head: Option<coven_protocol::circle::CircleControlCoord> = None;
545        for (index, (candidate, _)) in retained.iter().enumerate() {
546            let mut covered = false;
547            for (other_index, (_, other_control)) in retained.iter().enumerate() {
548                if other_index == index {
549                    continue;
550                }
551                if Self::verified_circle_control_covers_on(
552                    records,
553                    authority,
554                    root,
555                    circle_id,
556                    other_control,
557                    candidate,
558                )? {
559                    covered = true;
560                    break;
561                }
562            }
563            if !covered {
564                if head.is_some() {
565                    return Err(DbError::Message(format!(
566                        "Circle {circle_id} has multiple head controls"
567                    )));
568                }
569                head = Some(candidate.clone());
570            }
571        }
572        Ok(head)
573    }
574
575    pub(super) fn verified_circle_activation_on(
576        records: StoreRecords<'_>,
577        authority: &mut dyn super::verified_store_authority::VerifiedStoreLookup,
578        root: &coven_protocol::store_commit::StoreRootRef,
579        circle_id: coven_protocol::circle::CircleId,
580        control: &coven_protocol::circle::CircleControlCoord,
581    ) -> Result<Option<coven_protocol::circle_activation::VerifiedCircleReference>, DbError> {
582        let Some(activation_commit) = records.circle_activation_commit_ref(circle_id, control)?
583        else {
584            return Ok(None);
585        };
586        let retained = Self::load_retained_merge_materialization_by_ref_on(
587            records,
588            root,
589            authority,
590            &activation_commit,
591        )?;
592        retained.circle_activation(circle_id, control).map(Some)
593    }
594
595    pub async fn verified_circle_control_covers(
596        &self,
597        root: coven_protocol::store_commit::StoreRootRef,
598        circle_id: coven_protocol::circle::CircleId,
599        current: coven_protocol::circle::PreparedCircleControl,
600        prior: coven_protocol::circle::CircleControlCoord,
601    ) -> Result<bool, DbError> {
602        self.call_store(move |session| {
603            session.verified_circle_control_covers(&root, circle_id, &current, &prior)
604        })
605        .await
606    }
607
608    pub(super) fn verified_circle_control_covers_on(
609        records: StoreRecords<'_>,
610        authority: &mut dyn super::verified_store_authority::VerifiedStoreLookup,
611        root: &coven_protocol::store_commit::StoreRootRef,
612        circle_id: coven_protocol::circle::CircleId,
613        current: &coven_protocol::circle::PreparedCircleControl,
614        prior: &coven_protocol::circle::CircleControlCoord,
615    ) -> Result<bool, DbError> {
616        if current.value.circle_id != circle_id {
617            return Err(DbError::Message(
618                "Circle control lineage starts outside its Circle".to_string(),
619            ));
620        }
621        if current.coord == *prior {
622            return Ok(true);
623        }
624        let mut pending = current
625            .value
626            .access_epoch()
627            .covered_control_heads
628            .iter()
629            .map(|head| (current.clone(), head.coord.clone()))
630            .collect::<Vec<_>>();
631        let mut visited = std::collections::BTreeSet::new();
632        while let Some((successor, coordinate)) = pending.pop() {
633            if !visited.insert(coordinate.clone()) {
634                continue;
635            }
636            let predecessor = Self::verified_circle_activation_on(
637                records,
638                authority,
639                root,
640                circle_id,
641                &coordinate,
642            )?
643            .ok_or_else(|| {
644                DbError::Message(format!(
645                    "Circle {circle_id} control lineage omits retained control {coordinate:?}"
646                ))
647            })?;
648            if !successor.value.causally_covers(&predecessor.control.value) {
649                return Err(DbError::Message(format!(
650                    "Circle {circle_id} control lineage contains a non-causal edge"
651                )));
652            }
653            if predecessor.control.coord == *prior {
654                return Ok(true);
655            }
656            pending.extend(
657                predecessor
658                    .control
659                    .value
660                    .access_epoch()
661                    .covered_control_heads
662                    .iter()
663                    .map(|head| (predecessor.control.clone(), head.coord.clone())),
664            );
665        }
666        Ok(false)
667    }
668}
669
670pub(crate) fn circle_blob_opening_protection_on(
671    records: StoreRecords<'_>,
672    verified_store: &mut dyn super::verified_store_authority::VerifiedStoreLookup,
673    root: &coven_protocol::store_commit::StoreRootRef,
674    circle_id: coven_protocol::circle::CircleId,
675    expected_control: &coven_protocol::circle::CircleControlCoord,
676    expected_key_fingerprint: coven_keys::encryption::KeyFingerprint,
677) -> Result<coven_protocol::objects::BlobSpoolProtection, DbError> {
678    let Some(authority) = StoreDatabase::verified_circle_activation_on(
679        records,
680        verified_store,
681        root,
682        circle_id,
683        expected_control,
684    )?
685    else {
686        return Err(DbError::Message(format!(
687            "Circle {circle_id} has no retained authority for control {expected_control:?}"
688        )));
689    };
690    if authority.control.value.key_fingerprint() != expected_key_fingerprint {
691        return Err(DbError::Message(format!(
692            "Circle {circle_id} blob key {expected_key_fingerprint} differs from \
693                 exact control {expected_control:?}"
694        )));
695    }
696
697    let controls = records.circle_controls(circle_id)?;
698
699    let mut retained_key = None;
700    for control in controls {
701        let activation = StoreDatabase::verified_circle_activation_on(
702            records,
703            verified_store,
704            root,
705            circle_id,
706            &control,
707        )?
708        .ok_or_else(|| {
709            DbError::Message(format!(
710                "Circle {circle_id} activation index lost control {control:?}"
711            ))
712        })?;
713        let Some((generation, key)) = activation
714            .retained_key_entry(expected_key_fingerprint)
715            .map_err(DbError::from)?
716        else {
717            continue;
718        };
719        let candidate = EncryptionService::from_key_at_generation(generation, key);
720        if retained_key
721            .as_ref()
722            .is_some_and(|existing: &EncryptionService| {
723                existing.current_generation() != generation || existing.key_bytes() != key
724            })
725        {
726            return Err(DbError::Message(format!(
727                "Circle {circle_id} retains inconsistent key material for fingerprint \
728                     {expected_key_fingerprint}"
729            )));
730        }
731        retained_key = Some(candidate);
732    }
733    retained_key
734        .map(coven_protocol::objects::BlobSpoolProtection::Opaque)
735        .ok_or_else(|| {
736            DbError::Message(format!(
737                "Circle {circle_id} retains no local key for fingerprint \
738                     {expected_key_fingerprint}"
739            ))
740        })
741}
742
743impl crate::store::store_session::StoreTransaction<'_, '_> {
744    pub(super) fn circle_blob_opening_protection(
745        self,
746        verified_store: &mut dyn super::verified_store_authority::VerifiedStoreLookup,
747        root: &coven_protocol::store_commit::StoreRootRef,
748        circle_id: coven_protocol::circle::CircleId,
749        expected_control: &coven_protocol::circle::CircleControlCoord,
750        expected_key_fingerprint: coven_keys::encryption::KeyFingerprint,
751    ) -> Result<coven_protocol::objects::BlobSpoolProtection, DbError> {
752        circle_blob_opening_protection_on(
753            crate::store::store_session::StoreRecords::new(self.transaction, self.store_dir),
754            verified_store,
755            root,
756            circle_id,
757            expected_control,
758            expected_key_fingerprint,
759        )
760    }
761}