add DAG topological sort as alternative ordering strategy
Introduce Ordering::Dag alongside the existing Ordering::Position.
Set RANGER_DAG_ORDER=1 to switch at runtime.

DAG ordering uses Kahn's algorithm on 'before' edges with min-ID
tiebreaker. Position-based lex ordering remains the default and is
unchanged.

New library code:
- edge::ordered_ids — deterministic topo sort for a set of task IDs
- edge::splice_out_before — remove a task from before-edge chains
- edge::insert_before_task / insert_after_task — chain insertion
- edge::list_for_task_ids — batch edge fetch
- task::move_task_dag — move via edge manipulation
- task::reorder_dag — state-change placement via DAG

Removed the one-outgoing-before constraint on edges (was linked-list
model; DAG ordering needs fan-out for proper root/leaf placement).

All functions accept an Ordering parameter; CLI resolves it from
RANGER_DAG_ORDER env var.
change okzskoxqpltpozotlznqnpxmmypxnrsn
commit 0d7f4a6734d433cd33088efea2c0270c5f9806ed
author Alpha Chen <alpha@kejadlen.dev>
date
parent qwnowosq
diff --git a/src/bin/ranger/commands/backlog.rs b/src/bin/ranger/commands/backlog.rs
index a611656..31c279f 100644
--- a/src/bin/ranger/commands/backlog.rs
+++ b/src/bin/ranger/commands/backlog.rs
@@ -7,6 +7,7 @@ use ranger::models::{Backlog, State};
 use ranger::ops;
 use ranger::ops::task::ListFilter;
 
+use super::task::resolve_ordering;
 use crate::completions;
 use crate::output;
 
