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        /// Free-form source-kind tag (e.g. `"message"`, `"text"`,
79        /// `"json"`). Opaque to grit — callers filter on it.
80        #[serde(default)]
81        kind: String,
82        /// Raw text; FTS-indexed.
83        content: String,
84        /// Event time of the episode.
85        occurred_at: TimestampMs,
86        /// Namespace.
87        #[serde(default)]
88        group_id: String,
89        /// Node/edge ids this episode is provenance for.
90        #[serde(default)]
91        mentions: Vec<Uuid>,
92    },
93
94    /// Revise a node's entity metadata (Layer 2 decides the new values —
95    /// summary refresh, name/label promotion; grit executes). `None` fields
96    /// are untouched.
97    ///
98    /// Each provided field is a last-writer-wins register: concurrent
99    /// updates converge to the highest HLC per field, independently of
100    /// apply order (recorded append-only in `node_updates`, folded into the
101    /// node row — the same out-of-order machinery as edge invalidations).
102    /// Prior values remain in the oplog; this is metadata revision with an
103    /// audit trail, not fact mutation — facts live on edges and stay
104    /// append-only (Design Invariant 4).
105    ///
106    /// Deliberately cannot touch identity (`id`, `group_id`), lifecycle
107    /// (`expired_at`, `merged_into`), or anything on edges.
108    UpdateNode {
109        /// Node to update.
110        id: Uuid,
111        /// New display name, if changing.
112        #[serde(default, skip_serializing_if = "Option::is_none")]
113        name: Option<String>,
114        /// New summary, if changing.
115        #[serde(default, skip_serializing_if = "Option::is_none")]
116        summary: Option<String>,
117        /// New entity kind, if changing.
118        #[serde(default, skip_serializing_if = "Option::is_none")]
119        kind: Option<String>,
120        /// New attributes (whole-value replace — grit does not deep-merge;
121        /// the caller composes the full object), if changing.
122        #[serde(default, skip_serializing_if = "Option::is_none")]
123        attrs: Option<Json>,
124    },
125
126    /// Close an edge's event-time interval: the fact stopped being true at
127    /// `invalid_at`. Never deletes; concurrent invalidations converge to the
128    /// minimum (earliest) time. The edge row's `expired_at` is untouched
129    /// (that means full belief retraction in grit's read model); when the
130    /// invalidation itself was learned is `edge_invalidations.recorded_at`.
131    InvalidateEdge {
132        /// Edge to invalidate.
133        edge_id: Uuid,
134        /// Event time the fact stopped holding.
135        invalid_at: TimestampMs,
136    },
137
138    /// Fold node `from` into node `into`. Layer 2 decides *whether* (an LLM
139    /// judgment); grit only executes. Re-points edges and mentions, marks
140    /// `from` expired with a `merged_into` audit pointer.
141    MergeNodes {
142        /// The node that disappears.
143        from: Uuid,
144        /// The node that absorbs it.
145        into: Uuid,
146    },
147
148    /// Explicit, audited right-to-forget. The ONLY destructive op: hard-deletes
149    /// and permanently tombstones exactly the listed ids (a tombstoned id can
150    /// never be re-added, which is what makes purge commute with concurrent
151    /// adds). List incident edge ids explicitly — their fact sentences carry
152    /// the content being forgotten; [`crate::Grit::node_history`] enumerates
153    /// them. The purge op itself stays in the oplog as the audit record.
154    Purge {
155        /// Node/edge/episode ids to erase.
156        ids: Vec<Uuid>,
157    },
158}
159
160fn empty_object() -> Json {
161    Json::Object(serde_json::Map::new())
162}
163
164/// One row of the oplog: an op plus its global identity and merge metadata.
165/// This is the unit a future sync mechanism ships between devices.
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167pub struct OplogEntry {
168    /// UUIDv7 identity of the op itself (dedup key across devices).
169    pub id: Uuid,
170    /// Hybrid logical clock at issue time; total merge order.
171    pub hlc: Hlc,
172    /// Device that issued the op.
173    pub device_id: String,
174    /// The op.
175    pub op: GraphOp,
176}