Skip to main content

khive_runtime/
atomic_plan.rs

1//! ADR-099 (cross-op atomicity for bulk apply) — prepared write-plan types.
2//!
3//! Async prepare materializes a synchronous write plan outside any
4//! transaction; commit later applies its statements as DML under a per-op
5//! SAVEPOINT. This module defines the plan *shapes* only, one family per
6//! admissible verb group (`update`, `delete`, `link`, `merge`,
7//! `gtd.transition`, `gtd.complete`, the governance verbs) — not yet wired
8//! into a live handler or the dispatch path. 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}