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    ) -> Result<Self, StoreProtocolError> {
172        reference.verify_proposal(proposal)?;
173        let record = self
174            .devices
175            .get(&reference.target.device_id)
176            .ok_or(StoreProtocolError::DeviceStateMismatch)?;
177        if record.registration != reference.target
178            || !matches!(record.status, StoreDeviceStatus::Active)
179            || record.proposals.contains_key(&reference.proposal_id)
180        {
181            return Err(StoreProtocolError::DeviceStateMismatch);
182        }
183        Self::merge([
184            self.clone(),
185            Self::exclusion_effect(StoreDeviceProposalState::Pending {
186                proposal: reference,
187            })?,
188        ])
189    }
190
191    pub fn cancel_exclusion(
192        &self,
193        cancellation: StoreDeviceExclusionCancellationRef,
194    ) -> Result<Self, StoreProtocolError> {
195        let record = self
196            .devices
197            .get(&cancellation.proposal.target.device_id)
198            .ok_or(StoreProtocolError::DeviceStateMismatch)?;
199        let state = record
200            .proposals
201            .get(&cancellation.proposal.proposal_id)
202            .ok_or(StoreProtocolError::DeviceStateMismatch)?;
203        if !matches!(state, StoreDeviceProposalState::Pending { proposal } if proposal == &cancellation.proposal)
204        {
205            return Err(StoreProtocolError::DeviceStateMismatch);
206        }
207        Self::merge([
208            self.clone(),
209            Self::exclusion_effect(StoreDeviceProposalState::Cancelled {
210                outcome: cancellation,
211            })?,
212        ])
213    }
214
215    pub fn exclude(&self, exclusion: StoreDeviceExclusionRef) -> Result<Self, StoreProtocolError> {
216        let record = self
217            .devices
218            .get(&exclusion.proposal.target.device_id)
219            .ok_or(StoreProtocolError::DeviceStateMismatch)?;
220        if record.registration != exclusion.proposal.target
221            || !matches!(record.status, StoreDeviceStatus::Active)
222            || !matches!(
223                record.proposals.get(&exclusion.proposal.proposal_id),
224                Some(StoreDeviceProposalState::Pending { proposal }) if proposal == &exclusion.proposal
225            )
226        {
227            return Err(StoreProtocolError::DeviceStateMismatch);
228        }
229        Self::merge([
230            self.clone(),
231            Self::exclusion_effect(StoreDeviceProposalState::Superseded {
232                proposal: exclusion.proposal.clone(),
233                terminals: vec![exclusion],
234            })?,
235        ])
236    }
237
238    /// The continuing effect of one authenticated exclusion operation. This
239    /// describes its state contribution; its caller establishes acceptance.
240    pub(super) fn exclusion_effect(
241        effect: StoreDeviceProposalState,
242    ) -> Result<Self, StoreProtocolError> {
243        let (proposal, status) = match &effect {
244            StoreDeviceProposalState::Pending { proposal } => (proposal, StoreDeviceStatus::Active),
245            StoreDeviceProposalState::Cancelled { outcome } => {
246                (&outcome.proposal, StoreDeviceStatus::Active)
247            }
248            StoreDeviceProposalState::Superseded {
249                proposal,
250                terminals,
251            } => (
252                proposal,
253                StoreDeviceStatus::Inactive {
254                    terminals: terminals.clone(),
255                },
256            ),
257        };
258        let registration = proposal.target.clone();
259        let proposals = BTreeMap::from([(proposal.proposal_id, effect)]);
260        Self::from_parts(
261            BTreeMap::from([(
262                registration.device_id,
263                StoreDeviceRecord {
264                    registration,
265                    proposals,
266                    status,
267                },
268            )]),
269            Vec::new(),
270        )
271    }
272
273    pub fn merge(states: impl IntoIterator<Item = Self>) -> Result<Self, StoreProtocolError> {
274        let mut devices = BTreeMap::new();
275        let mut recovery = BTreeMap::<MembershipGrantId, OwnerRecoveryPosition>::new();
276        for state in states {
277            for (device_id, record) in state.devices {
278                match devices.entry(device_id) {
279                    std::collections::btree_map::Entry::Vacant(entry) => {
280                        entry.insert(record);
281                    }
282                    std::collections::btree_map::Entry::Occupied(mut entry) => {
283                        if entry.get().registration != record.registration {
284                            return Err(StoreProtocolError::DeviceStateMismatch);
285                        }
286                        let merged_status =
287                            merge_device_status(entry.get().status.clone(), record.status)?;
288                        let mut merged_proposals = merge_device_proposals(
289                            entry.get().proposals.clone(),
290                            record.proposals,
291                        )?;
292                        if let StoreDeviceStatus::Inactive { terminals, .. } = &merged_status {
293                            supersede_pending_proposals(&mut merged_proposals, terminals);
294                        }
295                        entry.get_mut().status = merged_status;
296                        entry.get_mut().proposals = merged_proposals;
297                    }
298                }
299            }
300            for cursor in state.recovery {
301                match recovery.entry(cursor.owner_grant) {
302                    std::collections::btree_map::Entry::Vacant(entry) => {
303                        entry.insert(cursor.position);
304                    }
305                    std::collections::btree_map::Entry::Occupied(mut entry) => {
306                        // Stream heads on either side of a recovery commit
307                        // name different positions on the grant's one chain;
308                        // the merged state stands at the furthest.
309                        let merged = entry.get().merge(&cursor.position)?;
310                        entry.insert(merged);
311                    }
312                }
313            }
314        }
315        Self::from_parts(
316            devices,
317            recovery
318                .into_iter()
319                .map(|(owner_grant, position)| OwnerRecoveryCursor {
320                    owner_grant,
321                    position,
322                })
323                .collect(),
324        )
325    }
326
327    fn from_parts(
328        devices: BTreeMap<StoreDeviceId, StoreDeviceRecord>,
329        mut recovery: Vec<OwnerRecoveryCursor>,
330    ) -> Result<Self, StoreProtocolError> {
331        recovery.sort();
332        validate_recovery_cursors(&recovery)?;
333        validate_store_device_records(&devices)?;
334        let state_hash = ObjectHash::digest(&domain_json(
335            b"coven.store-device-state.v1\0",
336            &(&devices, &recovery),
337        ));
338        Ok(Self {
339            devices,
340            recovery,
341            state_hash,
342        })
343    }
344}
345
346fn supersede_pending_proposals(
347    proposals: &mut BTreeMap<StoreDeviceExclusionProposalId, StoreDeviceProposalState>,
348    terminals: &[StoreDeviceExclusionRef],
349) {
350    for state in proposals.values_mut() {
351        if let StoreDeviceProposalState::Pending { proposal } = state {
352            *state = StoreDeviceProposalState::Superseded {
353                proposal: proposal.clone(),
354                terminals: terminals.to_vec(),
355            };
356        }
357    }
358}
359
360fn merge_device_proposals(
361    mut left: BTreeMap<StoreDeviceExclusionProposalId, StoreDeviceProposalState>,
362    right: BTreeMap<StoreDeviceExclusionProposalId, StoreDeviceProposalState>,
363) -> Result<BTreeMap<StoreDeviceExclusionProposalId, StoreDeviceProposalState>, StoreProtocolError>
364{
365    for (proposal_id, right_state) in right {
366        match left.entry(proposal_id) {
367            std::collections::btree_map::Entry::Vacant(entry) => {
368                entry.insert(right_state);
369            }
370            std::collections::btree_map::Entry::Occupied(mut entry) => {
371                let merged = merge_device_proposal_state(entry.get().clone(), right_state)?;
372                entry.insert(merged);
373            }
374        }
375    }
376    Ok(left)
377}
378
379fn merge_device_proposal_state(
380    left: StoreDeviceProposalState,
381    right: StoreDeviceProposalState,
382) -> Result<StoreDeviceProposalState, StoreProtocolError> {
383    let left_proposal = match &left {
384        StoreDeviceProposalState::Pending { proposal }
385        | StoreDeviceProposalState::Superseded { proposal, .. } => proposal,
386        StoreDeviceProposalState::Cancelled { outcome } => &outcome.proposal,
387    };
388    let right_proposal = match &right {
389        StoreDeviceProposalState::Pending { proposal }
390        | StoreDeviceProposalState::Superseded { proposal, .. } => proposal,
391        StoreDeviceProposalState::Cancelled { outcome } => &outcome.proposal,
392    };
393    if left_proposal != right_proposal {
394        return Err(StoreProtocolError::DeviceStateMismatch);
395    }
396    match (left, right) {
397        (
398            StoreDeviceProposalState::Pending { proposal },
399            StoreDeviceProposalState::Pending { .. },
400        ) => Ok(StoreDeviceProposalState::Pending { proposal }),
401        (
402            StoreDeviceProposalState::Cancelled { outcome },
403            StoreDeviceProposalState::Cancelled { outcome: other },
404        ) => {
405            if outcome != other {
406                return Err(StoreProtocolError::DeviceStateMismatch);
407            }
408            Ok(StoreDeviceProposalState::Cancelled { outcome })
409        }
410        (StoreDeviceProposalState::Cancelled { outcome }, _)
411        | (_, StoreDeviceProposalState::Cancelled { outcome }) => {
412            Ok(StoreDeviceProposalState::Cancelled { outcome })
413        }
414        (
415            StoreDeviceProposalState::Superseded {
416                proposal,
417                terminals: left,
418            },
419            StoreDeviceProposalState::Superseded {
420                terminals: right, ..
421            },
422        ) => Ok(StoreDeviceProposalState::Superseded {
423            proposal,
424            terminals: merge_terminal_refs(left, right)?,
425        }),
426        (
427            StoreDeviceProposalState::Superseded {
428                proposal,
429                terminals,
430            },
431            _,
432        )
433        | (
434            _,
435            StoreDeviceProposalState::Superseded {
436                proposal,
437                terminals,
438            },
439        ) => Ok(StoreDeviceProposalState::Superseded {
440            proposal,
441            terminals,
442        }),
443    }
444}
445
446pub(crate) fn merge_device_status(
447    left: StoreDeviceStatus,
448    right: StoreDeviceStatus,
449) -> Result<StoreDeviceStatus, StoreProtocolError> {
450    match (left, right) {
451        (StoreDeviceStatus::Active, StoreDeviceStatus::Active) => Ok(StoreDeviceStatus::Active),
452        (StoreDeviceStatus::Inactive { terminals }, StoreDeviceStatus::Active)
453        | (StoreDeviceStatus::Active, StoreDeviceStatus::Inactive { terminals }) => {
454            Ok(StoreDeviceStatus::Inactive { terminals })
455        }
456        (
457            StoreDeviceStatus::Inactive { terminals: left },
458            StoreDeviceStatus::Inactive { terminals: right },
459        ) => Ok(StoreDeviceStatus::Inactive {
460            terminals: merge_terminal_refs(left, right)?,
461        }),
462    }
463}
464
465fn merge_terminal_refs(
466    left: Vec<StoreDeviceExclusionRef>,
467    right: Vec<StoreDeviceExclusionRef>,
468) -> Result<Vec<StoreDeviceExclusionRef>, StoreProtocolError> {
469    let terminals = left
470        .into_iter()
471        .chain(right)
472        .collect::<BTreeSet<_>>()
473        .into_iter()
474        .collect::<Vec<_>>();
475    validate_terminal_refs(&terminals)?;
476    Ok(terminals)
477}
478
479pub(crate) fn merge_history_cuts(
480    left: StoreHistoryCut,
481    right: StoreHistoryCut,
482) -> Result<StoreHistoryCut, StoreProtocolError> {
483    {
484        let StoreHistoryCut(mut left) = left;
485        let StoreHistoryCut(right) = right;
486        for (stream, reference) in right {
487            match left.entry(stream) {
488                std::collections::btree_map::Entry::Vacant(entry) => {
489                    entry.insert(reference);
490                }
491                std::collections::btree_map::Entry::Occupied(mut entry) => {
492                    let current = entry.get();
493                    if reference.coord.sequence() > current.coord.sequence() {
494                        entry.insert(reference);
495                    } else if reference.coord.sequence() == current.coord.sequence()
496                        && reference != *current
497                    {
498                        return Err(StoreProtocolError::DeviceStateMismatch);
499                    }
500                }
501            }
502        }
503        Ok(StoreHistoryCut(left))
504    }
505}
506
507fn validate_store_device_records(
508    devices: &BTreeMap<StoreDeviceId, StoreDeviceRecord>,
509) -> Result<(), StoreProtocolError> {
510    for (device_id, record) in devices {
511        if record.registration.device_id != *device_id {
512            return Err(StoreProtocolError::DeviceStateMismatch);
513        }
514        for (proposal_id, state) in &record.proposals {
515            let proposal = match state {
516                StoreDeviceProposalState::Pending { proposal }
517                | StoreDeviceProposalState::Superseded { proposal, .. } => proposal,
518                StoreDeviceProposalState::Cancelled { outcome } => &outcome.proposal,
519            };
520            if proposal.proposal_id != *proposal_id {
521                return Err(StoreProtocolError::DeviceStateMismatch);
522            }
523            if proposal.target != record.registration {
524                return Err(StoreProtocolError::DeviceStateMismatch);
525            }
526            if let StoreDeviceProposalState::Superseded { terminals, .. } = state {
527                validate_terminal_refs(terminals)?;
528            }
529        }
530        if let StoreDeviceStatus::Inactive { terminals } = &record.status {
531            validate_terminal_refs(terminals)?;
532            if record
533                .proposals
534                .values()
535                .any(|state| matches!(state, StoreDeviceProposalState::Pending { .. }))
536            {
537                return Err(StoreProtocolError::DeviceStateMismatch);
538            }
539        }
540    }
541    Ok(())
542}
543
544fn validate_terminal_refs(terminals: &[StoreDeviceExclusionRef]) -> Result<(), StoreProtocolError> {
545    if terminals.is_empty() || terminals.windows(2).any(|pair| pair[0] >= pair[1]) {
546        return Err(StoreProtocolError::DeviceStateMismatch);
547    }
548    Ok(())
549}
550
551pub(crate) fn canonical_recovery_cursors(
552    mut recovery: Vec<OwnerRecoveryCursor>,
553) -> Result<Vec<OwnerRecoveryCursor>, StoreProtocolError> {
554    recovery.sort();
555    validate_recovery_cursors(&recovery)?;
556    Ok(recovery)
557}
558
559pub(crate) fn validate_recovery_cursors(
560    recovery: &[OwnerRecoveryCursor],
561) -> Result<(), StoreProtocolError> {
562    if recovery.windows(2).any(|pair| pair[0] >= pair[1]) {
563        return Err(StoreProtocolError::OwnerRecoveryMismatch);
564    }
565    Ok(())
566}