grit-core 0.2.3

Embedded, bi-temporal property graph for agent memory: one SQLite file, in-process, deterministic
Documentation
//! [`GraphOp`] — the sync unit and the API's entire write vocabulary
//! (Design Invariants 4 & 5). Every mutation is an op; ops are appended to the
//! oplog and the graph tables are derived state applied in the same
//! transaction.
//!
//! Ops carry their own UUIDv7 ids so that replaying an oplog on another device
//! reproduces the graph byte-for-byte. Mint ids with [`crate::Grit::new_id`].

use serde::{Deserialize, Serialize};
use serde_json::Value as Json;
use uuid::Uuid;

use crate::clock::TimestampMs;
use crate::hlc::Hlc;

/// A single append-only write. See module docs.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum GraphOp {
    /// Create an entity node.
    AddNode {
        /// UUIDv7 identity, minted by the caller ([`crate::Grit::new_id`]).
        id: Uuid,
        /// Entity kind, free-form (e.g. `"person"`, `"concept"`).
        kind: String,
        /// Display name; FTS-indexed.
        name: String,
        /// One-paragraph summary; FTS-indexed.
        #[serde(default)]
        summary: String,
        /// Arbitrary JSON attributes.
        #[serde(default = "empty_object")]
        attrs: Json,
        /// Namespace; filtered in every query.
        #[serde(default)]
        group_id: String,
    },

    /// Create a fact edge between two nodes ("fat edge": the fact sentence
    /// lives on the edge).
    AddEdge {
        /// UUIDv7 identity.
        id: Uuid,
        /// Source node id.
        src: Uuid,
        /// Destination node id.
        dst: Uuid,
        /// Relation label (e.g. `"WORKS_AT"`).
        rel: String,
        /// Natural-language fact sentence; FTS-indexed.
        #[serde(default)]
        fact: String,
        /// Arbitrary JSON attributes.
        #[serde(default = "empty_object")]
        attrs: Json,
        /// Namespace.
        #[serde(default)]
        group_id: String,
        /// Event time the fact became true; `None` = unknown / always.
        #[serde(default)]
        valid_at: Option<TimestampMs>,
        /// Event time the fact stopped being true, when already known at
        /// creation (e.g. the source text states the end). Unlike
        /// [`GraphOp::InvalidateEdge`] this is NOT a belief retraction and
        /// sets no `expired_at` — the fact was recorded with a bounded
        /// interval from the start.
        #[serde(default)]
        invalid_at: Option<TimestampMs>,
    },

    /// Record raw provenance (a chat turn, a document chunk, a sensor-event
    /// summary) and link it to the nodes/edges it mentions.
    AddEpisode {
        /// UUIDv7 identity.
        id: Uuid,
        /// Where this came from (e.g. `"chat"`, `"doc:notes.md"`).
        source: String,
        /// Free-form source-kind tag (e.g. `"message"`, `"text"`,
        /// `"json"`). Opaque to grit — callers filter on it.
        #[serde(default)]
        kind: String,
        /// Raw text; FTS-indexed.
        content: String,
        /// Event time of the episode.
        occurred_at: TimestampMs,
        /// Namespace.
        #[serde(default)]
        group_id: String,
        /// Node/edge ids this episode is provenance for.
        #[serde(default)]
        mentions: Vec<Uuid>,
    },

    /// Revise a node's entity metadata (Layer 2 decides the new values —
    /// summary refresh, name/label promotion; grit executes). `None` fields
    /// are untouched.
    ///
    /// Each provided field is a last-writer-wins register: concurrent
    /// updates converge to the highest HLC per field, independently of
    /// apply order (recorded append-only in `node_updates`, folded into the
    /// node row — the same out-of-order machinery as edge invalidations).
    /// Prior values remain in the oplog; this is metadata revision with an
    /// audit trail, not fact mutation — facts live on edges and stay
    /// append-only (Design Invariant 4).
    ///
    /// Deliberately cannot touch identity (`id`, `group_id`), lifecycle
    /// (`expired_at`, `merged_into`), or anything on edges.
    UpdateNode {
        /// Node to update.
        id: Uuid,
        /// New display name, if changing.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        name: Option<String>,
        /// New summary, if changing.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        summary: Option<String>,
        /// New entity kind, if changing.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        kind: Option<String>,
        /// New attributes (whole-value replace — grit does not deep-merge;
        /// the caller composes the full object), if changing.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        attrs: Option<Json>,
    },

    /// Close an edge's event-time interval: the fact stopped being true at
    /// `invalid_at`. Never deletes; concurrent invalidations converge to the
    /// minimum (earliest) time. The edge row's `expired_at` is untouched
    /// (that means full belief retraction in grit's read model); when the
    /// invalidation itself was learned is `edge_invalidations.recorded_at`.
    InvalidateEdge {
        /// Edge to invalidate.
        edge_id: Uuid,
        /// Event time the fact stopped holding.
        invalid_at: TimestampMs,
    },

    /// Fold node `from` into node `into`. Layer 2 decides *whether* (an LLM
    /// judgment); grit only executes. Re-points edges and mentions, marks
    /// `from` expired with a `merged_into` audit pointer.
    MergeNodes {
        /// The node that disappears.
        from: Uuid,
        /// The node that absorbs it.
        into: Uuid,
    },

    /// Explicit, audited right-to-forget. The ONLY destructive op: hard-deletes
    /// and permanently tombstones exactly the listed ids (a tombstoned id can
    /// never be re-added, which is what makes purge commute with concurrent
    /// adds). List incident edge ids explicitly — their fact sentences carry
    /// the content being forgotten; [`crate::Grit::node_history`] enumerates
    /// them. The purge op itself stays in the oplog as the audit record.
    Purge {
        /// Node/edge/episode ids to erase.
        ids: Vec<Uuid>,
    },
}

fn empty_object() -> Json {
    Json::Object(serde_json::Map::new())
}

/// One row of the oplog: an op plus its global identity and merge metadata.
/// This is the unit a future sync mechanism ships between devices.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OplogEntry {
    /// UUIDv7 identity of the op itself (dedup key across devices).
    pub id: Uuid,
    /// Hybrid logical clock at issue time; total merge order.
    pub hlc: Hlc,
    /// Device that issued the op.
    pub device_id: String,
    /// The op.
    pub op: GraphOp,
}