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 DML or a typed GTD no-op read assertion 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. Every plan is deliberately
9//! inert (plain data, no async, no embedding reference).
10//!
11//! Two validation-staleness invariants every plan must satisfy:
12//! 1. **Predicate-based plans** carry an "all rows matching a condition"
13//! effect as a statement evaluated inside the transaction
14//! (`PlanPredicate`), never as a prepare-time-enumerated row list.
15//! 2. **Affected-row guards** (`PlanStatement::guard`) are attached to the
16//! exact statement they validate, checked in-transaction; a mismatch
17//! fails the op and rolls back the whole unit.
18//!
19//! See `docs/atomic-plan.md` for why guards are per-statement rather than
20//! per-plan.
21
22use uuid::Uuid;
23
24use khive_storage::SqlStatement;
25
26/// One statement in a plan, paired with the guard (if any) that validates
27/// it. **Runner contract:** a present `guard` is checked against the
28/// affected-row count of applying `statement` alone (`SqlWriter::execute`'s
29/// return value), or the result-row count for a GTD no-op read assertion,
30/// not a batch total and not another statement's count.
31/// `guard: None` means prepare made no row-existence
32/// assumption about this particular statement (e.g. a cascade delete that
33/// may legitimately touch zero rows).
34#[derive(Debug, Clone)]
35pub struct PlanStatement {
36 /// The DML, or GTD no-op SELECT assertion, to apply inside the atomic unit.
37 pub statement: SqlStatement,
38 /// The expected-effect guard for `statement`, if prepare's validation
39 /// assumed a target row exists for it.
40 pub guard: Option<AffectedRowGuard>,
41}
42
43/// The predicate a prepare pass validated a plan's target against, replayed
44/// as a statement evaluated **inside** the transaction (ADR-099 D1, rule 1:
45/// "predicate-based plans wherever a write's scope depends on current
46/// state"). Carrying the predicate rather than a prepare-time-enumerated row
47/// list is what lets a later op in the same file (e.g. an intervening
48/// `link`) be visible to this plan's apply.
49#[derive(Debug, Clone)]
50pub struct PlanPredicate {
51 /// Human-readable description of the condition, for diagnostics (e.g.
52 /// `"source_id = :from"`).
53 pub description: String,
54 /// The in-transaction statement whose scope is evaluated against
55 /// current (committed-so-far) state, not prepare-time state.
56 pub statement: SqlStatement,
57}
58
59/// An affected-row guard (ADR-099 D1, rule 2): the row-count prepare assumed
60/// its target write would affect, re-verified in-transaction. A prepare-time
61/// validation is a plan *hypothesis*, never a commitment — if the guard does
62/// not hold at apply time, the op fails inside the atomic unit and the whole
63/// unit rolls back (ADR-099 acceptance criteria: "zero-row apply fails the
64/// unit").
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct AffectedRowGuard {
67 /// Minimum affected-row count for the guard to hold (inclusive).
68 pub expected_min: u64,
69 /// Maximum affected-row count for the guard to hold (inclusive), or
70 /// `None` for "no upper bound" (e.g. a predicate-based rewire that may
71 /// touch any number of rows).
72 pub expected_max: Option<u64>,
73}
74
75impl AffectedRowGuard {
76 /// A guard requiring exactly `n` affected rows (the common case for a
77 /// single-target `update`/`delete`/`link` statement).
78 pub fn exactly(n: u64) -> Self {
79 Self {
80 expected_min: n,
81 expected_max: Some(n),
82 }
83 }
84
85 /// A guard requiring at least one affected row and no upper bound (the
86 /// shape for a predicate-based rewire that may touch any number of rows,
87 /// e.g. `merge`'s edge rewire).
88 pub fn at_least_one() -> Self {
89 Self {
90 expected_min: 1,
91 expected_max: None,
92 }
93 }
94
95 /// Whether an observed affected-row count satisfies this guard.
96 pub fn holds_for(&self, affected: u64) -> bool {
97 affected >= self.expected_min
98 && self.expected_max.map(|max| affected <= max).unwrap_or(true)
99 }
100}
101
102/// A deferred side effect recorded during prepare and run once, after the
103/// atomic unit commits (ADR-099 D1, "post-commit pass"). v1's admissible set
104/// computes no embeddings during prepare (D3's `update`/`merge` caveat), so
105/// the only post-commit effects are reindex kicks computed from the
106/// **committed** row content, plus the GAP-5 addition under B3: the
107/// best-effort GTD lifecycle audit row.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum PostCommitEffect {
110 /// No deferred side effect for this op.
111 None,
112 /// Re-embed and re-warm the given entity's vector row from its committed
113 /// content (ADR-099 D3 `update` caveat: entity name/description change).
114 ReindexEntity { entity_id: Uuid },
115 /// Re-embed and re-warm the given note's vector row from its committed
116 /// content (ADR-099 D3 `update` caveat: note name/content change).
117 ReindexNote { note_id: Uuid, version: i64 },
118 /// Invalidate consumers without recreating explicitly removed vectors.
119 NoteChanged { note_id: Uuid, kind: String },
120 /// Append one `gtd_lifecycle_audit` row for a committed `gtd.transition`
121 /// or `gtd.complete` (ADR-099 B3, GAP-5): canonical `handle_transition`/
122 /// `handle_complete` call `ensure_audit_schema` +
123 /// `write_audit_record_with_status`
124 /// (`khive-pack-gtd::handlers`) as a best-effort side write — a failed
125 /// audit insert must never roll back an already-committed transition.
126 /// Carries exactly the fields the lifecycle-audit helper needs. Applied
127 /// outside `khive-runtime` (crate-direction: `khive-pack-gtd` depends on
128 /// `khive-runtime`, not the other way around) — this crate's own
129 /// `apply_post_commit_effects` treats this variant as a no-op; the
130 /// `kkernel` caller that owns both crates applies it by calling the
131 /// canonical `ensure_audit_schema`/`write_audit_record_with_status`
132 /// functions
133 /// directly.
134 GtdAudit {
135 task_id: Uuid,
136 from_status: String,
137 to_status: String,
138 note: Option<String>,
139 namespace: String,
140 },
141 /// A committed note delete (soft or hard) — fire the pack-installed
142 /// note-mutation hook with the deleted note's kind (#750:
143 /// `DeletePlan` previously carried no `post_commit`
144 /// slot at all, so an atomic note delete never reached
145 /// `KhiveRuntime::fire_note_mutation_hook`, unlike `operations.rs`'s
146 /// `delete_note`, which fires it directly after a successful row
147 /// delete). Entity deletes have no equivalent — the hook system is
148 /// note-only (`khive-pack-memory`'s warm ANN cache is the only
149 /// installed consumer today).
150 NoteDeleted { note_id: Uuid, kind: String },
151}
152
153/// The natural key a committed symmetric edge update's surviving row must
154/// be looked up by (ADR-099 B3, second
155/// half). `khive-db`'s `edge_symmetric_absorb_or_update_inplace_statement`
156/// pair never trusts a prepare-time-computed target id (see that builder's
157/// doc comment); a caller rendering this op's result derives the actual
158/// surviving id by querying `graph_edges`'s own
159/// `UNIQUE(namespace, source_id, target_id, relation)` constraint via
160/// `KhiveRuntime::get_edge_by_natural_key_including_deleted`, which — unlike
161/// `list_edges` — includes soft-deleted rows: the ADR-039 DO NOTHING absorption
162/// arm can commit leaving the surviving canonical row tombstoned
163/// (khive#1213/#1214), and `list_edges` would report "not found" for exactly
164/// that row. Looked up strictly after commit.
165#[derive(Debug, Clone)]
166pub struct EdgeNaturalKey {
167 pub(crate) namespace: String,
168 pub(crate) canon_source_id: Uuid,
169 pub(crate) canon_target_id: Uuid,
170 pub(crate) relation: khive_storage::EdgeRelation,
171}
172
173impl EdgeNaturalKey {
174 /// The namespace containing the surviving edge.
175 pub fn namespace(&self) -> &str {
176 &self.namespace
177 }
178
179 /// The canonical source endpoint of the surviving edge.
180 pub fn canon_source_id(&self) -> Uuid {
181 self.canon_source_id
182 }
183
184 /// The canonical target endpoint of the surviving edge.
185 pub fn canon_target_id(&self) -> Uuid {
186 self.canon_target_id
187 }
188
189 /// The surviving edge's relation.
190 pub fn relation(&self) -> khive_storage::EdgeRelation {
191 self.relation
192 }
193}
194
195/// Write plan for an `update` op (entity or note shape — ADR-099 D3's
196/// `update` caveat covers both substrates the same way: row/FTS DML in the
197/// plan, any reindex deferred to `post_commit`).
198///
199/// Deferred effects are assigned by this crate's prepare pass and cannot be
200/// supplied by callers constructing a plan directly:
201///
202/// ```compile_fail
203/// use khive_runtime::{PostCommitEffect, UpdatePlan};
204/// use uuid::Uuid;
205///
206/// let id = Uuid::nil();
207/// let plan = UpdatePlan {
208/// target_id: id,
209/// statements: Vec::new(),
210/// post_commit: PostCommitEffect::ReindexEntity { entity_id: id },
211/// edge_natural_key: None,
212/// idempotent_noop: false,
213/// };
214/// ```
215///
216/// A plan returned by a prepare function cannot have its validated
217/// statements cleared or replaced before it reaches the runner:
218///
219/// ```compile_fail
220/// use khive_runtime::UpdatePlan;
221///
222/// fn clear_prepared_statements(mut prepared: UpdatePlan) {
223/// prepared.statements.clear();
224/// }
225/// ```
226#[derive(Debug, Clone)]
227pub struct UpdatePlan {
228 /// The id of the entity or note being updated. For a symmetric edge
229 /// update this is the CALLER's requested id — advisory only, never the
230 /// basis for post-commit result rendering (see [`EdgeNaturalKey`]).
231 pub(crate) target_id: Uuid,
232 /// Row + FTS DML statements to apply inside the atomic unit, in order.
233 /// The row-update statement carries the existence guard; any FTS-mirror
234 /// statement that follows it is unguarded (its target row's existence
235 /// was already asserted by the row-update statement's own guard).
236 pub(crate) statements: Vec<PlanStatement>,
237 /// Deferred reindex assigned by the prepare pass when the update changed
238 /// name, description, or content.
239 pub(crate) post_commit: PostCommitEffect,
240 /// `Some` only for a symmetric edge update — the natural key a caller
241 /// must use to derive the committed surviving row post-commit, rather
242 /// than trusting `target_id`. `None` for every other update shape
243 /// (entity, note, non-symmetric edge), where `target_id` alone is
244 /// already an exact, non-advisory identifier.
245 pub(crate) edge_natural_key: Option<EdgeNaturalKey>,
246 /// True for a note patch whose normalized values already equal the
247 /// snapshot. Such plans carry a guarded SELECT assertion and must not
248 /// execute DML that would advance the note revision.
249 pub(crate) idempotent_noop: bool,
250 pub(crate) entity_guard: Option<crate::entity_write::EntityWriteGuard>,
251 pub(crate) note_guard: Option<crate::note_write::NoteWriteGuard>,
252 pub(crate) note_vector_purge: Option<crate::note_write::NoteVectors>,
253 pub(crate) note_embedding_inheritance: Option<crate::note_write::NoteEmbeddingInheritance>,
254 /// Typed kind-owned graph operations, applied after the note statements in
255 /// the same savepoint. Assertions never execute as writes.
256 pub(crate) graph_effects: Vec<NoteUpdateStatement>,
257}
258
259/// Only runtime preparation can append these to an update. Packs return typed
260/// graph requests, not SQL or execution flags.
261#[derive(Debug, Clone)]
262pub(crate) enum NoteUpdateStatement {
263 Write(PlanStatement),
264 Assert(PlanStatement),
265}
266
267impl UpdatePlan {
268 /// The record id supplied to the prepare pass.
269 pub fn target_id(&self) -> Uuid {
270 self.target_id
271 }
272
273 /// The committed lookup key required for a symmetric edge update.
274 pub fn edge_natural_key(&self) -> Option<&EdgeNaturalKey> {
275 self.edge_natural_key.as_ref()
276 }
277
278 /// The potential deferred effect. The writer resolves inherited embedding
279 /// membership before issuing the committed-effects token.
280 pub fn post_commit(&self) -> &PostCommitEffect {
281 &self.post_commit
282 }
283
284 /// Whether this plan is a mutation-free note update assertion.
285 pub fn is_idempotent_noop(&self) -> bool {
286 self.idempotent_noop
287 }
288}
289
290/// Write plan for an `AddEntity` proposal change: a fresh entity row plus its
291/// FTS document in the same atomic unit. Vector indexing remains a deferred
292/// effect because embedding may suspend.
293#[derive(Debug, Clone)]
294pub struct AddEntityPlan {
295 /// The freshly generated id of the entity being created.
296 pub(crate) entity_id: Uuid,
297 /// Row + FTS insert statements to apply inside the atomic unit, in
298 /// order. The row-insert statement carries the existence guard; the
299 /// FTS-insert statement that follows it is unguarded (an ordinary
300 /// `INSERT` into a virtual table with no conflicting row).
301 pub(crate) statements: Vec<PlanStatement>,
302 /// Reindex the committed entity after the transaction closes, as
303 /// assigned by the prepare pass.
304 pub(crate) post_commit: PostCommitEffect,
305}
306
307impl AddEntityPlan {
308 /// The id generated for the prepared entity.
309 pub fn entity_id(&self) -> Uuid {
310 self.entity_id
311 }
312}
313
314/// Write plan for an `AddNote` proposal change: a fresh note row plus its FTS
315/// document in the same atomic unit.
316#[derive(Debug, Clone)]
317pub struct AddNotePlan {
318 /// The freshly generated id of the note being created.
319 pub(crate) note_id: Uuid,
320 pub(crate) note_guard: Option<crate::note_write::NoteWriteGuard>,
321 /// Row + FTS insert statements to apply inside the atomic unit, in
322 /// order, mirroring [`AddEntityPlan::statements`].
323 pub(crate) statements: Vec<PlanStatement>,
324 /// Reindex the committed note after the transaction closes, as assigned
325 /// by the prepare pass.
326 pub(crate) post_commit: PostCommitEffect,
327}
328
329impl AddNotePlan {
330 /// The id generated for the prepared note.
331 pub fn note_id(&self) -> Uuid {
332 self.note_id
333 }
334}
335
336/// Write plan for a `delete` op (soft or hard).
337///
338/// Deferred effects are assigned by this crate's prepare pass and cannot be
339/// attached to a statement-free plan by external callers:
340///
341/// ```compile_fail
342/// use khive_runtime::{DeletePlan, PostCommitEffect};
343/// use uuid::Uuid;
344///
345/// let id = Uuid::nil();
346/// let plan = DeletePlan {
347/// target_id: id,
348/// statements: Vec::new(),
349/// post_commit: PostCommitEffect::NoteDeleted {
350/// note_id: id,
351/// kind: "observation".to_owned(),
352/// },
353/// };
354/// ```
355#[derive(Debug, Clone)]
356pub struct DeletePlan {
357 /// The id of the entity or note being deleted.
358 pub(crate) target_id: Uuid,
359 /// Row DML (and, for a hard delete, incident-edge cascade DML) to apply
360 /// inside the atomic unit, in order. The target-row delete statement
361 /// carries the existence guard; a cascade edge-delete statement (hard
362 /// delete only) is unguarded — it may legitimately affect zero rows if
363 /// the target had no incident edges.
364 pub(crate) statements: Vec<PlanStatement>,
365 /// Deferred note-mutation-hook fire assigned by the prepare pass for a
366 /// note delete (#750 2). `PostCommitEffect::None` for entity and edge
367 /// deletes — the hook system is note-only.
368 pub(crate) post_commit: PostCommitEffect,
369}
370
371impl DeletePlan {
372 /// The record id supplied to the prepare pass.
373 pub fn target_id(&self) -> Uuid {
374 self.target_id
375 }
376}
377
378/// Write plan for a `link` op (create a typed directed edge). Endpoint
379/// existence is checked **structurally**, not via an unanchored plan-level
380/// guard: `statement` is a guarded `INSERT ... SELECT ... WHERE EXISTS`
381/// shape whose `SELECT` re-probes both endpoints inside the transaction, so
382/// the runner's affected-row check on this one statement *is* the
383/// in-transaction existence probe (ADR-099 acceptance criteria's
384/// dangling-edge case — `[delete(X, hard), link(A, X)]` — is closed by this
385/// guard failing once X is gone, regardless of statement ordering
386/// convention).
387#[derive(Debug, Clone)]
388pub struct LinkPlan {
389 pub(crate) source_id: Uuid,
390 pub(crate) target_id: Uuid,
391 /// Guarded edge mutation followed by its event-plane append statements.
392 /// The first statement's affected-row count is also the endpoint and
393 /// compare-and-swap probe; event statements are reached only after it
394 /// succeeds.
395 pub(crate) statements: Vec<PlanStatement>,
396 /// Prepare-time disposition, protected by the first statement's guard.
397 pub(crate) disposition: khive_storage::EdgeUpsertDisposition,
398}
399
400impl LinkPlan {
401 /// The canonical source endpoint used by the prepared statement.
402 pub fn source_id(&self) -> Uuid {
403 self.source_id
404 }
405
406 /// The canonical target endpoint used by the prepared statement.
407 pub fn target_id(&self) -> Uuid {
408 self.target_id
409 }
410
411 /// Whether this atomic link created, replaced, or explicitly resurrected
412 /// its natural-key row.
413 pub fn disposition(&self) -> khive_storage::EdgeUpsertDisposition {
414 self.disposition
415 }
416}
417
418/// Write plan for a `merge` op (deduplicate two entities). Rewires and
419/// lifecycle writes are split into separate fields precisely so a guard is
420/// never ambiguous between them: the edge rewire is **predicate-based**
421/// (ADR-099 D1 rule 1) and may touch zero or many rows depending on earlier
422/// in-file writes, so it is never guarded; the `from`/`into` entity
423/// lifecycle write assumes both rows exist, so it always is.
424#[derive(Debug, Clone)]
425pub struct MergePlan {
426 pub(crate) into_id: Uuid,
427 pub(crate) from_id: Uuid,
428 /// Predicate-based edge-rewire statement(s)
429 /// (`UPDATE graph_edges SET source_id = :into WHERE source_id = :from`-
430 /// shaped), evaluated inside the transaction so they structurally see
431 /// any earlier op's edge writes in the same file (ADR-099 acceptance
432 /// criteria: "merge rewires see earlier in-file writes"). Never
433 /// guarded — a rewire touching zero rows is a legitimate outcome.
434 pub(crate) rewires: Vec<PlanPredicate>,
435 /// The `from` entity's soft-delete/tombstone DML (and any other
436 /// lifecycle write prepare assumed a target row exists for). Always
437 /// guarded — prepare validated `into`/`from` both exist.
438 pub(crate) lifecycle: Vec<PlanStatement>,
439}
440
441impl MergePlan {
442 /// The entity retained by the prepared merge.
443 pub fn into_id(&self) -> Uuid {
444 self.into_id
445 }
446
447 /// The entity retired by the prepared merge.
448 pub fn from_id(&self) -> Uuid {
449 self.from_id
450 }
451}
452
453/// Write plan for a `gtd.transition` op (explicit task lifecycle change).
454#[derive(Debug, Clone)]
455pub struct GtdTransitionPlan {
456 pub(crate) task_id: Uuid,
457 /// Task-property DML to apply inside the atomic unit. Property-only status
458 /// mutation triggers no reindex (ADR-099 D3). The transition
459 /// statement carries the guard over the exact decision snapshot's note
460 /// revision, deletion marker, and semantic status (prepare validated the
461 /// current status and requested transition were legal). For an idempotent
462 /// no-op (`current == target` after `normalize_status`) this contains one
463 /// guarded SELECT assertion that revalidates the prepare snapshot
464 /// under the commit transaction. Atomic v1 and canonical dispatch both
465 /// persist no caller note.
466 pub(crate) statements: Vec<PlanStatement>,
467 /// Explicit result-shape and execution discriminator: true executes the
468 /// snapshot assertion through the writer's read API and guards its result
469 /// count; false executes DML and guards affected rows.
470 pub(crate) idempotent_noop: bool,
471 /// Deferred lifecycle audit row assigned by the prepare pass (GAP-5):
472 /// `PostCommitEffect::None` for the idempotent no-op case. This matches
473 /// canonical dispatch, which emits no audit row for a same-status request.
474 pub(crate) post_commit: PostCommitEffect,
475}
476
477impl GtdTransitionPlan {
478 /// Build a plan from its already-decided statements, no-op classification,
479 /// and audit effect.
480 /// Callers outside this crate (e.g. the atomic-apply layer wiring
481 /// `gtd.transition` into a multi-op unit) cannot construct the struct
482 /// literal directly since its fields are crate-private.
483 pub fn new(
484 task_id: Uuid,
485 statements: Vec<PlanStatement>,
486 idempotent_noop: bool,
487 post_commit: PostCommitEffect,
488 ) -> Self {
489 Self {
490 task_id,
491 statements,
492 idempotent_noop,
493 post_commit,
494 }
495 }
496
497 /// The task targeted by the prepared transition.
498 pub fn task_id(&self) -> Uuid {
499 self.task_id
500 }
501
502 /// Guarded task-property DML for this transition, or the guarded
503 /// mutation-free snapshot assertion for an idempotent no-op.
504 pub fn statements(&self) -> &[PlanStatement] {
505 &self.statements
506 }
507
508 /// Whether prepare classified this transition as already at its target.
509 pub fn is_idempotent_noop(&self) -> bool {
510 self.idempotent_noop
511 }
512
513 /// The deferred lifecycle audit effect assigned by the prepare pass.
514 pub fn post_commit(&self) -> &PostCommitEffect {
515 &self.post_commit
516 }
517}
518
519/// Write plan for a `gtd.complete` op (task lifecycle terminal transition).
520#[derive(Debug, Clone)]
521pub struct GtdCompletePlan {
522 pub(crate) task_id: Uuid,
523 /// Status + `completed_at` property DML to apply inside the atomic unit.
524 /// The statement guards the exact decision snapshot's note revision,
525 /// deletion marker, and semantic status after prepare validated that the
526 /// task was in a completable state.
527 pub(crate) statements: Vec<PlanStatement>,
528 /// Deferred lifecycle audit row assigned by the prepare pass (GAP-5):
529 /// mirrors `handle_complete`'s best-effort
530 /// `write_audit_record_with_status` call.
531 pub(crate) post_commit: PostCommitEffect,
532}
533
534impl GtdCompletePlan {
535 /// Build a plan from its already-decided statements and audit effect.
536 /// Callers outside this crate (e.g. the atomic-apply layer wiring
537 /// `gtd.complete` into a multi-op unit) cannot construct the struct
538 /// literal directly since its fields are crate-private.
539 pub fn new(
540 task_id: Uuid,
541 statements: Vec<PlanStatement>,
542 post_commit: PostCommitEffect,
543 ) -> Self {
544 Self {
545 task_id,
546 statements,
547 post_commit,
548 }
549 }
550
551 /// The task targeted by the prepared completion.
552 pub fn task_id(&self) -> Uuid {
553 self.task_id
554 }
555
556 /// Guarded status + `completed_at` property DML for this completion.
557 pub fn statements(&self) -> &[PlanStatement] {
558 &self.statements
559 }
560
561 /// The deferred lifecycle audit effect assigned by the prepare pass.
562 pub fn post_commit(&self) -> &PostCommitEffect {
563 &self.post_commit
564 }
565}
566
567/// Which governance verb (`propose` / `review` / `withdraw`) a
568/// [`GovernancePlan`] applies.
569#[derive(Debug, Clone, Copy, PartialEq, Eq)]
570pub enum GovernanceOp {
571 Propose,
572 Review,
573 Withdraw,
574}
575
576/// Write plan for a governance op (`propose`, `review`, or `withdraw` — the
577/// event-sourced change-proposal lifecycle, ADR-046).
578#[derive(Debug, Clone)]
579pub struct GovernancePlan {
580 pub(crate) op: GovernanceOp,
581 pub(crate) proposal_id: Uuid,
582 /// Event-log + status DML to apply inside the atomic unit. The
583 /// lifecycle-state-check statement carries the guard (prepare validated
584 /// the proposal was in a state admitting this transition).
585 pub(crate) statements: Vec<PlanStatement>,
586}
587
588impl GovernancePlan {
589 /// The governance operation represented by this plan.
590 pub fn op(&self) -> GovernanceOp {
591 self.op
592 }
593
594 /// The proposal targeted by this plan.
595 pub fn proposal_id(&self) -> Uuid {
596 self.proposal_id
597 }
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603
604 fn stmt(label: &str) -> SqlStatement {
605 SqlStatement {
606 sql: "UPDATE t SET x = ? WHERE id = ?".to_string(),
607 params: vec![],
608 label: Some(label.to_string()),
609 }
610 }
611
612 fn guarded(label: &str, guard: AffectedRowGuard) -> PlanStatement {
613 PlanStatement {
614 statement: stmt(label),
615 guard: Some(guard),
616 }
617 }
618
619 fn unguarded(label: &str) -> PlanStatement {
620 PlanStatement {
621 statement: stmt(label),
622 guard: None,
623 }
624 }
625
626 #[test]
627 fn affected_row_guard_exactly_holds_only_for_n() {
628 let g = AffectedRowGuard::exactly(1);
629 assert!(!g.holds_for(0));
630 assert!(g.holds_for(1));
631 assert!(!g.holds_for(2));
632 }
633
634 #[test]
635 fn affected_row_guard_at_least_one_has_no_upper_bound() {
636 let g = AffectedRowGuard::at_least_one();
637 assert!(!g.holds_for(0));
638 assert!(g.holds_for(1));
639 assert!(g.holds_for(1_000));
640 }
641
642 #[test]
643 fn update_plan_guard_is_anchored_to_the_row_statement_not_the_fts_mirror() {
644 let id = Uuid::new_v4();
645 let plan = UpdatePlan {
646 graph_effects: Vec::new(),
647 entity_guard: None,
648 note_guard: None,
649 note_vector_purge: None,
650 note_embedding_inheritance: None,
651 target_id: id,
652 statements: vec![
653 guarded("update-row", AffectedRowGuard::exactly(1)),
654 unguarded("update-fts-mirror"),
655 ],
656 post_commit: PostCommitEffect::ReindexEntity { entity_id: id },
657 edge_natural_key: None,
658 idempotent_noop: false,
659 };
660 assert_eq!(plan.target_id, id);
661 assert_eq!(plan.statements[0].guard, Some(AffectedRowGuard::exactly(1)));
662 assert_eq!(plan.statements[1].guard, None);
663 assert_eq!(
664 plan.post_commit,
665 PostCommitEffect::ReindexEntity { entity_id: id }
666 );
667 }
668
669 #[test]
670 fn delete_plan_guard_is_anchored_to_the_target_row_not_the_cascade() {
671 let plan = DeletePlan {
672 target_id: Uuid::new_v4(),
673 post_commit: PostCommitEffect::None,
674 statements: vec![
675 guarded("delete-row", AffectedRowGuard::exactly(1)),
676 unguarded("cascade-edges"),
677 ],
678 };
679 let row_guard = plan.statements[0].guard.expect("row delete is guarded");
680 assert!(row_guard.holds_for(1));
681 assert!(!row_guard.holds_for(0));
682 assert_eq!(plan.statements[1].guard, None);
683 }
684
685 #[test]
686 fn link_plan_guard_is_the_endpoint_existence_probe_itself() {
687 let source = Uuid::new_v4();
688 let target = Uuid::new_v4();
689 let plan = LinkPlan {
690 source_id: source,
691 target_id: target,
692 statements: vec![guarded(
693 "insert-edge-where-exists",
694 AffectedRowGuard::exactly(1),
695 )],
696 disposition: khive_storage::EdgeUpsertDisposition::Created,
697 };
698 assert_eq!(plan.source_id, source);
699 assert_eq!(plan.target_id, target);
700 // Dangling-edge acceptance criterion: once an endpoint row is gone,
701 // the guarded INSERT...WHERE EXISTS affects 0 rows and the guard
702 // on *that exact statement* must fail, not silently pass.
703 let guard = plan.statements[0].guard.expect("link insert is guarded");
704 assert!(!guard.holds_for(0));
705 }
706
707 #[test]
708 fn merge_plan_rewires_are_never_guarded_lifecycle_writes_always_are() {
709 let into = Uuid::new_v4();
710 let from = Uuid::new_v4();
711 let rewire = PlanPredicate {
712 description: "source_id = :from".to_string(),
713 statement: SqlStatement {
714 sql: "UPDATE graph_edges SET source_id = ? WHERE source_id = ?".to_string(),
715 params: vec![],
716 label: Some("merge-rewire".to_string()),
717 },
718 };
719 let plan = MergePlan {
720 into_id: into,
721 from_id: from,
722 rewires: vec![rewire],
723 lifecycle: vec![guarded(
724 "tombstone-from-entity",
725 AffectedRowGuard::exactly(1),
726 )],
727 };
728 assert_eq!(plan.into_id, into);
729 assert_eq!(plan.from_id, from);
730 assert_eq!(plan.rewires[0].description, "source_id = :from");
731 // A predicate-based rewire may legitimately touch zero or many rows
732 // depending on how many edges an earlier in-file op inserted — the
733 // type carries no guard field for it at all.
734 let lifecycle_guard = plan.lifecycle[0].guard.expect("lifecycle write is guarded");
735 assert!(!lifecycle_guard.holds_for(0));
736 }
737
738 #[test]
739 fn gtd_transition_plan_triggers_no_reindex_by_construction() {
740 let plan = GtdTransitionPlan {
741 task_id: Uuid::new_v4(),
742 statements: vec![guarded("update-status", AffectedRowGuard::exactly(1))],
743 idempotent_noop: false,
744 post_commit: PostCommitEffect::None,
745 };
746 // A status-only transition never triggers a reindex: the type has no
747 // *reindex* post-commit variant to construct, only the best-effort
748 // `GtdAudit` lifecycle-audit effect, which itself does no embedding work.
749 assert_eq!(plan.statements.len(), 1);
750 assert!(plan.statements[0].guard.is_some());
751 assert_eq!(plan.post_commit, PostCommitEffect::None);
752 }
753
754 #[test]
755 fn gtd_transition_plan_idempotent_noop_carries_guarded_assertion_and_no_audit() {
756 // Atomic v1 keeps current == target mutation-free and audit-free, but
757 // must revalidate the prepare snapshot in a multi-op unit. A canonical
758 // no-op carrying a note has a separate note-event contract.
759 let plan = GtdTransitionPlan {
760 task_id: Uuid::new_v4(),
761 statements: vec![guarded(
762 "assert-noop-snapshot",
763 AffectedRowGuard::exactly(1),
764 )],
765 idempotent_noop: true,
766 post_commit: PostCommitEffect::None,
767 };
768 assert_eq!(plan.statements.len(), 1);
769 assert!(plan.statements[0].guard.is_some());
770 assert!(plan.is_idempotent_noop());
771 assert_eq!(plan.post_commit, PostCommitEffect::None);
772 }
773
774 #[test]
775 fn gtd_transition_plan_carries_gtd_audit_post_commit_effect() {
776 let task_id = Uuid::new_v4();
777 let plan = GtdTransitionPlan {
778 task_id,
779 statements: vec![guarded("update-status", AffectedRowGuard::exactly(1))],
780 idempotent_noop: false,
781 post_commit: PostCommitEffect::GtdAudit {
782 task_id,
783 from_status: "inbox".to_string(),
784 to_status: "next".to_string(),
785 note: Some("handed off".to_string()),
786 namespace: "local".to_string(),
787 },
788 };
789 assert_eq!(
790 plan.post_commit,
791 PostCommitEffect::GtdAudit {
792 task_id,
793 from_status: "inbox".to_string(),
794 to_status: "next".to_string(),
795 note: Some("handed off".to_string()),
796 namespace: "local".to_string(),
797 }
798 );
799 }
800
801 #[test]
802 fn gtd_complete_plan_guards_the_single_snapshot_property_write() {
803 let plan = GtdCompletePlan {
804 task_id: Uuid::new_v4(),
805 statements: vec![guarded(
806 "update-status-and-completed-at",
807 AffectedRowGuard::exactly(1),
808 )],
809 post_commit: PostCommitEffect::None,
810 };
811 assert_eq!(plan.statements.len(), 1);
812 let guard = plan.statements[0]
813 .guard
814 .expect("snapshot property write is guarded");
815 assert!(guard.holds_for(1));
816 }
817
818 #[test]
819 fn governance_plan_covers_all_three_lifecycle_ops() {
820 for op in [
821 GovernanceOp::Propose,
822 GovernanceOp::Review,
823 GovernanceOp::Withdraw,
824 ] {
825 let plan = GovernancePlan {
826 op,
827 proposal_id: Uuid::new_v4(),
828 statements: vec![guarded("governance-event", AffectedRowGuard::exactly(1))],
829 };
830 assert_eq!(plan.op, op);
831 assert!(plan.statements[0].guard.is_some());
832 }
833 }
834
835 #[test]
836 fn add_entity_plan_guard_is_anchored_to_the_row_statement_not_the_fts_mirror() {
837 let id = Uuid::new_v4();
838 let plan = AddEntityPlan {
839 entity_id: id,
840 statements: vec![
841 guarded("entity-insert", AffectedRowGuard::exactly(1)),
842 unguarded("entity-fts-insert"),
843 ],
844 post_commit: PostCommitEffect::ReindexEntity { entity_id: id },
845 };
846 assert_eq!(plan.entity_id, id);
847 assert_eq!(plan.statements[0].guard, Some(AffectedRowGuard::exactly(1)));
848 assert_eq!(plan.statements[1].guard, None);
849 assert_eq!(
850 plan.post_commit,
851 PostCommitEffect::ReindexEntity { entity_id: id }
852 );
853 }
854
855 #[test]
856 fn add_note_plan_guard_is_anchored_to_the_row_statement_not_the_fts_mirror() {
857 let id = Uuid::new_v4();
858 let plan = AddNotePlan {
859 note_guard: None,
860 note_id: id,
861 statements: vec![
862 guarded("note-insert", AffectedRowGuard::exactly(1)),
863 unguarded("note-fts-insert"),
864 ],
865 post_commit: PostCommitEffect::ReindexNote {
866 note_id: id,
867 version: 1,
868 },
869 };
870 assert_eq!(plan.note_id, id);
871 assert_eq!(plan.statements[0].guard, Some(AffectedRowGuard::exactly(1)));
872 assert_eq!(plan.statements[1].guard, None);
873 assert_eq!(
874 plan.post_commit,
875 PostCommitEffect::ReindexNote {
876 note_id: id,
877 version: 1
878 }
879 );
880 }
881
882 #[test]
883 fn plans_are_plain_data_no_async_no_embedding() {
884 // Documents a compile-time property: every plan type above derives
885 // only Debug/Clone/PartialEq, never Future or an embedding-provider
886 // trait. Plans must stay inert data: flag any edit that adds an
887 // async method or embedding-model field to one of these types.
888 let _ = PostCommitEffect::None;
889 }
890}