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 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}