use uuid::Uuid;
use khive_storage::SqlStatement;
#[derive(Debug, Clone)]
pub struct PlanStatement {
pub statement: SqlStatement,
pub guard: Option<AffectedRowGuard>,
}
#[derive(Debug, Clone)]
pub struct PlanPredicate {
pub description: String,
pub statement: SqlStatement,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AffectedRowGuard {
pub expected_min: u64,
pub expected_max: Option<u64>,
}
impl AffectedRowGuard {
pub fn exactly(n: u64) -> Self {
Self {
expected_min: n,
expected_max: Some(n),
}
}
pub fn at_least_one() -> Self {
Self {
expected_min: 1,
expected_max: None,
}
}
pub fn holds_for(&self, affected: u64) -> bool {
affected >= self.expected_min
&& self.expected_max.map(|max| affected <= max).unwrap_or(true)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PostCommitEffect {
None,
ReindexEntity { entity_id: Uuid },
ReindexNote { note_id: Uuid, version: i64 },
NoteChanged { note_id: Uuid, kind: String },
GtdAudit {
task_id: Uuid,
from_status: String,
to_status: String,
note: Option<String>,
namespace: String,
},
NoteDeleted { note_id: Uuid, kind: String },
}
#[derive(Debug, Clone)]
pub struct EdgeNaturalKey {
pub(crate) namespace: String,
pub(crate) canon_source_id: Uuid,
pub(crate) canon_target_id: Uuid,
pub(crate) relation: khive_storage::EdgeRelation,
}
impl EdgeNaturalKey {
pub fn namespace(&self) -> &str {
&self.namespace
}
pub fn canon_source_id(&self) -> Uuid {
self.canon_source_id
}
pub fn canon_target_id(&self) -> Uuid {
self.canon_target_id
}
pub fn relation(&self) -> khive_storage::EdgeRelation {
self.relation
}
}
#[derive(Debug, Clone)]
pub struct UpdatePlan {
pub(crate) target_id: Uuid,
pub(crate) statements: Vec<PlanStatement>,
pub(crate) post_commit: PostCommitEffect,
pub(crate) edge_natural_key: Option<EdgeNaturalKey>,
pub(crate) idempotent_noop: bool,
pub(crate) entity_guard: Option<crate::entity_write::EntityWriteGuard>,
pub(crate) note_guard: Option<crate::note_write::NoteWriteGuard>,
pub(crate) note_vector_purge: Option<crate::note_write::NoteVectors>,
pub(crate) note_embedding_inheritance: Option<crate::note_write::NoteEmbeddingInheritance>,
pub(crate) graph_effects: Vec<NoteUpdateStatement>,
}
#[derive(Debug, Clone)]
pub(crate) enum NoteUpdateStatement {
Write(PlanStatement),
Assert(PlanStatement),
}
impl UpdatePlan {
pub fn target_id(&self) -> Uuid {
self.target_id
}
pub fn edge_natural_key(&self) -> Option<&EdgeNaturalKey> {
self.edge_natural_key.as_ref()
}
pub fn post_commit(&self) -> &PostCommitEffect {
&self.post_commit
}
pub fn is_idempotent_noop(&self) -> bool {
self.idempotent_noop
}
}
#[derive(Debug, Clone)]
pub struct AddEntityPlan {
pub(crate) entity_id: Uuid,
pub(crate) statements: Vec<PlanStatement>,
pub(crate) post_commit: PostCommitEffect,
}
impl AddEntityPlan {
pub fn entity_id(&self) -> Uuid {
self.entity_id
}
}
#[derive(Debug, Clone)]
pub struct AddNotePlan {
pub(crate) note_id: Uuid,
pub(crate) note_guard: Option<crate::note_write::NoteWriteGuard>,
pub(crate) statements: Vec<PlanStatement>,
pub(crate) post_commit: PostCommitEffect,
}
impl AddNotePlan {
pub fn note_id(&self) -> Uuid {
self.note_id
}
}
#[derive(Debug, Clone)]
pub struct DeletePlan {
pub(crate) target_id: Uuid,
pub(crate) statements: Vec<PlanStatement>,
pub(crate) post_commit: PostCommitEffect,
}
impl DeletePlan {
pub fn target_id(&self) -> Uuid {
self.target_id
}
}
#[derive(Debug, Clone)]
pub struct LinkPlan {
pub(crate) source_id: Uuid,
pub(crate) target_id: Uuid,
pub(crate) statements: Vec<PlanStatement>,
pub(crate) disposition: khive_storage::EdgeUpsertDisposition,
}
impl LinkPlan {
pub fn source_id(&self) -> Uuid {
self.source_id
}
pub fn target_id(&self) -> Uuid {
self.target_id
}
pub fn disposition(&self) -> khive_storage::EdgeUpsertDisposition {
self.disposition
}
}
#[derive(Debug, Clone)]
pub struct MergePlan {
pub(crate) into_id: Uuid,
pub(crate) from_id: Uuid,
pub(crate) rewires: Vec<PlanPredicate>,
pub(crate) lifecycle: Vec<PlanStatement>,
}
impl MergePlan {
pub fn into_id(&self) -> Uuid {
self.into_id
}
pub fn from_id(&self) -> Uuid {
self.from_id
}
}
#[derive(Debug, Clone)]
pub struct GtdTransitionPlan {
pub(crate) task_id: Uuid,
pub(crate) statements: Vec<PlanStatement>,
pub(crate) idempotent_noop: bool,
pub(crate) post_commit: PostCommitEffect,
}
impl GtdTransitionPlan {
pub fn new(
task_id: Uuid,
statements: Vec<PlanStatement>,
idempotent_noop: bool,
post_commit: PostCommitEffect,
) -> Self {
Self {
task_id,
statements,
idempotent_noop,
post_commit,
}
}
pub fn task_id(&self) -> Uuid {
self.task_id
}
pub fn statements(&self) -> &[PlanStatement] {
&self.statements
}
pub fn is_idempotent_noop(&self) -> bool {
self.idempotent_noop
}
pub fn post_commit(&self) -> &PostCommitEffect {
&self.post_commit
}
}
#[derive(Debug, Clone)]
pub struct GtdCompletePlan {
pub(crate) task_id: Uuid,
pub(crate) statements: Vec<PlanStatement>,
pub(crate) post_commit: PostCommitEffect,
}
impl GtdCompletePlan {
pub fn new(
task_id: Uuid,
statements: Vec<PlanStatement>,
post_commit: PostCommitEffect,
) -> Self {
Self {
task_id,
statements,
post_commit,
}
}
pub fn task_id(&self) -> Uuid {
self.task_id
}
pub fn statements(&self) -> &[PlanStatement] {
&self.statements
}
pub fn post_commit(&self) -> &PostCommitEffect {
&self.post_commit
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GovernanceOp {
Propose,
Review,
Withdraw,
}
#[derive(Debug, Clone)]
pub struct GovernancePlan {
pub(crate) op: GovernanceOp,
pub(crate) proposal_id: Uuid,
pub(crate) statements: Vec<PlanStatement>,
}
impl GovernancePlan {
pub fn op(&self) -> GovernanceOp {
self.op
}
pub fn proposal_id(&self) -> Uuid {
self.proposal_id
}
}
#[cfg(test)]
mod tests {
use super::*;
fn stmt(label: &str) -> SqlStatement {
SqlStatement {
sql: "UPDATE t SET x = ? WHERE id = ?".to_string(),
params: vec![],
label: Some(label.to_string()),
}
}
fn guarded(label: &str, guard: AffectedRowGuard) -> PlanStatement {
PlanStatement {
statement: stmt(label),
guard: Some(guard),
}
}
fn unguarded(label: &str) -> PlanStatement {
PlanStatement {
statement: stmt(label),
guard: None,
}
}
#[test]
fn affected_row_guard_exactly_holds_only_for_n() {
let g = AffectedRowGuard::exactly(1);
assert!(!g.holds_for(0));
assert!(g.holds_for(1));
assert!(!g.holds_for(2));
}
#[test]
fn affected_row_guard_at_least_one_has_no_upper_bound() {
let g = AffectedRowGuard::at_least_one();
assert!(!g.holds_for(0));
assert!(g.holds_for(1));
assert!(g.holds_for(1_000));
}
#[test]
fn update_plan_guard_is_anchored_to_the_row_statement_not_the_fts_mirror() {
let id = Uuid::new_v4();
let plan = UpdatePlan {
graph_effects: Vec::new(),
entity_guard: None,
note_guard: None,
note_vector_purge: None,
note_embedding_inheritance: None,
target_id: id,
statements: vec![
guarded("update-row", AffectedRowGuard::exactly(1)),
unguarded("update-fts-mirror"),
],
post_commit: PostCommitEffect::ReindexEntity { entity_id: id },
edge_natural_key: None,
idempotent_noop: false,
};
assert_eq!(plan.target_id, id);
assert_eq!(plan.statements[0].guard, Some(AffectedRowGuard::exactly(1)));
assert_eq!(plan.statements[1].guard, None);
assert_eq!(
plan.post_commit,
PostCommitEffect::ReindexEntity { entity_id: id }
);
}
#[test]
fn delete_plan_guard_is_anchored_to_the_target_row_not_the_cascade() {
let plan = DeletePlan {
target_id: Uuid::new_v4(),
post_commit: PostCommitEffect::None,
statements: vec![
guarded("delete-row", AffectedRowGuard::exactly(1)),
unguarded("cascade-edges"),
],
};
let row_guard = plan.statements[0].guard.expect("row delete is guarded");
assert!(row_guard.holds_for(1));
assert!(!row_guard.holds_for(0));
assert_eq!(plan.statements[1].guard, None);
}
#[test]
fn link_plan_guard_is_the_endpoint_existence_probe_itself() {
let source = Uuid::new_v4();
let target = Uuid::new_v4();
let plan = LinkPlan {
source_id: source,
target_id: target,
statements: vec![guarded(
"insert-edge-where-exists",
AffectedRowGuard::exactly(1),
)],
disposition: khive_storage::EdgeUpsertDisposition::Created,
};
assert_eq!(plan.source_id, source);
assert_eq!(plan.target_id, target);
let guard = plan.statements[0].guard.expect("link insert is guarded");
assert!(!guard.holds_for(0));
}
#[test]
fn merge_plan_rewires_are_never_guarded_lifecycle_writes_always_are() {
let into = Uuid::new_v4();
let from = Uuid::new_v4();
let rewire = PlanPredicate {
description: "source_id = :from".to_string(),
statement: SqlStatement {
sql: "UPDATE graph_edges SET source_id = ? WHERE source_id = ?".to_string(),
params: vec![],
label: Some("merge-rewire".to_string()),
},
};
let plan = MergePlan {
into_id: into,
from_id: from,
rewires: vec![rewire],
lifecycle: vec![guarded(
"tombstone-from-entity",
AffectedRowGuard::exactly(1),
)],
};
assert_eq!(plan.into_id, into);
assert_eq!(plan.from_id, from);
assert_eq!(plan.rewires[0].description, "source_id = :from");
let lifecycle_guard = plan.lifecycle[0].guard.expect("lifecycle write is guarded");
assert!(!lifecycle_guard.holds_for(0));
}
#[test]
fn gtd_transition_plan_triggers_no_reindex_by_construction() {
let plan = GtdTransitionPlan {
task_id: Uuid::new_v4(),
statements: vec![guarded("update-status", AffectedRowGuard::exactly(1))],
idempotent_noop: false,
post_commit: PostCommitEffect::None,
};
assert_eq!(plan.statements.len(), 1);
assert!(plan.statements[0].guard.is_some());
assert_eq!(plan.post_commit, PostCommitEffect::None);
}
#[test]
fn gtd_transition_plan_idempotent_noop_carries_guarded_assertion_and_no_audit() {
let plan = GtdTransitionPlan {
task_id: Uuid::new_v4(),
statements: vec![guarded(
"assert-noop-snapshot",
AffectedRowGuard::exactly(1),
)],
idempotent_noop: true,
post_commit: PostCommitEffect::None,
};
assert_eq!(plan.statements.len(), 1);
assert!(plan.statements[0].guard.is_some());
assert!(plan.is_idempotent_noop());
assert_eq!(plan.post_commit, PostCommitEffect::None);
}
#[test]
fn gtd_transition_plan_carries_gtd_audit_post_commit_effect() {
let task_id = Uuid::new_v4();
let plan = GtdTransitionPlan {
task_id,
statements: vec![guarded("update-status", AffectedRowGuard::exactly(1))],
idempotent_noop: false,
post_commit: PostCommitEffect::GtdAudit {
task_id,
from_status: "inbox".to_string(),
to_status: "next".to_string(),
note: Some("handed off".to_string()),
namespace: "local".to_string(),
},
};
assert_eq!(
plan.post_commit,
PostCommitEffect::GtdAudit {
task_id,
from_status: "inbox".to_string(),
to_status: "next".to_string(),
note: Some("handed off".to_string()),
namespace: "local".to_string(),
}
);
}
#[test]
fn gtd_complete_plan_guards_the_single_snapshot_property_write() {
let plan = GtdCompletePlan {
task_id: Uuid::new_v4(),
statements: vec![guarded(
"update-status-and-completed-at",
AffectedRowGuard::exactly(1),
)],
post_commit: PostCommitEffect::None,
};
assert_eq!(plan.statements.len(), 1);
let guard = plan.statements[0]
.guard
.expect("snapshot property write is guarded");
assert!(guard.holds_for(1));
}
#[test]
fn governance_plan_covers_all_three_lifecycle_ops() {
for op in [
GovernanceOp::Propose,
GovernanceOp::Review,
GovernanceOp::Withdraw,
] {
let plan = GovernancePlan {
op,
proposal_id: Uuid::new_v4(),
statements: vec![guarded("governance-event", AffectedRowGuard::exactly(1))],
};
assert_eq!(plan.op, op);
assert!(plan.statements[0].guard.is_some());
}
}
#[test]
fn add_entity_plan_guard_is_anchored_to_the_row_statement_not_the_fts_mirror() {
let id = Uuid::new_v4();
let plan = AddEntityPlan {
entity_id: id,
statements: vec![
guarded("entity-insert", AffectedRowGuard::exactly(1)),
unguarded("entity-fts-insert"),
],
post_commit: PostCommitEffect::ReindexEntity { entity_id: id },
};
assert_eq!(plan.entity_id, id);
assert_eq!(plan.statements[0].guard, Some(AffectedRowGuard::exactly(1)));
assert_eq!(plan.statements[1].guard, None);
assert_eq!(
plan.post_commit,
PostCommitEffect::ReindexEntity { entity_id: id }
);
}
#[test]
fn add_note_plan_guard_is_anchored_to_the_row_statement_not_the_fts_mirror() {
let id = Uuid::new_v4();
let plan = AddNotePlan {
note_guard: None,
note_id: id,
statements: vec![
guarded("note-insert", AffectedRowGuard::exactly(1)),
unguarded("note-fts-insert"),
],
post_commit: PostCommitEffect::ReindexNote {
note_id: id,
version: 1,
},
};
assert_eq!(plan.note_id, id);
assert_eq!(plan.statements[0].guard, Some(AffectedRowGuard::exactly(1)));
assert_eq!(plan.statements[1].guard, None);
assert_eq!(
plan.post_commit,
PostCommitEffect::ReindexNote {
note_id: id,
version: 1
}
);
}
#[test]
fn plans_are_plain_data_no_async_no_embedding() {
let _ = PostCommitEffect::None;
}
}