Skip to main content

khive_runtime/
atomic_plan.rs

1//! ADR-099 (cross-op atomicity for bulk apply) — prepared write-plan types.
2//!
3//! Async prepare materializes a synchronous write plan outside any
4//! transaction; commit later applies its statements as DML under a per-op
5//! SAVEPOINT. This module defines the plan *shapes* only, one family per
6//! admissible verb group (`update`, `delete`, `link`, `merge`,
7//! `gtd.transition`, `gtd.complete`, the governance verbs): not yet wired
8//! into a live handler or the dispatch path.
9//!
10//! Every plan is deliberately inert (plain data, no async, no embedding
11//! reference) and exists to satisfy two validation-staleness rules:
12//!
13//! 1. **Predicate-based plans** — a plan whose effect covers "all rows
14//!    matching a condition" carries that condition as a statement evaluated
15//!    inside the transaction, never as a prepare-time-enumerated row list
16//!    (`PlanPredicate`).
17//! 2. **Affected-row guards** — any statement whose prepare-time validation
18//!    assumed a target row exists carries an expected-effect guard, checked
19//!    in-transaction; a mismatch fails the op and rolls back the whole unit
20//!    (`PlanStatement::guard`).
21//!
22//! A guard is attached to the exact [`PlanStatement`] it validates, never to
23//! the plan as a whole: affected-row counts come back per-statement or as a
24//! batch total, so a plan-level guard field could not tell a runner which
25//! statement's count it is checking. Each plan therefore carries
26//! `Vec<PlanStatement>` (or, for `merge`, the split `rewires`/`lifecycle`
27//! fields below), and the runner applies each statement individually,
28//! checking any present guard against that statement's own affected-row
29//! count before moving to the next.
30
31use uuid::Uuid;
32
33use khive_storage::SqlStatement;
34
35/// One statement in a plan, paired with the guard (if any) that validates
36/// it. **Runner contract:** a present `guard` is checked against the
37/// affected-row count of applying `statement` alone (`SqlWriter::execute`'s
38/// return value for this statement), not a batch total and not another
39/// statement's count. `guard: None` means prepare made no row-existence
40/// assumption about this particular statement (e.g. a cascade delete that
41/// may legitimately touch zero rows).
42#[derive(Debug, Clone)]
43pub struct PlanStatement {
44    /// The DML to apply inside the atomic unit.
45    pub statement: SqlStatement,
46    /// The expected-effect guard for `statement`, if prepare's validation
47    /// assumed a target row exists for it.
48    pub guard: Option<AffectedRowGuard>,
49}
50
51/// The predicate a prepare pass validated a plan's target against, replayed
52/// as a statement evaluated **inside** the transaction (ADR-099 D1, rule 1:
53/// "predicate-based plans wherever a write's scope depends on current
54/// state"). Carrying the predicate rather than a prepare-time-enumerated row
55/// list is what lets a later op in the same file (e.g. an intervening
56/// `link`) be visible to this plan's apply.
57#[derive(Debug, Clone)]
58pub struct PlanPredicate {
59    /// Human-readable description of the condition, for diagnostics (e.g.
60    /// `"source_id = :from"`).
61    pub description: String,
62    /// The in-transaction statement whose scope is evaluated against
63    /// current (committed-so-far) state, not prepare-time state.
64    pub statement: SqlStatement,
65}
66
67/// An affected-row guard (ADR-099 D1, rule 2): the row-count prepare assumed
68/// its target write would affect, re-verified in-transaction. A prepare-time
69/// validation is a plan *hypothesis*, never a commitment — if the guard does
70/// not hold at apply time, the op fails inside the atomic unit and the whole
71/// unit rolls back (ADR-099 acceptance criteria: "zero-row apply fails the
72/// unit").
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub struct AffectedRowGuard {
75    /// Minimum affected-row count for the guard to hold (inclusive).
76    pub expected_min: u64,
77    /// Maximum affected-row count for the guard to hold (inclusive), or
78    /// `None` for "no upper bound" (e.g. a predicate-based rewire that may
79    /// touch any number of rows).
80    pub expected_max: Option<u64>,
81}
82
83impl AffectedRowGuard {
84    /// A guard requiring exactly `n` affected rows (the common case for a
85    /// single-target `update`/`delete`/`link` statement).
86    pub fn exactly(n: u64) -> Self {
87        Self {
88            expected_min: n,
89            expected_max: Some(n),
90        }
91    }
92
93    /// A guard requiring at least one affected row and no upper bound (the
94    /// shape for a predicate-based rewire that may touch any number of rows,
95    /// e.g. `merge`'s edge rewire).
96    pub fn at_least_one() -> Self {
97        Self {
98            expected_min: 1,
99            expected_max: None,
100        }
101    }
102
103    /// Whether an observed affected-row count satisfies this guard.
104    pub fn holds_for(&self, affected: u64) -> bool {
105        affected >= self.expected_min
106            && self.expected_max.map(|max| affected <= max).unwrap_or(true)
107    }
108}
109
110/// A deferred side effect recorded during prepare and run once, after the
111/// atomic unit commits (ADR-099 D1, "post-commit pass"). v1's admissible set
112/// computes no embeddings during prepare (D3's `update`/`merge` caveat), so
113/// the only post-commit effects are reindex kicks computed from the
114/// **committed** row content — plus, since the B3 fix round (GAP-5), the
115/// best-effort GTD lifecycle audit row.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum PostCommitEffect {
118    /// No deferred side effect for this op.
119    None,
120    /// Re-embed and re-warm the given entity's vector row from its committed
121    /// content (ADR-099 D3 `update` caveat: entity name/description change).
122    ReindexEntity { entity_id: Uuid },
123    /// Re-embed and re-warm the given note's vector row from its committed
124    /// content (ADR-099 D3 `update` caveat: note name/content change).
125    ReindexNote { note_id: Uuid },
126    /// Append one `gtd_lifecycle_audit` row for a committed `gtd.transition`
127    /// or `gtd.complete` (B3 fix round GAP-5): canonical `handle_transition`/
128    /// `handle_complete` call `ensure_audit_schema` + `write_audit_record`
129    /// (`khive-pack-gtd::handlers`) as a best-effort side write — a failed
130    /// audit insert must never roll back an already-committed transition.
131    /// Carries exactly the fields `write_audit_record` needs. Applied
132    /// outside `khive-runtime` (crate-direction: `khive-pack-gtd` depends on
133    /// `khive-runtime`, not the other way around) — this crate's own
134    /// `apply_post_commit_effects` treats this variant as a no-op; the
135    /// `kkernel` caller that owns both crates applies it by calling the
136    /// canonical `ensure_audit_schema`/`write_audit_record` functions
137    /// directly.
138    GtdAudit {
139        task_id: Uuid,
140        from_status: String,
141        to_status: String,
142        note: Option<String>,
143        namespace: String,
144    },
145    /// A committed note delete (soft or hard) — fire the pack-installed
146    /// note-mutation hook with the deleted note's kind (#750 fix-round 2,
147    /// codex r2 High 2: `DeletePlan` previously carried no `post_commit`
148    /// slot at all, so an atomic note delete never reached
149    /// `KhiveRuntime::fire_note_mutation_hook`, unlike `operations.rs`'s
150    /// `delete_note`, which fires it directly after a successful row
151    /// delete). Entity deletes have no equivalent — the hook system is
152    /// note-only (`khive-pack-memory`'s warm ANN cache is the only
153    /// installed consumer today).
154    NoteDeleted { note_id: Uuid, kind: String },
155}
156
157/// The natural key a committed symmetric edge update's surviving row must
158/// be looked up by (ADR-099 B3 r9, codex r8 Blocker finding 1, second
159/// half). `khive-db`'s `edge_symmetric_refresh_or_update_inplace_statement`
160/// pair never trusts a prepare-time-computed target id (see that builder's
161/// doc comment); a caller rendering this op's result derives the actual
162/// surviving id by querying `graph_edges`'s own
163/// `UNIQUE(namespace, source_id, target_id, relation)` constraint (e.g. via
164/// `KhiveRuntime::list_edges` filtered on these fields — the same mechanism
165/// the atomic `link` op's own result rendering already uses), strictly
166/// after commit.
167#[derive(Debug, Clone)]
168pub struct EdgeNaturalKey {
169    pub namespace: String,
170    pub canon_source_id: Uuid,
171    pub canon_target_id: Uuid,
172    pub relation: khive_storage::EdgeRelation,
173}
174
175/// Write plan for an `update` op (entity or note shape — ADR-099 D3's
176/// `update` caveat covers both substrates the same way: row/FTS DML in the
177/// plan, any reindex deferred to `post_commit`).
178#[derive(Debug, Clone)]
179pub struct UpdatePlan {
180    /// The id of the entity or note being updated. For a symmetric edge
181    /// update this is the CALLER's requested id — advisory only, never the
182    /// basis for post-commit result rendering (see [`EdgeNaturalKey`]).
183    pub target_id: Uuid,
184    /// Row + FTS DML statements to apply inside the atomic unit, in order.
185    /// The row-update statement carries the existence guard; any FTS-mirror
186    /// statement that follows it is unguarded (its target row's existence
187    /// was already asserted by the row-update statement's own guard).
188    pub statements: Vec<PlanStatement>,
189    /// Deferred reindex, if the update changed name/description/content.
190    pub post_commit: PostCommitEffect,
191    /// `Some` only for a symmetric edge update — the natural key a caller
192    /// must use to derive the committed surviving row post-commit, rather
193    /// than trusting `target_id`. `None` for every other update shape
194    /// (entity, note, non-symmetric edge), where `target_id` alone is
195    /// already an exact, non-advisory identifier.
196    pub edge_natural_key: Option<EdgeNaturalKey>,
197}
198
199/// Write plan for a `delete` op (soft or hard).
200#[derive(Debug, Clone)]
201pub struct DeletePlan {
202    /// The id of the entity or note being deleted.
203    pub target_id: Uuid,
204    /// Row DML (and, for a hard delete, incident-edge cascade DML) to apply
205    /// inside the atomic unit, in order. The target-row delete statement
206    /// carries the existence guard; a cascade edge-delete statement (hard
207    /// delete only) is unguarded — it may legitimately affect zero rows if
208    /// the target had no incident edges.
209    pub statements: Vec<PlanStatement>,
210    /// Deferred note-mutation-hook fire, for a note delete (#750 fix-round
211    /// 2). `PostCommitEffect::None` for entity and edge deletes — the hook
212    /// system is note-only.
213    pub post_commit: PostCommitEffect,
214}
215
216/// Write plan for a `link` op (create a typed directed edge). Endpoint
217/// existence is checked **structurally**, not via an unanchored plan-level
218/// guard: `statement` is a guarded `INSERT ... SELECT ... WHERE EXISTS`
219/// shape whose `SELECT` re-probes both endpoints inside the transaction, so
220/// the runner's affected-row check on this one statement *is* the
221/// in-transaction existence probe (ADR-099 acceptance criteria's
222/// dangling-edge case — `[delete(X, hard), link(A, X)]` — is closed by this
223/// guard failing once X is gone, regardless of statement ordering
224/// convention).
225#[derive(Debug, Clone)]
226pub struct LinkPlan {
227    pub source_id: Uuid,
228    pub target_id: Uuid,
229    /// The guarded `INSERT ... SELECT ... WHERE EXISTS(...)` statement:
230    /// its affected-row count is the endpoint-existence probe.
231    pub statement: PlanStatement,
232}
233
234/// Write plan for a `merge` op (deduplicate two entities). Rewires and
235/// lifecycle writes are split into separate fields precisely so a guard is
236/// never ambiguous between them: the edge rewire is **predicate-based**
237/// (ADR-099 D1 rule 1) and may touch zero or many rows depending on earlier
238/// in-file writes, so it is never guarded; the `from`/`into` entity
239/// lifecycle write assumes both rows exist, so it always is.
240#[derive(Debug, Clone)]
241pub struct MergePlan {
242    pub into_id: Uuid,
243    pub from_id: Uuid,
244    /// Predicate-based edge-rewire statement(s)
245    /// (`UPDATE graph_edges SET source_id = :into WHERE source_id = :from`-
246    /// shaped), evaluated inside the transaction so they structurally see
247    /// any earlier op's edge writes in the same file (ADR-099 acceptance
248    /// criteria: "merge rewires see earlier in-file writes"). Never
249    /// guarded — a rewire touching zero rows is a legitimate outcome.
250    pub rewires: Vec<PlanPredicate>,
251    /// The `from` entity's soft-delete/tombstone DML (and any other
252    /// lifecycle write prepare assumed a target row exists for). Always
253    /// guarded — prepare validated `into`/`from` both exist.
254    pub lifecycle: Vec<PlanStatement>,
255}
256
257/// Write plan for a `gtd.transition` op (explicit task lifecycle change).
258#[derive(Debug, Clone)]
259pub struct GtdTransitionPlan {
260    pub task_id: Uuid,
261    /// Status-column DML to apply inside the atomic unit. Property-only
262    /// status mutation — triggers no reindex (ADR-099 D3). The transition
263    /// statement carries the guard (prepare validated the current status
264    /// and the requested transition were legal). Empty for an idempotent
265    /// no-op (`current == target` after `normalize_status`, GAP-5/GAP-6 fix
266    /// round) — canonical performs no write in that case either
267    /// (`handlers.rs:995-1005`).
268    pub statements: Vec<PlanStatement>,
269    /// Deferred lifecycle audit row (GAP-5 fix round) — `PostCommitEffect::
270    /// None` for the idempotent no-op case, matching canonical's early
271    /// return before its own `ensure_audit_schema`/`write_audit_record`
272    /// call.
273    pub post_commit: PostCommitEffect,
274}
275
276/// Write plan for a `gtd.complete` op (task lifecycle terminal transition).
277#[derive(Debug, Clone)]
278pub struct GtdCompletePlan {
279    pub task_id: Uuid,
280    /// Status + `completed_at` DML to apply inside the atomic unit, in
281    /// order. The status-update statement carries the guard (prepare
282    /// validated the task was in a completable state); the `completed_at`
283    /// write targets the same already-guarded row and is unguarded.
284    pub statements: Vec<PlanStatement>,
285    /// Deferred lifecycle audit row (GAP-5 fix round) — mirrors
286    /// `handle_complete`'s best-effort `write_audit_record` call.
287    pub post_commit: PostCommitEffect,
288}
289
290/// Which governance verb (`propose` / `review` / `withdraw`) a
291/// [`GovernancePlan`] applies.
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293pub enum GovernanceOp {
294    Propose,
295    Review,
296    Withdraw,
297}
298
299/// Write plan for a governance op (`propose`, `review`, or `withdraw` — the
300/// event-sourced change-proposal lifecycle, ADR-046).
301#[derive(Debug, Clone)]
302pub struct GovernancePlan {
303    pub op: GovernanceOp,
304    pub proposal_id: Uuid,
305    /// Event-log + status DML to apply inside the atomic unit. The
306    /// lifecycle-state-check statement carries the guard (prepare validated
307    /// the proposal was in a state admitting this transition).
308    pub statements: Vec<PlanStatement>,
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    fn stmt(label: &str) -> SqlStatement {
316        SqlStatement {
317            sql: "UPDATE t SET x = ? WHERE id = ?".to_string(),
318            params: vec![],
319            label: Some(label.to_string()),
320        }
321    }
322
323    fn guarded(label: &str, guard: AffectedRowGuard) -> PlanStatement {
324        PlanStatement {
325            statement: stmt(label),
326            guard: Some(guard),
327        }
328    }
329
330    fn unguarded(label: &str) -> PlanStatement {
331        PlanStatement {
332            statement: stmt(label),
333            guard: None,
334        }
335    }
336
337    #[test]
338    fn affected_row_guard_exactly_holds_only_for_n() {
339        let g = AffectedRowGuard::exactly(1);
340        assert!(!g.holds_for(0));
341        assert!(g.holds_for(1));
342        assert!(!g.holds_for(2));
343    }
344
345    #[test]
346    fn affected_row_guard_at_least_one_has_no_upper_bound() {
347        let g = AffectedRowGuard::at_least_one();
348        assert!(!g.holds_for(0));
349        assert!(g.holds_for(1));
350        assert!(g.holds_for(1_000));
351    }
352
353    #[test]
354    fn update_plan_guard_is_anchored_to_the_row_statement_not_the_fts_mirror() {
355        let id = Uuid::new_v4();
356        let plan = UpdatePlan {
357            target_id: id,
358            statements: vec![
359                guarded("update-row", AffectedRowGuard::exactly(1)),
360                unguarded("update-fts-mirror"),
361            ],
362            post_commit: PostCommitEffect::ReindexEntity { entity_id: id },
363            edge_natural_key: None,
364        };
365        assert_eq!(plan.target_id, id);
366        assert_eq!(plan.statements[0].guard, Some(AffectedRowGuard::exactly(1)));
367        assert_eq!(plan.statements[1].guard, None);
368        assert_eq!(
369            plan.post_commit,
370            PostCommitEffect::ReindexEntity { entity_id: id }
371        );
372    }
373
374    #[test]
375    fn delete_plan_guard_is_anchored_to_the_target_row_not_the_cascade() {
376        let plan = DeletePlan {
377            target_id: Uuid::new_v4(),
378            post_commit: PostCommitEffect::None,
379            statements: vec![
380                guarded("delete-row", AffectedRowGuard::exactly(1)),
381                unguarded("cascade-edges"),
382            ],
383        };
384        let row_guard = plan.statements[0].guard.expect("row delete is guarded");
385        assert!(row_guard.holds_for(1));
386        assert!(!row_guard.holds_for(0));
387        assert_eq!(plan.statements[1].guard, None);
388    }
389
390    #[test]
391    fn link_plan_guard_is_the_endpoint_existence_probe_itself() {
392        let source = Uuid::new_v4();
393        let target = Uuid::new_v4();
394        let plan = LinkPlan {
395            source_id: source,
396            target_id: target,
397            statement: guarded("insert-edge-where-exists", AffectedRowGuard::exactly(1)),
398        };
399        assert_eq!(plan.source_id, source);
400        assert_eq!(plan.target_id, target);
401        // Dangling-edge acceptance criterion: once an endpoint row is gone,
402        // the guarded INSERT...WHERE EXISTS affects 0 rows and the guard
403        // on *that exact statement* must fail, not silently pass.
404        let guard = plan.statement.guard.expect("link insert is guarded");
405        assert!(!guard.holds_for(0));
406    }
407
408    #[test]
409    fn merge_plan_rewires_are_never_guarded_lifecycle_writes_always_are() {
410        let into = Uuid::new_v4();
411        let from = Uuid::new_v4();
412        let rewire = PlanPredicate {
413            description: "source_id = :from".to_string(),
414            statement: SqlStatement {
415                sql: "UPDATE graph_edges SET source_id = ? WHERE source_id = ?".to_string(),
416                params: vec![],
417                label: Some("merge-rewire".to_string()),
418            },
419        };
420        let plan = MergePlan {
421            into_id: into,
422            from_id: from,
423            rewires: vec![rewire],
424            lifecycle: vec![guarded(
425                "tombstone-from-entity",
426                AffectedRowGuard::exactly(1),
427            )],
428        };
429        assert_eq!(plan.into_id, into);
430        assert_eq!(plan.from_id, from);
431        assert_eq!(plan.rewires[0].description, "source_id = :from");
432        // A predicate-based rewire may legitimately touch zero or many rows
433        // depending on how many edges an earlier in-file op inserted — the
434        // type carries no guard field for it at all.
435        let lifecycle_guard = plan.lifecycle[0].guard.expect("lifecycle write is guarded");
436        assert!(!lifecycle_guard.holds_for(0));
437    }
438
439    #[test]
440    fn gtd_transition_plan_triggers_no_reindex_by_construction() {
441        let plan = GtdTransitionPlan {
442            task_id: Uuid::new_v4(),
443            statements: vec![guarded("update-status", AffectedRowGuard::exactly(1))],
444            post_commit: PostCommitEffect::None,
445        };
446        // A status-only transition never triggers a reindex: the type has no
447        // *reindex* post-commit variant to construct, only the best-effort
448        // `GtdAudit` lifecycle-audit effect, which itself does no embedding work.
449        assert_eq!(plan.statements.len(), 1);
450        assert!(plan.statements[0].guard.is_some());
451        assert_eq!(plan.post_commit, PostCommitEffect::None);
452    }
453
454    #[test]
455    fn gtd_transition_plan_idempotent_noop_carries_no_statements_and_no_audit() {
456        // current == target after normalization is an idempotent no-op:
457        // canonical performs no write and never reaches its audit-record call.
458        let plan = GtdTransitionPlan {
459            task_id: Uuid::new_v4(),
460            statements: vec![],
461            post_commit: PostCommitEffect::None,
462        };
463        assert!(plan.statements.is_empty());
464        assert_eq!(plan.post_commit, PostCommitEffect::None);
465    }
466
467    #[test]
468    fn gtd_transition_plan_carries_gtd_audit_post_commit_effect() {
469        let task_id = Uuid::new_v4();
470        let plan = GtdTransitionPlan {
471            task_id,
472            statements: vec![guarded("update-status", AffectedRowGuard::exactly(1))],
473            post_commit: PostCommitEffect::GtdAudit {
474                task_id,
475                from_status: "inbox".to_string(),
476                to_status: "next".to_string(),
477                note: Some("handed off".to_string()),
478                namespace: "local".to_string(),
479            },
480        };
481        assert_eq!(
482            plan.post_commit,
483            PostCommitEffect::GtdAudit {
484                task_id,
485                from_status: "inbox".to_string(),
486                to_status: "next".to_string(),
487                note: Some("handed off".to_string()),
488                namespace: "local".to_string(),
489            }
490        );
491    }
492
493    #[test]
494    fn gtd_complete_plan_guard_is_anchored_to_the_status_write() {
495        let plan = GtdCompletePlan {
496            task_id: Uuid::new_v4(),
497            statements: vec![
498                guarded("update-status", AffectedRowGuard::exactly(1)),
499                unguarded("update-completed-at"),
500            ],
501            post_commit: PostCommitEffect::None,
502        };
503        assert_eq!(plan.statements.len(), 2);
504        let guard = plan.statements[0].guard.expect("status write is guarded");
505        assert!(guard.holds_for(1));
506        assert_eq!(plan.statements[1].guard, None);
507    }
508
509    #[test]
510    fn governance_plan_covers_all_three_lifecycle_ops() {
511        for op in [
512            GovernanceOp::Propose,
513            GovernanceOp::Review,
514            GovernanceOp::Withdraw,
515        ] {
516            let plan = GovernancePlan {
517                op,
518                proposal_id: Uuid::new_v4(),
519                statements: vec![guarded("governance-event", AffectedRowGuard::exactly(1))],
520            };
521            assert_eq!(plan.op, op);
522            assert!(plan.statements[0].guard.is_some());
523        }
524    }
525
526    #[test]
527    fn plans_are_plain_data_no_async_no_embedding() {
528        // Documents a compile-time property: every plan type above derives
529        // only Debug/Clone/PartialEq, never Future or an embedding-provider
530        // trait. Plans must stay inert data: flag any edit that adds an
531        // async method or embedding-model field to one of these types.
532        let _ = PostCommitEffect::None;
533    }
534}