Skip to main content

coven_protocol/store_commit/
circle_snapshot.rs

1use super::*;
2
3/// Exact coordinate of one signed Circle snapshot on its author's per-Circle
4/// snapshot stream.
5#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
6#[serde(deny_unknown_fields)]
7pub struct CircleSnapshotRef {
8    pub generation: u64,
9    pub snapshot_hash: ObjectHash,
10    pub object: ExactObjectRef,
11}
12
13/// The device-authorized activation binding one device's per-Circle snapshot
14/// stream to its Circle. Such a stream has no first slot in the registration —
15/// like the per-Circle acknowledgement stream, it is anchored on the deterministic
16/// generation-zero slot both the author and every reader compute.
17pub fn circle_snapshot_stream_activation(
18    store_root_hash: ObjectHash,
19    author_registration: &StoreDeviceRegistrationRef,
20    circle_id: CircleId,
21    device_id: &str,
22) -> Result<StreamActivationId, StoreProtocolError> {
23    let first_slot = ObjectSlot::logical(format!(
24        "{}.json",
25        circle_snapshot_slot_prefix(circle_id, device_id, 0)
26    ))?;
27    Ok(StreamActivation::device_authorized(
28        store_root_hash,
29        author_registration.clone(),
30        DeviceStreamAnchor::CircleSnapshots {
31            circle_id,
32            first_slot,
33        },
34    )
35    .activation_id())
36}
37
38/// The exact predecessor and create-once successor slot binding one Circle
39/// snapshot into its per-(device, Circle) stream.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct CircleSnapshotSuccessorLink {
43    pub activation: StreamActivationId,
44    pub predecessor: Option<CircleSnapshotRef>,
45    pub next_slot: ObjectSlot,
46}
47
48/// One device's signed, Circle-sealed snapshot of the private Circle history it
49/// holds at an exact Store frontier. The installable payload is a
50/// `CircleBootstrapRef` — the same image format a member-addition bootstrap
51/// carries — so a verifier installs a snapshot with the bootstrap machinery. The
52/// metadata additionally binds the exact control, epoch, and key fingerprint the
53/// image derives from and the per-(device, Circle) snapshot stream position.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct CircleSnapshotMetaBody {
57    pub store_root_hash: ObjectHash,
58    pub circle_id: CircleId,
59    pub author_registration: StoreDeviceRegistrationRef,
60    pub control: CircleControlCoord,
61    pub epoch_id: CircleEpochId,
62    pub key_fingerprint: KeyFingerprint,
63    pub generation: u64,
64    /// The exact cut, schema, routing hash, image, and pinned blob refs the
65    /// image contains — the same shape a member-addition bootstrap carries. The
66    /// cut is `bootstrap.coverage`.
67    pub bootstrap: CircleBootstrapRef,
68    pub created_at: String,
69    pub successor: CircleSnapshotSuccessorLink,
70}
71
72impl SignedBody for CircleSnapshotMetaBody {
73    const DOMAIN: &'static [u8] = CIRCLE_SNAPSHOT_DOMAIN;
74}
75
76pub type CircleSnapshotMeta = Signed<CircleSnapshotMetaBody>;
77
78impl CircleSnapshotMeta {
79    #[allow(clippy::too_many_arguments)]
80    pub fn signed(
81        store_root_hash: ObjectHash,
82        circle_id: CircleId,
83        author_registration: StoreDeviceRegistrationRef,
84        control: CircleControlCoord,
85        epoch_id: CircleEpochId,
86        key_fingerprint: KeyFingerprint,
87        generation: u64,
88        bootstrap: CircleBootstrapRef,
89        created_at: String,
90        successor: CircleSnapshotSuccessorLink,
91        device_signer: &UserKeypair,
92    ) -> Result<Self, StoreProtocolError> {
93        validate_circle_snapshot_generation(generation, successor.predecessor.as_ref())?;
94        validate_circle_snapshot_state(&control, &bootstrap.coverage)?;
95        Ok(Signed::sign(
96            CircleSnapshotMetaBody {
97                store_root_hash,
98                circle_id,
99                author_registration,
100                control,
101                epoch_id,
102                key_fingerprint,
103                generation,
104                bootstrap,
105                created_at,
106                successor,
107            },
108            device_signer,
109        ))
110    }
111
112    pub fn snapshot_hash(&self) -> ObjectHash {
113        self.hash()
114    }
115
116    pub fn semantic_hash_from_bytes(bytes: &[u8]) -> Result<ObjectHash, StoreProtocolError> {
117        let meta: Self = crate::objects::decode_protocol_object(bytes)?;
118        Ok(meta.snapshot_hash())
119    }
120
121    /// Verify one exact Circle snapshot against its expected reference and author
122    /// registration. The successor's stream activation is not recomputed here:
123    /// a Circle snapshot stream has no per-(device, Circle) first slot in the
124    /// registration for a reader to derive, so the create-once successor slot and
125    /// predecessor chain establish stream position, exactly as the Circle
126    /// acknowledgement stream does.
127    pub fn parse_at(
128        bytes: &[u8],
129        expected_store_root_hash: ObjectHash,
130        expected: &CircleSnapshotRef,
131        author: &StoreDeviceRegistration,
132    ) -> Result<Self, StoreProtocolError> {
133        let meta: Self = crate::objects::decode_protocol_object(bytes)?;
134        meta.require_version()?;
135        crate::objects::verify_store_root(expected_store_root_hash, meta.store_root_hash)?;
136        meta.author_registration.verify_registration(author)?;
137        if meta.generation != expected.generation {
138            return Err(StoreProtocolError::RelocatedSlot {
139                expected: circle_snapshot_slot_prefix(
140                    meta.circle_id,
141                    &author.device_id.to_string(),
142                    expected.generation,
143                ),
144                actual: circle_snapshot_slot_prefix(
145                    meta.circle_id,
146                    &author.device_id.to_string(),
147                    meta.generation,
148                ),
149            });
150        }
151        validate_circle_snapshot_generation(meta.generation, meta.successor.predecessor.as_ref())?;
152        validate_circle_snapshot_state(&meta.control, &meta.bootstrap.coverage)?;
153        meta.verify_by(&author.device_signing_pubkey)?;
154        let actual = meta.snapshot_hash();
155        if actual != expected.snapshot_hash {
156            return Err(StoreProtocolError::ObjectHashMismatch {
157                expected: expected.snapshot_hash,
158                actual,
159            });
160        }
161        Ok(meta)
162    }
163}
164
165fn validate_circle_snapshot_state(
166    control: &CircleControlCoord,
167    coverage: &CommitFrontier,
168) -> Result<(), StoreProtocolError> {
169    control.validate()?;
170    super::validation::validate_commit_frontier(coverage)
171}
172
173fn validate_circle_snapshot_generation(
174    generation: u64,
175    predecessor: Option<&CircleSnapshotRef>,
176) -> Result<(), StoreProtocolError> {
177    match (generation, predecessor) {
178        (0, None) => Ok(()),
179        (0, Some(_)) | (_, None) => Err(StoreProtocolError::Malformed(
180            "Circle snapshot generation and predecessor disagree".to_string(),
181        )),
182        (generation, Some(predecessor)) => {
183            let expected = predecessor.generation.checked_add(1).ok_or_else(|| {
184                StoreProtocolError::Malformed("Circle snapshot generation overflow".to_string())
185            })?;
186            if generation != expected {
187                return Err(StoreProtocolError::Malformed(
188                    "Circle snapshot generation does not follow its predecessor".to_string(),
189                ));
190            }
191            Ok(())
192        }
193    }
194}