Skip to main content

coven_protocol/store_commit/device_state/
resolution.rs

1use super::*;
2
3#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4#[serde(rename_all = "snake_case", deny_unknown_fields)]
5pub enum StoreDeviceProposalState {
6    Pending {
7        proposal: StoreDeviceExclusionProposalRef,
8    },
9    Cancelled {
10        outcome: StoreDeviceExclusionCancellationRef,
11    },
12    Superseded {
13        proposal: StoreDeviceExclusionProposalRef,
14        terminals: Vec<StoreDeviceExclusionRef>,
15    },
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct ResolvedStoreDeviceState {
21    pub devices: BTreeMap<StoreDeviceId, StoreDeviceRecord>,
22    pub recovery: Vec<OwnerRecoveryCursor>,
23    pub state_hash: ObjectHash,
24}
25
26impl ResolvedStoreDeviceState {
27    pub fn validate_canonical(&self) -> Result<(), StoreProtocolError> {
28        let canonical = Self::from_parts(self.devices.clone(), self.recovery.clone())?;
29        if canonical != *self {
30            return Err(StoreProtocolError::DeviceStateMismatch);
31        }
32        Ok(())
33    }
34
35    pub fn founder(
36        root: &StoreRootRef,
37        founder_registration: StoreDeviceRegistrationRef,
38        founder_pubkey: &str,
39        founder_grant: MembershipGrantId,
40        founder_recovery: &GrantStreamAnchor,
41    ) -> Result<Self, StoreProtocolError> {
42        let cursor = OwnerRecoveryCursor {
43            owner_grant: founder_grant.clone(),
44            position: OwnerRecoveryPosition::BeforeFirst {
45                activation: OwnerRecoveryActivationId::derive(
46                    root,
47                    founder_pubkey,
48                    &founder_grant,
49                    founder_recovery,
50                )?,
51            },
52        };
53        let devices = BTreeMap::from([(
54            founder_registration.device_id,
55            StoreDeviceRecord {
56                registration: founder_registration,
57                proposals: BTreeMap::new(),
58                status: StoreDeviceStatus::Active,
59            },
60        )]);
61        Self::from_parts(devices, vec![cursor])
62    }
63
64    pub fn activate_registration(
65        &self,
66        registration: StoreDeviceRegistrationRef,
67        recovery: Option<OwnerRecoveryCursor>,
68    ) -> Result<Self, StoreProtocolError> {
69        if self.devices.contains_key(&registration.device_id) {
70            return Err(StoreProtocolError::DuplicateDeviceRegistration {
71                device_id: registration.device_id.to_string(),
72            });
73        }
74        let mut devices = self.devices.clone();
75        devices.insert(
76            registration.device_id,
77            StoreDeviceRecord {
78                registration,
79                proposals: BTreeMap::new(),
80                status: StoreDeviceStatus::Active,
81            },
82        );
83        let mut cursors = self.recovery.clone();
84        if let Some(cursor) = recovery {
85            if let Some(existing) = cursors
86                .iter_mut()
87                .find(|existing| existing.owner_grant == cursor.owner_grant)
88            {
89                *existing = cursor;
90            } else {
91                cursors.push(cursor);
92            }
93        }
94        Self::from_parts(devices, cursors)
95    }
96
97    pub fn activate_owner_recovery(
98        &self,
99        owner_grant: MembershipGrantId,
100        activation: OwnerRecoveryActivationId,
101    ) -> Result<Self, StoreProtocolError> {
102        if self
103            .recovery
104            .iter()
105            .any(|cursor| cursor.owner_grant == owner_grant)
106        {
107            return Err(StoreProtocolError::OwnerRecoveryMismatch);
108        }
109        let mut recovery = self.recovery.clone();
110        recovery.push(OwnerRecoveryCursor {
111            owner_grant,
112            position: OwnerRecoveryPosition::BeforeFirst { activation },
113        });
114        Self::from_parts(self.devices.clone(), recovery)
115    }
116
117    pub fn preactivate_recovery_author(
118        mut self,
119        commit: &StoreBatchCommit,
120        registrations: &[ActivatedStoreDeviceRegistration],
121    ) -> Result<(Self, Option<StoreDeviceRegistrationRef>), StoreProtocolError> {
122        if commit.device_registrations().len() != registrations.len() {
123            return Err(StoreProtocolError::Malformed(
124                "verified registrations do not cover every activation".to_string(),
125            ));
126        }
127        for (activated, registration) in commit.device_registrations().iter().zip(registrations) {
128            registration.verify_reference(activated)?;
129            if activated.registration == commit.author_registration {
130                if let Some(cursor) = registration.recovery_cursor()? {
131                    self =
132                        self.activate_registration(activated.registration.clone(), Some(cursor))?;
133                    return Ok((self, Some(activated.registration.clone())));
134                }
135            }
136        }
137        Ok((self, None))
138    }
139
140    pub fn apply_verified_lifecycle(
141        mut self,
142        commit: &StoreBatchCommit,
143        registrations: &[ActivatedStoreDeviceRegistration],
144        preactivated: Option<&StoreDeviceRegistrationRef>,
145        owner_recovery: Option<(MembershipGrantId, OwnerRecoveryActivationId)>,
146    ) -> Result<Self, StoreProtocolError> {
147        if commit.device_registrations().len() != registrations.len() {
148            return Err(StoreProtocolError::Malformed(
149                "verified registrations do not cover every activation".to_string(),
150            ));
151        }
152        for (activated, registration) in commit.device_registrations().iter().zip(registrations) {
153            registration.verify_reference(activated)?;
154            if preactivated != Some(&activated.registration) {
155                self = self.activate_registration(
156                    activated.registration.clone(),
157                    registration.recovery_cursor()?,
158                )?;
159            }
160        }
161        if let Some((grant_id, activation)) = owner_recovery {
162            self = self.activate_owner_recovery(grant_id, activation)?;
163        }
164        Ok(self)
165    }
166
167    pub fn propose_exclusion(
168        &self,
169        reference: StoreDeviceExclusionProposalRef,
170        proposal: &StoreDeviceExclusionProposal,
171        predecessor_ref: &StoreDeviceStateRef,
172    ) -> Result<Self, StoreProtocolError> {
173        reference.verify_proposal(proposal)?;
174        if &proposal.frozen_device_state != predecessor_ref
175            || predecessor_ref.state_hash() != self.state_hash
176        {
177            return Err(StoreProtocolError::DeviceStateMismatch);
178        }
179        let mut devices = self.devices.clone();
180        let record = devices
181            .get_mut(&reference.target.device_id)
182            .ok_or(StoreProtocolError::DeviceStateMismatch)?;
183        if record.registration != reference.target
184            || !matches!(record.status, StoreDeviceStatus::Active)
185            || record.proposals.contains_key(&reference.proposal_id)
186        {
187            return Err(StoreProtocolError::DeviceStateMismatch);
188        }
189        record.proposals.insert(
190            reference.proposal_id,
191            StoreDeviceProposalState::Pending {
192                proposal: reference,
193            },
194        );
195        Self::from_parts(devices, self.recovery.clone())
196    }
197
198    pub fn cancel_exclusion(
199        &self,
200        cancellation: StoreDeviceExclusionCancellationRef,
201    ) -> Result<Self, StoreProtocolError> {
202        let mut devices = self.devices.clone();
203        let record = devices
204            .get_mut(&cancellation.proposal.target.device_id)
205            .ok_or(StoreProtocolError::DeviceStateMismatch)?;
206        let state = record
207            .proposals
208            .get_mut(&cancellation.proposal.proposal_id)
209            .ok_or(StoreProtocolError::DeviceStateMismatch)?;
210        if !matches!(state, StoreDeviceProposalState::Pending { proposal } if proposal == &cancellation.proposal)
211        {
212            return Err(StoreProtocolError::DeviceStateMismatch);
213        }
214        *state = StoreDeviceProposalState::Cancelled {
215            outcome: cancellation,
216        };
217        Self::from_parts(devices, self.recovery.clone())
218    }
219
220    pub fn exclude(
221        &self,
222        exclusion: StoreDeviceExclusionRef,
223        accepted_cut: StoreHistoryCut,
224    ) -> Result<Self, StoreProtocolError> {
225        validate_store_history_cut(&accepted_cut)?;
226        let mut devices = self.devices.clone();
227        let record = devices
228            .get_mut(&exclusion.proposal.target.device_id)
229            .ok_or(StoreProtocolError::DeviceStateMismatch)?;
230        if record.registration != exclusion.proposal.target
231            || !matches!(record.status, StoreDeviceStatus::Active)
232            || !matches!(
233                record.proposals.get(&exclusion.proposal.proposal_id),
234                Some(StoreDeviceProposalState::Pending { proposal }) if proposal == &exclusion.proposal
235            )
236        {
237            return Err(StoreProtocolError::DeviceStateMismatch);
238        }
239        let terminals = vec![exclusion];
240        supersede_pending_proposals(&mut record.proposals, &terminals);
241        record.status = StoreDeviceStatus::Inactive {
242            terminals,
243            accepted_cut,
244        };
245        Self::from_parts(devices, self.recovery.clone())
246    }
247
248    pub fn merge(states: impl IntoIterator<Item = Self>) -> Result<Self, StoreProtocolError> {
249        let mut devices = BTreeMap::new();
250        let mut recovery = BTreeMap::<MembershipGrantId, OwnerRecoveryPosition>::new();
251        for state in states {
252            for (device_id, record) in state.devices {
253                match devices.entry(device_id) {
254                    std::collections::btree_map::Entry::Vacant(entry) => {
255                        entry.insert(record);
256                    }
257                    std::collections::btree_map::Entry::Occupied(mut entry) => {
258                        if entry.get().registration != record.registration {
259                            return Err(StoreProtocolError::DeviceStateMismatch);
260                        }
261                        let merged_status =
262                            merge_device_status(entry.get().status.clone(), record.status)?;
263                        let mut merged_proposals = merge_device_proposals(
264                            entry.get().proposals.clone(),
265                            record.proposals,
266                        )?;
267                        if let StoreDeviceStatus::Inactive { terminals, .. } = &merged_status {
268                            supersede_pending_proposals(&mut merged_proposals, terminals);
269                        }
270                        entry.get_mut().status = merged_status;
271                        entry.get_mut().proposals = merged_proposals;
272                    }
273                }
274            }
275            for cursor in state.recovery {
276                match recovery.entry(cursor.owner_grant) {
277                    std::collections::btree_map::Entry::Vacant(entry) => {
278                        entry.insert(cursor.position);
279                    }
280                    std::collections::btree_map::Entry::Occupied(mut entry) => {
281                        // Stream heads on either side of a recovery commit
282                        // name different positions on the grant's one chain;
283                        // the merged state stands at the furthest.
284                        let merged = entry.get().merge(&cursor.position)?;
285                        entry.insert(merged);
286                    }
287                }
288            }
289        }
290        Self::from_parts(
291            devices,
292            recovery
293                .into_iter()
294                .map(|(owner_grant, position)| OwnerRecoveryCursor {
295                    owner_grant,
296                    position,
297                })
298                .collect(),
299        )
300    }
301
302    fn from_parts(
303        devices: BTreeMap<StoreDeviceId, StoreDeviceRecord>,
304        mut recovery: Vec<OwnerRecoveryCursor>,
305    ) -> Result<Self, StoreProtocolError> {
306        recovery.sort();
307        validate_recovery_cursors(&recovery)?;
308        validate_store_device_records(&devices)?;
309        let state_hash = ObjectHash::digest(&domain_json(
310            b"coven.store-device-state.v1\0",
311            &(&devices, &recovery),
312        ));
313        Ok(Self {
314            devices,
315            recovery,
316            state_hash,
317        })
318    }
319}
320
321fn supersede_pending_proposals(
322    proposals: &mut BTreeMap<StoreDeviceExclusionProposalId, StoreDeviceProposalState>,
323    terminals: &[StoreDeviceExclusionRef],
324) {
325    for state in proposals.values_mut() {
326        if let StoreDeviceProposalState::Pending { proposal } = state {
327            *state = StoreDeviceProposalState::Superseded {
328                proposal: proposal.clone(),
329                terminals: terminals.to_vec(),
330            };
331        }
332    }
333}
334
335fn merge_device_proposals(
336    mut left: BTreeMap<StoreDeviceExclusionProposalId, StoreDeviceProposalState>,
337    right: BTreeMap<StoreDeviceExclusionProposalId, StoreDeviceProposalState>,
338) -> Result<BTreeMap<StoreDeviceExclusionProposalId, StoreDeviceProposalState>, StoreProtocolError>
339{
340    for (proposal_id, right_state) in right {
341        match left.entry(proposal_id) {
342            std::collections::btree_map::Entry::Vacant(entry) => {
343                entry.insert(right_state);
344            }
345            std::collections::btree_map::Entry::Occupied(mut entry) => {
346                let merged = merge_device_proposal_state(entry.get().clone(), right_state)?;
347                entry.insert(merged);
348            }
349        }
350    }
351    Ok(left)
352}
353
354fn merge_device_proposal_state(
355    left: StoreDeviceProposalState,
356    right: StoreDeviceProposalState,
357) -> Result<StoreDeviceProposalState, StoreProtocolError> {
358    let left_proposal = match &left {
359        StoreDeviceProposalState::Pending { proposal }
360        | StoreDeviceProposalState::Superseded { proposal, .. } => proposal,
361        StoreDeviceProposalState::Cancelled { outcome } => &outcome.proposal,
362    };
363    let right_proposal = match &right {
364        StoreDeviceProposalState::Pending { proposal }
365        | StoreDeviceProposalState::Superseded { proposal, .. } => proposal,
366        StoreDeviceProposalState::Cancelled { outcome } => &outcome.proposal,
367    };
368    if left_proposal != right_proposal {
369        return Err(StoreProtocolError::DeviceStateMismatch);
370    }
371    match (left, right) {
372        (
373            StoreDeviceProposalState::Pending { proposal },
374            StoreDeviceProposalState::Pending { .. },
375        ) => Ok(StoreDeviceProposalState::Pending { proposal }),
376        (
377            StoreDeviceProposalState::Cancelled { outcome },
378            StoreDeviceProposalState::Cancelled { outcome: other },
379        ) => {
380            if outcome != other {
381                return Err(StoreProtocolError::DeviceStateMismatch);
382            }
383            Ok(StoreDeviceProposalState::Cancelled { outcome })
384        }
385        (StoreDeviceProposalState::Cancelled { outcome }, _)
386        | (_, StoreDeviceProposalState::Cancelled { outcome }) => {
387            Ok(StoreDeviceProposalState::Cancelled { outcome })
388        }
389        (
390            StoreDeviceProposalState::Superseded {
391                proposal,
392                terminals: left,
393            },
394            StoreDeviceProposalState::Superseded {
395                terminals: right, ..
396            },
397        ) => Ok(StoreDeviceProposalState::Superseded {
398            proposal,
399            terminals: merge_terminal_refs(left, right)?,
400        }),
401        (
402            StoreDeviceProposalState::Superseded {
403                proposal,
404                terminals,
405            },
406            _,
407        )
408        | (
409            _,
410            StoreDeviceProposalState::Superseded {
411                proposal,
412                terminals,
413            },
414        ) => Ok(StoreDeviceProposalState::Superseded {
415            proposal,
416            terminals,
417        }),
418    }
419}
420
421pub(crate) fn merge_device_status(
422    left: StoreDeviceStatus,
423    right: StoreDeviceStatus,
424) -> Result<StoreDeviceStatus, StoreProtocolError> {
425    match (left, right) {
426        (StoreDeviceStatus::Active, StoreDeviceStatus::Active) => Ok(StoreDeviceStatus::Active),
427        (
428            StoreDeviceStatus::Inactive {
429                terminals,
430                accepted_cut,
431            },
432            StoreDeviceStatus::Active,
433        )
434        | (
435            StoreDeviceStatus::Active,
436            StoreDeviceStatus::Inactive {
437                terminals,
438                accepted_cut,
439            },
440        ) => Ok(StoreDeviceStatus::Inactive {
441            terminals,
442            accepted_cut,
443        }),
444        (
445            StoreDeviceStatus::Inactive {
446                terminals: left_terminals,
447                accepted_cut: left_cut,
448            },
449            StoreDeviceStatus::Inactive {
450                terminals: right_terminals,
451                accepted_cut: right_cut,
452            },
453        ) => Ok(StoreDeviceStatus::Inactive {
454            terminals: merge_terminal_refs(left_terminals, right_terminals)?,
455            accepted_cut: intersect_terminal_history_cuts(left_cut, right_cut)?,
456        }),
457    }
458}
459
460fn merge_terminal_refs(
461    left: Vec<StoreDeviceExclusionRef>,
462    right: Vec<StoreDeviceExclusionRef>,
463) -> Result<Vec<StoreDeviceExclusionRef>, StoreProtocolError> {
464    let terminals = left
465        .into_iter()
466        .chain(right)
467        .collect::<BTreeSet<_>>()
468        .into_iter()
469        .collect::<Vec<_>>();
470    validate_terminal_refs(&terminals)?;
471    Ok(terminals)
472}
473
474pub(crate) fn merge_history_cuts(
475    left: StoreHistoryCut,
476    right: StoreHistoryCut,
477) -> Result<StoreHistoryCut, StoreProtocolError> {
478    {
479        let StoreHistoryCut(mut left) = left;
480        let StoreHistoryCut(right) = right;
481        for (stream, reference) in right {
482            match left.entry(stream) {
483                std::collections::btree_map::Entry::Vacant(entry) => {
484                    entry.insert(reference);
485                }
486                std::collections::btree_map::Entry::Occupied(mut entry) => {
487                    let current = entry.get();
488                    if reference.coord.sequence() > current.coord.sequence() {
489                        entry.insert(reference);
490                    } else if reference.coord.sequence() == current.coord.sequence()
491                        && reference != *current
492                    {
493                        return Err(StoreProtocolError::DeviceStateMismatch);
494                    }
495                }
496            }
497        }
498        Ok(StoreHistoryCut(left))
499    }
500}
501
502fn intersect_terminal_history_cuts(
503    left: StoreHistoryCut,
504    right: StoreHistoryCut,
505) -> Result<StoreHistoryCut, StoreProtocolError> {
506    {
507        let StoreHistoryCut(left) = left;
508        let StoreHistoryCut(right) = right;
509        let mut intersection = BTreeMap::new();
510        for (stream, left_reference) in left {
511            let Some(right_reference) = right.get(&stream) else {
512                continue;
513            };
514            let left_sequence = left_reference.coord.sequence();
515            let right_sequence = right_reference.coord.sequence();
516            let reference = if left_sequence < right_sequence {
517                left_reference
518            } else if right_sequence < left_sequence {
519                right_reference.clone()
520            } else if left_reference == *right_reference {
521                left_reference
522            } else {
523                return Err(StoreProtocolError::DeviceStateMismatch);
524            };
525            intersection.insert(stream, reference);
526        }
527        Ok(StoreHistoryCut(intersection))
528    }
529}
530
531fn validate_store_device_records(
532    devices: &BTreeMap<StoreDeviceId, StoreDeviceRecord>,
533) -> Result<(), StoreProtocolError> {
534    for (device_id, record) in devices {
535        if record.registration.device_id != *device_id {
536            return Err(StoreProtocolError::DeviceStateMismatch);
537        }
538        for (proposal_id, state) in &record.proposals {
539            let proposal = match state {
540                StoreDeviceProposalState::Pending { proposal }
541                | StoreDeviceProposalState::Superseded { proposal, .. } => proposal,
542                StoreDeviceProposalState::Cancelled { outcome } => &outcome.proposal,
543            };
544            if proposal.proposal_id != *proposal_id {
545                return Err(StoreProtocolError::DeviceStateMismatch);
546            }
547            if proposal.target != record.registration {
548                return Err(StoreProtocolError::DeviceStateMismatch);
549            }
550            if let StoreDeviceProposalState::Superseded { terminals, .. } = state {
551                validate_terminal_refs(terminals)?;
552            }
553        }
554        if let StoreDeviceStatus::Inactive {
555            terminals,
556            accepted_cut,
557        } = &record.status
558        {
559            validate_terminal_refs(terminals)?;
560            validate_store_history_cut(accepted_cut)?;
561            if record
562                .proposals
563                .values()
564                .any(|state| matches!(state, StoreDeviceProposalState::Pending { .. }))
565            {
566                return Err(StoreProtocolError::DeviceStateMismatch);
567            }
568        }
569    }
570    Ok(())
571}
572
573fn validate_terminal_refs(terminals: &[StoreDeviceExclusionRef]) -> Result<(), StoreProtocolError> {
574    if terminals.is_empty() || terminals.windows(2).any(|pair| pair[0] >= pair[1]) {
575        return Err(StoreProtocolError::DeviceStateMismatch);
576    }
577    Ok(())
578}
579
580pub(crate) fn canonical_recovery_cursors(
581    mut recovery: Vec<OwnerRecoveryCursor>,
582) -> Result<Vec<OwnerRecoveryCursor>, StoreProtocolError> {
583    recovery.sort();
584    validate_recovery_cursors(&recovery)?;
585    Ok(recovery)
586}
587
588pub(crate) fn validate_recovery_cursors(
589    recovery: &[OwnerRecoveryCursor],
590) -> Result<(), StoreProtocolError> {
591    if recovery.windows(2).any(|pair| pair[0] >= pair[1]) {
592        return Err(StoreProtocolError::OwnerRecoveryMismatch);
593    }
594    Ok(())
595}