Skip to main content

grit_core/
ops.rs

1//! [`GraphOp`] — the sync unit and the API's entire write vocabulary
2//! (Design Invariants 4 & 5). Every mutation is an op; ops are appended to the
3//! oplog and the graph tables are derived state applied in the same
4//! transaction.
5//!
6//! Ops carry their own UUIDv7 ids so that replaying an oplog on another device
7//! reproduces the graph byte-for-byte. Mint ids with [`crate::Grit::new_id`].
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value as Json;
11use uuid::Uuid;
12
13use crate::clock::TimestampMs;
14use crate::hlc::Hlc;
15
16/// A single append-only write. See module docs.
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18#[serde(tag = "op", rename_all = "snake_case")]
19pub enum GraphOp {
20    /// Create an entity node.
21    AddNode {
22        /// UUIDv7 identity, minted by the caller ([`crate::Grit::new_id`]).
23        id: Uuid,
24        /// Entity kind, free-form (e.g. `"person"`, `"concept"`).
25        kind: String,
26        /// Display name; FTS-indexed.
27        name: String,
28        /// One-paragraph summary; FTS-indexed.
29        #[serde(default)]
30        summary: String,
31        /// Arbitrary JSON attributes.
32        #[serde(default = "empty_object")]
33        attrs: Json,
34        /// Namespace; filtered in every query.
35        #[serde(default)]
36        group_id: String,
37    },
38
39    /// Create a fact edge between two nodes ("fat edge": the fact sentence
40    /// lives on the edge).
41    AddEdge {
42        /// UUIDv7 identity.
43        id: Uuid,
44        /// Source node id.
45        src: Uuid,
46        /// Destination node id.
47        dst: Uuid,
48        /// Relation label (e.g. `"WORKS_AT"`).
49        rel: String,
50        /// Natural-language fact sentence; FTS-indexed.
51        #[serde(default)]
52        fact: String,
53        /// Arbitrary JSON attributes.
54        #[serde(default = "empty_object")]
55        attrs: Json,
56        /// Namespace.
57        #[serde(default)]
58        group_id: String,
59        /// Event time the fact became true; `None` = unknown / always.
60        #[serde(default)]
61        valid_at: Option<TimestampMs>,
62        /// Event time the fact stopped being true, when already known at
63        /// creation (e.g. the source text states the end). Unlike
64        /// [`GraphOp::InvalidateEdge`] this is NOT a belief retraction and
65        /// sets no `expired_at` — the fact was recorded with a bounded
66        /// interval from the start.
67        #[serde(default)]
68        invalid_at: Option<TimestampMs>,
69    },
70
71    /// Record raw provenance (a chat turn, a document chunk, a sensor-event
72    /// summary) and link it to the nodes/edges it mentions.
73    AddEpisode {
74        /// UUIDv7 identity.
75        id: Uuid,
76        /// Where this came from (e.g. `"chat"`, `"doc:notes.md"`).
77        source: String,
78        /// Raw text; FTS-indexed.
79        content: String,
80        /// Event time of the episode.
81        occurred_at: TimestampMs,
82        /// Namespace.
83        #[serde(default)]
84        group_id: String,
85        /// Node/edge ids this episode is provenance for.
86        #[serde(default)]
87        mentions: Vec<Uuid>,
88    },
89
90    /// Revise a node's entity metadata (Layer 2 decides the new values —
91    /// summary refresh, name/label promotion; grit executes). `None` fields
92    /// are untouched.
93    ///
94    /// Each provided field is a last-writer-wins register: concurrent
95    /// updates converge to the highest HLC per field, independently of
96    /// apply order (recorded append-only in `node_updates`, folded into the
97    /// node row — the same out-of-order machinery as edge invalidations).
98    /// Prior values remain in the oplog; this is metadata revision with an
99    /// audit trail, not fact mutation — facts live on edges and stay
100    /// append-only (Design Invariant 4).
101    ///
102    /// Deliberately cannot touch identity (`id`, `group_id`), lifecycle
103    /// (`expired_at`, `merged_into`), or anything on edges.
104    UpdateNode {
105        /// Node to update.
106        id: Uuid,
107        /// New display name, if changing.
108        #[serde(default, skip_serializing_if = "Option::is_none")]
109        name: Option<String>,
110        /// New summary, if changing.
111        #[serde(default, skip_serializing_if = "Option::is_none")]
112        summary: Option<String>,
113        /// New entity kind, if changing.
114        #[serde(default, skip_serializing_if = "Option::is_none")]
115        kind: Option<String>,
116        /// New attributes (whole-value replace — grit does not deep-merge;
117        /// the caller composes the full object), if changing.
118        #[serde(default, skip_serializing_if = "Option::is_none")]
119        attrs: Option<Json>,
120    },
121
122    /// Close an edge's event-time interval: the fact stopped being true at
123    /// `invalid_at`. Never deletes; concurrent invalidations converge to the
124    /// minimum (earliest) time. The edge row's `expired_at` is untouched
125    /// (that means full belief retraction in grit's read model); when the
126    /// invalidation itself was learned is `edge_invalidations.recorded_at`.
127    InvalidateEdge {
128        /// Edge to invalidate.
129        edge_id: Uuid,
130        /// Event time the fact stopped holding.
131        invalid_at: TimestampMs,
132    },
133
134    /// Fold node `from` into node `into`. Layer 2 decides *whether* (an LLM
135    /// judgment); grit only executes. Re-points edges and mentions, marks
136    /// `from` expired with a `merged_into` audit pointer.
137    MergeNodes {
138        /// The node that disappears.
139        from: Uuid,
140        /// The node that absorbs it.
141        into: Uuid,
142    },
143
144    /// Explicit, audited right-to-forget. The ONLY destructive op: hard-deletes
145    /// and permanently tombstones exactly the listed ids (a tombstoned id can
146    /// never be re-added, which is what makes purge commute with concurrent
147    /// adds). List incident edge ids explicitly — their fact sentences carry
148    /// the content being forgotten; [`crate::Grit::node_history`] enumerates
149    /// them. The purge op itself stays in the oplog as the audit record.
150    Purge {
151        /// Node/edge/episode ids to erase.
152        ids: Vec<Uuid>,
153    },
154}
155
156fn empty_object() -> Json {
157    Json::Object(serde_json::Map::new())
158}
159
160/// One row of the oplog: an op plus its global identity and merge metadata.
161/// This is the unit a future sync mechanism ships between devices.
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163pub struct OplogEntry {
164    /// UUIDv7 identity of the op itself (dedup key across devices).
165    pub id: Uuid,
166    /// Hybrid logical clock at issue time; total merge order.
167    pub hlc: Hlc,
168    /// Device that issued the op.
169    pub device_id: String,
170    /// The op.
171    pub op: GraphOp,
172}