Skip to main content

khive_runtime/curation/
note_merge_guard.rs

1//! Guard for a note merge whose caller planned it from an earlier read.
2//!
3//! A caller that groups notes, then merges one pair at a time, needs the merge
4//! to refuse when the world it planned against has moved. Every check here runs
5//! on the merge's own writer connection, inside the merge's own transaction,
6//! after both notes were read there and before the first write, so a refusal
7//! changes no note, edge, index entry or event. The checks form a closed set
8//! evaluated with runtime-owned statements; a caller supplies values, never SQL
9//! or code.
10
11use super::*;
12
13/// Longest serialized `annotation` a guard may carry.
14const MAX_ANNOTATION_BYTES: usize = 4 * 1024;
15
16/// Most merge records an `EntityLineageReaches` walk follows.
17const MAX_LINEAGE_STEPS: usize = 64;
18
19/// A fact the caller relied on when it planned the merge, re-checked inside the
20/// merge transaction. The first one that does not hold refuses the merge.
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub enum MergeAssertion {
23    /// The entity exists in the caller's namespace, is not deleted and was not
24    /// merged away.
25    EntityLive { entity: Uuid },
26    /// `canonical` is live in the caller's namespace (see
27    /// [`MergeAssertion::EntityLive`]), and either `entity` equals `canonical` or
28    /// the walk that starts at `entity` and follows, in the caller's namespace,
29    /// the record each merged-away entity keeps of the entity it was merged into
30    /// arrives at `canonical` within 64 steps. A missing record, a deleted entity
31    /// with no such record, or a cycle ends the walk without holding. A
32    /// `canonical` that was itself merged away or deleted never holds, even when
33    /// the walk passes through it.
34    EntityLineageReaches { entity: Uuid, canonical: Uuid },
35    /// A live `relation` edge from `note` to `target` exists in the caller's
36    /// namespace, with its target in this store.
37    NoteEdgeTo {
38        note: Uuid,
39        relation: EdgeRelation,
40        target: Uuid,
41    },
42    /// `allowed` is a live entity of kind `target_kind` in the caller's namespace,
43    /// and every live `relation` edge stored under the caller's namespace whose
44    /// source is `note` and whose target is a live entity of kind `target_kind`
45    /// in the caller's namespace targets `allowed`. Only the caller's
46    /// namespace's edge rows and entities are read: an edge row stored under
47    /// another namespace, and an edge to any other target (a note, an event, an
48    /// edge, a deleted entity, an entity of another kind, anything in another
49    /// namespace or another store, or no record) does not count, so the outcome
50    /// depends on no record outside the caller's namespace.
51    NoteEdgeTargetsWithin {
52        note: Uuid,
53        relation: EdgeRelation,
54        target_kind: String,
55        allowed: Uuid,
56    },
57}
58
59impl MergeAssertion {
60    fn name(&self) -> &'static str {
61        match self {
62            Self::EntityLive { .. } => "EntityLive",
63            Self::EntityLineageReaches { .. } => "EntityLineageReaches",
64            Self::NoteEdgeTo { .. } => "NoteEdgeTo",
65            Self::NoteEdgeTargetsWithin { .. } => "NoteEdgeTargetsWithin",
66        }
67    }
68
69    fn holds(
70        &self,
71        conn: &rusqlite::Connection,
72        namespace: &str,
73        budget: &mut MergeTxBudget,
74    ) -> Result<bool, MergeSqlError> {
75        match self {
76            Self::EntityLive { entity } => entity_is_live(conn, namespace, *entity, budget),
77            Self::EntityLineageReaches { entity, canonical } => {
78                lineage_reaches(conn, namespace, *entity, *canonical, budget)
79            }
80            Self::NoteEdgeTo {
81                note,
82                relation,
83                target,
84            } => {
85                budget.charge(1, 128, "checking a merge assertion edge")?;
86                Ok(conn.query_row(
87                    crate::sql!("merge_guard_note_edge_select"),
88                    rusqlite::params![
89                        namespace,
90                        relation.as_str(),
91                        note.to_string(),
92                        target.to_string(),
93                    ],
94                    |row| row.get(0),
95                )?)
96            }
97            Self::NoteEdgeTargetsWithin {
98                note,
99                relation,
100                target_kind,
101                allowed,
102            } => {
103                budget.charge(1, 128, "checking a merge assertion allowed target")?;
104                let allowed_kind: Option<String> = conn
105                    .query_row(
106                        crate::sql!("merge_guard_entity_live_kind_select"),
107                        rusqlite::params![allowed.to_string(), namespace],
108                        |row| row.get(0),
109                    )
110                    .optional()?;
111                if allowed_kind.as_deref() != Some(target_kind.as_str()) {
112                    return Ok(false);
113                }
114                let mut stmt = conn.prepare(crate::sql!("merge_guard_note_edge_targets_select"))?;
115                let mut rows = stmt.query(rusqlite::params![
116                    note.to_string(),
117                    relation.as_str(),
118                    namespace,
119                    target_kind,
120                ])?;
121                while let Some(row) = rows.next()? {
122                    budget.charge(1, 128, "checking merge assertion edge targets")?;
123                    // Counted only when the statement found a live entity of the
124                    // kind in the caller's namespace; every other target, whatever
125                    // it is, reads as not counted.
126                    let counted: bool = row.get(1)?;
127                    if counted {
128                        let target: String = row.get(0)?;
129                        match Uuid::parse_str(&target) {
130                            Ok(target) if target == *allowed => {}
131                            _ => return Ok(false),
132                        }
133                    }
134                }
135                Ok(true)
136            }
137        }
138    }
139}
140
141fn entity_is_live(
142    conn: &rusqlite::Connection,
143    namespace: &str,
144    entity: Uuid,
145    budget: &mut MergeTxBudget,
146) -> Result<bool, MergeSqlError> {
147    budget.charge(1, 128, "checking a merge assertion entity")?;
148    Ok(conn.query_row(
149        crate::sql!("merge_guard_entity_live_select"),
150        rusqlite::params![entity.to_string(), namespace],
151        |row| row.get(0),
152    )?)
153}
154
155fn lineage_reaches(
156    conn: &rusqlite::Connection,
157    namespace: &str,
158    entity: Uuid,
159    canonical: Uuid,
160    budget: &mut MergeTxBudget,
161) -> Result<bool, MergeSqlError> {
162    if !entity_is_live(conn, namespace, canonical, budget)? {
163        return Ok(false);
164    }
165    let mut seen = HashSet::new();
166    let mut current = entity;
167    for step in 0..=MAX_LINEAGE_STEPS {
168        if current == canonical {
169            return Ok(true);
170        }
171        if step == MAX_LINEAGE_STEPS || !seen.insert(current) {
172            return Ok(false);
173        }
174        budget.charge(1, 128, "following merge assertion lineage")?;
175        let merged_into: Option<Option<String>> = conn
176            .query_row(
177                crate::sql!("merge_guard_entity_merged_into_select"),
178                rusqlite::params![current.to_string(), namespace],
179                |row| row.get(0),
180            )
181            .optional()?;
182        match merged_into
183            .flatten()
184            .and_then(|value| Uuid::parse_str(&value).ok())
185        {
186            Some(next) => current = next,
187            None => return Ok(false),
188        }
189    }
190    Ok(false)
191}
192
193/// What the caller read, and what it requires to still be true, for one merge.
194#[derive(Clone, Debug)]
195pub struct NoteMergeGuard {
196    /// The stored version of the survivor the caller read.
197    pub into_version: i64,
198    /// The stored version of the merged-away note the caller read.
199    pub from_version: i64,
200    /// Facts to re-check inside the merge transaction, evaluated in order.
201    pub assertions: Vec<MergeAssertion>,
202    /// Top-level property keys and values that replace the survivor's after the
203    /// field-level merge rules ran and before the provenance entry is appended.
204    pub survivor_properties: serde_json::Map<String, Value>,
205    /// One JSON value, at most 4 KiB serialized, recorded unchanged under
206    /// `annotation` in the `_merge_history` entry this merge appends.
207    pub annotation: Option<Value>,
208}
209
210/// The outcome of a guarded merge. `kept_version` is the survivor's stored
211/// version after the merge, so the caller can merge the next duplicate into the
212/// same survivor without reading it again. A dry run commits nothing and
213/// reports the version the survivor currently has.
214#[derive(Clone, Debug)]
215pub struct GuardedNoteMerge {
216    pub summary: MergeSummary,
217    pub kept_version: i64,
218}
219
220fn refusal(message: String) -> MergeSqlError {
221    MergeSqlError::Refusal(RuntimeError::Khive(KhiveError::conflict(message)))
222}
223
224impl NoteMergeGuard {
225    /// The checks that read no stored state. They run before the transaction
226    /// starts, so a malformed guard never holds the writer.
227    pub(super) fn check_values(&self) -> RuntimeResult<()> {
228        if self.survivor_properties.contains_key("_merge_history") {
229            return Err(RuntimeError::InvalidInput(
230                "a guarded merge cannot override `_merge_history`: the merge owns its provenance"
231                    .into(),
232            ));
233        }
234        let overrides = Value::Object(self.survivor_properties.clone());
235        crate::secret_gate::reject_reserved_secret_gate_property(Some(&overrides))?;
236        // The whole map, so a credential in a key is refused with the values.
237        crate::secret_gate::check_json_at(&overrides, "note", "properties")?;
238        if let Some(annotation) = &self.annotation {
239            let bytes = serde_json::to_vec(annotation)
240                .map_err(|error| RuntimeError::InvalidInput(error.to_string()))?
241                .len();
242            if bytes > MAX_ANNOTATION_BYTES {
243                return Err(RuntimeError::InvalidInput(format!(
244                    "merge annotation is {bytes} bytes serialized; the limit is \
245                     {MAX_ANNOTATION_BYTES}"
246                )));
247            }
248            crate::secret_gate::check_json_at(annotation, "note", "merge_annotation")?;
249        }
250        Ok(())
251    }
252
253    /// Versions, then assertions in order, against the transaction's own view.
254    pub(super) fn enforce(
255        &self,
256        conn: &rusqlite::Connection,
257        namespace: &str,
258        into: &Note,
259        from: &Note,
260        budget: &mut MergeTxBudget,
261    ) -> Result<(), MergeSqlError> {
262        for (note, expected) in [(into, self.into_version), (from, self.from_version)] {
263            if note.version != expected {
264                return Err(MergeSqlError::Refusal(stale_note_snapshot_error(note.id)));
265            }
266        }
267        for (index, assertion) in self.assertions.iter().enumerate() {
268            if !assertion.holds(conn, namespace, budget)? {
269                return Err(refusal(format!(
270                    "guarded note merge refused: assertion {index} ({}) does not hold",
271                    assertion.name()
272                )));
273            }
274        }
275        Ok(())
276    }
277
278    /// Apply the property override and the history annotation to the properties
279    /// the field-level rules produced, after the keys the merge keeps from the
280    /// survivor were restored and before the provenance entry is appended.
281    pub(super) fn apply_to_survivor(
282        &self,
283        kind: &str,
284        preserve_owner_established: bool,
285        properties: &mut Option<Value>,
286        history_entry: &mut Value,
287    ) -> Result<(), MergeSqlError> {
288        if let Some(key) = self.survivor_properties.keys().find(|key| {
289            kind_owned_properties(kind).contains(&key.as_str())
290                || (preserve_owner_established
291                    && OWNER_ESTABLISHED_PROPERTIES.contains(&key.as_str()))
292        }) {
293            return Err(MergeSqlError::Refusal(RuntimeError::InvalidInput(format!(
294                "`{key}` is owned by `{kind}` notes and cannot be overridden by a guarded merge"
295            ))));
296        }
297        if !self.survivor_properties.is_empty() {
298            // No properties at all is an empty object the override can fill: the
299            // unguarded merge builds one for its own history entry as well.
300            let Value::Object(object) =
301                properties.get_or_insert_with(|| Value::Object(serde_json::Map::new()))
302            else {
303                return Err(refusal(
304                    "guarded note merge refused: the merged properties are not an object".into(),
305                ));
306            };
307            object.extend(
308                self.survivor_properties
309                    .iter()
310                    .map(|(key, value)| (key.clone(), value.clone())),
311            );
312        }
313        // The unguarded merge drops its own provenance entry when the history
314        // the field-level rules left is not an array (under `PreferFrom` it can
315        // come from the absorbed note). A guarded merge refuses instead.
316        if properties
317            .as_ref()
318            .and_then(|props| props.get("_merge_history"))
319            .is_some_and(|history| !history.is_array())
320        {
321            return Err(refusal(
322                "guarded note merge refused: `_merge_history` is not an array".into(),
323            ));
324        }
325        if let (Some(annotation), Some(entry)) = (&self.annotation, history_entry.as_object_mut()) {
326            entry.insert("annotation".into(), annotation.clone());
327        }
328        Ok(())
329    }
330}
331
332#[cfg(test)]
333#[path = "note_merge_guard_tests.rs"]
334mod tests;