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