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}