@@ -49,6 +50,7 @@ pub enum BacklogCommands {
 
 pub async fn run(pool: &SqlitePool, command: BacklogCommands, json: bool) -> Result<()> {
     let mut conn = pool.acquire().await?;
+    let ordering = resolve_ordering();
 
     match command {
         BacklogCommands::Create { name } => {
@@ -84,7 +86,7 @@ pub async fn run(pool: &SqlitePool, command: BacklogCommands, json: bool) -> Res
                         state: Some(state.clone()),
                         ..Default::default()
                     };
-                    let tasks = ops::task::list(&mut conn, backlog.id, &filter).await?;
+                    let tasks = ops::task::list(&mut conn, backlog.id, &filter, ordering).await?;
                     if !tasks.is_empty() {
                         state_groups
                             .insert(state.to_string(), serde_json::to_value(&tasks).unwrap());
@@ -106,7 +108,7 @@ pub async fn run(pool: &SqlitePool, command: BacklogCommands, json: bool) -> Res
                         state: Some(state.clone()),
                         ..Default::default()
                     };
-                    let tasks = ops::task::list(&mut conn, backlog.id, &filter).await?;
+                    let tasks = ops::task::list(&mut conn, backlog.id, &filter, ordering).await?;
                     if !tasks.is_empty() {
                         println!("\n[{}]", state);
                         for t in &tasks {
diff --git a/src/bin/ranger/commands/serve.rs b/src/bin/ranger/commands/serve.rs
index 703a481..2a4cbfa 100644
--- a/src/bin/ranger/commands/serve.rs
+++ b/src/bin/ranger/commands/serve.rs
@@ -4,13 +4,15 @@ use axum::response::{IntoResponse, Redirect};
 use axum::{Router, routing::get};
 use maud::{DOCTYPE, Markup, PreEscaped, html};
 use ranger::key;
-use ranger::models::Task;
+use ranger::models::{Ordering, Task};
 use ranger::ops;
 use ranger::ops::task::ListFilter;
 use sqlx::SqlitePool;
 use std::net::SocketAddr;
 use tokio::net::TcpListener;
 
+use super::task::resolve_ordering;
+
 /// Static CSS embedded at compile time from `static/style.css`.
 const STYLE_CSS: &str = include_str!("../../../../static/style.css");
 
@@ -18,6 +20,7 @@ const STYLE_CSS: &str = include_str!("../../../../static/style.css");
 struct AppState {
     pool: SqlitePool,
     default_backlog: Option<String>,
+    ordering: Ordering,
 }
 
 pub async fn run(
@@ -28,6 +31,7 @@ pub async fn run(
     let state = AppState {
         pool: pool.clone(),
         default_backlog,
+        ordering: resolve_ordering(),
     };
 
     let app = Router::new()
@@ -118,7 +122,7 @@ async fn render_board(state: &AppState, backlog_name: &str) -> color_eyre::Resul
             state: Some(s.clone()),
             ..Default::default()
         };
-        let tasks = ops::task::list(&mut conn, backlog.id, &filter).await?;
+        let tasks = ops::task::list(&mut conn, backlog.id, &filter, state.ordering).await?;
         let views = to_task_views(&tasks, &prefixes, &mut conn).await?;
         match s {
             ranger::models::State::InProgress => in_progress = views,
diff --git a/src/bin/ranger/commands/task.rs b/src/bin/ranger/commands/task.rs
index 1e4e6f9..69080bc 100644
--- a/src/bin/ranger/commands/task.rs
+++ b/src/bin/ranger/commands/task.rs
@@ -5,13 +5,22 @@ use clap_complete::engine::ArgValueCompleter;
 use color_eyre::eyre::{Result, bail};
 use ranger::db::{SqliteConnection, SqlitePool};
 use ranger::key;
-use ranger::models::{State, Task};
+use ranger::models::{Ordering, State, Task};
 use ranger::ops;
 use ranger::ops::task::{ListFilter, Placement};
 
 use crate::completions;
 use crate::output;
 
+/// Read the ordering strategy from the `RANGER_DAG_ORDER` env var.
+/// Set `RANGER_DAG_ORDER=1` to use DAG topological ordering.
+pub fn resolve_ordering() -> Ordering {
+    match std::env::var("RANGER_DAG_ORDER").as_deref() {
+        Ok("1") | Ok("true") => Ordering::Dag,
+        _ => Ordering::Position,
+    }
+}
+
 /// Positioning flags shared by create, edit, and move.
 #[derive(Args)]
 pub struct PositionArgs {
@@ -170,6 +179,7 @@ pub async fn default_backlog_id(pool: &SqlitePool) -> Option<i64> {
 
 pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result<()> {
     let backlog_scope = default_backlog_id(pool).await;
+    let ordering = resolve_ordering();
 
     match command {
         TaskCommands::Create {
@@ -197,7 +207,7 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
             .await?;
 
             if let Some(ref anchors) = anchors {
-                ops::task::move_task(&mut tx, &task, anchors.as_placement()).await?;
+                ops::task::move_task(&mut tx, &task, anchors.as_placement(), ordering).await?;
             }
 
             tx.commit().await?;
@@ -224,7 +234,7 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
                 let bl = ops::backlog::get_by_name(&mut conn, backlog_name).await?;
                 let backlog_keys = ops::task::keys_for_backlog(&mut conn, bl.id).await?;
                 let prefixes = key::unique_prefix_lengths(&backlog_keys);
-                let tasks = ops::task::list(&mut conn, bl.id, &filter).await?;
+                let tasks = ops::task::list(&mut conn, bl.id, &filter, ordering).await?;
                 output::print_list(&tasks, json, |t| print_task(t, &prefixes));
             } else {
                 // List all tasks (no backlog filter)
@@ -233,7 +243,7 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
                 let backlogs = ops::backlog::list(&mut conn).await?;
                 let mut all_tasks = Vec::new();
                 for bl in &backlogs {
-                    let tasks = ops::task::list(&mut conn, bl.id, &filter).await?;
+                    let tasks = ops::task::list(&mut conn, bl.id, &filter, ordering).await?;
                     for t in tasks {
                         if !all_tasks.iter().any(|at: &Task| at.id == t.id) {
                             all_tasks.push(t);
@@ -310,11 +320,12 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
                 title.as_deref(),
                 description.as_deref(),
                 state,
+                ordering,
             )
             .await?;
 
             if let Some(ref anchors) = anchors {
-                ops::task::move_task(&mut conn, &updated, anchors.as_placement()).await?;
+                ops::task::move_task(&mut conn, &updated, anchors.as_placement(), ordering).await?;
             }
 
             let all_keys = ops::task::all_keys(&mut conn).await?;
@@ -328,7 +339,8 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
 
             match anchors {
                 Some(anchors) => {
-                    ops::task::move_task(&mut conn, &task, anchors.as_placement()).await?;
+                    ops::task::move_task(&mut conn, &task, anchors.as_placement(), ordering)
+                        .await?;
                     let all_keys = ops::task::all_keys(&mut conn).await?;
                     let prefixes = key::unique_prefix_lengths(&all_keys);
                     println!(
diff --git a/src/error.rs b/src/error.rs
index 3017f7e..92e71de 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -13,8 +13,6 @@ pub enum RangerError {
     BacklogNotFound(String),
     #[error("adding this edge would create a cycle")]
     CycleDetected,
-    #[error("task already has an outgoing 'before' edge")]
-    DuplicateBeforeEdge,
     #[error("database error: {0}")]
     Db(#[from] sqlx::Error),
     #[error("migration error: {0}")]
diff --git a/src/models.rs b/src/models.rs
index 4e58d5f..29c1db2 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -3,6 +3,16 @@ use sqlx::FromRow;
 
 use crate::timestamp::Timestamp;
 
+/// Controls how tasks are ordered within a backlog/state group.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum Ordering {
+    /// Lexicographic position strings (legacy).
+    #[default]
+    Position,
+    /// DAG topological sort using `before` edges, with task ID as tiebreaker.
+    Dag,
+}
+
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "snake_case")]
 pub enum State {
diff --git a/src/ops/edge.rs b/src/ops/edge.rs
index 0c75025..4085081 100644
--- a/src/ops/edge.rs
+++ b/src/ops/edge.rs
@@ -3,7 +3,8 @@ use crate::models::{EdgeType, TaskEdge};
 use petgraph::graph::DiGraph;
 use petgraph::graph::NodeIndex;
 use sqlx::sqlite::SqliteConnection;
-use std::collections::HashMap;
+use std::cmp::Reverse;
+use std::collections::{BinaryHeap, HashMap, HashSet};
 
 pub async fn add(
     conn: &mut SqliteConnection,
@@ -15,20 +16,6 @@ pub async fn add(
     let edges = list_all(&mut *conn).await?;
     check_cycle(&edges, from_task_id, to_task_id)?;
 
-    // Enforce: at most one outgoing 'before' edge per task
-    if edge_type == EdgeType::Before {
-        let existing: Option<i64> = sqlx::query_scalar(
-            "SELECT id FROM task_edges WHERE from_task_id = ? AND edge_type = 'before'",
-        )
-        .bind(from_task_id)
-        .fetch_optional(&mut *conn)
-        .await?;
-
-        if existing.is_some() {
-            return Err(RangerError::DuplicateBeforeEdge);
-        }
-    }
-
     let edge = sqlx::query_as::<_, TaskEdge>(
         "INSERT INTO task_edges (from_task_id, to_task_id, edge_type) \
          VALUES (?, ?, ?) \
@@ -157,6 +144,209 @@ pub fn topological_sort(edges: &[TaskEdge]) -> Vec<i64> {
     }
 }
 
+/// Deterministic topological sort using Kahn's algorithm with min-ID tiebreaker.
+///
+/// Only considers `before` edges between task IDs in `task_ids`.
+/// Every ID in `task_ids` appears exactly once in the output.
+pub fn ordered_ids(task_ids: &[i64], edges: &[TaskEdge]) -> Vec<i64> {
+    if task_ids.is_empty() {
+        return Vec::new();
+    }
+
+    let id_set: HashSet<i64> = task_ids.iter().copied().collect();
+
+    // Build adjacency list and in-degree map — only 'before' edges within the set
+    let mut adj: HashMap<i64, Vec<i64>> = HashMap::new();
+    let mut in_degree: HashMap<i64, usize> = HashMap::new();
+
+    for &id in task_ids {
+        adj.entry(id).or_default();
+        in_degree.entry(id).or_insert(0);
+    }
+
+    for edge in edges {
+        if edge.edge_type == EdgeType::Before
+            && id_set.contains(&edge.from_task_id)
+            && id_set.contains(&edge.to_task_id)
+        {
+            adj.entry(edge.from_task_id)
+                .or_default()
+                .push(edge.to_task_id);
+            *in_degree.entry(edge.to_task_id).or_insert(0) += 1;
+        }
+    }
+
+    // Kahn's algorithm with a min-heap for deterministic ordering
+    let mut heap: BinaryHeap<Reverse<i64>> = BinaryHeap::new();
+    for (&id, &deg) in &in_degree {
+        if deg == 0 {
+            heap.push(Reverse(id));
+        }
+    }
+
+    let mut result = Vec::with_capacity(task_ids.len());
+    while let Some(Reverse(id)) = heap.pop() {
+        result.push(id);
+        for &next in &adj[&id] {
+            let deg = in_degree.get_mut(&next).unwrap();
+            *deg -= 1;
+            if *deg == 0 {
+                heap.push(Reverse(next));
+            }
+        }
+    }
+
+    result
+}
+
+/// Remove a task from any `before` chains it participates in.
+///
+/// If the task has predecessors (P → task) and a successor (task → S),
+/// reconnects each predecessor directly to the successor (P → S).
+pub async fn splice_out_before(
+    conn: &mut SqliteConnection,
+    task_id: i64,
+) -> Result<(), RangerError> {
+    // Get the outgoing 'before' edge: task → successor
+    let outgoing: Option<TaskEdge> = sqlx::query_as(
+        "SELECT id, from_task_id, to_task_id, edge_type, created_at \
+         FROM task_edges WHERE from_task_id = ? AND edge_type = 'before'",
+    )
+    .bind(task_id)
+    .fetch_optional(&mut *conn)
+    .await?;
+
+    // Get all incoming 'before' edges: predecessor → task
+    let incoming: Vec<TaskEdge> = sqlx::query_as(
+        "SELECT id, from_task_id, to_task_id, edge_type, created_at \
+         FROM task_edges WHERE to_task_id = ? AND edge_type = 'before'",
+    )
+    .bind(task_id)
+    .fetch_all(&mut *conn)
+    .await?;
+
+    // Delete all 'before' edges involving this task
+    sqlx::query("DELETE FROM task_edges WHERE (from_task_id = ? OR to_task_id = ?) AND edge_type = 'before'")
+        .bind(task_id)
+        .bind(task_id)
+        .execute(&mut *conn)
+        .await?;
+
+    // Reconnect: each predecessor → successor
+    if let Some(succ) = &outgoing {
+        for pred in &incoming {
+            sqlx::query(
+                "INSERT OR IGNORE INTO task_edges (from_task_id, to_task_id, edge_type) \
+                 VALUES (?, ?, 'before')",
+            )
+            .bind(pred.from_task_id)
+            .bind(succ.to_task_id)
+            .execute(&mut *conn)
+            .await?;
+        }
+    }
+
+    Ok(())
+}
+
+/// Insert a task immediately before a target in the `before` chain.
+///
+/// Any predecessors of the target are rewired to point to the inserted task.
+pub async fn insert_before_task(
+    conn: &mut SqliteConnection,
+    task_id: i64,
+    target_id: i64,
+) -> Result<(), RangerError> {
+    // Rewire incoming 'before' edges to target → point to task instead
+    let incoming: Vec<TaskEdge> = sqlx::query_as(
+        "SELECT id, from_task_id, to_task_id, edge_type, created_at \
+         FROM task_edges WHERE to_task_id = ? AND edge_type = 'before'",
+    )
+    .bind(target_id)
+    .fetch_all(&mut *conn)
+    .await?;
+
+    for pred in &incoming {
+        sqlx::query("DELETE FROM task_edges WHERE id = ?")
+            .bind(pred.id)
+            .execute(&mut *conn)
+            .await?;
+        sqlx::query(
+            "INSERT OR IGNORE INTO task_edges (from_task_id, to_task_id, edge_type) \
+             VALUES (?, ?, 'before')",
+        )
+        .bind(pred.from_task_id)
+        .bind(task_id)
+        .execute(&mut *conn)
+        .await?;
+    }
+
+    // Add task → target
+    add(&mut *conn, task_id, target_id, EdgeType::Before).await?;
+    Ok(())
+}
+
+/// Insert a task immediately after an anchor in the `before` chain.
+///
+/// All outgoing `before` edges from the anchor are rewired through the task.
+pub async fn insert_after_task(
+    conn: &mut SqliteConnection,
+    task_id: i64,
+    anchor_id: i64,
+) -> Result<(), RangerError> {
+    // Collect all outgoing 'before' edges from anchor
+    let outgoing: Vec<TaskEdge> = sqlx::query_as(
+        "SELECT id, from_task_id, to_task_id, edge_type, created_at \
+         FROM task_edges WHERE from_task_id = ? AND edge_type = 'before'",
+    )
+    .bind(anchor_id)
+    .fetch_all(&mut *conn)
+    .await?;
+
+    for succ in &outgoing {
+        let successor_id = succ.to_task_id;
+        // Remove anchor → successor
+        remove(&mut *conn, anchor_id, successor_id, EdgeType::Before).await?;
+        // Add task → successor
+        add(&mut *conn, task_id, successor_id, EdgeType::Before).await?;
+    }
+
+    // Add anchor → task
+    add(&mut *conn, anchor_id, task_id, EdgeType::Before).await?;
+    Ok(())
+}
+
+/// Fetch all edges involving any of the given task IDs.
+pub async fn list_for_task_ids(
+    conn: &mut SqliteConnection,
+    task_ids: &[i64],
+) -> Result<Vec<TaskEdge>, RangerError> {
+    if task_ids.is_empty() {
+        return Ok(Vec::new());
+    }
+
+    // Build a comma-separated placeholder list
+    let placeholders: String = task_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
+    let query = format!(
+        "SELECT id, from_task_id, to_task_id, edge_type, created_at \
+         FROM task_edges \
+         WHERE from_task_id IN ({placeholders}) OR to_task_id IN ({placeholders}) \
+         ORDER BY created_at"
+    );
+
+    let mut q = sqlx::query_as::<_, TaskEdge>(&query);
+    // Bind task_ids twice (once for each IN clause)
+    for &id in task_ids {
+        q = q.bind(id);
+    }
+    for &id in task_ids {
+        q = q.bind(id);
+    }
+
+    let edges = q.fetch_all(&mut *conn).await?;
+    Ok(edges)
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -275,7 +465,7 @@ mod tests {
     }
 
     #[tokio::test]
-    async fn duplicate_before_edge_rejected() {
+    async fn multiple_before_edges_allowed() {
         let pool = test_pool().await;
         let mut conn = pool.acquire().await.unwrap();
         let bl = backlog::create(&mut conn, "Test").await.unwrap();
@@ -284,11 +474,14 @@ mod tests {
         let t3 = create_task(&mut conn, bl.id, "Task 3").await;
 
         add(&mut conn, t1, t2, EdgeType::Before).await.unwrap();
-        let err = add(&mut conn, t1, t3, EdgeType::Before).await.unwrap_err();
-        assert!(
-            err.to_string().contains("before"),
-            "expected duplicate before error, got: {err}"
-        );
+        add(&mut conn, t1, t3, EdgeType::Before).await.unwrap();
+
+        let edges = list_for_task(&mut conn, t1).await.unwrap();
+        let before_edges: Vec<_> = edges
+            .iter()
+            .filter(|e| e.edge_type == EdgeType::Before)
+            .collect();
+        assert_eq!(before_edges.len(), 2);
     }
 
     #[tokio::test]
@@ -403,4 +596,266 @@ mod tests {
         let sorted = topological_sort(&edges);
         assert_eq!(sorted, vec![t1, t2, t3]);
     }
+
+    // -- ordered_ids tests --
+
+    #[test]
+    fn ordered_ids_empty() {
+        assert!(ordered_ids(&[], &[]).is_empty());
+    }
+
+    #[test]
+    fn ordered_ids_no_edges_sorts_by_id() {
+        let result = ordered_ids(&[30, 10, 20], &[]);
+        assert_eq!(result, vec![10, 20, 30]);
+    }
+
+    #[tokio::test]
+    async fn ordered_ids_respects_before_edges() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+        let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+        let t2 = create_task(&mut conn, bl.id, "Task 2").await;
+        let t3 = create_task(&mut conn, bl.id, "Task 3").await;
+
+        // t3 before t1 (override natural id order)
+        add(&mut conn, t3, t1, EdgeType::Before).await.unwrap();
+
+        let edges = list_all(&mut conn).await.unwrap();
+        let result = ordered_ids(&[t1, t2, t3], &edges);
+        // t2 has lowest id among unconstrained, t3 must come before t1
+        assert_eq!(result, vec![t2, t3, t1]);
+    }
+
+    #[tokio::test]
+    async fn ordered_ids_ignores_blocks_edges() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+        let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+        let t2 = create_task(&mut conn, bl.id, "Task 2").await;
+
+        // t2 blocks t1 — should NOT affect ordering
+        add(&mut conn, t2, t1, EdgeType::Blocks).await.unwrap();
+
+        let edges = list_all(&mut conn).await.unwrap();
+        let result = ordered_ids(&[t1, t2], &edges);
+        assert_eq!(result, vec![t1, t2], "blocks edges should not affect order");
+    }
+
+    #[tokio::test]
+    async fn ordered_ids_filters_to_given_ids() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+        let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+        let t2 = create_task(&mut conn, bl.id, "Task 2").await;
+        let t3 = create_task(&mut conn, bl.id, "Task 3").await;
+
+        add(&mut conn, t1, t2, EdgeType::Before).await.unwrap();
+
+        let edges = list_all(&mut conn).await.unwrap();
+        // Only ask about t2 and t3 — edge t1→t2 should be ignored since t1 not in set
+        let result = ordered_ids(&[t2, t3], &edges);
+        assert_eq!(result, vec![t2, t3]);
+    }
+
+    // -- splice_out_before tests --
+
+    #[tokio::test]
+    async fn splice_out_middle_of_chain() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+        let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+        let t2 = create_task(&mut conn, bl.id, "Task 2").await;
+        let t3 = create_task(&mut conn, bl.id, "Task 3").await;
+
+        add(&mut conn, t1, t2, EdgeType::Before).await.unwrap();
+        add(&mut conn, t2, t3, EdgeType::Before).await.unwrap();
+
+        splice_out_before(&mut conn, t2).await.unwrap();
+
+        // t1 → t3 should now exist; no edges involving t2
+        let edges = list_all(&mut conn).await.unwrap();
+        assert_eq!(edges.len(), 1);
+        assert_eq!(edges[0].from_task_id, t1);
+        assert_eq!(edges[0].to_task_id, t3);
+        assert_eq!(edges[0].edge_type, EdgeType::Before);
+    }
+
+    #[tokio::test]
+    async fn splice_out_head_of_chain() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+        let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+        let t2 = create_task(&mut conn, bl.id, "Task 2").await;
+
+        add(&mut conn, t1, t2, EdgeType::Before).await.unwrap();
+
+        splice_out_before(&mut conn, t1).await.unwrap();
+
+        let edges = list_all(&mut conn).await.unwrap();
+        assert!(edges.is_empty());
+    }
+
+    #[tokio::test]
+    async fn splice_out_tail_of_chain() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+        let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+        let t2 = create_task(&mut conn, bl.id, "Task 2").await;
+
+        add(&mut conn, t1, t2, EdgeType::Before).await.unwrap();
+
+        splice_out_before(&mut conn, t2).await.unwrap();
+
+        let edges = list_all(&mut conn).await.unwrap();
+        assert!(edges.is_empty());
+    }
+
+    #[tokio::test]
+    async fn splice_out_preserves_blocks_edges() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+        let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+        let t2 = create_task(&mut conn, bl.id, "Task 2").await;
+
+        add(&mut conn, t1, t2, EdgeType::Before).await.unwrap();
+        add(&mut conn, t1, t2, EdgeType::Blocks).await.unwrap();
+
+        splice_out_before(&mut conn, t2).await.unwrap();
+
+        let edges = list_all(&mut conn).await.unwrap();
+        assert_eq!(edges.len(), 1);
+        assert_eq!(edges[0].edge_type, EdgeType::Blocks);
+    }
+
+    #[tokio::test]
+    async fn splice_out_no_edges_is_noop() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+        let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+
+        // Should not error
+        splice_out_before(&mut conn, t1).await.unwrap();
+
+        let edges = list_all(&mut conn).await.unwrap();
+        assert!(edges.is_empty());
+    }
+
+    // -- insert_before_task tests --
+
+    #[tokio::test]
+    async fn insert_before_into_chain() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+        let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+        let t2 = create_task(&mut conn, bl.id, "Task 2").await;
+        let t3 = create_task(&mut conn, bl.id, "Task 3").await;
+
+        // Chain: t1 → t2
+        add(&mut conn, t1, t2, EdgeType::Before).await.unwrap();
+
+        // Insert t3 before t2 → chain becomes t1 → t3 → t2
+        insert_before_task(&mut conn, t3, t2).await.unwrap();
+
+        let edges = list_all(&mut conn).await.unwrap();
+        let before_edges: Vec<_> = edges
+            .iter()
+            .filter(|e| e.edge_type == EdgeType::Before)
+            .collect();
+        assert_eq!(before_edges.len(), 2);
+
+        let sorted = ordered_ids(&[t1, t2, t3], &edges);
+        assert_eq!(sorted, vec![t1, t3, t2]);
+    }
+
+    #[tokio::test]
+    async fn insert_before_at_head() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+        let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+        let t2 = create_task(&mut conn, bl.id, "Task 2").await;
+
+        // Insert t2 before t1 (t1 has no predecessor)
+        insert_before_task(&mut conn, t2, t1).await.unwrap();
+
+        let edges = list_all(&mut conn).await.unwrap();
+        let sorted = ordered_ids(&[t1, t2], &edges);
+        assert_eq!(sorted, vec![t2, t1]);
+    }
+
+    // -- insert_after_task tests --
+
+    #[tokio::test]
+    async fn insert_after_into_chain() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+        let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+        let t2 = create_task(&mut conn, bl.id, "Task 2").await;
+        let t3 = create_task(&mut conn, bl.id, "Task 3").await;
+
+        // Chain: t1 → t2
+        add(&mut conn, t1, t2, EdgeType::Before).await.unwrap();
+
+        // Insert t3 after t1 → chain becomes t1 → t3 → t2
+        insert_after_task(&mut conn, t3, t1).await.unwrap();
+
+        let edges = list_all(&mut conn).await.unwrap();
+        let sorted = ordered_ids(&[t1, t2, t3], &edges);
+        assert_eq!(sorted, vec![t1, t3, t2]);
+    }
+
+    #[tokio::test]
+    async fn insert_after_at_tail() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+        let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+        let t2 = create_task(&mut conn, bl.id, "Task 2").await;
+
+        // Insert t2 after t1 (t1 has no successor)
+        insert_after_task(&mut conn, t2, t1).await.unwrap();
+
+        let edges = list_all(&mut conn).await.unwrap();
+        let sorted = ordered_ids(&[t1, t2], &edges);
+        assert_eq!(sorted, vec![t1, t2]);
+    }
+
+    // -- list_for_task_ids tests --
+
+    #[tokio::test]
+    async fn list_for_task_ids_empty() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let edges = list_for_task_ids(&mut conn, &[]).await.unwrap();
+        assert!(edges.is_empty());
+    }
+
+    #[tokio::test]
+    async fn list_for_task_ids_returns_relevant_edges() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+        let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+        let t2 = create_task(&mut conn, bl.id, "Task 2").await;
+        let t3 = create_task(&mut conn, bl.id, "Task 3").await;
+
+        add(&mut conn, t1, t2, EdgeType::Before).await.unwrap();
+        add(&mut conn, t2, t3, EdgeType::Before).await.unwrap();
+
+        // Only ask about t1 and t2
+        let edges = list_for_task_ids(&mut conn, &[t1, t2]).await.unwrap();
+        // Should get t1→t2 and t2→t3 (t2 is involved in both)
+        assert_eq!(edges.len(), 2);
+    }
 }
diff --git a/src/ops/task.rs b/src/ops/task.rs
index 969f1da..7c18f16 100644
--- a/src/ops/task.rs
+++ b/src/ops/task.rs
@@ -1,8 +1,10 @@
 use crate::error::RangerError;
 use crate::key;
-use crate::models::{State, Task};
+use crate::models::{Ordering, State, Task};
+use crate::ops::edge;
 use crate::position;
 use sqlx::sqlite::SqliteConnection;
+use std::collections::HashMap;
 
 const TASK_COLUMNS: &str = "tasks.id, tasks.key, tasks.backlog_id, tasks.title, tasks.description, tasks.state, tasks.position, tasks.archived, tasks.created_at, tasks.updated_at";
 
@@ -61,6 +63,7 @@ pub async fn list(
     conn: &mut SqliteConnection,
     backlog_id: i64,
     filter: &ListFilter,
+    ordering: Ordering,
 ) -> Result<Vec<Task>, RangerError> {
     let archived_clause = if filter.include_archived {
         ""
@@ -74,11 +77,15 @@ pub async fn list(
         ""
     };
 
-    let tasks = if let Some(state) = &filter.state {
+    let order_clause = match ordering {
+        Ordering::Position => " ORDER BY position",
+        Ordering::Dag => " ORDER BY id",
+    };
+
+    let mut tasks = if let Some(state) = &filter.state {
         let query = format!(
             "SELECT {TASK_COLUMNS} FROM tasks{tag_join} \
-             WHERE backlog_id = ? AND state = ?{archived_clause} \
-             ORDER BY position"
+             WHERE backlog_id = ? AND state = ?{archived_clause}{order_clause}"
         );
         let mut q = sqlx::query_as::<_, Task>(&query);
         if let Some(tag) = &filter.tag {
@@ -91,8 +98,7 @@ pub async fn list(
     } else {
         let query = format!(
             "SELECT {TASK_COLUMNS} FROM tasks{tag_join} \
-             WHERE backlog_id = ?{archived_clause} \
-             ORDER BY position"
+             WHERE backlog_id = ?{archived_clause}{order_clause}"
         );
         let mut q = sqlx::query_as::<_, Task>(&query);
         if let Some(tag) = &filter.tag {
@@ -100,6 +106,20 @@ pub async fn list(
         }
         q.bind(backlog_id).fetch_all(&mut *conn).await?
     };
+
+    if ordering == Ordering::Dag {
+        let task_ids: Vec<i64> = tasks.iter().map(|t| t.id).collect();
+        let edges = edge::list_for_task_ids(&mut *conn, &task_ids).await?;
+        let sorted_ids = edge::ordered_ids(&task_ids, &edges);
+
+        let task_map: HashMap<i64, usize> = sorted_ids
+            .iter()
+            .enumerate()
+            .map(|(i, &id)| (id, i))
+            .collect();
+        tasks.sort_by_key(|t| task_map.get(&t.id).copied().unwrap_or(usize::MAX));
+    }
+
     Ok(tasks)
 }
 
@@ -165,6 +185,7 @@ pub async fn edit(
     title: Option<&str>,
     description: Option<&str>,
     state: Option<State>,
+    ordering: Ordering,
 ) -> Result<Task, RangerError> {
     if let Some(title) = title {
         sqlx::query("UPDATE tasks SET title = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?")
@@ -194,7 +215,7 @@ pub async fn edit(
             .await?;
 
         if old_state != *new_state {
-            reorder(&mut *conn, task_id, &old_state, new_state).await?;
+            reorder(&mut *conn, task_id, &old_state, new_state, ordering).await?;
         }
     }
 
@@ -243,6 +264,7 @@ pub async fn move_task(
     conn: &mut SqliteConnection,
     task: &Task,
     placement: Placement<'_>,
+    ordering: Ordering,
 ) -> Result<(), RangerError> {
     for anchor in placement.anchors() {
         if anchor.state != task.state {
@@ -253,6 +275,17 @@ pub async fn move_task(
         }
     }
 
+    match ordering {
+        Ordering::Position => move_task_position(&mut *conn, task, &placement).await,
+        Ordering::Dag => move_task_dag(&mut *conn, task, &placement).await,
+    }
+}
+
+async fn move_task_position(
+    conn: &mut SqliteConnection,
+    task: &Task,
+    placement: &Placement<'_>,
+) -> Result<(), RangerError> {
     let new_pos = match placement {
         Placement::After(anchor) => {
             let next =
@@ -272,7 +305,51 @@ pub async fn move_task(
     set_position(&mut *conn, task.id, &new_pos).await
 }
 
-/// Reorder a task's position when its state changes.
+async fn move_task_dag(
+    conn: &mut SqliteConnection,
+    task: &Task,
+    placement: &Placement<'_>,
+) -> Result<(), RangerError> {
+    edge::splice_out_before(&mut *conn, task.id).await?;
+
+    match placement {
+        Placement::Before(anchor) => {
+            edge::insert_before_task(&mut *conn, task.id, anchor.id).await?;
+        }
+        Placement::After(anchor) => {
+            edge::insert_after_task(&mut *conn, task.id, anchor.id).await?;
+        }
+        Placement::Between { after, before } => {
+            // Remove direct edge between anchors if it exists
+            edge::remove(
+                &mut *conn,
+                after.id,
+                before.id,
+                crate::models::EdgeType::Before,
+            )
+            .await?;
+            // Insert: after → task → before
+            edge::add(
+                &mut *conn,
+                after.id,
+                task.id,
+                crate::models::EdgeType::Before,
+            )
+            .await?;
+            edge::add(
+                &mut *conn,
+                task.id,
+                before.id,
+                crate::models::EdgeType::Before,
+            )
+            .await?;
+        }
+    }
+
+    Ok(())
+}
+
+/// Reorder a task when its state changes.
 ///
 /// Moving up (toward done): place at end of target state group.
 /// Moving down (toward icebox): place at beginning of target state group.
@@ -281,6 +358,19 @@ async fn reorder(
     task_id: i64,
     old_state: &State,
     new_state: &State,
+    ordering: Ordering,
+) -> Result<(), RangerError> {
+    match ordering {
+        Ordering::Position => reorder_position(&mut *conn, task_id, old_state, new_state).await,
+        Ordering::Dag => reorder_dag(&mut *conn, task_id, old_state, new_state).await,
+    }
+}
+
+async fn reorder_position(
+    conn: &mut SqliteConnection,
+    task_id: i64,
+    old_state: &State,
+    new_state: &State,
 ) -> Result<(), RangerError> {
     let backlog_id: i64 = sqlx::query_scalar("SELECT backlog_id FROM tasks WHERE id = ?")
         .bind(task_id)
@@ -322,6 +412,76 @@ async fn reorder(
     set_position(&mut *conn, task_id, &new_pos).await
 }
 
+async fn reorder_dag(
+    conn: &mut SqliteConnection,
+    task_id: i64,
+    _old_state: &State,
+    new_state: &State,
+) -> Result<(), RangerError> {
+    let backlog_id: i64 = sqlx::query_scalar("SELECT backlog_id FROM tasks WHERE id = ?")
+        .bind(task_id)
+        .fetch_one(&mut *conn)
+        .await?;
+
+    // Remove the task from its old before-edge chains
+    edge::splice_out_before(&mut *conn, task_id).await?;
+
+    // Find tasks in the target state to determine placement
+    let target_tasks: Vec<Task> = {
+        let query = format!(
+            "SELECT {TASK_COLUMNS} FROM tasks \
+             WHERE backlog_id = ? AND state = ? AND id != ? \
+             ORDER BY id"
+        );
+        sqlx::query_as::<_, Task>(&query)
+            .bind(backlog_id)
+            .bind(new_state.as_str())
+            .bind(task_id)
+            .fetch_all(&mut *conn)
+            .await?
+    };
+
+    if target_tasks.is_empty() {
+        return Ok(());
+    }
+
+    let task_ids: Vec<i64> = target_tasks.iter().map(|t| t.id).collect();
+    let edges = edge::list_for_task_ids(&mut *conn, &task_ids).await?;
+    let before_edge_type = crate::models::EdgeType::Before;
+
+    // Find roots (no incoming before) and leaves (no outgoing before) within the state
+    let has_incoming: std::collections::HashSet<i64> = edges
+        .iter()
+        .filter(|e| e.edge_type == before_edge_type && task_ids.contains(&e.from_task_id))
+        .map(|e| e.to_task_id)
+        .collect();
+    let has_outgoing: std::collections::HashSet<i64> = edges
+        .iter()
+        .filter(|e| e.edge_type == before_edge_type && task_ids.contains(&e.to_task_id))
+        .map(|e| e.from_task_id)
+        .collect();
+
+    let moving_up = new_state.rank() > _old_state.rank();
+
+    if moving_up {
+        // Place at end: add leaf → task for every leaf (no outgoing before)
+        for &id in &task_ids {
+            if !has_outgoing.contains(&id) {
+                edge::add(&mut *conn, id, task_id, before_edge_type.clone()).await?;
+            }
+        }
+    } else {
+        // Place at beginning: add task → root for every root (no incoming before)
+        for &id in &task_ids {
+            if !has_incoming.contains(&id) {
+                edge::add(&mut *conn, task_id, id, before_edge_type.clone()).await?;
+            }
+        }
+    }
+
+    Ok(())
+}
+
 // -- Position query helpers --
 
 async fn last_position(
@@ -449,6 +609,7 @@ pub async fn rebalance(conn: &mut SqliteConnection, backlog_id: i64) -> Result<u
             include_archived: true,
             ..Default::default()
         },
+        Ordering::Position,
     )
     .await?;
     let positions = position::spread(tasks.len());
@@ -545,7 +706,7 @@ mod tests {
         .await
         .unwrap();
 
-        let tasks = list(&mut conn, bl.id, &ListFilter::default())
+        let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
             .await
             .unwrap();
         assert_eq!(tasks.len(), 3);
@@ -589,6 +750,7 @@ mod tests {
                 state: Some(State::Icebox),
                 ..Default::default()
             },
+            Ordering::Position,
         )
         .await
         .unwrap();
@@ -602,6 +764,7 @@ mod tests {
                 state: Some(State::Queued),
                 ..Default::default()
             },
+            Ordering::Position,
         )
         .await
         .unwrap();
@@ -655,6 +818,7 @@ mod tests {
             Some("Updated"),
             Some("A description"),
             Some(State::Queued),
+            Ordering::Position,
         )
         .await
         .unwrap();
@@ -686,7 +850,7 @@ mod tests {
         let result = get_by_key_prefix(&mut conn, &task.key, None).await;
         assert!(result.is_err());
 
-        let tasks = list(&mut conn, bl.id, &ListFilter::default())
+        let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
             .await
             .unwrap();
         assert_eq!(tasks.len(), 0);
@@ -788,9 +952,16 @@ mod tests {
         .await
         .unwrap();
 
-        let updated = edit(&mut conn, task.id, Some("New title"), None, None)
-            .await
-            .unwrap();
+        let updated = edit(
+            &mut conn,
+            task.id,
+            Some("New title"),
+            None,
+            None,
+            Ordering::Position,
+        )
+        .await
+        .unwrap();
         assert_eq!(updated.title, "New title");
         assert_eq!(updated.state, State::Icebox);
     }
@@ -824,11 +995,11 @@ mod tests {
         .unwrap();
 
         // Move first task after second
-        move_task(&mut conn, &t1, Placement::After(&t2))
+        move_task(&mut conn, &t1, Placement::After(&t2), Ordering::Position)
             .await
             .unwrap();
 
-        let tasks = list(&mut conn, bl.id, &ListFilter::default())
+        let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
             .await
             .unwrap();
         assert_eq!(tasks[0].id, t2.id);
@@ -874,11 +1045,11 @@ mod tests {
         .await
         .unwrap();
 
-        move_task(&mut conn, &t3, Placement::Before(&t1))
+        move_task(&mut conn, &t3, Placement::Before(&t1), Ordering::Position)
             .await
             .unwrap();
 
-        let tasks = list(&mut conn, bl.id, &ListFilter::default())
+        let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
             .await
             .unwrap();
         assert_eq!(tasks[0].id, t3.id);
@@ -925,11 +1096,11 @@ mod tests {
         .await
         .unwrap();
 
-        move_task(&mut conn, &t1, Placement::After(&t3))
+        move_task(&mut conn, &t1, Placement::After(&t3), Ordering::Position)
             .await
             .unwrap();
 
-        let tasks = list(&mut conn, bl.id, &ListFilter::default())
+        let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
             .await
             .unwrap();
         assert_eq!(tasks[0].id, t2.id);
@@ -983,11 +1154,12 @@ mod tests {
                 after: &t1,
                 before: &t2,
             },
+            Ordering::Position,
         )
         .await
         .unwrap();
 
-        let tasks = list(&mut conn, bl.id, &ListFilter::default())
+        let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
             .await
             .unwrap();
         assert_eq!(tasks[0].id, t1.id);
@@ -1023,9 +1195,14 @@ mod tests {
         .await
         .unwrap();
 
-        let err = move_task(&mut conn, &queued, Placement::Before(&done))
-            .await
-            .unwrap_err();
+        let err = move_task(
+            &mut conn,
+            &queued,
+            Placement::Before(&done),
+            Ordering::Position,
+        )
+        .await
+        .unwrap_err();
         assert!(err.to_string().contains("queued"));
         assert!(err.to_string().contains("done"));
     }
@@ -1072,9 +1249,16 @@ mod tests {
         .unwrap();
 
         // Move queued task to done — should land after Done 2
-        let updated = edit(&mut conn, q1.id, None, None, Some(State::Done))
-            .await
-            .unwrap();
+        let updated = edit(
+            &mut conn,
+            q1.id,
+            None,
+            None,
+            Some(State::Done),
+            Ordering::Position,
+        )
+        .await
+        .unwrap();
         assert_eq!(updated.state, State::Done);
 
         let done = list(
@@ -1084,6 +1268,7 @@ mod tests {
                 state: Some(State::Done),
                 ..Default::default()
             },
+            Ordering::Position,
         )
         .await
         .unwrap();
@@ -1138,9 +1323,16 @@ mod tests {
         .unwrap();
 
         // Move in_progress task to queued — should land before Queued 1
-        let updated = edit(&mut conn, ip.id, None, None, Some(State::Queued))
-            .await
-            .unwrap();
+        let updated = edit(
+            &mut conn,
+            ip.id,
+            None,
+            None,
+            Some(State::Queued),
+            Ordering::Position,
+        )
+        .await
+        .unwrap();
         assert_eq!(updated.state, State::Queued);
 
         let queued = list(
@@ -1150,6 +1342,7 @@ mod tests {
                 state: Some(State::Queued),
                 ..Default::default()
             },
+            Ordering::Position,
         )
         .await
         .unwrap();
@@ -1194,9 +1387,16 @@ mod tests {
         let original_pos = t1.position.clone();
 
         // Edit to same state — position should not change
-        let updated = edit(&mut conn, t1.id, None, None, Some(State::Queued))
-            .await
-            .unwrap();
+        let updated = edit(
+            &mut conn,
+            t1.id,
+            None,
+            None,
+            Some(State::Queued),
+            Ordering::Position,
+        )
+        .await
+        .unwrap();
         assert_eq!(updated.position, original_pos);
 
         let queued = list(
@@ -1206,6 +1406,7 @@ mod tests {
                 state: Some(State::Queued),
                 ..Default::default()
             },
+            Ordering::Position,
         )
         .await
         .unwrap();
@@ -1232,9 +1433,16 @@ mod tests {
         .unwrap();
 
         // Move to done (empty group) — should succeed
-        let updated = edit(&mut conn, t1.id, None, None, Some(State::Done))
-            .await
-            .unwrap();
+        let updated = edit(
+            &mut conn,
+            t1.id,
+            None,
+            None,
+            Some(State::Done),
+            Ordering::Position,
+        )
+        .await
+        .unwrap();
         assert_eq!(updated.state, State::Done);
 
         let done = list(
@@ -1244,6 +1452,7 @@ mod tests {
                 state: Some(State::Done),
                 ..Default::default()
             },
+            Ordering::Position,
         )
         .await
         .unwrap();
@@ -1270,9 +1479,16 @@ mod tests {
         .unwrap();
 
         // Move to icebox (empty group) — should succeed
-        let updated = edit(&mut conn, t1.id, None, None, Some(State::Icebox))
-            .await
-            .unwrap();
+        let updated = edit(
+            &mut conn,
+            t1.id,
+            None,
+            None,
+            Some(State::Icebox),
+            Ordering::Position,
+        )
+        .await
+        .unwrap();
         assert_eq!(updated.state, State::Icebox);
 
         let icebox = list(
@@ -1282,6 +1498,7 @@ mod tests {
                 state: Some(State::Icebox),
                 ..Default::default()
             },
+            Ordering::Position,
         )
         .await
         .unwrap();
@@ -1310,17 +1527,18 @@ mod tests {
             .unwrap();
         }
 
-        let before: Vec<String> = list(&mut conn, bl.id, &ListFilter::default())
-            .await
-            .unwrap()
-            .iter()
-            .map(|t| t.position.clone())
-            .collect();
+        let before: Vec<String> =
+            list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
+                .await
+                .unwrap()
+                .iter()
+                .map(|t| t.position.clone())
+                .collect();
 
         let count = rebalance(&mut conn, bl.id).await.unwrap();
         assert_eq!(count, 3);
 
-        let after = list(&mut conn, bl.id, &ListFilter::default())
+        let after = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
             .await
             .unwrap();
         let after_positions: Vec<String> = after.iter().map(|t| t.position.clone()).collect();
@@ -1423,7 +1641,7 @@ mod tests {
         assert!(archived.archived);
 
         // Default list excludes archived
-        let visible = list(&mut conn, bl.id, &ListFilter::default())
+        let visible = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
             .await
             .unwrap();
         assert_eq!(visible.len(), 1);
@@ -1437,6 +1655,7 @@ mod tests {
                 include_archived: true,
                 ..Default::default()
             },
+            Ordering::Position,
         )
         .await
         .unwrap();
@@ -1446,7 +1665,7 @@ mod tests {
         let restored = set_archived(&mut conn, t2.id, false).await.unwrap();
         assert!(!restored.archived);
 
-        let visible = list(&mut conn, bl.id, &ListFilter::default())
+        let visible = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
             .await
             .unwrap();
         assert_eq!(visible.len(), 2);
@@ -1490,10 +1709,740 @@ mod tests {
                 tag: Some("bug".to_string()),
                 ..Default::default()
             },
+            Ordering::Position,
         )
         .await
         .unwrap();
         assert_eq!(results.len(), 1);
         assert_eq!(results[0].title, "Tagged queued");
     }
+
+    // ---- DAG ordering tests ----
+
+    #[tokio::test]
+    async fn dag_list_orders_by_id_with_no_edges() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        let t1 = create(
+            &mut conn,
+            CreateTask {
+                title: "A",
+                backlog_id: bl.id,
+                state: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let t2 = create(
+            &mut conn,
+            CreateTask {
+                title: "B",
+                backlog_id: bl.id,
+                state: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let t3 = create(
+            &mut conn,
+            CreateTask {
+                title: "C",
+                backlog_id: bl.id,
+                state: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+
+        let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Dag)
+            .await
+            .unwrap();
+        assert_eq!(tasks.len(), 3);
+        assert_eq!(tasks[0].id, t1.id);
+        assert_eq!(tasks[1].id, t2.id);
+        assert_eq!(tasks[2].id, t3.id);
+    }
+
+    #[tokio::test]
+    async fn dag_list_respects_before_edges() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        let t1 = create(
+            &mut conn,
+            CreateTask {
+                title: "A",
+                backlog_id: bl.id,
+                state: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let t2 = create(
+            &mut conn,
+            CreateTask {
+                title: "B",
+                backlog_id: bl.id,
+                state: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let t3 = create(
+            &mut conn,
+            CreateTask {
+                title: "C",
+                backlog_id: bl.id,
+                state: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+
+        // t3 should come before t1 (override natural id order)
+        crate::ops::edge::add(&mut conn, t3.id, t1.id, crate::models::EdgeType::Before)
+            .await
+            .unwrap();
+
+        let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Dag)
+            .await
+            .unwrap();
+        assert_eq!(tasks[0].id, t2.id, "t2 has lowest id, no constraints");
+        assert_eq!(tasks[1].id, t3.id, "t3 before t1 by edge");
+        assert_eq!(tasks[2].id, t1.id);
+    }
+
+    #[tokio::test]
+    async fn dag_move_before() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        let t1 = create(
+            &mut conn,
+            CreateTask {
+                title: "A",
+                backlog_id: bl.id,
+                state: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let t2 = create(
+            &mut conn,
+            CreateTask {
+                title: "B",
+                backlog_id: bl.id,
+                state: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+
+        // Move t2 before t1
+        move_task(&mut conn, &t2, Placement::Before(&t1), Ordering::Dag)
+            .await
+            .unwrap();
+
+        let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Dag)
+            .await
+            .unwrap();
+        assert_eq!(tasks[0].id, t2.id);
+        assert_eq!(tasks[1].id, t1.id);
+    }
+
+    #[tokio::test]
+    async fn dag_move_after() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        let t1 = create(
+            &mut conn,
+            CreateTask {
+                title: "A",
+                backlog_id: bl.id,
+                state: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let t2 = create(
+            &mut conn,
+            CreateTask {
+                title: "B",
+                backlog_id: bl.id,
+                state: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let t3 = create(
+            &mut conn,
+            CreateTask {
+                title: "C",
+                backlog_id: bl.id,
+                state: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+
+        // Move t1 after t3 (t1 is normally first by id)
+        move_task(&mut conn, &t1, Placement::After(&t3), Ordering::Dag)
+            .await
+            .unwrap();
+
+        let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Dag)
+            .await
+            .unwrap();
+        assert_eq!(tasks[0].id, t2.id);
+        assert_eq!(tasks[1].id, t3.id);
+        assert_eq!(tasks[2].id, t1.id);
+    }
+
+    #[tokio::test]
+    async fn dag_move_between() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        let t1 = create(
+            &mut conn,
+            CreateTask {
+                title: "A",
+                backlog_id: bl.id,
+                state: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let t2 = create(
+            &mut conn,
+            CreateTask {
+                title: "B",
+                backlog_id: bl.id,
+                state: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let t3 = create(
+            &mut conn,
+            CreateTask {
+                title: "C",
+                backlog_id: bl.id,
+                state: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+
+        // Set up chain: t1 → t2
+        crate::ops::edge::add(&mut conn, t1.id, t2.id, crate::models::EdgeType::Before)
+            .await
+            .unwrap();
+
+        // Move t3 between t1 and t2
+        move_task(
+            &mut conn,
+            &t3,
+            Placement::Between {
+                after: &t1,
+                before: &t2,
+            },
+            Ordering::Dag,
+        )
+        .await
+        .unwrap();
+
+        let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Dag)
+            .await
+            .unwrap();
+        assert_eq!(tasks[0].id, t1.id);
+        assert_eq!(tasks[1].id, t3.id);
+        assert_eq!(tasks[2].id, t2.id);
+    }
+
+    #[tokio::test]
+    async fn dag_move_rejects_cross_state() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        let queued = create(
+            &mut conn,
+            CreateTask {
+                title: "Q",
+                backlog_id: bl.id,
+                state: Some(State::Queued),
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let done = create(
+            &mut conn,
+            CreateTask {
+                title: "D",
+                backlog_id: bl.id,
+                state: Some(State::Done),
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+
+        let err = move_task(&mut conn, &queued, Placement::Before(&done), Ordering::Dag)
+            .await
+            .unwrap_err();
+        assert!(err.to_string().contains("queued"));
+        assert!(err.to_string().contains("done"));
+    }
+
+    #[tokio::test]
+    async fn dag_state_change_up_places_at_end() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        let d1 = create(
+            &mut conn,
+            CreateTask {
+                title: "Done 1",
+                backlog_id: bl.id,
+                state: Some(State::Done),
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let d2 = create(
+            &mut conn,
+            CreateTask {
+                title: "Done 2",
+                backlog_id: bl.id,
+                state: Some(State::Done),
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let q1 = create(
+            &mut conn,
+            CreateTask {
+                title: "Queued 1",
+                backlog_id: bl.id,
+                state: Some(State::Queued),
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+
+        let updated = edit(
+            &mut conn,
+            q1.id,
+            None,
+            None,
+            Some(State::Done),
+            Ordering::Dag,
+        )
+        .await
+        .unwrap();
+        assert_eq!(updated.state, State::Done);
+
+        let done = list(
+            &mut conn,
+            bl.id,
+            &ListFilter {
+                state: Some(State::Done),
+                ..Default::default()
+            },
+            Ordering::Dag,
+        )
+        .await
+        .unwrap();
+        assert_eq!(done.len(), 3);
+        assert_eq!(done[0].id, d1.id);
+        assert_eq!(done[1].id, d2.id);
+        assert_eq!(done[2].id, q1.id, "newly done task should be at end");
+    }
+
+    #[tokio::test]
+    async fn dag_state_change_down_places_at_beginning() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        let q1 = create(
+            &mut conn,
+            CreateTask {
+                title: "Queued 1",
+                backlog_id: bl.id,
+                state: Some(State::Queued),
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let q2 = create(
+            &mut conn,
+            CreateTask {
+                title: "Queued 2",
+                backlog_id: bl.id,
+                state: Some(State::Queued),
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let ip = create(
+            &mut conn,
+            CreateTask {
+                title: "In Progress",
+                backlog_id: bl.id,
+                state: Some(State::InProgress),
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+
+        let updated = edit(
+            &mut conn,
+            ip.id,
+            None,
+            None,
+            Some(State::Queued),
+            Ordering::Dag,
+        )
+        .await
+        .unwrap();
+        assert_eq!(updated.state, State::Queued);
+
+        let queued = list(
+            &mut conn,
+            bl.id,
+            &ListFilter {
+                state: Some(State::Queued),
+                ..Default::default()
+            },
+            Ordering::Dag,
+        )
+        .await
+        .unwrap();
+        assert_eq!(queued.len(), 3);
+        assert_eq!(queued[0].id, ip.id, "demoted task should be at beginning");
+        assert_eq!(queued[1].id, q1.id);
+        assert_eq!(queued[2].id, q2.id);
+    }
+
+    #[tokio::test]
+    async fn dag_state_change_to_empty_group() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        let t1 = create(
+            &mut conn,
+            CreateTask {
+                title: "Queued",
+                backlog_id: bl.id,
+                state: Some(State::Queued),
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+
+        let updated = edit(
+            &mut conn,
+            t1.id,
+            None,
+            None,
+            Some(State::Done),
+            Ordering::Dag,
+        )
+        .await
+        .unwrap();
+        assert_eq!(updated.state, State::Done);
+
+        let done = list(
+            &mut conn,
+            bl.id,
+            &ListFilter {
+                state: Some(State::Done),
+                ..Default::default()
+            },
+            Ordering::Dag,
+        )
+        .await
+        .unwrap();
+        assert_eq!(done.len(), 1);
+        assert_eq!(done[0].id, t1.id);
+    }
+
+    #[tokio::test]
+    async fn dag_state_change_same_state_is_noop() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        let t1 = create(
+            &mut conn,
+            CreateTask {
+                title: "Queued",
+                backlog_id: bl.id,
+                state: Some(State::Queued),
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+
+        let updated = edit(
+            &mut conn,
+            t1.id,
+            None,
+            None,
+            Some(State::Queued),
+            Ordering::Dag,
+        )
+        .await
+        .unwrap();
+        assert_eq!(updated.state, State::Queued);
+
+        // No edges should have been created
+        let edges = crate::ops::edge::list_all(&mut conn).await.unwrap();
+        assert!(edges.is_empty());
+    }
+
+    #[tokio::test]
+    async fn dag_state_change_up_into_group_with_existing_edges() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        // Done tasks with an existing chain: d1 → d2
+        let d1 = create(
+            &mut conn,
+            CreateTask {
+                title: "Done 1",
+                backlog_id: bl.id,
+                state: Some(State::Done),
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let d2 = create(
+            &mut conn,
+            CreateTask {
+                title: "Done 2",
+                backlog_id: bl.id,
+                state: Some(State::Done),
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        crate::ops::edge::add(&mut conn, d1.id, d2.id, crate::models::EdgeType::Before)
+            .await
+            .unwrap();
+
+        // Queued task to promote
+        let q1 = create(
+            &mut conn,
+            CreateTask {
+                title: "Queued 1",
+                backlog_id: bl.id,
+                state: Some(State::Queued),
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+
+        edit(
+            &mut conn,
+            q1.id,
+            None,
+            None,
+            Some(State::Done),
+            Ordering::Dag,
+        )
+        .await
+        .unwrap();
+
+        let done = list(
+            &mut conn,
+            bl.id,
+            &ListFilter {
+                state: Some(State::Done),
+                ..Default::default()
+            },
+            Ordering::Dag,
+        )
+        .await
+        .unwrap();
+        // d1 → d2 chain, q1 added after d2 (d2 is the only leaf)
+        assert_eq!(done.len(), 3);
+        assert_eq!(done[0].id, d1.id);
+        assert_eq!(done[1].id, d2.id);
+        assert_eq!(done[2].id, q1.id);
+    }
+
+    #[tokio::test]
+    async fn dag_state_change_down_into_group_with_existing_edges() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        // Queued tasks with an existing chain: q1 → q2
+        let q1 = create(
+            &mut conn,
+            CreateTask {
+                title: "Queued 1",
+                backlog_id: bl.id,
+                state: Some(State::Queued),
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let q2 = create(
+            &mut conn,
+            CreateTask {
+                title: "Queued 2",
+                backlog_id: bl.id,
+                state: Some(State::Queued),
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        crate::ops::edge::add(&mut conn, q1.id, q2.id, crate::models::EdgeType::Before)
+            .await
+            .unwrap();
+
+        // In-progress task to demote
+        let ip = create(
+            &mut conn,
+            CreateTask {
+                title: "IP",
+                backlog_id: bl.id,
+                state: Some(State::InProgress),
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+
+        edit(
+            &mut conn,
+            ip.id,
+            None,
+            None,
+            Some(State::Queued),
+            Ordering::Dag,
+        )
+        .await
+        .unwrap();
+
+        let queued = list(
+            &mut conn,
+            bl.id,
+            &ListFilter {
+                state: Some(State::Queued),
+                ..Default::default()
+            },
+            Ordering::Dag,
+        )
+        .await
+        .unwrap();
+        // ip added before q1 (q1 is the only root), chain: ip → q1 → q2
+        assert_eq!(queued.len(), 3);
+        assert_eq!(queued[0].id, ip.id);
+        assert_eq!(queued[1].id, q1.id);
+        assert_eq!(queued[2].id, q2.id);
+    }
+
+    #[tokio::test]
+    async fn dag_move_splices_out_of_old_chain() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        let t1 = create(
+            &mut conn,
+            CreateTask {
+                title: "A",
+                backlog_id: bl.id,
+                state: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let t2 = create(
+            &mut conn,
+            CreateTask {
+                title: "B",
+                backlog_id: bl.id,
+                state: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let t3 = create(
+            &mut conn,
+            CreateTask {
+                title: "C",
+                backlog_id: bl.id,
+                state: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+
+        // Chain: t1 → t2 → t3
+        crate::ops::edge::add(&mut conn, t1.id, t2.id, crate::models::EdgeType::Before)
+            .await
+            .unwrap();
+        crate::ops::edge::add(&mut conn, t2.id, t3.id, crate::models::EdgeType::Before)
+            .await
+            .unwrap();
+
+        // Move t2 after t3 — should splice out and re-chain t1 → t3
+        move_task(&mut conn, &t2, Placement::After(&t3), Ordering::Dag)
+            .await
+            .unwrap();
+
+        let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Dag)
+            .await
+            .unwrap();
+        assert_eq!(tasks[0].id, t1.id);
+        assert_eq!(tasks[1].id, t3.id);
+        assert_eq!(tasks[2].id, t2.id);
+    }
 }