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}