Skip to main content

khive_changeset/
op.rs

1//! The six typed change-set operations and their stage-time payloads.
2
3use std::collections::BTreeMap;
4
5use khive_types::{
6    EdgeRelation, Entity, EntityKind, Id128, Link, Namespace, Note, NoteStatus, PropertyValue,
7};
8use serde::{Deserialize, Deserializer, Serialize};
9
10use crate::strict::{StrictEntity, StrictLink, StrictNote};
11
12/// One staged mutation, internally tagged by the snake-case `op` field.
13///
14/// See `crates/khive-changeset/docs/api/create-and-link.md` for wire examples.
15#[derive(Clone, Debug, Serialize, Deserialize)]
16#[serde(tag = "op", rename_all = "snake_case")]
17pub enum Op {
18    Create(CreateOp),
19    Link(LinkOp),
20    Update(UpdateOp),
21    Delete(DeleteOp),
22    Restore(RestoreOp),
23    Merge(MergeOp),
24}
25
26/// Creates an entity or note with an ID minted and stabilized at stage time.
27///
28/// See `crates/khive-changeset/docs/api/create-and-link.md` for field semantics.
29#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct CreateOp {
32    pub id: Id128,
33    pub namespace: Namespace,
34    pub target: CreateTarget,
35}
36
37/// Substrate-specific create fields, tagged by `kind`.
38#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
39#[serde(tag = "kind", rename_all = "snake_case")]
40pub enum CreateTarget {
41    Entity(EntityCreateFields),
42    Note(NoteCreateFields),
43}
44
45/// Fields staged for a new entity.
46#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct EntityCreateFields {
49    pub entity_kind: EntityKind,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub entity_type: Option<String>,
52    pub name: String,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub description: Option<String>,
55    #[serde(default)]
56    pub properties: BTreeMap<String, PropertyValue>,
57    #[serde(default)]
58    pub tags: Vec<String>,
59}
60
61/// Fields staged for a new note.
62#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct NoteCreateFields {
65    /// Pack-declared note kind string (e.g. `"observation"`); not a closed enum.
66    pub note_kind: String,
67    pub content: String,
68    #[serde(default)]
69    pub properties: BTreeMap<String, PropertyValue>,
70    #[serde(default)]
71    pub tags: Vec<String>,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub salience: Option<f64>,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub decay_factor: Option<f64>,
76}
77
78/// Creates a directed edge whose ID is minted at stage time.
79///
80/// Endpoints may reference stage-time IDs from this or another change-set.
81/// `weight` must be finite and in `[0.0, 1.0]` when deserialized.
82/// See `crates/khive-changeset/docs/api/create-and-link.md` for field semantics.
83#[derive(Clone, Debug, PartialEq, Serialize)]
84#[serde(into = "LinkOpRaw")]
85pub struct LinkOp {
86    pub id: Id128,
87    pub namespace: Namespace,
88    pub source: Id128,
89    pub target: Id128,
90    pub relation: EdgeRelation,
91    pub weight: f64,
92    pub properties: BTreeMap<String, PropertyValue>,
93}
94
95#[derive(Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97struct LinkOpRaw {
98    id: Id128,
99    namespace: Namespace,
100    source: Id128,
101    target: Id128,
102    relation: EdgeRelation,
103    weight: f64,
104    #[serde(default)]
105    properties: BTreeMap<String, PropertyValue>,
106}
107
108impl From<LinkOp> for LinkOpRaw {
109    fn from(l: LinkOp) -> Self {
110        Self {
111            id: l.id,
112            namespace: l.namespace,
113            source: l.source,
114            target: l.target,
115            relation: l.relation,
116            weight: l.weight,
117            properties: l.properties,
118        }
119    }
120}
121
122impl TryFrom<LinkOpRaw> for LinkOp {
123    type Error = String;
124
125    fn try_from(raw: LinkOpRaw) -> Result<Self, Self::Error> {
126        if !raw.weight.is_finite() {
127            return Err(format!("LinkOp weight must be finite, got {}", raw.weight));
128        }
129        if !khive_types::validate_edge_weight(raw.weight) {
130            return Err(format!(
131                "LinkOp weight must be in [0.0, 1.0], got {}",
132                raw.weight
133            ));
134        }
135        Ok(LinkOp {
136            id: raw.id,
137            namespace: raw.namespace,
138            source: raw.source,
139            target: raw.target,
140            relation: raw.relation,
141            weight: raw.weight,
142            properties: raw.properties,
143        })
144    }
145}
146
147impl<'de> Deserialize<'de> for LinkOp {
148    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
149    where
150        D: serde::Deserializer<'de>,
151    {
152        let raw = LinkOpRaw::deserialize(deserializer)?;
153        LinkOp::try_from(raw).map_err(serde::de::Error::custom)
154    }
155}
156
157/// Patches one record with a stage-time preimage of exactly the touched fields.
158///
159/// Patch and preimage must target the same substrate and have identical field
160/// presence; both construction and deserialization enforce this invariant.
161/// See `crates/khive-changeset/docs/api/update.md` for nullable-field semantics.
162#[derive(Clone, Debug, PartialEq, Serialize)]
163pub struct UpdateOp {
164    target_id: Id128,
165    patch: UpdatePatch,
166    preimage: UpdatePreimage,
167}
168
169impl UpdateOp {
170    /// Constructs an update after validating patch/preimage congruence.
171    ///
172    /// # Errors
173    ///
174    /// Returns an error for differing substrates or field sets, or for an
175    /// invalid captured salience, decay factor, or edge weight, or an invalid
176    /// new edge weight.
177    pub fn new(
178        target_id: Id128,
179        patch: UpdatePatch,
180        preimage: UpdatePreimage,
181    ) -> Result<Self, String> {
182        validate_update_congruence(&patch, &preimage)?;
183        Ok(UpdateOp {
184            target_id,
185            patch,
186            preimage,
187        })
188    }
189
190    /// The identifier of the record this update targets.
191    pub fn target_id(&self) -> Id128 {
192        self.target_id
193    }
194
195    /// The staged patch.
196    pub fn patch(&self) -> &UpdatePatch {
197        &self.patch
198    }
199
200    /// The field-scoped prior value the patch touches.
201    pub fn preimage(&self) -> &UpdatePreimage {
202        &self.preimage
203    }
204}
205
206#[derive(Deserialize)]
207#[serde(deny_unknown_fields)]
208struct UpdateOpRaw {
209    target_id: Id128,
210    patch: UpdatePatch,
211    preimage: UpdatePreimage,
212}
213
214impl<'de> Deserialize<'de> for UpdateOp {
215    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
216    where
217        D: Deserializer<'de>,
218    {
219        let raw = UpdateOpRaw::deserialize(deserializer)?;
220        UpdateOp::new(raw.target_id, raw.patch, raw.preimage).map_err(serde::de::Error::custom)
221    }
222}
223
224/// Substrate-specific patch tagged by `target`.
225///
226/// See `crates/khive-changeset/docs/api/update.md` for field-presence semantics.
227#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
228#[serde(tag = "target", rename_all = "snake_case")]
229pub enum UpdatePatch {
230    Entity(EntityPatch),
231    Note(NotePatch),
232    Edge(EdgePatch),
233}
234
235/// Entity fields to mutate; absent means unchanged and `Some(None)` clears
236/// a nullable field (ADR-014 tri-state, applied to `description` and
237/// `entity_type`).
238#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
239#[serde(deny_unknown_fields)]
240pub struct EntityPatch {
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub name: Option<String>,
243    #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
244    pub description: Option<Option<String>>,
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub properties: Option<BTreeMap<String, PropertyValue>>,
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub tags: Option<Vec<String>>,
249    #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
250    pub entity_type: Option<Option<String>>,
251}
252
253/// Note fields to mutate; nested options distinguish unchanged, clear, and set.
254#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
255#[serde(deny_unknown_fields)]
256pub struct NotePatch {
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub content: Option<String>,
259    #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
260    pub salience: Option<Option<f64>>,
261    #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
262    pub decay_factor: Option<Option<f64>>,
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub properties: Option<BTreeMap<String, PropertyValue>>,
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub tags: Option<Vec<String>>,
267}
268
269/// Edge fields to mutate; weight must be finite and in `[0.0, 1.0]`.
270#[derive(Clone, Debug, PartialEq, Serialize)]
271#[serde(into = "EdgePatchRaw")]
272pub struct EdgePatch {
273    pub relation: Option<EdgeRelation>,
274    pub weight: Option<f64>,
275}
276
277#[derive(Serialize, Deserialize)]
278#[serde(deny_unknown_fields)]
279struct EdgePatchRaw {
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    relation: Option<EdgeRelation>,
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    weight: Option<f64>,
284}
285
286impl From<EdgePatch> for EdgePatchRaw {
287    fn from(p: EdgePatch) -> Self {
288        Self {
289            relation: p.relation,
290            weight: p.weight,
291        }
292    }
293}
294
295impl TryFrom<EdgePatchRaw> for EdgePatch {
296    type Error = String;
297
298    fn try_from(raw: EdgePatchRaw) -> Result<Self, Self::Error> {
299        let patch = EdgePatch {
300            relation: raw.relation,
301            weight: raw.weight,
302        };
303        patch.validate_weight()?;
304        Ok(patch)
305    }
306}
307
308impl EdgePatch {
309    fn validate_weight(&self) -> Result<(), String> {
310        if let Some(w) = self.weight {
311            if !w.is_finite() {
312                return Err(format!("EdgePatch weight must be finite, got {w}"));
313            }
314            if !khive_types::validate_edge_weight(w) {
315                return Err(format!("EdgePatch weight must be in [0.0, 1.0], got {w}"));
316            }
317        }
318        Ok(())
319    }
320}
321
322impl<'de> Deserialize<'de> for EdgePatch {
323    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
324    where
325        D: Deserializer<'de>,
326    {
327        let raw = EdgePatchRaw::deserialize(deserializer)?;
328        EdgePatch::try_from(raw).map_err(serde::de::Error::custom)
329    }
330}
331
332/// Distinguishes an absent patch field from an explicit `null` clear.
333mod opt_opt {
334    use serde::{Deserialize, Deserializer, Serialize, Serializer};
335
336    pub fn serialize<T, S>(val: &Option<Option<T>>, s: S) -> Result<S::Ok, S::Error>
337    where
338        T: Serialize,
339        S: Serializer,
340    {
341        match val {
342            None => unreachable!("skip_serializing_if guards the None case"),
343            Some(inner) => inner.serialize(s),
344        }
345    }
346
347    pub fn deserialize<'de, T, D>(d: D) -> Result<Option<Option<T>>, D::Error>
348    where
349        T: Deserialize<'de>,
350        D: Deserializer<'de>,
351    {
352        let opt: Option<T> = Option::deserialize(d)?;
353        Ok(Some(opt))
354    }
355}
356
357/// Prior entity values for exactly the fields touched by [`EntityPatch`].
358#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
359#[serde(deny_unknown_fields)]
360pub struct EntityPreimage {
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub name: Option<String>,
363    #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
364    pub description: Option<Option<String>>,
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub properties: Option<BTreeMap<String, PropertyValue>>,
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub tags: Option<Vec<String>>,
369    #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
370    pub entity_type: Option<Option<String>>,
371}
372
373/// Prior note values for exactly the fields touched by [`NotePatch`].
374///
375/// Captured salience is finite in `[0.0, 1.0]`; decay factor is finite and non-negative.
376#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
377#[serde(deny_unknown_fields)]
378pub struct NotePreimage {
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub content: Option<String>,
381    #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
382    pub salience: Option<Option<f64>>,
383    #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_opt")]
384    pub decay_factor: Option<Option<f64>>,
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub properties: Option<BTreeMap<String, PropertyValue>>,
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub tags: Option<Vec<String>>,
389}
390
391/// Prior edge values for exactly the fields touched by [`EdgePatch`].
392///
393/// Captured weight must be finite and in `[0.0, 1.0]`.
394#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
395#[serde(deny_unknown_fields)]
396pub struct EdgePreimage {
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub relation: Option<EdgeRelation>,
399    #[serde(default, skip_serializing_if = "Option::is_none")]
400    pub weight: Option<f64>,
401}
402
403/// Field-scoped preimage tagged with the same `target` as [`UpdatePatch`].
404#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
405#[serde(tag = "target", rename_all = "snake_case")]
406pub enum UpdatePreimage {
407    Entity(EntityPreimage),
408    Note(NotePreimage),
409    Edge(EdgePreimage),
410}
411
412/// Enforces substrate, exact field-set, and captured numeric-value invariants.
413fn validate_update_congruence(
414    patch: &UpdatePatch,
415    preimage: &UpdatePreimage,
416) -> Result<(), String> {
417    match (patch, preimage) {
418        (UpdatePatch::Entity(p), UpdatePreimage::Entity(pre)) => {
419            check_touched("name", p.name.is_some(), pre.name.is_some())?;
420            check_touched(
421                "description",
422                p.description.is_some(),
423                pre.description.is_some(),
424            )?;
425            check_touched(
426                "properties",
427                p.properties.is_some(),
428                pre.properties.is_some(),
429            )?;
430            check_touched("tags", p.tags.is_some(), pre.tags.is_some())?;
431            check_touched(
432                "entity_type",
433                p.entity_type.is_some(),
434                pre.entity_type.is_some(),
435            )?;
436            Ok(())
437        }
438        (UpdatePatch::Note(p), UpdatePreimage::Note(pre)) => {
439            check_touched("content", p.content.is_some(), pre.content.is_some())?;
440            check_touched("salience", p.salience.is_some(), pre.salience.is_some())?;
441            check_touched(
442                "decay_factor",
443                p.decay_factor.is_some(),
444                pre.decay_factor.is_some(),
445            )?;
446            check_touched(
447                "properties",
448                p.properties.is_some(),
449                pre.properties.is_some(),
450            )?;
451            check_touched("tags", p.tags.is_some(), pre.tags.is_some())?;
452            if let Some(Some(s)) = pre.salience {
453                if !s.is_finite() || !(0.0..=1.0).contains(&s) {
454                    return Err(format!(
455                        "UpdateOp note preimage salience must be finite and in [0.0, 1.0], got {s}"
456                    ));
457                }
458            }
459            if let Some(Some(d)) = pre.decay_factor {
460                if !d.is_finite() || d < 0.0 {
461                    return Err(format!(
462                        "UpdateOp note preimage decay_factor must be finite and non-negative, got {d}"
463                    ));
464                }
465            }
466            Ok(())
467        }
468        (UpdatePatch::Edge(p), UpdatePreimage::Edge(pre)) => {
469            check_touched("relation", p.relation.is_some(), pre.relation.is_some())?;
470            check_touched("weight", p.weight.is_some(), pre.weight.is_some())?;
471            if let Some(w) = pre.weight {
472                if !khive_types::validate_edge_weight(w) {
473                    return Err(format!(
474                        "UpdateOp edge preimage weight must be finite and in [0.0, 1.0], got {w}"
475                    ));
476                }
477            }
478            p.validate_weight()?;
479            Ok(())
480        }
481        _ => Err(format!(
482            "UpdateOp patch target ({}) does not match preimage target ({})",
483            patch_target_name(patch),
484            preimage_target_name(preimage)
485        )),
486    }
487}
488
489fn check_touched(field: &str, patch_touched: bool, preimage_present: bool) -> Result<(), String> {
490    match (patch_touched, preimage_present) {
491        (true, false) => Err(format!(
492            "UpdateOp preimage is missing `{field}`, which the patch sets or clears"
493        )),
494        (false, true) => Err(format!(
495            "UpdateOp preimage carries `{field}`, which the patch leaves unchanged"
496        )),
497        _ => Ok(()),
498    }
499}
500
501fn patch_target_name(patch: &UpdatePatch) -> &'static str {
502    match patch {
503        UpdatePatch::Entity(_) => "entity",
504        UpdatePatch::Note(_) => "note",
505        UpdatePatch::Edge(_) => "edge",
506    }
507}
508
509fn preimage_target_name(preimage: &UpdatePreimage) -> &'static str {
510    match preimage {
511        UpdatePreimage::Entity(_) => "entity",
512        UpdatePreimage::Note(_) => "note",
513        UpdatePreimage::Edge(_) => "edge",
514    }
515}
516
517/// Removes one record with its required, full stage-time preimage.
518///
519/// Deserialization requires `target_id` to equal the embedded record ID.
520/// See `crates/khive-changeset/docs/api/delete-and-merge.md` for strictness rules.
521#[derive(Clone, Debug, Serialize)]
522pub struct DeleteOp {
523    pub target_id: Id128,
524    pub hard: bool,
525    pub preimage: DeletePreimage,
526}
527
528#[derive(Deserialize)]
529#[serde(deny_unknown_fields)]
530struct DeleteOpRaw {
531    target_id: Id128,
532    hard: bool,
533    preimage: DeletePreimage,
534}
535
536impl<'de> Deserialize<'de> for DeleteOp {
537    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
538    where
539        D: Deserializer<'de>,
540    {
541        let raw = DeleteOpRaw::deserialize(deserializer)?;
542        let preimage_id = raw.preimage.record_id();
543        if raw.target_id != preimage_id {
544            return Err(serde::de::Error::custom(format!(
545                "DeleteOp target_id {} does not match preimage record id {preimage_id}",
546                raw.target_id
547            )));
548        }
549        Ok(DeleteOp {
550            target_id: raw.target_id,
551            hard: raw.hard,
552            preimage: raw.preimage,
553        })
554    }
555}
556
557/// Restores a soft-deleted record to its captured live content.
558///
559/// The full record uses the same strict wire mirrors as [`DeletePreimage`].
560/// See `crates/khive-changeset/docs/api/restore.md` for lifecycle semantics.
561#[derive(Clone, Debug, Serialize)]
562pub struct RestoreOp {
563    target_id: Id128,
564    record: DeletePreimage,
565}
566
567impl RestoreOp {
568    /// Checks target identity, live state, and the embedded numeric invariants.
569    ///
570    /// # Errors
571    ///
572    /// Refuses a mismatched ID, a tombstoned record, a non-active note, or invalid numeric values.
573    pub fn new(target_id: Id128, record: DeletePreimage) -> Result<Self, String> {
574        if target_id != record.record_id() {
575            return Err(format!(
576                "RestoreOp target_id {target_id} does not match record id {}",
577                record.record_id()
578            ));
579        }
580        if let DeletePreimage::Note(row) = &record {
581            if row.status != NoteStatus::Active {
582                return Err("RestoreOp note record must be active; live restore cannot recover another status".into());
583            }
584        }
585        let (live, valid) = match &record {
586            DeletePreimage::Entity(row) => (row.deleted_at.is_none(), true),
587            DeletePreimage::Note(row) => (row.deleted_at.is_none(), row.is_valid()),
588            DeletePreimage::Edge(row) => (row.deleted_at.is_none(), row.is_valid()),
589        };
590        if !live {
591            return Err("RestoreOp record must have no deleted_at tombstone".into());
592        }
593        if !valid {
594            return Err("RestoreOp record has invalid numeric values".into());
595        }
596        Ok(Self { target_id, record })
597    }
598
599    /// The identifier of the existing soft-deleted record.
600    pub fn target_id(&self) -> Id128 {
601        self.target_id
602    }
603
604    /// The full expected live record, including captured lifecycle metadata.
605    pub fn record(&self) -> &DeletePreimage {
606        &self.record
607    }
608}
609
610#[derive(Deserialize)]
611#[serde(deny_unknown_fields)]
612struct RestoreOpRaw {
613    target_id: Id128,
614    record: DeletePreimage,
615}
616
617impl<'de> Deserialize<'de> for RestoreOp {
618    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
619    where
620        D: Deserializer<'de>,
621    {
622        let raw = RestoreOpRaw::deserialize(deserializer)?;
623        Self::new(raw.target_id, raw.record).map_err(serde::de::Error::custom)
624    }
625}
626
627/// Full prior record state, tagged by `substrate` to avoid its embedded `kind` field.
628#[derive(Clone, Debug, Serialize)]
629#[serde(tag = "substrate", rename_all = "snake_case")]
630pub enum DeletePreimage {
631    Entity(Box<Entity>),
632    Note(Box<Note>),
633    Edge(Box<Link>),
634}
635
636impl DeletePreimage {
637    /// The identifier of the record this preimage was captured from.
638    fn record_id(&self) -> Id128 {
639        match self {
640            DeletePreimage::Entity(e) => e.header.id,
641            DeletePreimage::Note(n) => n.header.id,
642            DeletePreimage::Edge(l) => l.id,
643        }
644    }
645}
646
647/// Strict deserialize-only mirror of [`DeletePreimage`].
648#[derive(Deserialize)]
649#[serde(tag = "substrate", rename_all = "snake_case")]
650enum DeletePreimageRaw {
651    Entity(StrictEntity),
652    Note(StrictNote),
653    Edge(StrictLink),
654}
655
656impl<'de> Deserialize<'de> for DeletePreimage {
657    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
658    where
659        D: Deserializer<'de>,
660    {
661        let raw = DeletePreimageRaw::deserialize(deserializer)?;
662        Ok(match raw {
663            DeletePreimageRaw::Entity(e) => DeletePreimage::Entity(Box::new(e.into())),
664            DeletePreimageRaw::Note(n) => {
665                DeletePreimage::Note(Box::new(n.try_into().map_err(serde::de::Error::custom)?))
666            }
667            DeletePreimageRaw::Edge(l) => {
668                DeletePreimage::Edge(Box::new(l.try_into().map_err(serde::de::Error::custom)?))
669            }
670        })
671    }
672}
673
674/// Merges two entities with required participant and incident-edge preimages.
675///
676/// Deserialization matches both IDs and requires every incident edge to touch
677/// at least one participant.
678/// See `crates/khive-changeset/docs/api/delete-and-merge.md` for the full contract.
679#[derive(Clone, Debug, Serialize)]
680pub struct MergeOp {
681    pub into_id: Id128,
682    pub from_id: Id128,
683    pub preimage: MergePreimage,
684}
685
686#[derive(Deserialize)]
687#[serde(deny_unknown_fields)]
688struct MergeOpRaw {
689    into_id: Id128,
690    from_id: Id128,
691    preimage: MergePreimage,
692}
693
694impl<'de> Deserialize<'de> for MergeOp {
695    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
696    where
697        D: Deserializer<'de>,
698    {
699        let raw = MergeOpRaw::deserialize(deserializer)?;
700        if raw.into_id != raw.preimage.into.header.id {
701            return Err(serde::de::Error::custom(format!(
702                "MergeOp into_id {} does not match preimage.into record id {}",
703                raw.into_id, raw.preimage.into.header.id
704            )));
705        }
706        if raw.from_id != raw.preimage.from.header.id {
707            return Err(serde::de::Error::custom(format!(
708                "MergeOp from_id {} does not match preimage.from record id {}",
709                raw.from_id, raw.preimage.from.header.id
710            )));
711        }
712        for edge in &raw.preimage.incident_edges {
713            let touches_into = edge.source == raw.into_id || edge.target == raw.into_id;
714            let touches_from = edge.source == raw.from_id || edge.target == raw.from_id;
715            if !(touches_into || touches_from) {
716                return Err(serde::de::Error::custom(format!(
717                    "MergeOp incident edge {} references neither into_id {} nor from_id {}",
718                    edge.id, raw.into_id, raw.from_id
719                )));
720            }
721        }
722        Ok(MergeOp {
723            into_id: raw.into_id,
724            from_id: raw.from_id,
725            preimage: raw.preimage,
726        })
727    }
728}
729
730/// Full prior state required to validate and rewire an entity merge.
731///
732/// See `crates/khive-changeset/docs/api/delete-and-merge.md` for edge coverage rules.
733#[derive(Clone, Debug, Serialize)]
734pub struct MergePreimage {
735    pub into: Box<Entity>,
736    pub from: Box<Entity>,
737    pub incident_edges: Vec<Link>,
738}
739
740#[derive(Deserialize)]
741#[serde(deny_unknown_fields)]
742struct MergePreimageRaw {
743    into: StrictEntity,
744    from: StrictEntity,
745    incident_edges: Vec<StrictLink>,
746}
747
748impl<'de> Deserialize<'de> for MergePreimage {
749    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
750    where
751        D: Deserializer<'de>,
752    {
753        let raw = MergePreimageRaw::deserialize(deserializer)?;
754        let incident_edges = raw
755            .incident_edges
756            .into_iter()
757            .map(|l| l.try_into().map_err(serde::de::Error::custom))
758            .collect::<Result<Vec<Link>, D::Error>>()?;
759        Ok(MergePreimage {
760            into: Box::new(raw.into.into()),
761            from: Box::new(raw.from.into()),
762            incident_edges,
763        })
764    }
765}
766
767#[cfg(test)]
768mod tests {
769    use super::*;
770
771    #[test]
772    fn link_op_rejects_out_of_range_weight() {
773        let json = serde_json::json!({
774            "id": "00000000-0000-0000-0000-000000000001",
775            "namespace": "local",
776            "source": "00000000-0000-0000-0000-000000000002",
777            "target": "00000000-0000-0000-0000-000000000003",
778            "relation": "extends",
779            "weight": 1.5,
780            "properties": {}
781        });
782        let result: Result<LinkOp, _> = serde_json::from_value(json);
783        assert!(result.is_err());
784    }
785
786    #[test]
787    fn create_op_rejects_unknown_field() {
788        let json = serde_json::json!({
789            "id": "00000000-0000-0000-0000-000000000001",
790            "namespace": "local",
791            "target": {
792                "kind": "entity",
793                "entity_kind": "concept",
794                "name": "X",
795                "surprise": true
796            }
797        });
798        let result: Result<CreateOp, _> = serde_json::from_value(json);
799        assert!(result.is_err());
800    }
801
802    #[test]
803    fn entity_patch_distinguishes_absent_and_null_description() {
804        let absent: EntityPatch = serde_json::from_value(serde_json::json!({})).unwrap();
805        assert_eq!(absent.description, None);
806
807        let cleared: EntityPatch =
808            serde_json::from_value(serde_json::json!({ "description": null })).unwrap();
809        assert_eq!(cleared.description, Some(None));
810
811        let set: EntityPatch =
812            serde_json::from_value(serde_json::json!({ "description": "new" })).unwrap();
813        assert_eq!(set.description, Some(Some("new".to_string())));
814    }
815
816    #[test]
817    fn delete_op_requires_preimage() {
818        let json = serde_json::json!({
819            "target_id": "00000000-0000-0000-0000-000000000001",
820            "hard": false
821        });
822        let result: Result<DeleteOp, _> = serde_json::from_value(json);
823        assert!(result.is_err());
824    }
825
826    #[test]
827    fn merge_op_requires_preimage() {
828        let json = serde_json::json!({
829            "into_id": "00000000-0000-0000-0000-000000000001",
830            "from_id": "00000000-0000-0000-0000-000000000002"
831        });
832        let result: Result<MergeOp, _> = serde_json::from_value(json);
833        assert!(result.is_err());
834    }
835
836    #[test]
837    fn update_op_rejects_preimage_missing_touched_field() {
838        let json = serde_json::json!({
839            "target_id": "00000000-0000-0000-0000-000000000001",
840            "patch": { "target": "entity", "name": "new-name" },
841            "preimage": { "target": "entity" }
842        });
843        let result: Result<UpdateOp, _> = serde_json::from_value(json);
844        assert!(result.is_err());
845    }
846
847    #[test]
848    fn update_op_rejects_preimage_with_extra_untouched_field() {
849        let json = serde_json::json!({
850            "target_id": "00000000-0000-0000-0000-000000000001",
851            "patch": { "target": "entity", "tags": ["a"] },
852            "preimage": {
853                "target": "entity",
854                "name": "stale-prior-name",
855                "tags": ["prior-a"]
856            }
857        });
858        let result: Result<UpdateOp, _> = serde_json::from_value(json);
859        assert!(result.is_err());
860    }
861
862    #[test]
863    fn update_op_rejects_mismatched_target_variant() {
864        let json = serde_json::json!({
865            "target_id": "00000000-0000-0000-0000-000000000001",
866            "patch": { "target": "entity", "name": "n" },
867            "preimage": { "target": "note", "content": "prior" }
868        });
869        let result: Result<UpdateOp, _> = serde_json::from_value(json);
870        assert!(result.is_err());
871    }
872
873    #[test]
874    fn update_op_explicit_null_clear_captures_prior_value() {
875        let json = serde_json::json!({
876            "target_id": "00000000-0000-0000-0000-000000000001",
877            "patch": { "target": "entity", "description": null },
878            "preimage": { "target": "entity", "description": "was-set" }
879        });
880        let op: UpdateOp = serde_json::from_value(json).unwrap();
881        match op.patch {
882            UpdatePatch::Entity(p) => assert_eq!(p.description, Some(None)),
883            other => panic!("expected entity patch, got {other:?}"),
884        }
885        match op.preimage {
886            UpdatePreimage::Entity(pre) => {
887                assert_eq!(pre.description, Some(Some("was-set".to_string())))
888            }
889            other => panic!("expected entity preimage, got {other:?}"),
890        }
891    }
892
893    #[test]
894    fn update_op_accepts_congruent_preimage() {
895        let json = serde_json::json!({
896            "target_id": "00000000-0000-0000-0000-000000000001",
897            "patch": { "target": "edge", "weight": 0.5 },
898            "preimage": { "target": "edge", "weight": 0.9 }
899        });
900        let result: Result<UpdateOp, _> = serde_json::from_value(json);
901        assert!(result.is_ok());
902    }
903
904    #[test]
905    fn update_op_rejects_out_of_range_preimage_weight() {
906        let json = serde_json::json!({
907            "target_id": "00000000-0000-0000-0000-000000000001",
908            "patch": { "target": "edge", "weight": 0.5 },
909            "preimage": { "target": "edge", "weight": 1.5 }
910        });
911        let result: Result<UpdateOp, _> = serde_json::from_value(json);
912        assert!(result.is_err());
913    }
914
915    #[test]
916    fn update_op_rejects_out_of_range_preimage_salience() {
917        let json = serde_json::json!({
918            "target_id": "00000000-0000-0000-0000-000000000001",
919            "patch": { "target": "note", "salience": 0.2 },
920            "preimage": { "target": "note", "salience": 1.9 }
921        });
922        let result: Result<UpdateOp, _> = serde_json::from_value(json);
923        assert!(result.is_err());
924    }
925
926    #[test]
927    fn update_op_new_rejects_incongruent_construction() {
928        let result = UpdateOp::new(
929            Id128::from_u128(1),
930            UpdatePatch::Entity(EntityPatch {
931                name: Some("new-name".to_string()),
932                description: None,
933                properties: None,
934                tags: None,
935                entity_type: None,
936            }),
937            UpdatePreimage::Entity(EntityPreimage {
938                name: None,
939                description: None,
940                properties: None,
941                tags: None,
942                entity_type: None,
943            }),
944        );
945        assert!(result.is_err());
946    }
947}