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