Skip to main content

khive_changeset/
invert.rs

1//! Pure, stage-time inversion against caller-supplied current records.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use khive_types::{Entity, Id128, Link, Note, NoteStatus};
6
7use crate::{
8    ChangeSet, CreateOp, CreateTarget, DeleteOp, DeletePreimage, EdgePatch, EdgePreimage,
9    EntityPatch, EntityPreimage, Envelope, LinkOp, NotePatch, NotePreimage, Op, RestoreOp,
10    UpdateOp, UpdatePatch, UpdatePreimage,
11};
12
13/// Current records, including soft-deleted records, read when staging a revert.
14///
15/// Each key must equal its record's identifier. The caller supplies records in
16/// the same field representation as the staged operations; inversion does not
17/// normalize properties, tags, namespaces, or default values. Ordered maps keep
18/// this input deterministic on native and wasm32 targets.
19///
20/// See `crates/khive-changeset/docs/api/invert.md` for caller responsibilities.
21#[derive(Clone, Debug, Default)]
22pub struct CurrentState {
23    /// Current entity records, keyed by their header identifiers.
24    pub entities: BTreeMap<Id128, Entity>,
25    /// Current note records, keyed by their header identifiers.
26    pub notes: BTreeMap<Id128, Note>,
27    /// Current edge records, keyed by their edge identifiers.
28    pub edges: BTreeMap<Id128, Link>,
29}
30
31/// A refusal to stage an inverse; no partially inverted change-set is returned.
32///
33/// Operation indexes are zero-based indexes in the original forward op-list.
34#[derive(Debug, thiserror::Error)]
35pub enum InvertError {
36    /// A revert must have its own provenance rather than reuse the forward envelope.
37    #[error("the inverse envelope must differ from the forward envelope")]
38    ReusedEnvelope,
39    /// This format cannot yet represent the exact inverse of this operation.
40    #[error("operation {index} ({op}) is not invertible")]
41    NotInvertible {
42        /// Original forward operation index.
43        index: usize,
44        /// Forward operation name.
45        op: &'static str,
46    },
47    /// The caller did not supply the target record in the required substrate map.
48    #[error("operation {index} is missing current record {id}")]
49    MissingCurrent {
50        /// Original forward operation index.
51        index: usize,
52        /// Identifier of the required current record.
53        id: Id128,
54    },
55    /// The working record no longer matches a field required by the forward operation.
56    #[error("operation {index} record {id} differs in field {field}")]
57    Invalidated {
58        /// Original forward operation index.
59        index: usize,
60        /// Identifier of the affected record.
61        id: Id128,
62        /// Mismatched field name; `id` also names map-key/record-ID mismatches.
63        field: &'static str,
64    },
65    /// The captured delete preimage cannot describe a valid live restored record.
66    #[error("operation {index} inverse restore is invalid: {reason}")]
67    InverseRestore {
68        /// Original forward operation index.
69        index: usize,
70        /// The checked restore constructor's diagnostic.
71        reason: String,
72    },
73    /// The existing checked update constructor refused the swapped patch/preimage.
74    #[error("operation {index} inverse update is invalid: {reason}")]
75    InverseUpdate {
76        /// Original forward operation index.
77        index: usize,
78        /// The checked constructor's diagnostic.
79        reason: String,
80    },
81}
82
83impl ChangeSet {
84    /// Produces a reverse-ordered inverse with the caller's fresh envelope.
85    ///
86    /// Current records are checked and rewound in a private working copy, so
87    /// repeated updates compare against the correct intermediate values. An
88    /// update checks only fields it touches. A create or link must still match
89    /// its staged fields and be live; its inverse is a soft delete carrying the
90    /// supplied full record, including its actual timestamps.
91    ///
92    /// This is a pure stage-time check, not an atomic comparison at landing.
93    /// The caller reads current records and supplies the revert's provenance.
94    /// Neither this change-set nor `current` is modified, even on refusal.
95    ///
96    /// # Errors
97    ///
98    /// Refuses a reused envelope, hard deletes/merges, missing or invalidated
99    /// current records, an unrestorable delete preimage, or an invalid swapped update.
100    pub fn invert(
101        &self,
102        envelope: Envelope,
103        current: &CurrentState,
104    ) -> Result<ChangeSet, InvertError> {
105        if envelope == self.envelope {
106            return Err(InvertError::ReusedEnvelope);
107        }
108        let mut working = current.clone();
109        // Restore records do not capture the earlier tombstone timestamp. Track
110        // rewound lifecycle state separately instead of fabricating a timestamp.
111        let mut tombstones = BTreeSet::new();
112        let mut ops = Vec::with_capacity(self.ops.len());
113        for (index, op) in self.ops.iter().enumerate().rev() {
114            let inverse = match op {
115                Op::Create(create) => {
116                    let substrate = match &create.target {
117                        CreateTarget::Entity(_) => 0,
118                        CreateTarget::Note(_) => 1,
119                    };
120                    check(
121                        index,
122                        create.id,
123                        "deleted_at",
124                        !tombstones.contains(&(substrate, create.id)),
125                    )?;
126                    invert_create(index, create, &mut working)?
127                }
128                Op::Link(link) => {
129                    check(
130                        index,
131                        link.id,
132                        "deleted_at",
133                        !tombstones.contains(&(2, link.id)),
134                    )?;
135                    invert_link(index, link, &mut working)?
136                }
137                Op::Update(update) => invert_update(index, update, &mut working)?,
138                Op::Delete(delete) if delete.hard => {
139                    return Err(InvertError::NotInvertible {
140                        index,
141                        op: "delete",
142                    });
143                }
144                Op::Delete(delete) => invert_delete(index, delete, &mut working, &mut tombstones)?,
145                Op::Restore(restore) => {
146                    invert_restore(index, restore, &mut working, &mut tombstones)?
147                }
148                Op::Merge(_) => {
149                    return Err(InvertError::NotInvertible { index, op: "merge" });
150                }
151            };
152            ops.push(inverse);
153        }
154        Ok(ChangeSet::new(envelope, ops))
155    }
156}
157
158fn check(index: usize, id: Id128, field: &'static str, matches: bool) -> Result<(), InvertError> {
159    if matches {
160        Ok(())
161    } else {
162        Err(InvertError::Invalidated { index, id, field })
163    }
164}
165
166type RecordKey = (u8, Id128);
167
168fn record_key(record: &DeletePreimage) -> RecordKey {
169    match record {
170        DeletePreimage::Entity(row) => (0, row.header.id),
171        DeletePreimage::Note(row) => (1, row.header.id),
172        DeletePreimage::Edge(row) => (2, row.id),
173    }
174}
175
176fn is_deleted(record: &DeletePreimage) -> bool {
177    match record {
178        DeletePreimage::Entity(row) => row.deleted_at.is_some(),
179        DeletePreimage::Note(row) => row.deleted_at.is_some(),
180        DeletePreimage::Edge(row) => row.deleted_at.is_some(),
181    }
182}
183
184fn current_record(
185    index: usize,
186    id: Id128,
187    expected: &DeletePreimage,
188    working: &CurrentState,
189) -> Result<DeletePreimage, InvertError> {
190    let record = match expected {
191        DeletePreimage::Entity(_) => working
192            .entities
193            .get(&id)
194            .cloned()
195            .map(Box::new)
196            .map(DeletePreimage::Entity),
197        DeletePreimage::Note(_) => working
198            .notes
199            .get(&id)
200            .cloned()
201            .map(Box::new)
202            .map(DeletePreimage::Note),
203        DeletePreimage::Edge(_) => working
204            .edges
205            .get(&id)
206            .cloned()
207            .map(Box::new)
208            .map(DeletePreimage::Edge),
209    }
210    .ok_or(InvertError::MissingCurrent { index, id })?;
211    check(index, id, "id", record_key(&record).1 == id)?;
212    Ok(record)
213}
214
215fn rewind_record(record: &DeletePreimage, working: &mut CurrentState) {
216    match record {
217        DeletePreimage::Entity(row) => {
218            working.entities.insert(row.header.id, *row.clone());
219        }
220        DeletePreimage::Note(row) => {
221            working.notes.insert(row.header.id, *row.clone());
222        }
223        DeletePreimage::Edge(row) => {
224            working.edges.insert(row.id, *row.clone());
225        }
226    }
227}
228
229fn check_record_content(
230    index: usize,
231    id: Id128,
232    current: &DeletePreimage,
233    expected: &DeletePreimage,
234) -> Result<(), InvertError> {
235    macro_rules! fields {
236        ($current:ident, $expected:ident, $($field:ident),+ $(,)?) => {
237            $(check(index, id, stringify!($field), $current.$field == $expected.$field)?;)+
238        };
239    }
240    match (current, expected) {
241        (DeletePreimage::Entity(row), DeletePreimage::Entity(prior)) => {
242            fields!(
243                row,
244                prior,
245                kind,
246                entity_type,
247                name,
248                description,
249                properties,
250                tags
251            );
252            check(
253                index,
254                id,
255                "namespace",
256                row.header.namespace == prior.header.namespace,
257            )?;
258            check(
259                index,
260                id,
261                "created_at",
262                row.header.created_at == prior.header.created_at,
263            )?;
264        }
265        (DeletePreimage::Note(row), DeletePreimage::Note(prior)) => {
266            fields!(
267                row,
268                prior,
269                kind,
270                status,
271                content,
272                properties,
273                tags,
274                salience,
275                decay_factor,
276                expires_at
277            );
278            check(
279                index,
280                id,
281                "namespace",
282                row.header.namespace == prior.header.namespace,
283            )?;
284            check(
285                index,
286                id,
287                "created_at",
288                row.header.created_at == prior.header.created_at,
289            )?;
290        }
291        (DeletePreimage::Edge(row), DeletePreimage::Edge(prior)) => {
292            fields!(
293                row, prior, namespace, source, target, relation, weight, properties, created_at
294            );
295        }
296        _ => check(index, id, "substrate", false)?,
297    }
298    Ok(())
299}
300
301fn invert_delete(
302    index: usize,
303    delete: &DeleteOp,
304    working: &mut CurrentState,
305    tombstones: &mut BTreeSet<RecordKey>,
306) -> Result<Op, InvertError> {
307    let id = delete.target_id;
308    let restore = RestoreOp::new(id, delete.preimage.clone())
309        .map_err(|reason| InvertError::InverseRestore { index, reason })?;
310    let mut current = current_record(index, id, &delete.preimage, working)?;
311    let key = record_key(&delete.preimage);
312    check(
313        index,
314        id,
315        "deleted_at",
316        is_deleted(&current) || tombstones.contains(&key),
317    )?;
318    if !tombstones.contains(&key) {
319        match (&mut current, &delete.preimage) {
320            (DeletePreimage::Entity(row), DeletePreimage::Entity(prior)) => {
321                check(
322                    index,
323                    id,
324                    "updated_at",
325                    row.header.updated_at == prior.header.updated_at,
326                )?;
327            }
328            (DeletePreimage::Note(row), DeletePreimage::Note(prior)) => {
329                check(
330                    index,
331                    id,
332                    "updated_at",
333                    row.header.updated_at == prior.header.updated_at,
334                )?;
335                check(index, id, "status", row.status == NoteStatus::Deleted)?;
336                row.status = prior.status;
337            }
338            _ => {}
339        }
340    }
341    check_record_content(index, id, &current, &delete.preimage)?;
342    rewind_record(&delete.preimage, working);
343    tombstones.remove(&key);
344    Ok(Op::Restore(restore))
345}
346
347fn invert_restore(
348    index: usize,
349    restore: &RestoreOp,
350    working: &mut CurrentState,
351    tombstones: &mut BTreeSet<RecordKey>,
352) -> Result<Op, InvertError> {
353    let id = restore.target_id();
354    let key = record_key(restore.record());
355    let current = current_record(index, id, restore.record(), working)?;
356    check(
357        index,
358        id,
359        "deleted_at",
360        !is_deleted(&current) && !tombstones.contains(&key),
361    )?;
362    check_record_content(index, id, &current, restore.record())?;
363    tombstones.insert(key);
364    Ok(Op::Delete(DeleteOp {
365        target_id: id,
366        hard: false,
367        preimage: current,
368    }))
369}
370
371fn invert_create(
372    index: usize,
373    create: &CreateOp,
374    working: &mut CurrentState,
375) -> Result<Op, InvertError> {
376    let id = create.id;
377    let preimage = match &create.target {
378        CreateTarget::Entity(fields) => {
379            let row = working
380                .entities
381                .get(&id)
382                .ok_or(InvertError::MissingCurrent { index, id })?;
383            check(index, id, "id", row.header.id == id)?;
384            check(
385                index,
386                id,
387                "namespace",
388                row.header.namespace == create.namespace,
389            )?;
390            check(index, id, "deleted_at", row.deleted_at.is_none())?;
391            check(index, id, "kind", row.kind == fields.entity_kind)?;
392            check(
393                index,
394                id,
395                "entity_type",
396                row.entity_type == fields.entity_type,
397            )?;
398            check(index, id, "name", row.name == fields.name)?;
399            check(
400                index,
401                id,
402                "description",
403                row.description == fields.description,
404            )?;
405            check(index, id, "properties", row.properties == fields.properties)?;
406            check(index, id, "tags", row.tags == fields.tags)?;
407            let captured = row.clone();
408            working.entities.remove(&id);
409            DeletePreimage::Entity(Box::new(captured))
410        }
411        CreateTarget::Note(fields) => {
412            let row = working
413                .notes
414                .get(&id)
415                .ok_or(InvertError::MissingCurrent { index, id })?;
416            check(index, id, "id", row.header.id == id)?;
417            check(
418                index,
419                id,
420                "namespace",
421                row.header.namespace == create.namespace,
422            )?;
423            check(index, id, "deleted_at", row.deleted_at.is_none())?;
424            check(index, id, "kind", row.kind == fields.note_kind)?;
425            check(index, id, "status", row.status == NoteStatus::default())?;
426            check(index, id, "expires_at", row.expires_at.is_none())?;
427            check(index, id, "content", row.content == fields.content)?;
428            check(index, id, "properties", row.properties == fields.properties)?;
429            check(index, id, "tags", row.tags == fields.tags)?;
430            check(index, id, "salience", row.salience == fields.salience)?;
431            check(
432                index,
433                id,
434                "decay_factor",
435                row.decay_factor == fields.decay_factor,
436            )?;
437            let captured = row.clone();
438            working.notes.remove(&id);
439            DeletePreimage::Note(Box::new(captured))
440        }
441    };
442    Ok(Op::Delete(DeleteOp {
443        target_id: id,
444        hard: false,
445        preimage,
446    }))
447}
448
449fn invert_link(index: usize, link: &LinkOp, working: &mut CurrentState) -> Result<Op, InvertError> {
450    let id = link.id;
451    let row = working
452        .edges
453        .get(&id)
454        .ok_or(InvertError::MissingCurrent { index, id })?;
455    check(index, id, "id", row.id == id)?;
456    check(
457        index,
458        id,
459        "namespace",
460        row.namespace == link.namespace.as_str(),
461    )?;
462    check(index, id, "deleted_at", row.deleted_at.is_none())?;
463    check(index, id, "source", row.source == link.source)?;
464    check(index, id, "target", row.target == link.target)?;
465    check(index, id, "relation", row.relation == link.relation)?;
466    check(index, id, "weight", row.weight == link.weight)?;
467    check(index, id, "properties", row.properties == link.properties)?;
468    let captured = row.clone();
469    working.edges.remove(&id);
470    Ok(Op::Delete(DeleteOp {
471        target_id: id,
472        hard: false,
473        preimage: DeletePreimage::Edge(Box::new(captured)),
474    }))
475}
476
477fn invert_update(
478    index: usize,
479    update: &UpdateOp,
480    working: &mut CurrentState,
481) -> Result<Op, InvertError> {
482    let id = update.target_id();
483    match (update.patch(), update.preimage()) {
484        (UpdatePatch::Entity(patch), UpdatePreimage::Entity(prior)) => {
485            let row = working
486                .entities
487                .get_mut(&id)
488                .ok_or(InvertError::MissingCurrent { index, id })?;
489            check(index, id, "id", row.header.id == id)?;
490            if let Some(value) = &patch.name {
491                check(index, id, "name", &row.name == value)?;
492            }
493            if let Some(value) = &patch.description {
494                check(
495                    index,
496                    id,
497                    "description",
498                    row.description.as_ref() == value.as_ref(),
499                )?;
500            }
501            if let Some(value) = &patch.entity_type {
502                check(
503                    index,
504                    id,
505                    "entity_type",
506                    row.entity_type.as_ref() == value.as_ref(),
507                )?;
508            }
509            if let Some(value) = &patch.properties {
510                check(index, id, "properties", &row.properties == value)?;
511            }
512            if let Some(value) = &patch.tags {
513                check(index, id, "tags", &row.tags == value)?;
514            }
515            let inverse = swapped_update(index, update)?;
516            if let Some(value) = &prior.name {
517                row.name = value.clone();
518            }
519            if let Some(value) = &prior.description {
520                row.description = value.clone();
521            }
522            if let Some(value) = &prior.entity_type {
523                row.entity_type = value.clone();
524            }
525            if let Some(value) = &prior.properties {
526                row.properties = value.clone();
527            }
528            if let Some(value) = &prior.tags {
529                row.tags = value.clone();
530            }
531            Ok(Op::Update(inverse))
532        }
533        (UpdatePatch::Note(patch), UpdatePreimage::Note(prior)) => {
534            let row = working
535                .notes
536                .get_mut(&id)
537                .ok_or(InvertError::MissingCurrent { index, id })?;
538            check(index, id, "id", row.header.id == id)?;
539            if let Some(value) = &patch.content {
540                check(index, id, "content", &row.content == value)?;
541            }
542            if let Some(value) = patch.salience {
543                check(index, id, "salience", row.salience == value)?;
544            }
545            if let Some(value) = patch.decay_factor {
546                check(index, id, "decay_factor", row.decay_factor == value)?;
547            }
548            if let Some(value) = &patch.properties {
549                check(index, id, "properties", &row.properties == value)?;
550            }
551            if let Some(value) = &patch.tags {
552                check(index, id, "tags", &row.tags == value)?;
553            }
554            let inverse = swapped_update(index, update)?;
555            if let Some(value) = &prior.content {
556                row.content = value.clone();
557            }
558            if let Some(value) = prior.salience {
559                row.salience = value;
560            }
561            if let Some(value) = prior.decay_factor {
562                row.decay_factor = value;
563            }
564            if let Some(value) = &prior.properties {
565                row.properties = value.clone();
566            }
567            if let Some(value) = &prior.tags {
568                row.tags = value.clone();
569            }
570            Ok(Op::Update(inverse))
571        }
572        (UpdatePatch::Edge(patch), UpdatePreimage::Edge(prior)) => {
573            let row = working
574                .edges
575                .get_mut(&id)
576                .ok_or(InvertError::MissingCurrent { index, id })?;
577            check(index, id, "id", row.id == id)?;
578            if let Some(value) = patch.relation {
579                check(index, id, "relation", row.relation == value)?;
580            }
581            if let Some(value) = patch.weight {
582                check(index, id, "weight", row.weight == value)?;
583            }
584            let inverse = swapped_update(index, update)?;
585            if let Some(value) = prior.relation {
586                row.relation = value;
587            }
588            if let Some(value) = prior.weight {
589                row.weight = value;
590            }
591            Ok(Op::Update(inverse))
592        }
593        // The checked constructor and decoder prevent this pairing. Keep the
594        // refusal typed rather than panic if that invariant ever changes.
595        _ => swapped_update(index, update).map(Op::Update),
596    }
597}
598
599fn swapped_update(index: usize, update: &UpdateOp) -> Result<UpdateOp, InvertError> {
600    let patch = match update.preimage() {
601        UpdatePreimage::Entity(prior) => UpdatePatch::Entity(EntityPatch {
602            name: prior.name.clone(),
603            description: prior.description.clone(),
604            properties: prior.properties.clone(),
605            tags: prior.tags.clone(),
606            entity_type: prior.entity_type.clone(),
607        }),
608        UpdatePreimage::Note(prior) => UpdatePatch::Note(NotePatch {
609            content: prior.content.clone(),
610            salience: prior.salience,
611            decay_factor: prior.decay_factor,
612            properties: prior.properties.clone(),
613            tags: prior.tags.clone(),
614        }),
615        UpdatePreimage::Edge(prior) => UpdatePatch::Edge(EdgePatch {
616            relation: prior.relation,
617            weight: prior.weight,
618        }),
619    };
620    let preimage = match update.patch() {
621        UpdatePatch::Entity(patch) => UpdatePreimage::Entity(EntityPreimage {
622            name: patch.name.clone(),
623            description: patch.description.clone(),
624            properties: patch.properties.clone(),
625            tags: patch.tags.clone(),
626            entity_type: patch.entity_type.clone(),
627        }),
628        UpdatePatch::Note(patch) => UpdatePreimage::Note(NotePreimage {
629            content: patch.content.clone(),
630            salience: patch.salience,
631            decay_factor: patch.decay_factor,
632            properties: patch.properties.clone(),
633            tags: patch.tags.clone(),
634        }),
635        UpdatePatch::Edge(patch) => UpdatePreimage::Edge(EdgePreimage {
636            relation: patch.relation,
637            weight: patch.weight,
638        }),
639    };
640    UpdateOp::new(update.target_id(), patch, preimage)
641        .map_err(|reason| InvertError::InverseUpdate { index, reason })
642}