Bring quire-ci schema design to quire-server
Replace the mutable state+failure_kind columns with timestamp-based
lifecycle and a single outcome enum, following the schema designed
for quire-ci.

Migration 0010 renames:
  queued_at_ms   → created_at
  started_at_ms  → dispatched_at
  finished_at_ms → resolved_at

And replaces state+failure_kind with outcome (NULL while running,
set on resolution): succeeded, failed-pipeline, failed-orphaned,
failed-internal, superseded.

RunState enum is removed from run.rs. Run now tracks dispatched/resolved
boolean flags derived from timestamps. transition() is replaced by
dispatch() and resolve(outcome). reconcile_orphans() and cancel_existing()
write timestamps+outcome directly. The bootstrap API checks dispatched_at
IS NULL instead of state = 'queued'.

Web layer (db.rs, templates.rs, handlers.rs, api.rs) updated to use new
column names. state() is now a derived method on template structs.
dev.rs seed fixtures updated to use outcome values.

https://claude.ai/code/session_0171wuJy2GRJZuj2oVYZ2iKe
change
commit 5f3b4af98584b29bbf2d8c5457ea6619a51582b1
author Claude <noreply@anthropic.com>
date
parent 6fb88648
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 9bf86bf..7418155 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -1,4 +1,3 @@
-mod quire;
 mod sink;
 
 use std::cell::RefCell;
diff --git a/quire-server/migrations/0010_outcome_schema.sql b/quire-server/migrations/0010_outcome_schema.sql
new file mode 100644
index 0000000..e0e9b7d
--- /dev/null
+++ b/quire-server/migrations/0010_outcome_schema.sql
@@ -0,0 +1,72 @@
+-- Replace state/failure_kind columns with timestamp-based lifecycle and
+-- outcome enum. Column renames:
+--   queued_at_ms   → created_at
+--   started_at_ms  → dispatched_at
+--   finished_at_ms → resolved_at
+--
+-- Lifecycle is now derived from whether timestamps are set:
+--   queued:   dispatched_at IS NULL AND resolved_at IS NULL
+--   active:   dispatched_at IS NOT NULL AND resolved_at IS NULL
+--   resolved: resolved_at IS NOT NULL (outcome IS NOT NULL)
+--
+-- outcome replaces state+failure_kind for resolved runs:
+--   state='succeeded'                          → 'succeeded'
+--   state='failed', failure_kind='orphaned'    → 'failed-orphaned'
+--   state='failed', failure_kind='process-crashed' → 'failed-internal'
+--   state='failed' (other)                     → 'failed-pipeline'
+--   state='canceled'                           → 'superseded'
+
+CREATE TABLE runs_new (
+  id             TEXT    PRIMARY KEY,
+  repo           TEXT    NOT NULL,
+  ref_name       TEXT    NOT NULL,
+  sha            TEXT    NOT NULL,
+  pushed_at_ms   INTEGER NOT NULL,
+  created_at     INTEGER NOT NULL,
+  dispatched_at  INTEGER,
+  resolved_at    INTEGER,
+  outcome        TEXT,
+  run_token      TEXT,
+  git_dir        TEXT,
+  traceparent    TEXT,
+
+  CHECK (dispatched_at IS NULL OR dispatched_at >= created_at),
+  CHECK (resolved_at   IS NULL OR resolved_at   >= created_at),
+  CHECK (resolved_at   IS NULL OR dispatched_at IS NULL
+         OR resolved_at >= dispatched_at),
+
+  CHECK ((resolved_at IS NULL) = (outcome IS NULL)),
+
+  CHECK (outcome IS NULL OR outcome IN (
+    'succeeded',
+    'failed-pipeline', 'failed-orphaned', 'failed-internal',
+    'superseded'
+  ))
+);
+
+INSERT INTO runs_new
+  SELECT
+    id, repo, ref_name, sha, pushed_at_ms,
+    queued_at_ms,
+    started_at_ms,
+    finished_at_ms,
+    CASE
+      WHEN state IN ('queued', 'active') THEN NULL
+      WHEN state = 'succeeded' THEN 'succeeded'
+      WHEN state = 'failed' THEN
+        CASE failure_kind
+          WHEN 'orphaned'        THEN 'failed-orphaned'
+          WHEN 'process-crashed' THEN 'failed-internal'
+          ELSE                        'failed-pipeline'
+        END
+      WHEN state = 'canceled' THEN 'superseded'
+      ELSE NULL
+    END,
+    run_token, git_dir, traceparent
+  FROM runs;
+
+DROP TABLE runs;
+ALTER TABLE runs_new RENAME TO runs;
+
+CREATE INDEX runs_repo_pushed_at ON runs(repo, pushed_at_ms DESC);
+CREATE INDEX runs_pending ON runs(created_at) WHERE outcome IS NULL;
diff --git a/quire-server/src/bin/quire/commands/dev.rs b/quire-server/src/bin/quire/commands/dev.rs
index edf7247..527b864 100644
--- a/quire-server/src/bin/quire/commands/dev.rs
+++ b/quire-server/src/bin/quire/commands/dev.rs
@@ -17,14 +17,14 @@ pub fn seed() -> Result<Quire> {
 }
 
 /// One run with its jobs. `pushed_delta_ms` is offset from "now" at seed time;
-/// `started_delta_ms` is offset from `pushed`; `duration_ms` is how long the
-/// run ran after starting.
+/// `dispatched_delta_ms` is offset from `pushed`; `duration_ms` is how long the
+/// run ran after dispatching.
 struct SeedRun {
-    state: &'static str,
+    outcome: Option<&'static str>,
     sha: &'static str,
     ref_name: &'static str,
     pushed_delta_ms: i64,
-    started_delta_ms: Option<i64>,
+    dispatched_delta_ms: Option<i64>,
     duration_ms: Option<i64>,
     jobs: Vec<SeedJob>,
 }
@@ -110,29 +110,29 @@ impl Seeder {
         let run_id = Uuid::now_v7().to_string();
 
         let pushed_at = self.base_ms + run.pushed_delta_ms;
-        let started_at = run.started_delta_ms.map(|d| pushed_at + d);
-        let finished_at = started_at.zip(run.duration_ms).map(|(s, d)| s + d);
+        let dispatched_at = run.dispatched_delta_ms.map(|d| pushed_at + d);
+        let resolved_at = dispatched_at.zip(run.duration_ms).map(|(s, d)| s + d);
 
         self.db
             .execute(
-                "INSERT INTO runs (id, repo, ref_name, sha, pushed_at_ms, state, failure_kind,
-                               queued_at_ms, started_at_ms, finished_at_ms)
-             VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9)",
+                "INSERT INTO runs (id, repo, ref_name, sha, pushed_at_ms,
+                               created_at, dispatched_at, resolved_at, outcome)
+             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
                 params![
                     run_id,
                     repo,
                     run.ref_name,
                     run.sha,
                     pushed_at,
-                    run.state,
-                    pushed_at, // queued_at_ms = pushed_at_ms
-                    started_at,
-                    finished_at,
+                    pushed_at, // created_at = pushed_at_ms
+                    dispatched_at,
+                    resolved_at,
+                    run.outcome,
                 ],
             )
             .into_diagnostic()?;
 
