Skip to main content

khive_runtime/
atomic_runner.rs

1//! ADR-099 migration step 3 (sub-slice B2) — the atomic runner: the
2//! synchronous commit-pass mechanism that applies a caller-supplied sequence
3//! of prepared write plans ([`crate::atomic_plan`]) as **ONE**
4//! [`SqlAccess::atomic_unit`], under a per-op `SAVEPOINT`, committing every
5//! plan or rolling back the whole unit.
6//!
7//! ADR-099 B3 caller: `kkernel exec --ops-file --atomic` — async prepare,
8//! then this runner's one synchronous commit pass, then async post-commit
9//! effects. Runtime callers also supply prepared plans directly (hard-delete
10//! in `operations.rs`). See `docs/api/atomic_runner.md` for the full
11//! three-phase shape (ADR-099 D1).
12//!
13//! # Safety: suspend-free invariant
14//!
15//! [`run_atomic_unit`] is the one place in this crate that builds an
16//! [`AtomicUnitOp`] closure for [`SqlAccess::atomic_unit`], whose contract
17//! requires the closure's future to resolve on its first poll — synchronous
18//! DML against the provided `&mut dyn SqlWriter` only, never a suspending
19//! `.await`. Every statement driven here comes from
20//! `AtomicOpPlan::plan_statements`, which can only ever produce
21//! [`PlanStatement`]s (plain parameterized SQL), so no code path in this
22//! module can hand `atomic_unit` a suspending future. The paired
23//! suspend-trap tests at the bottom of this file check both the happy-path
24//! (real commit pass resolves on first poll) and the misuse-is-caught case
25//! (a hand-built suspending closure fails loudly through the same seam). See
26//! `docs/api/atomic_runner.md#suspend-free-invariant` for the full argument.
27
28use std::any::Any;
29use std::sync::{Arc, Mutex};
30
31use khive_storage::{AtomicUnitOp, SqlAccess, SqlStatement, SqlWriter, StorageError};
32
33use crate::atomic_plan::{
34    AddEntityPlan, AddNotePlan, AffectedRowGuard, DeletePlan, GovernancePlan, GtdCompletePlan,
35    GtdTransitionPlan, LinkPlan, MergePlan, PlanStatement, PostCommitEffect, UpdatePlan,
36};
37
38/// One admissible op's prepared write plan (ADR-099 D3's v1 admissible verb
39/// groups), ready for the commit pass. This is the `Vec<AtomicOpPlan>` shape
40/// [`run_atomic_unit`] consumes — the runner is agnostic to which verb
41/// produced a given plan; it only needs each plan's ordered statements
42/// (`plan_statements`) and any deferred post-commit effect
43/// (`post_commit_effect`).
44#[derive(Debug, Clone)]
45pub enum AtomicOpPlan {
46    AddEntity(AddEntityPlan),
47    AddNote(AddNotePlan),
48    Update(UpdatePlan),
49    Delete(DeletePlan),
50    Link(LinkPlan),
51    Merge(MergePlan),
52    GtdTransition(GtdTransitionPlan),
53    GtdComplete(GtdCompletePlan),
54    Governance(GovernancePlan),
55}
56
57impl AtomicOpPlan {
58    /// This op's statements, in the order the commit pass must apply them.
59    ///
60    /// For [`AtomicOpPlan::Merge`], the predicate-based rewires
61    /// (`MergePlan::rewires`) come first, converted to unguarded
62    /// [`PlanStatement`]s (ADR-099 D1 rule 1 — a rewire may legitimately
63    /// touch zero or many rows and is never guarded), followed by the
64    /// always-guarded lifecycle writes (`MergePlan::lifecycle`). Every other
65    /// variant already carries a flat `Vec<PlanStatement>` in apply order.
66    fn plan_statements(&self) -> Vec<PlanStatement> {
67        match self {
68            AtomicOpPlan::AddEntity(p) => p.statements.clone(),
69            AtomicOpPlan::AddNote(p) => p.statements.clone(),
70            AtomicOpPlan::Update(p) => p.statements.clone(),
71            AtomicOpPlan::Delete(p) => p.statements.clone(),
72            AtomicOpPlan::Link(p) => vec![p.statement.clone()],
73            AtomicOpPlan::Merge(p) => {
74                let mut statements: Vec<PlanStatement> = p
75                    .rewires
76                    .iter()
77                    .map(|rewire| PlanStatement {
78                        statement: rewire.statement.clone(),
79                        guard: None,
80                    })
81                    .collect();
82                statements.extend(p.lifecycle.clone());
83                statements
84            }
85            AtomicOpPlan::GtdTransition(p) => p.statements.clone(),
86            AtomicOpPlan::GtdComplete(p) => p.statements.clone(),
87            AtomicOpPlan::Governance(p) => p.statements.clone(),
88        }
89    }
90
91    /// The deferred post-commit effect this op's plan recorded, if any.
92    ///
93    /// [`UpdatePlan`] carries a [`PostCommitEffect`] field for the `update`
94    /// reindex caveat (ADR-099 D3). Under B3 (GAP-5),
95    /// [`GtdTransitionPlan`]/[`GtdCompletePlan`] also carry one, for the
96    /// best-effort lifecycle audit row. Since #750, [`DeletePlan`] also
97    /// carries one, for the note-mutation
98    /// hook fire on a committed note delete. Every other admissible verb's
99    /// apply is pure DML with no deferred side effect. `merge`'s existing
100    /// post-transaction vector re-insert (D3: "merge already performs its
101    /// vector re-insert after its transaction") is the handler's own
102    /// pre-existing behavior outside this plan shape — [`MergePlan`] itself
103    /// carries no `post_commit` field, so this runner records none for it.
104    fn post_commit_effect(&self) -> Option<PostCommitEffect> {
105        match self {
106            AtomicOpPlan::AddEntity(p) if p.post_commit != PostCommitEffect::None => {
107                Some(p.post_commit.clone())
108            }
109            AtomicOpPlan::AddNote(p) if p.post_commit != PostCommitEffect::None => {
110                Some(p.post_commit.clone())
111            }
112            AtomicOpPlan::Update(p) if p.post_commit != PostCommitEffect::None => {
113                Some(p.post_commit.clone())
114            }
115            AtomicOpPlan::Delete(p) if p.post_commit != PostCommitEffect::None => {
116                Some(p.post_commit.clone())
117            }
118            AtomicOpPlan::GtdTransition(p) if p.post_commit != PostCommitEffect::None => {
119                Some(p.post_commit.clone())
120            }
121            AtomicOpPlan::GtdComplete(p) if p.post_commit != PostCommitEffect::None => {
122                Some(p.post_commit.clone())
123            }
124            _ => None,
125        }
126    }
127}
128
129/// Why a single op's plan failed inside the commit pass (ADR-099 acceptance
130/// criteria: "the failing op index is recorded").
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum AtomicOpFailure {
133    /// The statement executed without a SQL error, but its affected-row
134    /// count did not satisfy the guard prepare attached to it (ADR-099 D1
135    /// rule 2 — "a prepare-time validation is a plan hypothesis, re-verified
136    /// under the transaction, never a commitment"). This is the shape both
137    /// the dangling-edge and zero-row acceptance criteria trip.
138    GuardFailed {
139        statement_label: Option<String>,
140        expected: AffectedRowGuard,
141        observed: u64,
142    },
143    /// The statement itself returned a storage error (a genuine SQL
144    /// failure — malformed SQL, a constraint violation not modeled as a
145    /// guard, etc.), not a guard mismatch.
146    SqlError {
147        statement_label: Option<String>,
148        message: String,
149    },
150}
151
152/// The whole-unit outcome of a completed [`run_atomic_unit`] call — the
153/// commit pass ran to a clean, distinguishable verdict (never returned for
154/// a seam-level failure; see [`AtomicRunnerError`] for that case).
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum AtomicRunOutcome {
157    /// Every op's plan applied and the unit committed. Carries every op's
158    /// deferred post-commit effect, in op order. Draining this list
159    /// (running the actual reindex/embedding side effects) is phase 3 of
160    /// ADR-099 D1 and is out of scope for B2 — **the B3 wiring point**: a
161    /// production caller runs these after `run_atomic_unit` returns, outside
162    /// any transaction; a test caller in this file only asserts on the
163    /// list's contents.
164    Committed { post_commit: Vec<PostCommitEffect> },
165    /// The op at `failed_op_index` failed; the whole unit rolled back
166    /// (ADR-099 D1: "the whole unit rolls back and the failing op index is
167    /// recorded"). No op's writes are present in the database after this
168    /// outcome — including any earlier op whose own `SAVEPOINT` had already
169    /// been `RELEASE`d, because `RELEASE` only merges a savepoint's changes
170    /// into its parent transaction; it does not commit them independently
171    /// of the outer `atomic_unit` transaction's own COMMIT/ROLLBACK.
172    RolledBack {
173        failed_op_index: usize,
174        failure: AtomicOpFailure,
175    },
176}
177
178/// A failure of the `atomic_unit` seam itself — the storage layer refused
179/// or could not complete the call at all (read-only backend, no async
180/// runtime for the writer-task lookup, or an [`AtomicUnitOp`] that violated
181/// the suspend-free invariant and got caught by `block_on_sync`). Never
182/// returned for an ordinary op-level guard or SQL failure inside the unit —
183/// those surface as `AtomicRunOutcome::RolledBack`, not this error.
184#[derive(Debug)]
185pub struct AtomicRunnerError(pub StorageError);
186
187impl std::fmt::Display for AtomicRunnerError {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        write!(f, "atomic_unit seam failure: {}", self.0)
190    }
191}
192
193impl std::error::Error for AtomicRunnerError {
194    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
195        Some(&self.0)
196    }
197}
198
199fn raw_stmt(sql: String) -> SqlStatement {
200    SqlStatement {
201        sql,
202        params: vec![],
203        label: None,
204    }
205}
206
207async fn begin_savepoint(writer: &mut dyn SqlWriter, name: &str) -> Result<(), StorageError> {
208    writer
209        .execute(raw_stmt(format!("SAVEPOINT {name}")))
210        .await
211        .map(|_| ())
212}
213
214async fn release_savepoint(writer: &mut dyn SqlWriter, name: &str) -> Result<(), StorageError> {
215    writer
216        .execute(raw_stmt(format!("RELEASE {name}")))
217        .await
218        .map(|_| ())
219}
220
221async fn rollback_to_savepoint(writer: &mut dyn SqlWriter, name: &str) -> Result<(), StorageError> {
222    writer
223        .execute(raw_stmt(format!("ROLLBACK TO {name}")))
224        .await
225        .map(|_| ())
226}
227
228/// Apply one op's plan statements in order, checking each guarded
229/// statement's affected-row count before moving to the next (ADR-099 D1
230/// rule 2). Returns on the first statement that either errors or fails its
231/// guard — never applies a later statement once an earlier one in the same
232/// plan has failed.
233async fn apply_plan(
234    writer: &mut dyn SqlWriter,
235    plan: &AtomicOpPlan,
236) -> Result<(), AtomicOpFailure> {
237    for stmt in plan.plan_statements() {
238        let label = stmt.statement.label.clone();
239        let affected =
240            writer
241                .execute(stmt.statement)
242                .await
243                .map_err(|e| AtomicOpFailure::SqlError {
244                    statement_label: label.clone(),
245                    message: e.to_string(),
246                })?;
247        if let Some(guard) = stmt.guard {
248            if !guard.holds_for(affected) {
249                return Err(AtomicOpFailure::GuardFailed {
250                    statement_label: label,
251                    expected: guard,
252                    observed: affected,
253                });
254            }
255        }
256    }
257    Ok(())
258}
259
260/// Run `plans` as ONE atomic unit (ADR-099 D1 commit pass): open a single
261/// [`SqlAccess::atomic_unit`], apply every plan's statements under a named
262/// `SAVEPOINT` (`adr099_atomic_op_<n>`), and commit all or roll back all.
263///
264/// **This is the seam the atomic-unit suspend-free invariant governs (see
265/// the module doc comment above).** The closure built here drives only
266/// `AtomicOpPlan::plan_statements` — plain DML — against the writer
267/// `atomic_unit` hands it; it issues no transaction control of its own
268/// (`BEGIN`/`COMMIT`/`ROLLBACK` are owned entirely by `atomic_unit`, exactly
269/// like the existing `execute_batch` contract) beyond the per-op
270/// `SAVEPOINT`/`RELEASE`/`ROLLBACK TO` statements, which are themselves
271/// synchronous DML from SQLite's perspective, not transaction boundaries the
272/// storage layer needs to track.
273///
274/// On the first op whose plan fails (a guard mismatch or a genuine SQL
275/// error), this function does **not** propagate a generic storage error for
276/// that case: it unwinds just that op's own `SAVEPOINT` (best-effort — the
277/// outer `atomic_unit` transaction is rolled back in full regardless, per
278/// ADR-099 D1) and returns `Ok(AtomicRunOutcome::RolledBack { .. })` naming
279/// the failing op and why. [`AtomicRunnerError`] is reserved for a failure
280/// of the `atomic_unit` seam itself — the storage layer refusing or being
281/// unable to run the call at all.
282pub async fn run_atomic_unit(
283    access: &dyn SqlAccess,
284    plans: Vec<AtomicOpPlan>,
285) -> Result<AtomicRunOutcome, AtomicRunnerError> {
286    let failure_slot: Arc<Mutex<Option<(usize, AtomicOpFailure)>>> = Arc::new(Mutex::new(None));
287    let failure_slot_for_closure = Arc::clone(&failure_slot);
288
289    let op: AtomicUnitOp = Box::new(move |writer| {
290        Box::pin(async move {
291            let mut post_commit = Vec::new();
292            for (op_index, plan) in plans.iter().enumerate() {
293                let savepoint = format!("adr099_atomic_op_{op_index}");
294                begin_savepoint(writer, &savepoint).await?;
295                match apply_plan(writer, plan).await {
296                    Ok(()) => {
297                        release_savepoint(writer, &savepoint).await?;
298                        if let Some(effect) = plan.post_commit_effect() {
299                            post_commit.push(effect);
300                        }
301                    }
302                    Err(failure) => {
303                        // Best-effort unwind of just this op's own partial
304                        // DML. The outer `atomic_unit` transaction is rolled
305                        // back in full once this closure returns `Err`
306                        // regardless (ADR-099 D1: "the whole unit rolls
307                        // back") — a failure in this cleanup step is not
308                        // itself a correctness gap in the no-partial-state
309                        // guarantee, only a diagnostic nicety.
310                        let _ = rollback_to_savepoint(writer, &savepoint).await;
311                        let _ = release_savepoint(writer, &savepoint).await;
312                        *failure_slot_for_closure
313                            .lock()
314                            .expect("atomic runner failure slot poisoned") =
315                            Some((op_index, failure));
316                        return Err(StorageError::Internal(format!(
317                            "ADR-099 atomic unit aborted at op {op_index}"
318                        )));
319                    }
320                }
321            }
322            Ok(Box::new(post_commit) as Box<dyn Any + Send>)
323        })
324    });
325
326    match access.atomic_unit(op).await {
327        Ok(boxed) => {
328            let post_commit = *boxed.downcast::<Vec<PostCommitEffect>>().expect(
329                "run_atomic_unit's own closure always returns Box<Vec<PostCommitEffect>> on Ok",
330            );
331            Ok(AtomicRunOutcome::Committed { post_commit })
332        }
333        Err(storage_err) => {
334            let recorded = failure_slot
335                .lock()
336                .expect("atomic runner failure slot poisoned")
337                .take();
338            match recorded {
339                Some((failed_op_index, failure)) => Ok(AtomicRunOutcome::RolledBack {
340                    failed_op_index,
341                    failure,
342                }),
343                None => Err(AtomicRunnerError(storage_err)),
344            }
345        }
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    use std::sync::Arc as StdArc;
354
355    use khive_db::{ConnectionPool, PoolConfig, SqlBridge};
356    use khive_storage::types::{SqlValue, StorageResult as StorageResultAlias};
357    use uuid::Uuid;
358
359    /// A scratch pool wired exactly like the daemon.rs / sql_bridge.rs
360    /// tests above it: file-backed (atomic_unit's single-writer path is
361    /// only reachable file-backed), `write_queue_enabled: true` so
362    /// `atomic_unit` routes through the real `WriterTask` + `block_on_sync`
363    /// seam rather than the flag-off manual-transaction fallback — the
364    /// suspend-trap contract only fires on this path.
365    fn scratch_pool(name: &str) -> StdArc<ConnectionPool> {
366        let dir = tempfile::tempdir().expect("tempdir");
367        let path = dir.path().join(format!("{name}.db"));
368        let pool = StdArc::new(
369            ConnectionPool::new(PoolConfig {
370                path: Some(path),
371                write_queue_enabled: true,
372                ..PoolConfig::default()
373            })
374            .expect("pool open"),
375        );
376        // Leak the tempdir so the file lives for the pool's lifetime within
377        // one test function — every test here is single-scoped and short.
378        std::mem::forget(dir);
379        pool
380    }
381
382    /// Minimal real schema slice (`entities`, `graph_edges`) — copied from
383    /// `crates/khive-db/sql/entities-ddl.sql` / `graph-ddl.sql` rather than
384    /// invented ad hoc, since the dangling-edge and merge-rewire tests below
385    /// depend on `graph_edges` having no foreign-key enforcement (ADR-099
386    /// D1: "`graph_edges` has no foreign-key enforcement ... SQLite will
387    /// happily commit the inconsistency" absent the guard/predicate rules
388    /// this runner implements).
389    fn seed_schema(pool: &ConnectionPool) {
390        let writer = pool.try_writer().expect("writer");
391        writer
392            .conn()
393            .execute_batch(
394                "CREATE TABLE entities (
395                    id             TEXT PRIMARY KEY,
396                    namespace      TEXT NOT NULL,
397                    kind           TEXT NOT NULL,
398                    entity_type    TEXT,
399                    name           TEXT NOT NULL,
400                    description    TEXT,
401                    properties     TEXT,
402                    tags           TEXT NOT NULL DEFAULT '[]',
403                    created_at     INTEGER NOT NULL,
404                    updated_at     INTEGER NOT NULL,
405                    deleted_at     INTEGER,
406                    merged_into    TEXT,
407                    merge_event_id TEXT
408                );
409                CREATE TABLE graph_edges (
410                    namespace      TEXT NOT NULL,
411                    id             TEXT NOT NULL,
412                    source_id      TEXT NOT NULL,
413                    target_id      TEXT NOT NULL,
414                    relation       TEXT NOT NULL,
415                    weight         REAL NOT NULL DEFAULT 1.0,
416                    created_at     INTEGER NOT NULL,
417                    updated_at     INTEGER NOT NULL,
418                    deleted_at     INTEGER,
419                    metadata       TEXT,
420                    target_backend TEXT,
421                    PRIMARY KEY (namespace, id)
422                );",
423            )
424            .expect("seed schema");
425    }
426
427    fn insert_entity(pool: &ConnectionPool, id: Uuid, name: &str) {
428        let writer = pool.try_writer().expect("writer");
429        writer
430            .conn()
431            .execute(
432                "INSERT INTO entities \
433                 (id, namespace, kind, name, created_at, updated_at) \
434                 VALUES (?1, 'local', 'concept', ?2, 0, 0)",
435                rusqlite::params![id.to_string(), name],
436            )
437            .expect("insert entity");
438    }
439
440    fn entity_exists(pool: &ConnectionPool, id: Uuid) -> bool {
441        let writer = pool.try_writer().expect("writer");
442        let count: i64 = writer
443            .conn()
444            .query_row(
445                "SELECT COUNT(*) FROM entities WHERE id = ?1 AND deleted_at IS NULL",
446                rusqlite::params![id.to_string()],
447                |row| row.get(0),
448            )
449            .expect("query entity");
450        count > 0
451    }
452
453    fn edge_count(pool: &ConnectionPool, source: Uuid, target: Uuid) -> i64 {
454        let writer = pool.try_writer().expect("writer");
455        writer
456            .conn()
457            .query_row(
458                "SELECT COUNT(*) FROM graph_edges \
459                 WHERE source_id = ?1 AND target_id = ?2 AND deleted_at IS NULL",
460                rusqlite::params![source.to_string(), target.to_string()],
461                |row| row.get(0),
462            )
463            .expect("query edge")
464    }
465
466    fn entities_snapshot(pool: &ConnectionPool) -> Vec<String> {
467        let writer = pool.try_writer().expect("writer");
468        let mut stmt = writer
469            .conn()
470            .prepare("SELECT id, name, deleted_at FROM entities ORDER BY id")
471            .expect("prepare snapshot");
472        let rows = stmt
473            .query_map([], |row| {
474                let id: String = row.get(0)?;
475                let name: String = row.get(1)?;
476                let deleted_at: Option<i64> = row.get(2)?;
477                Ok(format!("{id}:{name}:{deleted_at:?}"))
478            })
479            .expect("query snapshot");
480        rows.collect::<Result<Vec<_>, _>>()
481            .expect("collect snapshot")
482    }
483
484    fn delete_plan(id: Uuid, label: &str) -> AtomicOpPlan {
485        AtomicOpPlan::Delete(DeletePlan {
486            target_id: id,
487            statements: vec![PlanStatement {
488                statement: SqlStatement {
489                    sql: "DELETE FROM entities WHERE id = ?1 AND deleted_at IS NULL".to_string(),
490                    params: vec![SqlValue::Text(id.to_string())],
491                    label: Some(label.to_string()),
492                },
493                guard: Some(AffectedRowGuard::exactly(1)),
494            }],
495            post_commit: PostCommitEffect::None,
496        })
497    }
498
499    fn rename_plan(id: Uuid, new_name: &str, label: &str) -> AtomicOpPlan {
500        AtomicOpPlan::Update(UpdatePlan {
501            target_id: id,
502            statements: vec![PlanStatement {
503                statement: SqlStatement {
504                    sql: "UPDATE entities SET name = ?1, updated_at = 1 \
505                          WHERE id = ?2 AND deleted_at IS NULL"
506                        .to_string(),
507                    params: vec![
508                        SqlValue::Text(new_name.to_string()),
509                        SqlValue::Text(id.to_string()),
510                    ],
511                    label: Some(label.to_string()),
512                },
513                guard: Some(AffectedRowGuard::exactly(1)),
514            }],
515            post_commit: PostCommitEffect::None,
516            edge_natural_key: None,
517        })
518    }
519
520    /// The guarded `INSERT ... SELECT ... WHERE EXISTS` shape `LinkPlan`
521    /// documents: the affected-row count of this ONE statement doubles as
522    /// the in-transaction endpoint-existence probe (ADR-099 acceptance
523    /// criteria: dangling-edge).
524    fn link_plan(edge_id: Uuid, source: Uuid, target: Uuid) -> AtomicOpPlan {
525        AtomicOpPlan::Link(LinkPlan {
526            source_id: source,
527            target_id: target,
528            statement: PlanStatement {
529                statement: SqlStatement {
530                    sql: "INSERT INTO graph_edges \
531                          (namespace, id, source_id, target_id, relation, created_at, updated_at) \
532                          SELECT 'local', ?1, ?2, ?3, 'annotates', 0, 0 \
533                          WHERE EXISTS (SELECT 1 FROM entities WHERE id = ?2 AND deleted_at IS NULL) \
534                            AND EXISTS (SELECT 1 FROM entities WHERE id = ?3 AND deleted_at IS NULL)"
535                        .to_string(),
536                    params: vec![
537                        SqlValue::Text(edge_id.to_string()),
538                        SqlValue::Text(source.to_string()),
539                        SqlValue::Text(target.to_string()),
540                    ],
541                    label: Some("insert-edge-where-exists".to_string()),
542                },
543                guard: Some(AffectedRowGuard::exactly(1)),
544            },
545        })
546    }
547
548    fn merge_plan(into_id: Uuid, from_id: Uuid) -> AtomicOpPlan {
549        AtomicOpPlan::Merge(MergePlan {
550            into_id,
551            from_id,
552            rewires: vec![crate::atomic_plan::PlanPredicate {
553                description: "source_id = :from".to_string(),
554                statement: SqlStatement {
555                    sql: "UPDATE graph_edges SET source_id = ?1, updated_at = 1 \
556                          WHERE source_id = ?2"
557                        .to_string(),
558                    params: vec![
559                        SqlValue::Text(into_id.to_string()),
560                        SqlValue::Text(from_id.to_string()),
561                    ],
562                    label: Some("merge-rewire".to_string()),
563                },
564            }],
565            lifecycle: vec![PlanStatement {
566                statement: SqlStatement {
567                    sql: "UPDATE entities SET deleted_at = 1, merged_into = ?1 \
568                          WHERE id = ?2 AND deleted_at IS NULL"
569                        .to_string(),
570                    params: vec![
571                        SqlValue::Text(into_id.to_string()),
572                        SqlValue::Text(from_id.to_string()),
573                    ],
574                    label: Some("tombstone-from-entity".to_string()),
575                },
576                guard: Some(AffectedRowGuard::exactly(1)),
577            }],
578        })
579    }
580
581    // ------------------------------------------------------------------
582    // 1. Rollback end-to-end
583    // ------------------------------------------------------------------
584
585    #[tokio::test]
586    async fn rollback_end_to_end_leaves_zero_partial_state() {
587        let pool = scratch_pool("rollback_end_to_end");
588        seed_schema(&pool);
589        let a = Uuid::new_v4();
590        let b = Uuid::new_v4();
591        insert_entity(&pool, a, "alpha");
592        insert_entity(&pool, b, "bravo");
593        let before = entities_snapshot(&pool);
594
595        let bridge = SqlBridge::new(StdArc::clone(&pool), true);
596        let plans = vec![
597            rename_plan(a, "alpha-renamed", "rename-a"),
598            // Induced failure: b does not exist under this id, so the
599            // guard (expects exactly 1 affected row) fails.
600            delete_plan(Uuid::new_v4(), "delete-nonexistent"),
601            rename_plan(b, "bravo-renamed", "rename-b"),
602        ];
603
604        let outcome = run_atomic_unit(&bridge, plans).await.expect("seam call ok");
605        match outcome {
606            AtomicRunOutcome::RolledBack {
607                failed_op_index,
608                failure,
609            } => {
610                assert_eq!(failed_op_index, 1);
611                assert!(matches!(failure, AtomicOpFailure::GuardFailed { .. }));
612            }
613            other => panic!("expected RolledBack, got {other:?}"),
614        }
615
616        let after = entities_snapshot(&pool);
617        assert_eq!(
618            before, after,
619            "a mid-unit failure must leave the database byte-for-byte unchanged"
620        );
621    }
622
623    // ------------------------------------------------------------------
624    // 2. Suspend-trap paired: the runner's happy path resolves on first
625    //    poll (commits); a hand-built suspending closure through the SAME
626    //    `atomic_unit` seam fails loudly instead of silently succeeding.
627    // ------------------------------------------------------------------
628
629    #[tokio::test]
630    async fn commit_pass_resolves_on_first_poll_and_commits() {
631        let pool = scratch_pool("suspend_trap_happy_path");
632        seed_schema(&pool);
633        let a = Uuid::new_v4();
634        insert_entity(&pool, a, "alpha");
635
636        let bridge = SqlBridge::new(StdArc::clone(&pool), true);
637        let plans = vec![rename_plan(a, "alpha-v2", "rename-a")];
638
639        let outcome = run_atomic_unit(&bridge, plans).await.expect("seam call ok");
640        assert!(
641            matches!(outcome, AtomicRunOutcome::Committed { .. }),
642            "the real commit-pass closure (SAVEPOINT + guarded DML only) must \
643             resolve on block_on_sync's first poll and commit: {outcome:?}"
644        );
645
646        let writer = pool.try_writer().expect("writer");
647        let name: String = writer
648            .conn()
649            .query_row(
650                "SELECT name FROM entities WHERE id = ?1",
651                rusqlite::params![a.to_string()],
652                |row| row.get(0),
653            )
654            .expect("query renamed entity");
655        assert_eq!(name, "alpha-v2");
656    }
657
658    #[tokio::test]
659    async fn hand_built_suspending_closure_fails_loudly_through_the_same_seam() {
660        // The exact misuse the module-doc "suspend-trap" promise guards
661        // against: a closure sent through this crate's OWN integration of
662        // `SqlAccess::atomic_unit` (the same `bridge` type `run_atomic_unit`
663        // uses) that reaches a real suspension point instead of staying
664        // synchronous DML — a stand-in for "a future edit admits a verb
665        // whose apply forgot to hoist its embedding out of the transaction"
666        // (ADR-099 acceptance criteria). `tokio::task::yield_now` returns
667        // `Poll::Pending` on its first poll by construction, so this is a
668        // real suspension, not `std::future::pending`'s permanent one.
669        let pool = scratch_pool("suspend_trap_misuse");
670        seed_schema(&pool);
671
672        let bridge = SqlBridge::new(StdArc::clone(&pool), true);
673        let suspending_op: AtomicUnitOp = Box::new(|_writer| {
674            Box::pin(async move {
675                tokio::task::yield_now().await;
676                Ok(Box::new(()) as Box<dyn Any + Send>)
677            })
678        });
679
680        let result: StorageResultAlias<Box<dyn Any + Send>> =
681            khive_storage::SqlAccess::atomic_unit(&bridge, suspending_op).await;
682        assert!(
683            result.is_err(),
684            "a closure that suspends on first poll must fail loudly through \
685             `atomic_unit`, never silently succeed or wedge; got {result:?}"
686        );
687    }
688
689    // ------------------------------------------------------------------
690    // 3. Staleness both directions
691    // ------------------------------------------------------------------
692
693    #[tokio::test]
694    async fn concurrent_mutation_between_prepare_and_apply_trips_guard_and_rolls_back() {
695        let pool = scratch_pool("staleness_tripped");
696        seed_schema(&pool);
697        let a = Uuid::new_v4();
698        insert_entity(&pool, a, "alpha");
699
700        // Plan prepared against pre-transaction state (a exists).
701        let plan = rename_plan(a, "alpha-v2", "rename-a");
702
703        // Concurrent mutation between prepare and apply: a is deleted by a
704        // separate writer before the commit pass runs.
705        {
706            let writer = pool.try_writer().expect("writer");
707            writer
708                .conn()
709                .execute(
710                    "DELETE FROM entities WHERE id = ?1",
711                    rusqlite::params![a.to_string()],
712                )
713                .expect("concurrent delete");
714        }
715
716        let bridge = SqlBridge::new(StdArc::clone(&pool), true);
717        let outcome = run_atomic_unit(&bridge, vec![plan])
718            .await
719            .expect("seam call ok");
720        match outcome {
721            AtomicRunOutcome::RolledBack {
722                failed_op_index,
723                failure,
724            } => {
725                assert_eq!(failed_op_index, 0);
726                assert!(matches!(
727                    failure,
728                    AtomicOpFailure::GuardFailed { observed: 0, .. }
729                ));
730            }
731            other => panic!("expected RolledBack from the stale guard, got {other:?}"),
732        }
733    }
734
735    #[tokio::test]
736    async fn no_mutation_twin_commits() {
737        let pool = scratch_pool("staleness_untripped");
738        seed_schema(&pool);
739        let a = Uuid::new_v4();
740        insert_entity(&pool, a, "alpha");
741
742        let plan = rename_plan(a, "alpha-v2", "rename-a");
743        let bridge = SqlBridge::new(StdArc::clone(&pool), true);
744        let outcome = run_atomic_unit(&bridge, vec![plan])
745            .await
746            .expect("seam call ok");
747        assert!(matches!(outcome, AtomicRunOutcome::Committed { .. }));
748        assert!(entity_exists(&pool, a));
749    }
750
751    // ------------------------------------------------------------------
752    // 4. Dangling-edge
753    // ------------------------------------------------------------------
754
755    #[tokio::test]
756    async fn dangling_edge_from_delete_then_link_rolls_back_whole_unit() {
757        let pool = scratch_pool("dangling_edge");
758        seed_schema(&pool);
759        let x = Uuid::new_v4();
760        let a = Uuid::new_v4();
761        insert_entity(&pool, x, "x");
762        insert_entity(&pool, a, "a");
763        let before = entities_snapshot(&pool);
764
765        let bridge = SqlBridge::new(StdArc::clone(&pool), true);
766        let edge_id = Uuid::new_v4();
767        let plans = vec![delete_plan(x, "delete-x"), link_plan(edge_id, a, x)];
768
769        let outcome = run_atomic_unit(&bridge, plans).await.expect("seam call ok");
770        match outcome {
771            AtomicRunOutcome::RolledBack {
772                failed_op_index,
773                failure,
774            } => {
775                assert_eq!(
776                    failed_op_index, 1,
777                    "delete(x) succeeds; link(a, x) is the op whose \
778                     endpoint-existence guard fails once x is gone"
779                );
780                assert!(matches!(
781                    failure,
782                    AtomicOpFailure::GuardFailed { observed: 0, .. }
783                ));
784            }
785            other => panic!("expected RolledBack, got {other:?}"),
786        }
787
788        assert_eq!(
789            edge_count(&pool, a, x),
790            0,
791            "no dangling edge may be committed"
792        );
793        assert_eq!(
794            entities_snapshot(&pool),
795            before,
796            "x must still exist — the whole unit rolled back, including the delete"
797        );
798    }
799
800    // ------------------------------------------------------------------
801    // 5. Merge-rewire
802    // ------------------------------------------------------------------
803
804    #[tokio::test]
805    async fn merge_rewire_sees_earlier_in_file_edge_write() {
806        let pool = scratch_pool("merge_rewire");
807        seed_schema(&pool);
808        let z = Uuid::new_v4();
809        let from = Uuid::new_v4();
810        let into = Uuid::new_v4();
811        insert_entity(&pool, z, "z");
812        insert_entity(&pool, from, "from-entity");
813        insert_entity(&pool, into, "into-entity");
814
815        let bridge = SqlBridge::new(StdArc::clone(&pool), true);
816        let edge_id = Uuid::new_v4();
817        // [link(from, z), merge(into, from)] — the rewire's predicate-based
818        // UPDATE must see the edge the earlier op in THIS SAME unit
819        // inserted (ADR-099 acceptance criteria: "merge rewires see earlier
820        // in-file writes").
821        let plans = vec![link_plan(edge_id, from, z), merge_plan(into, from)];
822
823        let outcome = run_atomic_unit(&bridge, plans).await.expect("seam call ok");
824        assert!(
825            matches!(outcome, AtomicRunOutcome::Committed { .. }),
826            "expected the unit to commit: {outcome:?}"
827        );
828
829        assert_eq!(
830            edge_count(&pool, from, z),
831            0,
832            "no live edge may remain sourced from the merged-away entity"
833        );
834        assert_eq!(
835            edge_count(&pool, into, z),
836            1,
837            "the edge must be rewired onto `into`"
838        );
839        assert!(
840            !entity_exists(&pool, from),
841            "the `from` entity must be tombstoned (soft-deleted)"
842        );
843    }
844
845    // ------------------------------------------------------------------
846    // 6. Zero-row
847    // ------------------------------------------------------------------
848
849    #[tokio::test]
850    async fn zero_row_apply_fails_the_whole_unit() {
851        let pool = scratch_pool("zero_row");
852        seed_schema(&pool);
853        let x = Uuid::new_v4();
854        insert_entity(&pool, x, "x");
855        let before = entities_snapshot(&pool);
856
857        let bridge = SqlBridge::new(StdArc::clone(&pool), true);
858        // [delete(x, hard), update(x)] — update's affected-row guard
859        // observes zero rows once x is already gone.
860        let plans = vec![
861            delete_plan(x, "delete-x"),
862            rename_plan(x, "x-v2", "update-x"),
863        ];
864
865        let outcome = run_atomic_unit(&bridge, plans).await.expect("seam call ok");
866        match outcome {
867            AtomicRunOutcome::RolledBack {
868                failed_op_index,
869                failure,
870            } => {
871                assert_eq!(failed_op_index, 1);
872                assert_eq!(
873                    failure,
874                    AtomicOpFailure::GuardFailed {
875                        statement_label: Some("update-x".to_string()),
876                        expected: AffectedRowGuard::exactly(1),
877                        observed: 0,
878                    }
879                );
880            }
881            other => panic!("expected RolledBack, got {other:?}"),
882        }
883
884        assert_eq!(
885            entities_snapshot(&pool),
886            before,
887            "x must be restored — the whole unit rolled back"
888        );
889    }
890
891    // ------------------------------------------------------------------
892    // Post-commit effect collection (UpdatePlan's reindex caveat, D3)
893    // ------------------------------------------------------------------
894
895    // ------------------------------------------------------------------
896    // 7. Daemon coexistence (ADR-099 B3 acceptance test 5)
897    // ------------------------------------------------------------------
898
899    /// An atomic unit must go through the SAME `WriterTaskHandle` queue as
900    /// an ordinary single-row write — not a separate connection that would
901    /// let it silently bypass single-writer serialization (ADR-067
902    /// Component A). This reuses the deterministic occupier pattern from
903    /// `khive-db::stores::graph_tests::upsert_edge_routes_through_writer_task_when_flag_enabled`
904    /// (Slice A): an occupier closure parks on a oneshot inside the writer
905    /// task's one drain slot, so both the atomic unit's `atomic_unit` call
906    /// and a concurrent ordinary `upsert_entity` call are forced to queue
907    /// behind it. `queue_depth` reaching 2 while both are pending, then
908    /// draining to 0 once the occupier releases and both complete, is the
909    /// proof that `run_atomic_unit` "discriminates" through the real queue
910    /// gauge rather than opening a competing connection.
911    #[tokio::test]
912    async fn atomic_unit_and_concurrent_normal_write_share_the_same_writer_queue() {
913        let pool = scratch_pool("daemon_coexistence");
914        seed_schema(&pool);
915        let a = Uuid::new_v4();
916        let b = Uuid::new_v4();
917        insert_entity(&pool, a, "alpha");
918
919        let writer_task = pool
920            .writer_task_handle()
921            .expect("writer task lookup")
922            .expect("writer task must be spawned with the flag on for a file-backed pool");
923
924        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
925        let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
926        let occupier = {
927            let writer_task = writer_task.clone();
928            tokio::spawn(async move {
929                writer_task
930                    .send(move |_conn| {
931                        let _ = started_tx.send(());
932                        let _ = release_rx.blocking_recv();
933                        Ok::<(), StorageError>(())
934                    })
935                    .await
936            })
937        };
938
939        started_rx
940            .await
941            .expect("occupier must signal it has started running inside the writer task");
942        assert_eq!(
943            writer_task.queue_depth(),
944            0,
945            "channel must start empty once the occupier has been dequeued and is running"
946        );
947
948        // The atomic unit: renames `a`.
949        let bridge = SqlBridge::new(StdArc::clone(&pool), true);
950        let atomic_plans = vec![rename_plan(a, "alpha-atomic", "rename-a-atomic")];
951        let atomic_task = tokio::spawn(async move { run_atomic_unit(&bridge, atomic_plans).await });
952
953        // A concurrent ORDINARY write, routed through the SAME
954        // `WriterTaskHandle::send` the real `with_writer` path uses
955        // (khive-db's own `graph_tests` occupier test asserts this for
956        // `upsert_edge`) — NOT a second `pool.try_writer()` connection,
957        // which would open a competing SQLite handle and contend on the
958        // file lock the occupier's `BEGIN IMMEDIATE` already holds,
959        // producing a `DatabaseBusy` false failure unrelated to the queue
960        // this test is actually proving something about.
961        let normal_write_task = {
962            let writer_task = writer_task.clone();
963            let b_str = b.to_string();
964            tokio::spawn(async move {
965                writer_task
966                    .send(move |conn| {
967                        conn.execute(
968                            "INSERT INTO entities \
969                             (id, namespace, kind, name, created_at, updated_at) \
970                             VALUES (?1, 'local', 'concept', ?2, 0, 0)",
971                            rusqlite::params![b_str, "bravo"],
972                        )
973                        .map_err(|e| StorageError::Internal(e.to_string()))
974                    })
975                    .await
976            })
977        };
978
979        // Both the atomic unit's `atomic_unit` call and (best-effort) the
980        // ordinary write must appear in the SAME queue while the occupier
981        // holds the slot — this is the coexistence proof: neither one opens
982        // a side-channel connection that would let it skip the queue.
983        let mut saw_both_enqueued = false;
984        for _ in 0..200 {
985            if writer_task.queue_depth() >= 2 {
986                saw_both_enqueued = true;
987                break;
988            }
989            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
990        }
991        assert!(
992            saw_both_enqueued,
993            "expected BOTH the atomic unit's atomic_unit() call and the \
994             concurrent ordinary write to appear in the writer task's queue \
995             while the occupier held the single drain slot (queue_depth \
996             should have reached 2) — observed {}; run_atomic_unit is not \
997             sharing the same queue as an ordinary write",
998            writer_task.queue_depth()
999        );
1000
1001        release_tx.send(()).expect("release occupier");
1002        occupier
1003            .await
1004            .expect("occupier task join")
1005            .expect("occupier op ok");
1006
1007        let atomic_outcome = atomic_task
1008            .await
1009            .expect("atomic task join")
1010            .expect("seam call ok");
1011        assert!(
1012            matches!(atomic_outcome, AtomicRunOutcome::Committed { .. }),
1013            "atomic unit must commit once the occupier releases: {atomic_outcome:?}"
1014        );
1015        normal_write_task
1016            .await
1017            .expect("normal write task join")
1018            .expect("normal write op ok");
1019
1020        assert!(
1021            entity_exists(&pool, a),
1022            "a must exist (renamed) after commit"
1023        );
1024        {
1025            // Scoped: the `WriterGuard` returned by `try_writer()` holds a
1026            // non-reentrant `parking_lot::Mutex` for as long as it lives.
1027            // Left unscoped, it would still be held when `entity_exists`
1028            // below tries to check out the same writer, deadlocking (in
1029            // practice, timing out after `checkout_timeout`) against itself
1030            // rather than against anything the writer task/occupier hold.
1031            let writer = pool.try_writer().expect("writer");
1032            let renamed: String = writer
1033                .conn()
1034                .query_row(
1035                    "SELECT name FROM entities WHERE id = ?1",
1036                    rusqlite::params![a.to_string()],
1037                    |row| row.get(0),
1038                )
1039                .expect("query renamed entity");
1040            assert_eq!(renamed, "alpha-atomic");
1041        }
1042        assert!(
1043            entity_exists(&pool, b),
1044            "the concurrent ordinary write must also have landed"
1045        );
1046    }
1047
1048    #[tokio::test]
1049    async fn post_commit_effects_are_collected_in_op_order_and_only_for_update() {
1050        let pool = scratch_pool("post_commit_collection");
1051        seed_schema(&pool);
1052        let a = Uuid::new_v4();
1053        let b = Uuid::new_v4();
1054        insert_entity(&pool, a, "alpha");
1055        insert_entity(&pool, b, "bravo");
1056
1057        let bridge = SqlBridge::new(StdArc::clone(&pool), true);
1058        let mut update_a = match rename_plan(a, "alpha-v2", "rename-a") {
1059            AtomicOpPlan::Update(p) => p,
1060            _ => unreachable!(),
1061        };
1062        update_a.post_commit = PostCommitEffect::ReindexEntity { entity_id: a };
1063        let plans = vec![
1064            AtomicOpPlan::Update(update_a),
1065            delete_plan(b, "delete-b"), // no post-commit effect for delete
1066        ];
1067
1068        let outcome = run_atomic_unit(&bridge, plans).await.expect("seam call ok");
1069        match outcome {
1070            AtomicRunOutcome::Committed { post_commit } => {
1071                assert_eq!(
1072                    post_commit,
1073                    vec![PostCommitEffect::ReindexEntity { entity_id: a }]
1074                );
1075            }
1076            other => panic!("expected Committed, got {other:?}"),
1077        }
1078    }
1079}