-        let Some(run_started_at) = started_at else {
+        let Some(run_dispatched_at) = dispatched_at else {
             return Ok(()); // queued run; no jobs to insert.
         };
 
@@ -144,7 +144,7 @@ impl Seeder {
             .join(&run_id);
 
         for job in &run.jobs {
-            let job_started_at = run_started_at + job.started_delta_ms;
+            let job_started_at = run_dispatched_at + job.started_delta_ms;
             let job_finished_at = job.duration_ms.map(|d| job_started_at + d);
 
             self.db
@@ -200,11 +200,11 @@ fn build_runs() -> Vec<SeedRun> {
     vec![
         // Run 1 — succeeded, all jobs passed.
         SeedRun {
-            state: "succeeded",
+            outcome: Some("succeeded"),
             sha: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
             ref_name: "refs/heads/main",
             pushed_delta_ms: 0,
-            started_delta_ms: Some(1000),
+            dispatched_delta_ms: Some(1000),
             duration_ms: Some(4000),
             jobs: vec![
                 SeedJob {
@@ -248,11 +248,11 @@ fn build_runs() -> Vec<SeedRun> {
         },
         // Run 2 — failed, one job failed.
         SeedRun {
-            state: "failed",
+            outcome: Some("failed-pipeline"),
             sha: "cafebabecafebabecafebabecafebabecafebabe",
             ref_name: "refs/heads/main",
             pushed_delta_ms: -600_000,
-            started_delta_ms: Some(1000),
+            dispatched_delta_ms: Some(1000),
             duration_ms: Some(7000),
             jobs: vec![
                 SeedJob {
@@ -294,13 +294,13 @@ fn build_runs() -> Vec<SeedRun> {
                 },
             ],
         },
-        // Run 3 — canceled, pushed then rebased.
+        // Run 3 — superseded, pushed then rebased.
         SeedRun {
-            state: "canceled",
+            outcome: Some("superseded"),
             sha: "1111111111111111111111111111111111111111",
             ref_name: "refs/heads/feature",
             pushed_delta_ms: -1_200_000,
-            started_delta_ms: Some(1000),
+            dispatched_delta_ms: Some(1000),
             duration_ms: Some(1000),
             jobs: vec![SeedJob {
                 job_id: "build",
@@ -319,11 +319,11 @@ fn build_runs() -> Vec<SeedRun> {
         },
         // Run 4 — active, still running.
         SeedRun {
-            state: "active",
+            outcome: None,
             sha: "2222222222222222222222222222222222222222",
             ref_name: "refs/heads/main",
             pushed_delta_ms: -5000,
-            started_delta_ms: Some(1000),
+            dispatched_delta_ms: Some(1000),
             duration_ms: None,
             jobs: vec![SeedJob {
                 job_id: "build",
@@ -351,21 +351,21 @@ fn build_runs() -> Vec<SeedRun> {
         },
         // Run 5 — queued but not started.
         SeedRun {
-            state: "queued",
+            outcome: None,
             sha: "3333333333333333333333333333333333333333",
             ref_name: "refs/heads/main",
             pushed_delta_ms: -1000,
-            started_delta_ms: None,
+            dispatched_delta_ms: None,
             duration_ms: None,
             jobs: vec![],
         },
         // Run 6 — succeeded, multi-job: lint + build + test.
         SeedRun {
-            state: "succeeded",
+            outcome: Some("succeeded"),
             sha: "4444444444444444444444444444444444444444",
             ref_name: "refs/heads/v2",
             pushed_delta_ms: -3_600_000,
-            started_delta_ms: Some(2000),
+            dispatched_delta_ms: Some(2000),
             duration_ms: Some(10_000),
             jobs: vec![
                 SeedJob {
@@ -414,11 +414,11 @@ fn build_runs() -> Vec<SeedRun> {
         },
         // Run 7 — failed, orphaned (container died mid-run).
         SeedRun {
-            state: "failed",
+            outcome: Some("failed-orphaned"),
             sha: "5555555555555555555555555555555555555555",
             ref_name: "refs/heads/main",
             pushed_delta_ms: -7_200_000,
-            started_delta_ms: Some(1000),
+            dispatched_delta_ms: Some(1000),
             duration_ms: Some(59_000),
             jobs: vec![
                 SeedJob {
diff --git a/quire-server/src/ci/error.rs b/quire-server/src/ci/error.rs
index 8da2215..d966fb3 100644
--- a/quire-server/src/ci/error.rs
+++ b/quire-server/src/ci/error.rs
@@ -3,7 +3,6 @@
 use miette::Diagnostic;
 
 use super::pipeline::{CompileError, PipelineError};
-use super::run::RunState;
 use super::runtime::RuntimeError;
 use quire_core::fennel::FennelError;
 use quire_core::secret;
@@ -22,8 +21,11 @@ pub enum Error {
     #[diagnostic(transparent)]
     Pipeline(Box<PipelineError>),
 
-    #[error("invalid run transition: {from:?} -> {to:?}")]
-    InvalidTransition { from: RunState, to: RunState },
+    #[error("run already dispatched")]
+    AlreadyDispatched,
+
+    #[error("run already resolved")]
+    AlreadyResolved,
 
     #[error(transparent)]
     Lua(Box<mlua::Error>),
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index 6570021..d3cb6ce 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -11,7 +11,7 @@ pub use quire_core::ci::pipeline::{
 pub use quire_core::ci::run::ApiSession;
 pub use quire_core::ci::run::RunMeta;
 pub use quire_core::ci::{pipeline, registration, runtime};
-pub use run::{Executor, Run, RunState, Runs, materialize_workspace, reconcile_orphans};
+pub use run::{Executor, Run, Runs, materialize_workspace, reconcile_orphans};
 use tracing_opentelemetry::OpenTelemetrySpanExt as _;
 
 /// A resolved commit reference.
@@ -508,22 +508,23 @@ exit 0
 
         // The run should have reached succeeded.
         let conn = crate::db::open(&quire.db_path()).expect("db");
-        let state: String = conn
+        let outcome: Option<String> = conn
             .query_row(
-                "SELECT state FROM runs WHERE sha = ?1",
+                "SELECT outcome FROM runs WHERE sha = ?1",
                 rusqlite::params![&sha],
                 |row| row.get(0),
             )
             .expect("should have a run");
         assert_eq!(
-            state, "succeeded",
+            outcome.as_deref(),
+            Some("succeeded"),
             "run should be succeeded after fake quire-ci exits 0"
         );
 
-        // No queued or active rows left behind.
+        // No unresolved rows left behind.
         let count: i64 = conn
             .query_row(
-                "SELECT COUNT(*) FROM runs WHERE state IN ('queued', 'active')",
+                "SELECT COUNT(*) FROM runs WHERE outcome IS NULL",
                 [],
                 |row| row.get(0),
             )
@@ -566,16 +567,19 @@ exit 0
         );
 
         let conn = crate::db::open(&quire.db_path()).expect("db");
-        let state: String = conn
+        let outcome: Option<String> = conn
             .query_row(
-                "SELECT state FROM runs WHERE sha = ?1",
+                "SELECT outcome FROM runs WHERE sha = ?1",
                 rusqlite::params![&sha],
                 |row| row.get(0),
             )
             .expect("should have a run");
-        assert_eq!(
-            state, "failed",
-            "run should be failed after quire-ci exits 1"
+        assert!(
+            outcome
+                .as_deref()
+                .map(|o| o.starts_with("failed"))
+                .unwrap_or(false),
+            "run should be failed after quire-ci exits 1, got: {outcome:?}"
         );
     }
 
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index 90e3caf..929fa5a 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -27,44 +27,6 @@ pub enum Executor {
     Process,
 }
 
-/// The state of a CI run.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum RunState {
-    Queued,
-    Active,
-    Succeeded,
-    Failed,
-    Canceled,
-}
-
-impl RunState {
-    pub fn as_str(&self) -> &'static str {
-        match self {
-            RunState::Queued => "queued",
-            RunState::Active => "active",
-            RunState::Succeeded => "succeeded",
-            RunState::Failed => "failed",
-            RunState::Canceled => "canceled",
-        }
-    }
-}
-
-impl std::str::FromStr for RunState {
-    type Err = ();
-
-    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
-        match s {
-            "queued" => Some(RunState::Queued),
-            "active" => Some(RunState::Active),
-            "succeeded" => Some(RunState::Succeeded),
-            "failed" => Some(RunState::Failed),
-            "canceled" => Some(RunState::Canceled),
-            _ => None,
-        }
-        .ok_or(())
-    }
-}
-
 /// Access to CI runs for a single repo.
 ///
 /// Owns the database path, repo name, and base directory for run
@@ -86,9 +48,9 @@ impl Runs {
         }
     }
 
-    /// Create a new run record in the `queued` state.
+    /// Create a new run record in the queued state.
     ///
-    /// Before inserting, cancels any existing `queued` or `active`
+    /// Before inserting, cancels any existing queued or active
     /// run for the same `(repo, ref)`.
     ///
     /// Inserts a row into `runs` and creates the run directory for
@@ -115,8 +77,8 @@ impl Runs {
         self.cancel_existing(&db, &meta.r#ref)?;
 
         db.execute(
-            "INSERT INTO runs (id, repo, ref_name, sha, pushed_at_ms, state, queued_at_ms, run_token)
-             VALUES (?1, ?2, ?3, ?4, ?5, 'queued', ?6, ?7)",
+            "INSERT INTO runs (id, repo, ref_name, sha, pushed_at_ms, created_at, run_token)
+             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
             rusqlite::params![
                 &id,
                 &self.repo,
@@ -135,12 +97,13 @@ impl Runs {
         Ok(Run {
             db_path: self.db_path.clone(),
             id,
-            state: RunState::Queued,
+            dispatched: false,
+            resolved: false,
             base_dir: self.base_dir.clone(),
         })
     }
 
-    /// Cancel any existing `queued` or `active` run for
+    /// Cancel any existing queued or active run for
     /// `(repo, ref)`. Different refs are unaffected.
     fn cancel_existing(&self, db: &rusqlite::Connection, ref_name: &str) -> Result<()> {
         let now = Timestamp::now().as_millisecond();
@@ -148,22 +111,30 @@ impl Runs {
         let active_ids: Vec<String> = db
             .prepare(
                 "SELECT id FROM runs
-                 WHERE repo = ?1 AND ref_name = ?2 AND state = 'active'",
+                 WHERE repo = ?1 AND ref_name = ?2
+                   AND dispatched_at IS NOT NULL AND resolved_at IS NULL",
             )?
             .query_map(rusqlite::params![&self.repo, ref_name], |row| row.get(0))?
             .collect::<std::result::Result<_, _>>()?;
 
         for run_id in &active_ids {
             db.execute(
-                "UPDATE runs SET state = 'canceled', finished_at_ms = ?1 WHERE id = ?2",
-                rusqlite::params![now, run_id],
+                "UPDATE runs SET \
+                    dispatched_at = COALESCE(dispatched_at, ?1), \
+                    resolved_at = COALESCE(resolved_at, ?2), \
+                    outcome = COALESCE(outcome, 'superseded') \
+                 WHERE id = ?3",
+                rusqlite::params![now, now, run_id],
             )?;
             tracing::info!(run_id = %run_id, "canceled active run");
         }
 
         let queued_count = db.execute(
-            "UPDATE runs SET state = 'canceled', finished_at_ms = ?1
-             WHERE repo = ?2 AND ref_name = ?3 AND state = 'queued'",
+            "UPDATE runs SET \
+                resolved_at = COALESCE(resolved_at, ?1), \
+                outcome = COALESCE(outcome, 'superseded') \
+             WHERE repo = ?2 AND ref_name = ?3
+               AND dispatched_at IS NULL AND resolved_at IS NULL",
             rusqlite::params![now, &self.repo, ref_name],
         )?;
         if queued_count > 0 {
@@ -174,17 +145,19 @@ impl Runs {
     }
 }
 
-/// Move every `queued` or `active` run to `failed` with
-/// `failure_kind = 'orphaned'`. Called once at server startup to clean
-/// up runs left behind by a prior instance. Operates across all repos —
-/// orphans aren't a per-repo concern.
+/// Move every queued or active run to `failed-orphaned`. Called once at
+/// server startup to clean up runs left behind by a prior instance.
+/// Operates across all repos — orphans aren't a per-repo concern.
 pub fn reconcile_orphans(db_path: &Path) -> Result<()> {
     let now = Timestamp::now().as_millisecond();
     let db = crate::db::open(db_path)?;
     let count = db.execute(
-        "UPDATE runs SET state = 'failed', finished_at_ms = ?1, failure_kind = 'orphaned'
-         WHERE state IN ('queued', 'active')",
-        rusqlite::params![now],
+        "UPDATE runs SET \
+            dispatched_at = COALESCE(dispatched_at, ?1), \
+            resolved_at = COALESCE(resolved_at, ?2), \
+            outcome = COALESCE(outcome, 'failed-orphaned') \
+         WHERE resolved_at IS NULL",
+        rusqlite::params![now, now],
     )?;
     if count > 0 {
         tracing::warn!(count, "reconciled orphaned runs");
@@ -194,13 +167,16 @@ pub fn reconcile_orphans(db_path: &Path) -> Result<()> {
 
 /// A CI run backed by a SQLite row.
 ///
-/// Owns the path to the database and the run's in-memory state cache.
+/// Owns the path to the database and the run's in-memory lifecycle flags.
 /// Reads and writes go through SQL. The run directory on disk holds
 /// the workspace and per-job log files.
 pub struct Run {
     db_path: PathBuf,
     id: String,
-    state: RunState,
+    /// Whether `dispatched_at` has been set (run is active or resolved).
+    dispatched: bool,
+    /// Whether `resolved_at` has been set (run is terminal).
+    resolved: bool,
     base_dir: PathBuf,
 }
 
@@ -215,29 +191,19 @@ impl Run {
         &self.id
     }
 
-    /// The run's current state.
-    pub fn state(&self) -> RunState {
-        self.state
-    }
-
     /// Open an existing run from the database by ID.
     pub fn open(db_path: PathBuf, id: String, base_dir: PathBuf) -> Result<Self> {
         let db = crate::db::open(&db_path)?;
-        let state_str: String = db.query_row(
-            "SELECT state FROM runs WHERE id = ?1",
+        let (dispatched_at, resolved_at): (Option<i64>, Option<i64>) = db.query_row(
+            "SELECT dispatched_at, resolved_at FROM runs WHERE id = ?1",
             rusqlite::params![&id],
-            |row| row.get(0),
+            |row| Ok((row.get(0)?, row.get(1)?)),
         )?;
-        let state: RunState = state_str.parse().map_err(|_| {
-            std::io::Error::new(
-                std::io::ErrorKind::InvalidData,
-                format!("invalid state in db: {state_str}"),
-            )
-        })?;
         Ok(Self {
             db_path,
             id,
-            state,
+            dispatched: dispatched_at.is_some(),
+            resolved: resolved_at.is_some(),
             base_dir,
         })
     }
@@ -252,7 +218,7 @@ impl Run {
     /// * `jobs/<job>/sh-<n>.log` — per-sh CRI logs, written by quire-ci
     ///   via `--out-dir`.
     ///
-    /// Run finishes `Succeeded` on exit 0, `Failed` otherwise. The DB
+    /// Run finishes `succeeded` on exit 0, `failed-*` otherwise. The DB
     /// rows are written even on failure so the web UI can render
     /// partial progress.
     pub fn execute(
@@ -264,15 +230,15 @@ impl Run {
         session: Option<&ApiSession>,
     ) -> Result<()> {
         // For API runs the GET /api/run/bootstrap endpoint owns the
-        // queued → active transition (it sets started_at_ms when quire-ci
-        // fetches the payload). Calling transition() here would set state =
-        // 'active' in the DB before quire-ci connects, causing the endpoint
-        // to return 410 Gone. Update local state only so the later
-        // transition(Succeeded/Failed) call passes the state-machine check.
+        // dispatch (it sets dispatched_at when quire-ci fetches the payload).
+        // Calling dispatch() here would set dispatched_at in the DB before
+        // quire-ci connects, causing the endpoint to return 410 Gone.
+        // Update local flag only so the later resolve() call skips the
+        // already-dispatched guard.
         if session.is_some() {
-            self.state = RunState::Active;
+            self.dispatched = true;
         } else {
-            self.transition(RunState::Active, None)?;
+            self.dispatch()?;
         }
 
         let run_dir = self.path();
@@ -339,7 +305,7 @@ impl Run {
         };
 
         if !status.success() {
-            self.transition(RunState::Failed, Some("process-crashed"))?;
+            self.resolve("failed-internal")?;
             return Err(Error::ProcessFailed {
                 exit: status.code(),
             });
@@ -350,13 +316,13 @@ impl Run {
         // treat that as a crash too.
         match run_outcome {
             Some(quire_core::ci::event::RunOutcome::Succeeded) => {
-                self.transition(RunState::Succeeded, None)?;
+                self.resolve("succeeded")?;
             }
             Some(quire_core::ci::event::RunOutcome::PipelineFailure) => {
-                self.transition(RunState::Failed, Some("pipeline-failure"))?;
+                self.resolve("failed-pipeline")?;
             }
             None => {
-                self.transition(RunState::Failed, Some("process-crashed"))?;
+                self.resolve("failed-internal")?;
                 return Err(Error::ProcessFailed {
                     exit: status.code(),
                 });
@@ -466,75 +432,38 @@ impl Run {
         Ok(())
     }
 
-    /// Transition the run from its current state to a new state.
-    ///
-    /// Allowed edges (see `docs/CI-STATE.md`):
-    ///
-    /// * `Queued → Active`
-    /// * `Queued → Succeeded`
-    /// * `Queued → Canceled`
-    /// * `Active → Succeeded`
-    /// * `Active → Failed`
-    /// * `Active → Canceled`
-    ///
-    /// `failure_kind` is recorded only when transitioning to
-    /// `Failed`; it is ignored for other targets. Pass a short tag
-    /// (`"quire-ci-exit"`) so the UI can distinguish job-pipeline
-    /// failures from `reconcile_orphans`'s `"orphaned"`. Each
-    /// timestamp and `failure_kind` is set at most once (via
-    /// `COALESCE`).
-    pub fn transition(&mut self, to: RunState, failure_kind: Option<&str>) -> Result<()> {
-        use RunState::*;
-        let allowed = matches!(
-            (self.state, to),
-            (Queued, Active)
-                | (Queued, Succeeded)
-                | (Queued, Canceled)
-                | (Active, Succeeded)
-                | (Active, Failed)
-                | (Active, Canceled)
-        );
-        if !allowed {
-            return Err(Error::InvalidTransition {
-                from: self.state,
-                to,
-            });
+    /// Mark the run as dispatched (queued → active). Sets `dispatched_at`.
+    pub fn dispatch(&mut self) -> Result<()> {
+        if self.dispatched || self.resolved {
+            return Err(Error::AlreadyDispatched);
         }
-
         let now = Timestamp::now().as_millisecond();
         let db = crate::db::open(&self.db_path)?;
+        db.execute(
+            "UPDATE runs SET dispatched_at = COALESCE(dispatched_at, ?1) WHERE id = ?2",
+            rusqlite::params![now, &self.id],
+        )?;
+        self.dispatched = true;
+        Ok(())
+    }
 
-        match to {
-            Active => {
-                db.execute(
-                    "UPDATE runs SET state = 'active', started_at_ms = COALESCE(started_at_ms, ?1)
-                     WHERE id = ?2",
-                    rusqlite::params![now, &self.id],
-                )?;
-            }
-            Succeeded | Canceled => {
-                db.execute(
-                    "UPDATE runs SET state = ?1, \
-                        started_at_ms = COALESCE(started_at_ms, ?2), \
-                        finished_at_ms = COALESCE(finished_at_ms, ?3) \
-                     WHERE id = ?4",
-                    rusqlite::params![to.as_str(), now, now, &self.id],
-                )?;
-            }
-            Failed => {
-                db.execute(
-                    "UPDATE runs SET state = 'failed', \
-                        started_at_ms = COALESCE(started_at_ms, ?1), \
-                        finished_at_ms = COALESCE(finished_at_ms, ?2), \
-                        failure_kind = COALESCE(failure_kind, ?3) \
-                     WHERE id = ?4",
-                    rusqlite::params![now, now, failure_kind, &self.id],
-                )?;
-            }
-            Queued => unreachable!("transition to Queued is not valid"),
+    /// Resolve the run with an outcome. Sets `resolved_at` and `outcome`.
+    pub fn resolve(&mut self, outcome: &str) -> Result<()> {
+        if self.resolved {
+            return Err(Error::AlreadyResolved);
         }
-
-        self.state = to;
+        let now = Timestamp::now().as_millisecond();
+        let db = crate::db::open(&self.db_path)?;
+        db.execute(
+            "UPDATE runs SET \
+                dispatched_at = COALESCE(dispatched_at, ?1), \
+                resolved_at = COALESCE(resolved_at, ?2), \
+                outcome = COALESCE(outcome, ?3) \
+             WHERE id = ?4",
+            rusqlite::params![now, now, outcome, &self.id],
+        )?;
+        self.dispatched = true;
+        self.resolved = true;
         Ok(())
     }
 
@@ -559,27 +488,38 @@ impl Run {
         })
     }
 
-    /// Read the `started_at` timestamp for this run, if set.
-    pub fn read_started_at(&self) -> Result<Option<Timestamp>> {
+    /// Read the `dispatched_at` timestamp for this run, if set.
+    pub fn read_dispatched_at(&self) -> Result<Option<Timestamp>> {
         let db = crate::db::open(&self.db_path)?;
         let ms: Option<i64> = db.query_row(
-            "SELECT started_at_ms FROM runs WHERE id = ?1",
+            "SELECT dispatched_at FROM runs WHERE id = ?1",
             rusqlite::params![&self.id],
             |row| row.get(0),
         )?;
         Ok(ms.map(|m| Timestamp::from_millisecond(m).expect("valid timestamp")))
     }
 
-    /// Read the `finished_at` timestamp for this run, if set.
-    pub fn read_finished_at(&self) -> Result<Option<Timestamp>> {
+    /// Read the `resolved_at` timestamp for this run, if set.
+    pub fn read_resolved_at(&self) -> Result<Option<Timestamp>> {
         let db = crate::db::open(&self.db_path)?;
         let ms: Option<i64> = db.query_row(
-            "SELECT finished_at_ms FROM runs WHERE id = ?1",
+            "SELECT resolved_at FROM runs WHERE id = ?1",
             rusqlite::params![&self.id],
             |row| row.get(0),
         )?;
         Ok(ms.map(|m| Timestamp::from_millisecond(m).expect("valid timestamp")))
     }
+
+    /// Read the `outcome` string for this run, if set.
+    pub fn read_outcome(&self) -> Result<Option<String>> {
+        let db = crate::db::open(&self.db_path)?;
+        let outcome: Option<String> = db.query_row(
+            "SELECT outcome FROM runs WHERE id = ?1",
+            rusqlite::params![&self.id],
+            |row| row.get(0),
+        )?;
+        Ok(outcome)
+    }
 }
 
 /// Take the final path component of a runs base (`runs/<repo>/`) and
@@ -756,20 +696,6 @@ mod tests {
         );
     }
 
-    #[test]
-    fn run_state_round_trips() {
-        for state in [
-            RunState::Queued,
-            RunState::Active,
-            RunState::Succeeded,
-            RunState::Failed,
-            RunState::Canceled,
-        ] {
-            assert!(state.as_str().parse::<RunState>().is_ok());
-        }
-        assert!("unknown".parse::<RunState>().is_err());
-    }
-
     #[test]
     fn create_generates_uuidv7_id() {
         let (_dir, quire) = tmp_quire();
@@ -832,7 +758,9 @@ mod tests {
             .create(&test_meta(), Some(&test_session()))
             .expect("create");
 
-        assert_eq!(run.state(), RunState::Queued);
+        // New run is not dispatched or resolved.
+        assert!(!run.dispatched);
+        assert!(!run.resolved);
 
         // Verify workspace directory was created.
         let workspace = run.path().join("workspace");
@@ -842,15 +770,15 @@ mod tests {
         let meta = run.read_meta().expect("read meta");
         assert_eq!(meta.sha, "abc123");
 
-        // No started_at yet.
-        let started = run.read_started_at().expect("read started_at");
-        assert!(started.is_none());
-        let finished = run.read_finished_at().expect("read finished_at");
-        assert!(finished.is_none());
+        // No dispatched_at or resolved_at yet.
+        let dispatched = run.read_dispatched_at().expect("read dispatched_at");
+        assert!(dispatched.is_none());
+        let resolved = run.read_resolved_at().expect("read resolved_at");
+        assert!(resolved.is_none());
     }
 
     #[test]
-    fn transition_updates_state_in_db() {
+    fn dispatch_updates_db() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
         let mut run = runs
@@ -858,167 +786,142 @@ mod tests {
             .expect("create");
         let id = run.id().to_string();
 
-        run.transition(RunState::Active, None).expect("transition");
-        assert_eq!(run.state(), RunState::Active);
+        run.dispatch().expect("dispatch");
+        assert!(run.dispatched);
+        assert!(!run.resolved);
 
-        // Verify started_at was stamped.
-        let started = run.read_started_at().expect("read started_at");
-        assert!(started.is_some(), "started_at should be stamped");
+        // Verify dispatched_at was stamped.
+        let dispatched = run.read_dispatched_at().expect("read dispatched_at");
+        assert!(dispatched.is_some(), "dispatched_at should be stamped");
 
         // Re-open the run and verify state persists.
         let reopened =
             Run::open(quire.db_path(), id.clone(), runs.base_dir.clone()).expect("reopen");
-        assert_eq!(reopened.state(), RunState::Active);
+        assert!(reopened.dispatched);
+        assert!(!reopened.resolved);
         assert_eq!(reopened.id(), id);
     }
 
     #[test]
-    fn transition_stamps_started_at_on_active() {
+    fn dispatch_stamps_dispatched_at() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
         let mut run = runs
             .create(&test_meta(), Some(&test_session()))
             .expect("create");
 
-        run.transition(RunState::Active, None).expect("to active");
-        let started = run.read_started_at().expect("read started_at");
-        assert!(started.is_some(), "started_at should be stamped");
-        assert!(run.read_finished_at().expect("read").is_none());
+        run.dispatch().expect("dispatch");
+        let dispatched = run.read_dispatched_at().expect("read dispatched_at");
+        assert!(dispatched.is_some(), "dispatched_at should be stamped");
+        assert!(run.read_resolved_at().expect("read").is_none());
     }
 
     #[test]
-    fn transition_stamps_finished_at_on_succeeded_and_failed() {
+    fn resolve_stamps_resolved_at_on_succeeded_and_failed() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
 
         let mut completed = runs
             .create(&test_meta(), Some(&test_session()))
             .expect("create");
-        completed
-            .transition(RunState::Active, None)
-            .expect("to active");
-        completed
-            .transition(RunState::Succeeded, None)
-            .expect("to succeed");
-        assert!(completed.read_finished_at().expect("read").is_some());
+        completed.dispatch().expect("dispatch");
+        completed.resolve("succeeded").expect("resolve succeeded");
+        assert!(completed.read_resolved_at().expect("read").is_some());
+        assert_eq!(
+            completed.read_outcome().expect("read outcome").as_deref(),
+            Some("succeeded")
+        );
 
         let mut failed = runs
             .create(&test_meta(), Some(&test_session()))
             .expect("create");
-        failed
-            .transition(RunState::Active, None)
-            .expect("to active");
-        failed
-            .transition(RunState::Failed, Some("job-error"))
-            .expect("to failed");
-        assert!(failed.read_finished_at().expect("read").is_some());
+        failed.dispatch().expect("dispatch");
+        failed.resolve("failed-pipeline").expect("resolve failed");
+        assert!(failed.read_resolved_at().expect("read").is_some());
+        assert_eq!(
+            failed.read_outcome().expect("read outcome").as_deref(),
+            Some("failed-pipeline")
+        );
     }
 
     #[test]
-    fn transition_records_failure_kind_on_failed() {
+    fn resolve_records_outcome() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
         let mut run = runs
             .create(&test_meta(), Some(&test_session()))
             .expect("create");
-        let id = run.id().to_string();
 
-        run.transition(RunState::Active, None).expect("to active");
-        run.transition(RunState::Failed, Some("job-error"))
-            .expect("to failed");
+        run.dispatch().expect("dispatch");
+        run.resolve("failed-internal").expect("resolve");
 
-        let db = crate::db::open(&quire.db_path()).expect("open db");
-        let kind: Option<String> = db
-            .query_row(
-                "SELECT failure_kind FROM runs WHERE id = ?1",
-                rusqlite::params![&id],
-                |row| row.get(0),
-            )
-            .expect("query");
-        assert_eq!(kind.as_deref(), Some("job-error"));
+        let outcome = run.read_outcome().expect("read outcome");
+        assert_eq!(outcome.as_deref(), Some("failed-internal"));
     }
 
     #[test]
-    fn transition_skips_failure_kind_when_none() {
+    fn resolve_rejects_double_resolve() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
-        let mut run = runs
+
+        // Already resolved -> error.
+        let mut completed = runs
             .create(&test_meta(), Some(&test_session()))
             .expect("create");
-        let id = run.id().to_string();
-
-        run.transition(RunState::Active, None).expect("to active");
-        run.transition(RunState::Failed, None).expect("to failed");
-
-        let db = crate::db::open(&quire.db_path()).expect("open db");
-        let kind: Option<String> = db
-            .query_row(
-                "SELECT failure_kind FROM runs WHERE id = ?1",
-                rusqlite::params![&id],
-                |row| row.get(0),
-            )
-            .expect("query");
-        assert!(kind.is_none());
+        completed.dispatch().expect("dispatch");
+        completed.resolve("succeeded").expect("to succeed");
+        assert!(completed.resolve("succeeded").is_err());
+        assert!(completed.resolve("failed-pipeline").is_err());
     }
 
     #[test]
-    fn transition_rejects_invalid_transitions() {
+    fn dispatch_rejects_double_dispatch() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
 
-        // Queued -> Failed is not allowed (must go via Active).
         let mut run = runs
             .create(&test_meta(), Some(&test_session()))
             .expect("create");
-        assert!(run.transition(RunState::Failed, None).is_err());
-
-        // Terminal -> anything is not allowed.
-        let mut completed = runs
-            .create(&test_meta(), Some(&test_session()))
-            .expect("create");
-        completed
-            .transition(RunState::Active, None)
-            .expect("to active");
-        completed
-            .transition(RunState::Succeeded, None)
-            .expect("to succeed");
-        assert!(completed.transition(RunState::Active, None).is_err());
-        assert!(completed.transition(RunState::Failed, None).is_err());
+        run.dispatch().expect("dispatch");
+        assert!(run.dispatch().is_err());
     }
 
     #[test]
-    fn transition_preserves_started_at_through_completion() {
+    fn resolve_preserves_dispatched_at_through_completion() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
         let mut run = runs
             .create(&test_meta(), Some(&test_session()))
             .expect("create");
 
-        run.transition(RunState::Active, None).expect("to active");
-        let started = run.read_started_at().expect("read started_at");
+        run.dispatch().expect("dispatch");
+        let dispatched = run.read_dispatched_at().expect("read dispatched_at");
 
-        run.transition(RunState::Succeeded, None)
-            .expect("to succeed");
+        run.resolve("succeeded").expect("resolve");
         assert_eq!(
-            run.read_started_at().expect("read"),
-            started,
-            "started_at preserved"
+            run.read_dispatched_at().expect("read"),
+            dispatched,
+            "dispatched_at preserved"
         );
     }
 
     #[test]
-    fn transition_full_lifecycle() {
+    fn full_lifecycle_dispatch_then_resolve() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
         let mut run = runs
             .create(&test_meta(), Some(&test_session()))
             .expect("create");
 
-        run.transition(RunState::Active, None).expect("to active");
-        run.transition(RunState::Succeeded, None)
-            .expect("to succeed");
+        run.dispatch().expect("dispatch");
+        run.resolve("succeeded").expect("resolve");
 
-        assert_eq!(run.state(), RunState::Succeeded);
+        assert!(run.dispatched);
+        assert!(run.resolved);
+        assert_eq!(
+            run.read_outcome().expect("outcome").as_deref(),
+            Some("succeeded")
+        );
     }
 
     #[test]
@@ -1033,7 +936,11 @@ mod tests {
         reconcile_orphans(&quire.db_path()).expect("reconcile");
 
         let reopened = Run::open(quire.db_path(), id, runs.base_dir.clone()).expect("reopen");
-        assert_eq!(reopened.state(), RunState::Failed);
+        assert!(reopened.resolved, "orphaned run should be resolved");
+        assert_eq!(
+            reopened.read_outcome().expect("outcome").as_deref(),
+            Some("failed-orphaned")
+        );
     }
 
     #[test]
@@ -1043,13 +950,17 @@ mod tests {
         let mut run = runs
             .create(&test_meta(), Some(&test_session()))
             .expect("create");
-        run.transition(RunState::Active, None).expect("to active");
+        run.dispatch().expect("dispatch");
         let id = run.id().to_string();
 
         reconcile_orphans(&quire.db_path()).expect("reconcile");
 
         let reopened = Run::open(quire.db_path(), id, runs.base_dir.clone()).expect("reopen");
-        assert_eq!(reopened.state(), RunState::Failed);
+        assert!(reopened.resolved, "orphaned active run should be resolved");
+        assert_eq!(
+            reopened.read_outcome().expect("outcome").as_deref(),
+            Some("failed-orphaned")
+        );
     }
 
     #[test]
@@ -1059,15 +970,17 @@ mod tests {
         let mut run = runs
             .create(&test_meta(), Some(&test_session()))
             .expect("create");
-        run.transition(RunState::Active, None).expect("to active");
-        run.transition(RunState::Succeeded, None)
-            .expect("to succeed");
+        run.dispatch().expect("dispatch");
+        run.resolve("succeeded").expect("resolve");
         let id = run.id().to_string();
 
         reconcile_orphans(&quire.db_path()).expect("reconcile");
 
         let reopened = Run::open(quire.db_path(), id, runs.base_dir.clone()).expect("reopen");
-        assert_eq!(reopened.state(), RunState::Succeeded);
+        assert_eq!(
+            reopened.read_outcome().expect("outcome").as_deref(),
+            Some("succeeded")
+        );
     }
 
     #[test]
@@ -1227,7 +1140,7 @@ mod tests {
             .create(&test_meta(), Some(&test_session()))
             .expect("create run1");
         let run1_id = run1.id().to_string();
-        assert_eq!(run1.state(), RunState::Queued);
+        assert!(!run1.dispatched && !run1.resolved);
 
         // Create second run for same (repo, ref) — should cancel the first.
         let meta2 = RunMeta {
@@ -1238,14 +1151,18 @@ mod tests {
         let run2 = runs
             .create(&meta2, Some(&test_session()))
             .expect("create run2");
-        assert_eq!(run2.state(), RunState::Queued);
+        assert!(!run2.dispatched && !run2.resolved);
 
-        // First run should now be canceled.
+        // First run should now be superseded (resolved).
         let reopened = Run::open(quire.db_path(), run1_id, runs.base_dir.clone()).expect("reopen");
-        assert_eq!(reopened.state(), RunState::Canceled);
+        assert!(reopened.resolved, "canceled run should be resolved");
+        assert_eq!(
+            reopened.read_outcome().expect("outcome").as_deref(),
+            Some("superseded")
+        );
         assert!(
-            reopened.read_finished_at().expect("read").is_some(),
-            "canceled run should have finished_at"
+            reopened.read_resolved_at().expect("read").is_some(),
+            "canceled run should have resolved_at"
         );
     }
 
@@ -1254,12 +1171,12 @@ mod tests {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
 
-        // Create and activate first run.
+        // Create and dispatch first run.
         let mut run1 = runs
             .create(&test_meta(), Some(&test_session()))
             .expect("create run1");
         let run1_id = run1.id().to_string();
-        run1.transition(RunState::Active, None).expect("to active");
+        run1.dispatch().expect("dispatch");
 
         // Create second run for same (repo, ref).
         let meta2 = RunMeta {
@@ -1270,14 +1187,18 @@ mod tests {
         let run2 = runs
             .create(&meta2, Some(&test_session()))
             .expect("create run2");
-        assert_eq!(run2.state(), RunState::Queued);
+        assert!(!run2.dispatched && !run2.resolved);
 
-        // First run should be canceled.
+        // First run should be superseded.
         let reopened = Run::open(quire.db_path(), run1_id, runs.base_dir.clone()).expect("reopen");
-        assert_eq!(reopened.state(), RunState::Canceled);
+        assert!(reopened.resolved, "canceled run should be resolved");
+        assert_eq!(
+            reopened.read_outcome().expect("outcome").as_deref(),
+            Some("superseded")
+        );
         assert!(
-            reopened.read_finished_at().expect("read").is_some(),
-            "canceled run should have finished_at"
+            reopened.read_resolved_at().expect("read").is_some(),
+            "canceled run should have resolved_at"
         );
     }
 
@@ -1302,9 +1223,9 @@ mod tests {
             .create(&meta2, Some(&test_session()))
             .expect("create run2");
 
-        // First run should still be queued.
+        // First run should still be queued (not dispatched, not resolved).
         let reopened = Run::open(quire.db_path(), run1_id, runs.base_dir.clone()).expect("reopen");
-        assert_eq!(reopened.state(), RunState::Queued);
+        assert!(!reopened.dispatched && !reopened.resolved);
     }
 
     #[test]
@@ -1317,9 +1238,8 @@ mod tests {
             .create(&test_meta(), Some(&test_session()))
             .expect("create run1");
         let run1_id = run1.id().to_string();
-        run1.transition(RunState::Active, None).expect("to active");
-        run1.transition(RunState::Succeeded, None)
-            .expect("to succeed");
+        run1.dispatch().expect("dispatch");
+        run1.resolve("succeeded").expect("resolve");
 
         // Create second run for same (repo, ref).
         let meta2 = RunMeta {
@@ -1333,50 +1253,30 @@ mod tests {
 
         // First run should still be succeeded.
         let reopened = Run::open(quire.db_path(), run1_id, runs.base_dir.clone()).expect("reopen");
-        assert_eq!(reopened.state(), RunState::Succeeded);
-    }
-
-    #[test]
-    fn transition_allows_queued_to_canceled() {
-        let (_dir, quire) = tmp_quire();
-        let runs = test_runs(&quire);
-        let mut run = runs
-            .create(&test_meta(), Some(&test_session()))
-            .expect("create");
-        run.transition(RunState::Canceled, None).expect("to cancel");
-        assert_eq!(run.state(), RunState::Canceled);
-    }
-
-    #[test]
-    fn transition_allows_active_to_canceled() {
-        let (_dir, quire) = tmp_quire();
-        let runs = test_runs(&quire);
-        let mut run = runs
-            .create(&test_meta(), Some(&test_session()))
-            .expect("create");
-        run.transition(RunState::Active, None).expect("to active");
-        run.transition(RunState::Canceled, None).expect("to cancel");
-        assert_eq!(run.state(), RunState::Canceled);
+        assert_eq!(
+            reopened.read_outcome().expect("outcome").as_deref(),
+            Some("succeeded")
+        );
     }
 
     #[test]
-    fn cancel_sets_finished_at() {
+    fn resolve_sets_resolved_at() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
         let mut run = runs
             .create(&test_meta(), Some(&test_session()))
             .expect("create");
-        run.transition(RunState::Active, None).expect("to active");
+        run.dispatch().expect("dispatch");
 
         assert!(
-            run.read_finished_at().expect("read").is_none(),
-            "should not have finished_at before cancel"
+            run.read_resolved_at().expect("read").is_none(),
+            "should not have resolved_at before resolve"
         );
 
-        run.transition(RunState::Canceled, None).expect("to cancel");
+        run.resolve("superseded").expect("resolve");
         assert!(
-            run.read_finished_at().expect("read").is_some(),
-            "canceled run should have finished_at"
+            run.read_resolved_at().expect("read").is_some(),
+            "resolved run should have resolved_at"
         );
     }
 }
diff --git a/quire-server/src/db.rs b/quire-server/src/db.rs
index b7a047d..45d23ca 100644
--- a/quire-server/src/db.rs
+++ b/quire-server/src/db.rs
@@ -21,6 +21,7 @@ static MIGRATIONS: std::sync::LazyLock<Migrations<'static>> = std::sync::LazyLoc
         M::up(include_str!("../migrations/0007_schema_cleanup.sql")),
         M::up(include_str!("../migrations/0008_rename_sh.sql")),
         M::up(include_str!("../migrations/0009_rename_ci_vocab.sql")),
+        M::up(include_str!("../migrations/0010_outcome_schema.sql")),
     ])
 });
 
diff --git a/quire-server/src/quire/web/api.rs b/quire-server/src/quire/web/api.rs
index ac8a879..b84318a 100644
--- a/quire-server/src/quire/web/api.rs
+++ b/quire-server/src/quire/web/api.rs
@@ -155,20 +155,20 @@ async fn get_bootstrap(
                 pushed_at_ms: i64,
                 git_dir: Option<String>,
                 traceparent: Option<String>,
-                state: String,
+                dispatched_at: Option<i64>,
                 repo: String,
             }
 
             let row: RunRow = db
                 .prepare(
-                    "SELECT sha, ref_name, pushed_at_ms, git_dir, traceparent, state, repo
+                    "SELECT sha, ref_name, pushed_at_ms, git_dir, traceparent, dispatched_at, repo
                      FROM runs WHERE id = ?1",
                 )?
                 .query_and_then(rusqlite::params![run_id], serde_rusqlite::from_row)?
                 .next()
                 .ok_or(rusqlite::Error::QueryReturnedNoRows)??;
 
-            if row.state != "queued" {
+            if row.dispatched_at.is_some() {
                 return Err(ApiError::Gone);
             }
 
@@ -181,10 +181,10 @@ async fn get_bootstrap(
                     .expect("db stores valid timestamps"),
             };
 
-            let started_at_ms = jiff::Timestamp::now().as_millisecond();
+            let dispatched_at_ms = jiff::Timestamp::now().as_millisecond();
             db.execute(
-                "UPDATE runs SET state = 'active', started_at_ms = ?1 WHERE id = ?2",
-                rusqlite::params![started_at_ms, run_id],
+                "UPDATE runs SET dispatched_at = ?1 WHERE id = ?2",
+                rusqlite::params![dispatched_at_ms, run_id],
             )?;
 
             Ok(Bootstrap {
diff --git a/quire-server/src/quire/web/db.rs b/quire-server/src/quire/web/db.rs
index a395469..c59e154 100644
--- a/quire-server/src/quire/web/db.rs
+++ b/quire-server/src/quire/web/db.rs
@@ -5,12 +5,23 @@ use crate::{Quire, Result};
 /// Raw run row from the database.
 pub struct RunRow {
     pub id: String,
-    pub state: String,
+    pub outcome: Option<String>,
     pub sha: String,
     pub ref_name: String,
-    pub queued_at_ms: i64,
-    pub started_at_ms: Option<i64>,
-    pub finished_at_ms: Option<i64>,
+    pub created_at: i64,
+    pub dispatched_at: Option<i64>,
+    pub resolved_at: Option<i64>,
+}
+
+impl RunRow {
+    pub fn derived_state(&self) -> &str {
+        match &self.outcome {
+            Some(o) if o.starts_with("failed") => "failed",
+            Some(o) => o.as_str(),
+            None if self.dispatched_at.is_some() => "active",
+            None => "queued",
+        }
+    }
 }
 
 /// Raw job row from the database.
@@ -37,9 +48,9 @@ pub fn load_runs(quire: &Quire, repo: &str) -> Result<Vec<RunRow>> {
         .lock()
         .map_err(|_| crate::error::Error::Io(std::io::Error::other("db mutex poisoned")))?;
     let mut stmt = db.prepare(
-        "SELECT id, state, sha, ref_name, queued_at_ms, started_at_ms, finished_at_ms
+        "SELECT id, outcome, sha, ref_name, created_at, dispatched_at, resolved_at
          FROM runs WHERE repo = ?1
-         ORDER BY queued_at_ms DESC
+         ORDER BY created_at DESC
          LIMIT 50",
     )?;
 
@@ -47,12 +58,12 @@ pub fn load_runs(quire: &Quire, repo: &str) -> Result<Vec<RunRow>> {
         .query_map(rusqlite::params![repo], |row| {
             Ok(RunRow {
                 id: row.get(0)?,
-                state: row.get(1)?,
+                outcome: row.get(1)?,
                 sha: row.get(2)?,
                 ref_name: row.get(3)?,
-                queued_at_ms: row.get(4)?,
-                started_at_ms: row.get(5)?,
-                finished_at_ms: row.get(6)?,
+                created_at: row.get(4)?,
+                dispatched_at: row.get(5)?,
+                resolved_at: row.get(6)?,
             })
         })?
         .collect::<rusqlite::Result<Vec<_>>>()?;
@@ -74,18 +85,18 @@ pub fn load_run_detail(quire: &Quire, repo: &str, run_id: &str) -> Result<RunDet
         .map_err(|_| crate::error::Error::Io(std::io::Error::other("db mutex poisoned")))?;
 
     let run = db.query_row(
-        "SELECT id, state, sha, ref_name, queued_at_ms, started_at_ms, finished_at_ms
+        "SELECT id, outcome, sha, ref_name, created_at, dispatched_at, resolved_at
          FROM runs WHERE id = ?1 AND repo = ?2",
         rusqlite::params![run_id, repo],
         |row| {
             Ok(RunRow {
                 id: row.get(0)?,
-                state: row.get(1)?,
+                outcome: row.get(1)?,
                 sha: row.get(2)?,
                 ref_name: row.get(3)?,
-                queued_at_ms: row.get(4)?,
-                started_at_ms: row.get(5)?,
-                finished_at_ms: row.get(6)?,
+                created_at: row.get(4)?,
+                dispatched_at: row.get(5)?,
+                resolved_at: row.get(6)?,
             })
         },
     )?;
diff --git a/quire-server/src/quire/web/handlers.rs b/quire-server/src/quire/web/handlers.rs
index 59e6066..adb401c 100644
--- a/quire-server/src/quire/web/handlers.rs
+++ b/quire-server/src/quire/web/handlers.rs
@@ -116,12 +116,12 @@ pub async fn run_list(State(quire): State<Quire>, AxumPath(repo): AxumPath<Strin
         .into_iter()
         .map(|r| RunListRow {
             id: r.id,
-            state: r.state,
+            outcome: r.outcome,
             sha: r.sha,
             ref_name: r.ref_name,
-            queued_at_ms: r.queued_at_ms,
-            started_at_ms: r.started_at_ms,
-            finished_at_ms: r.finished_at_ms,
+            created_at: r.created_at,
+            dispatched_at: r.dispatched_at,
+            resolved_at: r.resolved_at,
         })
         .collect();
 
@@ -170,12 +170,12 @@ pub async fn run_detail(
     };
 
     let detail_run = DetailRun {
-        state: detail.run.state,
+        outcome: detail.run.outcome,
         sha: detail.run.sha,
         ref_name: detail.run.ref_name,
-        queued_at_ms: detail.run.queued_at_ms,
-        started_at_ms: detail.run.started_at_ms,
-        finished_at_ms: detail.run.finished_at_ms,
+        created_at: detail.run.created_at,
+        dispatched_at: detail.run.dispatched_at,
+        resolved_at: detail.run.resolved_at,
     };
 
     // Group sh events by job_id, preserving DB order so positional index
@@ -315,20 +315,20 @@ mod tests {
         fn insert_run(
             &self,
             id: &str,
-            state: &str,
+            outcome: Option<&str>,
             sha: &str,
             ref_name: &str,
-            queued: i64,
-            started: Option<i64>,
-            finished: Option<i64>,
+            created: i64,
+            dispatched: Option<i64>,
+            resolved: Option<i64>,
         ) {
             let pool = self.quire.db_pool();
             let db = pool.lock().expect("lock");
             db.execute(
-                "INSERT INTO runs (id, repo, ref_name, sha, pushed_at_ms, state, failure_kind,
-                                  queued_at_ms, started_at_ms, finished_at_ms)
-                 VALUES (?1, 'example.git', ?2, ?3, ?4, ?5, NULL, ?4, ?6, ?7)",
-                rusqlite::params![id, ref_name, sha, queued, state, started, finished],
+                "INSERT INTO runs (id, repo, ref_name, sha, pushed_at_ms,
+                                  created_at, dispatched_at, resolved_at, outcome)
+                 VALUES (?1, 'example.git', ?2, ?3, ?4, ?4, ?5, ?6, ?7)",
+                rusqlite::params![id, ref_name, sha, created, dispatched, resolved, outcome],
             )
             .expect("insert run");
         }
@@ -393,7 +393,7 @@ mod tests {
         let env = TestEnv::new();
         env.insert_run(
             UUID1,
-            "succeeded",
+            Some("succeeded"),
             SHA1,
             "refs/heads/main",
             1000,
@@ -426,7 +426,7 @@ mod tests {
         let env = TestEnv::new();
         env.insert_run(
             UUID1,
-            "succeeded",
+            Some("succeeded"),
             SHA1,
             "refs/heads/main",
             1000,
diff --git a/quire-server/src/quire/web/templates.rs b/quire-server/src/quire/web/templates.rs
index fc0fc90..499667c 100644
--- a/quire-server/src/quire/web/templates.rs
+++ b/quire-server/src/quire/web/templates.rs
@@ -51,17 +51,26 @@ impl RunListTemplate {
 
 pub struct RunListRow {
     pub id: String,
-    pub state: String,
+    pub outcome: Option<String>,
     pub sha: String,
     pub ref_name: String,
-    pub queued_at_ms: i64,
-    pub started_at_ms: Option<i64>,
-    pub finished_at_ms: Option<i64>,
+    pub created_at: i64,
+    pub dispatched_at: Option<i64>,
+    pub resolved_at: Option<i64>,
 }
 
 impl RunListRow {
+    pub fn state(&self) -> &str {
+        match &self.outcome {
+            Some(o) if o.starts_with("failed") => "failed",
+            Some(o) => o.as_str(),
+            None if self.dispatched_at.is_some() => "active",
+            None => "queued",
+        }
+    }
+
     pub fn state_class(&self) -> &'static str {
-        format::state_class(&self.state)
+        format::state_class(self.state())
     }
 
     pub fn sha_short(&self) -> &str {
@@ -73,15 +82,15 @@ impl RunListRow {
     }
 
     pub fn queued_relative(&self) -> String {
-        format::format_timestamp_relative(self.queued_at_ms)
+        format::format_timestamp_relative(self.created_at)
     }
 
     pub fn queued_iso(&self) -> String {
-        format::format_timestamp_iso(self.queued_at_ms)
+        format::format_timestamp_iso(self.created_at)
     }
 
     pub fn duration_display(&self) -> String {
-        format::format_duration(self.started_at_ms, self.finished_at_ms)
+        format::format_duration(self.dispatched_at, self.resolved_at)
     }
 }
 
@@ -104,17 +113,26 @@ impl RunDetailTemplate {
 }
 
 pub struct DetailRun {
-    pub state: String,
+    pub outcome: Option<String>,
     pub sha: String,
     pub ref_name: String,
-    pub queued_at_ms: i64,
-    pub started_at_ms: Option<i64>,
-    pub finished_at_ms: Option<i64>,
+    pub created_at: i64,
+    pub dispatched_at: Option<i64>,
+    pub resolved_at: Option<i64>,
 }
 
 impl DetailRun {
+    pub fn state(&self) -> &str {
+        match &self.outcome {
+            Some(o) if o.starts_with("failed") => "failed",
+            Some(o) => o.as_str(),
+            None if self.dispatched_at.is_some() => "active",
+            None => "queued",
+        }
+    }
+
     pub fn state_class(&self) -> &'static str {
-        format::state_class(&self.state)
+        format::state_class(self.state())
     }
 
     pub fn sha_short(&self) -> &str {
@@ -126,51 +144,55 @@ impl DetailRun {
     }
 
     pub fn queued_relative(&self) -> String {
-        format::format_timestamp_relative(self.queued_at_ms)
+        format::format_timestamp_relative(self.created_at)
     }
 
     pub fn queued_iso(&self) -> String {
-        format::format_timestamp_iso(self.queued_at_ms)
+        format::format_timestamp_iso(self.created_at)
     }
 
     pub fn started_display(&self) -> String {
-        self.started_at_ms
+        self.dispatched_at
             .map(format::format_timestamp_relative)
             .unwrap_or_else(|| "—".to_string())
     }
 
     pub fn started_iso(&self) -> String {
-        self.started_at_ms
+        self.dispatched_at
             .map(format::format_timestamp_iso)
             .unwrap_or_default()
     }
 
     pub fn has_started(&self) -> bool {
-        self.started_at_ms.is_some()
+        self.dispatched_at.is_some()
     }
 
     pub fn finished_display(&self) -> String {
-        self.finished_at_ms
+        self.resolved_at
             .map(format::format_timestamp_relative)
             .unwrap_or_else(|| "—".to_string())
     }
 
     pub fn finished_iso(&self) -> String {
-        self.finished_at_ms
+        self.resolved_at
             .map(format::format_timestamp_iso)
             .unwrap_or_default()
     }
 
     pub fn has_finished(&self) -> bool {
-        self.finished_at_ms.is_some()
+        self.resolved_at.is_some()
+    }
+
+    pub fn is_resolved(&self) -> bool {
+        self.outcome.is_some()
     }
 
     pub fn is_terminal(&self) -> bool {
-        self.state == "succeeded" || self.state == "failed"
+        self.is_resolved()
     }
 
     pub fn duration_display(&self) -> String {
-        format::format_duration(self.started_at_ms, self.finished_at_ms)
+        format::format_duration(self.dispatched_at, self.resolved_at)
     }
 }
 
diff --git a/quire-server/templates/ci/run_detail.html b/quire-server/templates/ci/run_detail.html
index 37db90a..e5f1d79 100644
--- a/quire-server/templates/ci/run_detail.html
+++ b/quire-server/templates/ci/run_detail.html
@@ -11,7 +11,7 @@
   <div class="ci-meta-primary">
     <span class="c-accent">{{ run.sha_short() }}</span>
     · {{ run.branch_short() }}
-    · <span class="ci-state-label {{ run.state_class() }}"><span class="ci-status-dot {{ run.state_class() }}"></span> {{ run.state }}</span>
+    · <span class="ci-state-label {{ run.state_class() }}"><span class="ci-status-dot {{ run.state_class() }}"></span> {{ run.state() }}</span>
   </div>
   <div class="ci-meta-secondary">
     {% if run.is_terminal() %}