Rename CI state vocabulary
Aligns runs and jobs with standard CI terms: pending→queued,
complete→succeeded, superseded→canceled. Also drops the unused
pending/skipped/aborted job states from the CHECK — only active,
succeeded, and failed have producers today.

Assisted-by: Claude Opus 4.7 via Claude Code
change qrszrwskmutnrqnuqrkrslpruoruwrmp
commit 94d5d74e0ff2f1f7595cea06f29d6c6107bff111
author Alpha Chen <alpha@kejadlen.dev>
date
parent qzyppvsw
diff --git a/docs/CI-STATE.md b/docs/CI-STATE.md
index 0791cb5..bd74d04 100644
--- a/docs/CI-STATE.md
+++ b/docs/CI-STATE.md
@@ -15,26 +15,25 @@ A run owns its jobs; jobs FK on `(run_id, job_id)` and cascade delete.
 
 ```mermaid
 stateDiagram-v2
-    [*] --> pending : Runs.create
+    [*] --> queued : Runs.create
 
-    pending  --> active     : bootstrap endpoint
-    pending  --> superseded : supersede_existing
-    pending  --> failed     : reconcile_orphans
+    queued  --> active    : bootstrap endpoint
+    queued  --> canceled  : cancel_existing
+    queued  --> failed    : reconcile_orphans
 
-    active   --> complete   : transition Complete
-    active   --> failed     : pipeline-failure
-    active   --> failed     : process-crashed
-    active   --> superseded : supersede_existing
-    active   --> failed     : reconcile_orphans
+    active  --> succeeded : transition Succeeded
+    active  --> failed    : pipeline-failure
+    active  --> failed    : process-crashed
+    active  --> canceled  : cancel_existing
+    active  --> failed    : reconcile_orphans
 
-    complete   --> [*]
-    failed     --> [*]
-    superseded --> [*]
+    succeeded --> [*]
+    failed    --> [*]
+    canceled  --> [*]
 
-    note right of pending
+    note right of queued
       started_at_ms  IS NULL
       finished_at_ms IS NULL
-      container_id   IS NULL
     end note
 
     note right of active
@@ -42,9 +41,8 @@ stateDiagram-v2
       finished_at_ms still NULL
     end note
 
-    note right of complete
+    note right of succeeded
       started_at_ms, finished_at_ms set
-      container_id cleared
     end note
 ```
 
@@ -52,36 +50,36 @@ stateDiagram-v2
 
 | From → To | Where | When | `failure_kind` |
 | --- | --- | --- | --- |
-| `[*] → pending` | `Runs::create` (`quire-server/src/ci/run.rs:103`) | A push event arrives and a `runs` row is inserted. | — |
-| `pending → active` | Bootstrap endpoint (`api.rs`), called when `quire-ci` fetches bootstrap data | `quire-ci` connects to the server and marks the run active. Stamps `started_at_ms`. | — |
-| `active → complete` | `Run::transition`, called from `Run::execute` | `quire-ci` exited 0 and `RunFinished { outcome: Success }` was ingested. Stamps `finished_at_ms`, clears `container_id`. | — |
+| `[*] → queued` | `Runs::create` (`quire-server/src/ci/run.rs`) | A push event arrives and a `runs` row is inserted. | — |
+| `queued → active` | Bootstrap endpoint (`api.rs`), called when `quire-ci` fetches bootstrap data | `quire-ci` connects to the server and marks the run active. Stamps `started_at_ms`. | — |
+| `active → succeeded` | `Run::transition`, called from `Run::execute` | `quire-ci` exited 0 and `RunFinished { outcome: Succeeded }` was ingested. Stamps `finished_at_ms`. | — |
 | `active → failed` | `Run::execute` | `quire-ci` exited 0 and `RunFinished { outcome: PipelineFailure }` was ingested — a job's run-fn returned an error. | `"pipeline-failure"` |
 | `active → failed` | `Run::execute` | `quire-ci` exited non-zero, or exited 0 but emitted no `RunFinished` event (process crash or panic). | `"process-crashed"` |
-| `{pending, active} → superseded` | `Runs::supersede_existing` (`run.rs:155`) via raw SQL, **bypassing `transition`** | A new `Runs::create` for the same `(repo, ref)` arrived. Both pending and active rows are flipped directly. | — |
-| `{pending, active} → failed` | `reconcile_orphans` (`run.rs:205`) via raw SQL, **bypassing `transition`** | Startup-time cleanup of rows left behind by a previous `quire serve` instance. | `"orphaned"` |
+| `{queued, active} → canceled` | `Runs::cancel_existing` via raw SQL, **bypassing `transition`** | A new `Runs::create` for the same `(repo, ref)` arrived. Both queued and active rows are flipped directly. | — |
+| `{queued, active} → failed` | `reconcile_orphans` via raw SQL, **bypassing `transition`** | Startup-time cleanup of rows left behind by a previous `quire serve` instance. | `"orphaned"` |
 
 `Run::transition(to, failure_kind)`'s allowed-transition match:
 
 ```
-(Pending, Active) | (Pending, Complete) | (Pending, Superseded) |
-(Active,  Complete) | (Active,  Failed) | (Active,  Superseded)
+(Queued, Active) | (Queued, Succeeded) | (Queued, Canceled) |
+(Active, Succeeded) | (Active, Failed) | (Active, Canceled)
 ```
 
-In practice only `(Active, Complete)` and `(Active, Failed)` are exercised via `transition` — the `Pending → Active` edge is owned by the bootstrap endpoint (api.rs), and the supersede edges go through raw SQL (`supersede_existing`), not `transition`. The other edges are gated for defensive consistency, in case a future caller routes supersede through the typed API. Anything else — `Pending → Failed`, `Active → Pending`, or any transition out of a terminal state — returns `InvalidTransition`.
+In practice only `(Active, Succeeded)` and `(Active, Failed)` are exercised via `transition` — the `Queued → Active` edge is owned by the bootstrap endpoint (api.rs), and the cancel edges go through raw SQL (`cancel_existing`), not `transition`. The other edges are gated for defensive consistency, in case a future caller routes cancellation through the typed API. Anything else — `Queued → Failed`, `Active → Queued`, or any transition out of a terminal state — returns `InvalidTransition`.
 
-`failure_kind` is recorded only when `to == Failed`; it's ignored for `Active`, `Complete`, and `Superseded`.
+`failure_kind` is recorded only when `to == Failed`; it's ignored for `Active`, `Succeeded`, and `Canceled`.
 
 ### Database invariants
 
-The DB enforces shape per state via a `CHECK` constraint (see `migrations/0007_schema_cleanup.sql`):
+The DB enforces shape per state via a `CHECK` constraint (see `migrations/0009_rename_ci_vocab.sql`):
 
 | State | `started_at_ms` | `finished_at_ms` |
 | --- | --- | --- |
-| `pending` | NULL | NULL |
+| `queued` | NULL | NULL |
 | `active` | set | NULL |
-| `complete` | set | set |
+| `succeeded` | set | set |
 | `failed` | (any) | set |
-| `superseded` | (any) | set |
+| `canceled` | (any) | set |
 
 Plus monotonicity: `started_at_ms >= queued_at_ms`, `finished_at_ms >= started_at_ms`. `started_at_ms`, `finished_at_ms`, and `failure_kind` are stamped at most once each, via `COALESCE` in the `UPDATE`.
 
@@ -95,7 +93,7 @@ Nullable column populated by `Run::transition` when entering `Failed`, plus `rec
 | `"process-crashed"` | `Run::execute`: `quire-ci` exited non-zero, or exited 0 but never emitted a `RunFinished` event (panic or unexpected termination). |
 | `"orphaned"` | `reconcile_orphans` on startup. |
 
-Successful and superseded runs leave `failure_kind` NULL. The set is open — UI consumers should not assume it's exhaustive.
+Succeeded and canceled runs leave `failure_kind` NULL. The set is open — UI consumers should not assume it's exhaustive.
 
 ## Job state machine
 
@@ -103,43 +101,35 @@ Successful and superseded runs leave `failure_kind` NULL. The set is open — UI
 
 ```mermaid
 stateDiagram-v2
-    [*] --> complete : JobFinished complete
-    [*] --> failed   : JobFinished failed
+    [*] --> succeeded : JobFinished succeeded
+    [*] --> failed    : JobFinished failed
 
-    complete --> [*]
-    failed   --> [*]
+    succeeded --> [*]
+    failed    --> [*]
 
-    pending --> [*] : no producer yet
-    active  --> [*] : no producer yet
-    skipped --> [*] : no producer yet
-    aborted --> [*] : no producer yet
+    active --> [*] : no producer yet
 ```
 
 ### Transitions in code
 
-There is only one writer of `jobs` rows: `Run::ingest_events` (`run.rs:399`). It reads `events.jsonl` after the `quire-ci` subprocess exits and, for each `JobStarted`/`JobFinished` pair, inserts **one row directly in the terminal state**. The intermediate `active` state is held in an in-memory `pending_jobs` map during ingest and never persisted.
+There is only one writer of `jobs` rows: `Run::ingest_events`. It reads `events.jsonl` after the `quire-ci` subprocess exits and, for each `JobStarted`/`JobFinished` pair, inserts **one row directly in the terminal state**. The intermediate `active` state is held in an in-memory `inflight_jobs` map during ingest and never persisted.
 
 | From → To | Where | When |
 | --- | --- | --- |
-| `[*] → complete` | `Run::ingest_events` (`run.rs:399`) | `JobFinished { outcome: complete }` paired with a buffered `JobStarted`. |
+| `[*] → succeeded` | `Run::ingest_events` | `JobFinished { outcome: succeeded }` paired with a buffered `JobStarted`. |
 | `[*] → failed` | `Run::ingest_events` | `JobFinished { outcome: failed }` paired with a buffered `JobStarted`. |
 
 Consequence: while `quire-ci` is running, **no `jobs` rows exist for this run**. They all materialize at ingest time. Live progress is visible via `events.jsonl` or per-`sh` log files on disk, not via SQL.
 
 ### Database invariants
 
-`migrations/0001_initial.sql` allows six job states (`pending`, `active`, `complete`, `failed`, `skipped`, `aborted`) with these shape rules:
+`migrations/0009_rename_ci_vocab.sql` allows three job states (`active`, `succeeded`, `failed`) with these shape rules:
 
 | State | `started_at_ms` | `finished_at_ms` |
 | --- | --- | --- |
-| `pending` | NULL | NULL |
 | `active` | set | NULL |
-| `complete` | set | set |
+| `succeeded` | set | set |
 | `failed` | set | set |
-| `skipped` | NULL | set |
-| `aborted` | (any) | set |
-
-`skipped` carries `finished_at_ms` but not `started_at_ms` — the row exists to record "this job never ran" with a timestamp anchoring it to the run.
 
 ### Stop-on-first-failure inside `quire-ci`
 
@@ -152,7 +142,7 @@ if let Err(e) = result {
 }
 ```
 
-`JobStarted`/`JobFinished` are only emitted for jobs that actually ran. **Jobs downstream of the failure produce no events, so no `jobs` row at all** — not `skipped`, not anything. See Gaps below.
+`JobStarted`/`JobFinished` are only emitted for jobs that actually ran. **Jobs downstream of the failure produce no events, so no `jobs` row at all.** See Gaps below.
 
 ## Event flow: Process executor
 
@@ -180,8 +170,8 @@ sequenceDiagram
     Run->>Run: ingest_events(events.jsonl)
     Run->>DB: INSERT jobs (pass 1)
     Run->>DB: INSERT sh (pass 2)
-    alt RunFinished(Success) + exit 0
-        Run->>DB: UPDATE runs SET state='complete'
+    alt RunFinished(Succeeded) + exit 0
+        Run->>DB: UPDATE runs SET state='succeeded'
     else RunFinished(PipelineFailure) + exit 0
         Run->>DB: UPDATE runs SET state='failed' (failure_kind='pipeline-failure')
     else exit nonzero or no RunFinished
@@ -192,7 +182,7 @@ sequenceDiagram
 Wire events (`quire-core/src/ci/event.rs`):
 
 * `JobStarted { job_id }`
-* `JobFinished { job_id, outcome: complete | failed }` — `JobOutcome` is the closed set, not the full job-state enum.
+* `JobFinished { job_id, outcome: succeeded | failed }` — `JobOutcome` is the closed set, not the full job-state enum.
 * `ShStarted { job_id, cmd }` / `ShFinished { job_id, exit_code }`
 
 `Run::ingest_events` reads the file in two passes (jobs first to satisfy the FK on `(run_id, job_id)`, then sh). Ingest failures are logged but never demote the run's own outcome — a partial DB write is preferable to losing the pass/fail signal.
@@ -213,12 +203,12 @@ sequenceDiagram
     Hook->>Listener: PushEvent JSON over /var/quire/server.sock
     Listener->>Trigger: trigger(quire, &event)
     loop per updated ref
-      Trigger->>DB: supersede_existing (Pending|Active → Superseded for same repo/ref)
-      Trigger->>DB: INSERT runs (state=pending)
+      Trigger->>DB: cancel_existing (Queued|Active → Canceled for same repo/ref)
+      Trigger->>DB: INSERT runs (state=queued)
       Trigger->>FS: create run dir + workspace
       Trigger->>FS: git archive | tar -x  (materialize workspace)
       Trigger->>Exec: execute()
-      Exec->>DB: active → complete|failed (via bootstrap endpoint + ingest_events)
+      Exec->>DB: active → succeeded|failed (via bootstrap endpoint + ingest_events)
     end
 ```
 
@@ -234,15 +224,15 @@ Two things in `CI.md` that the code does *not* yet implement at this layer:
 | Column | Written by | Read by |
 | --- | --- | --- |
 | `id` | `Runs::create` | everywhere |
-| `repo` | `Runs::create` | `supersede_existing`, web handlers |
-| `ref_name` | `Runs::create` | `supersede_existing`, web handlers, bootstrap response |
+| `repo` | `Runs::create` | `cancel_existing`, web handlers |
+| `ref_name` | `Runs::create` | `cancel_existing`, web handlers, bootstrap response |
 | `sha` | `Runs::create` | `read_meta`, bootstrap response, web handlers |
 | `pushed_at_ms` | `Runs::create` | `read_meta`, web handlers |
-| `state` | `Runs::create` (→ `pending`) + every transition | everywhere |
+| `state` | `Runs::create` (→ `queued`) + every transition | everywhere |
 | `failure_kind` | `Run::transition(Failed, …)`, `reconcile_orphans` | web handlers |
 | `queued_at_ms` | `Runs::create` | web handlers |
-| `started_at_ms` | `transition(Active)`, also stamped as fallback in `Complete/Failed/Superseded` | `read_started_at`, web handlers |
-| `finished_at_ms` | `transition(Complete/Failed/Superseded)` | `read_finished_at`, web handlers |
+| `started_at_ms` | `transition(Active)`, also stamped as fallback in `Succeeded/Failed/Canceled` | `read_started_at`, web handlers |
+| `finished_at_ms` | `transition(Succeeded/Failed/Canceled)` | `read_finished_at`, web handlers |
 | `run_token` | `Runs::create` (API sessions only) | `verify_run_token` middleware |
 | `git_dir` | `Run::store_bootstrap_data` (API sessions only) | bootstrap endpoint |
 | `traceparent` | `Run::store_bootstrap_data` (API sessions only) | bootstrap endpoint |
@@ -253,7 +243,7 @@ Migration 0007 dropped eight columns that carried no live data with the Process
 
 All six columns (`run_id`, `job_id`, `state`, `exit_code`, `started_at_ms`, `finished_at_ms`) are written by `Run::ingest_events` and read by the web detail view. All live.
 
-The schema permits six states (`pending`, `active`, `complete`, `failed`, `skipped`, `aborted`) but `ingest_events` only writes `complete` and `failed`. The other four states have no producer today — see Gaps below.
+The schema permits three states (`active`, `succeeded`, `failed`) but `ingest_events` only writes `succeeded` and `failed`. `active` has no producer today — see Gaps below.
 
 ### `sh` table
 
@@ -266,9 +256,7 @@ States the schema admits — or `CI.md` commits to — that no code path produce
 | Gap | Schema/spec | Producer needed |
 | --- | --- | --- |
 | Job `active` rows during execution | Schema-allowed | `ingest_events` inserts one row per job at JobFinished time. While `quire-ci` is running, the `jobs` table has nothing for this run. Live UI of "currently running job" needs an active-row writer — either eager ingest, or a separate writer inside `quire-ci`. |
-| Job `pending` rows | Schema-allowed | Useful for "queued jobs in topo order" UI. Today jobs go straight from no-row to a terminal row. |
-| Job `skipped` rows for dependents of a failed job | Schema-allowed | `quire-ci`'s loop `break`s on first failure and emits no events for downstream jobs. To populate `skipped`, either `quire-ci` would emit `JobSkipped` events for unrun topo-order jobs, or the ingester would compute them from the pipeline graph + the surviving JobFinished rows. |
-| Job `aborted` rows | Schema-allowed | Needed when a run is killed mid-flight — e.g. by `supersede_existing` `docker kill`-ing the container. Today the run row flips to `superseded`, but no `jobs` rows are written for the work that was in flight. |
+| Job `skipped` outcome for dependents of a failed job | Tracked in ranger `wwpxzuvq` | `quire-ci`'s loop `break`s on first failure and emits no events for downstream jobs. Would need `skipped` re-added to the jobs CHECK constraint; producer would emit `JobSkipped` events from `quire-ci` or compute them in the ingester from the pipeline graph. |
 | `:allow-failure` job flag | Documented in `CI.md` as v1 | Not implemented anywhere in `quire-core`, `quire-ci`, or `quire-server`. The structural validator doesn't recognize the key; the executor treats every job error as fatal. |
 | Queue + Notify wakeup | `CI.md` "Communication" section | `trigger` runs synchronously on the listener task. No queue scan, no Notify, no separate runner task. |
 
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 29066a2..5179ad3 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -520,7 +520,7 @@ fn run_pipeline(
         sink.emit(Event {
             at_ms: jiff::Timestamp::now().as_millisecond(),
             kind: EventKind::RunFinished {
-                outcome: RunOutcome::Success,
+                outcome: RunOutcome::Succeeded,
             },
         })
         .expect("emit run_finished");
@@ -613,7 +613,7 @@ fn run_pipeline(
         runtime.leave_job();
 
         let outcome = if result.is_ok() {
-            JobOutcome::Complete
+            JobOutcome::Succeeded
         } else {
             JobOutcome::Failed
         };
@@ -642,7 +642,7 @@ fn run_pipeline(
         tracing::warn!(job = %job_id, error = err as &(dyn std::error::Error + 'static), "job run-fn failed");
         RunOutcome::PipelineFailure
     } else {
-        RunOutcome::Success
+        RunOutcome::Succeeded
     };
 
     sink.borrow_mut()
diff --git a/quire-core/src/ci/event.rs b/quire-core/src/ci/event.rs
index 5c56e86..9a2db15 100644
--- a/quire-core/src/ci/event.rs
+++ b/quire-core/src/ci/event.rs
@@ -15,15 +15,15 @@ use serde::{Deserialize, Serialize};
 #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
 #[serde(rename_all = "snake_case")]
 pub enum JobOutcome {
-    Complete,
+    Succeeded,
     Failed,
 }
 
-/// Outcome of the complete pipeline run, carried by [`EventKind::RunFinished`].
+/// Outcome of the pipeline run, carried by [`EventKind::RunFinished`].
 #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
 #[serde(rename_all = "snake_case")]
 pub enum RunOutcome {
-    Success,
+    Succeeded,
     PipelineFailure,
 }
 
@@ -42,7 +42,7 @@ pub struct Event {
 pub enum EventKind {
     /// A job's run-fn is about to fire.
     JobStarted { job_id: String },
-    /// A job's run-fn returned. `outcome` is `complete` if the run-fn
+    /// A job's run-fn returned. `outcome` is `succeeded` if the run-fn
     /// returned `Ok`, else `failed`.
     JobFinished { job_id: String, outcome: JobOutcome },
     /// An sh process is about to spawn.
@@ -80,13 +80,13 @@ mod tests {
             at_ms: 250,
             kind: EventKind::JobFinished {
                 job_id: "build".into(),
-                outcome: JobOutcome::Complete,
+                outcome: JobOutcome::Succeeded,
             },
         };
         let json = serde_json::to_string(&event).unwrap();
         assert_eq!(
             json,
-            r#"{"at_ms":250,"type":"job_finished","job_id":"build","outcome":"complete"}"#
+            r#"{"at_ms":250,"type":"job_finished","job_id":"build","outcome":"succeeded"}"#
         );
     }
 
diff --git a/quire-server/migrations/0009_rename_ci_vocab.sql b/quire-server/migrations/0009_rename_ci_vocab.sql
new file mode 100644
index 0000000..1087489
--- /dev/null
+++ b/quire-server/migrations/0009_rename_ci_vocab.sql
@@ -0,0 +1,94 @@
+-- Rename CI state vocabulary on both runs and jobs.
+--
+-- runs:  pending → queued, complete → succeeded, superseded → canceled
+-- jobs:  complete → succeeded, plus drop the unused pending/skipped/aborted
+--        states from the CHECK constraint (no producer writes them today;
+--        skipped support is tracked separately and would re-add it with a
+--        real producer).
+--
+-- SQLite can't ALTER CHECK constraints, so both tables are rebuilt. The
+-- INSERT…SELECT rewrites the state column inline. Pattern matches
+-- migration 0007.
+
+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,
+  state          TEXT    NOT NULL,
+  failure_kind   TEXT,
+  queued_at_ms   INTEGER NOT NULL,
+  started_at_ms  INTEGER,
+  finished_at_ms INTEGER,
+  run_token      TEXT,
+  git_dir        TEXT,
+  traceparent    TEXT,
+
+  CHECK (state IN ('queued', 'active', 'succeeded', 'failed', 'canceled')),
+
+  CHECK (started_at_ms  IS NULL OR started_at_ms  >= queued_at_ms),
+  CHECK (finished_at_ms IS NULL OR finished_at_ms >= queued_at_ms),
+  CHECK (finished_at_ms IS NULL OR started_at_ms  IS NULL
+         OR finished_at_ms >= started_at_ms),
+
+  CHECK (CASE state
+    WHEN 'queued'    THEN started_at_ms IS NULL     AND finished_at_ms IS NULL
+    WHEN 'active'    THEN started_at_ms IS NOT NULL AND finished_at_ms IS NULL
+    WHEN 'succeeded' THEN started_at_ms IS NOT NULL AND finished_at_ms IS NOT NULL
+    WHEN 'failed'    THEN finished_at_ms IS NOT NULL
+    WHEN 'canceled'  THEN finished_at_ms IS NOT NULL
+  END)
+);
+
+INSERT INTO runs_new
+  SELECT id, repo, ref_name, sha, pushed_at_ms,
+         CASE state
+           WHEN 'pending'    THEN 'queued'
+           WHEN 'complete'   THEN 'succeeded'
+           WHEN 'superseded' THEN 'canceled'
+           ELSE state
+         END,
+         failure_kind, queued_at_ms, started_at_ms, finished_at_ms,
+         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_state          ON runs(state);
+
+CREATE TABLE jobs_new (
+  run_id          TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
+  job_id          TEXT NOT NULL,
+  state           TEXT NOT NULL,
+  exit_code       INTEGER,
+  started_at_ms   INTEGER,
+  finished_at_ms  INTEGER,
+
+  CHECK (state IN ('active', 'succeeded', 'failed')),
+
+  CHECK (started_at_ms IS NULL OR finished_at_ms IS NULL
+         OR finished_at_ms >= started_at_ms),
+
+  CHECK (CASE state
+    WHEN 'active'    THEN started_at_ms IS NOT NULL AND finished_at_ms IS NULL
+    WHEN 'succeeded' THEN started_at_ms IS NOT NULL AND finished_at_ms IS NOT NULL
+    WHEN 'failed'    THEN started_at_ms IS NOT NULL AND finished_at_ms IS NOT NULL
+  END),
+
+  PRIMARY KEY (run_id, job_id)
+);
+
+INSERT INTO jobs_new
+  SELECT run_id, job_id,
+         CASE state
+           WHEN 'complete' THEN 'succeeded'
+           ELSE state
+         END,
+         exit_code, started_at_ms, finished_at_ms
+  FROM jobs;
+
+DROP TABLE jobs;
+ALTER TABLE jobs_new RENAME TO jobs;
diff --git a/quire-server/src/bin/quire/commands/dev.rs b/quire-server/src/bin/quire/commands/dev.rs
index ba780c9..edf7247 100644
--- a/quire-server/src/bin/quire/commands/dev.rs
+++ b/quire-server/src/bin/quire/commands/dev.rs
@@ -9,8 +9,8 @@ use quire::Quire;
 /// Seed a tempdir with realistic CI run data and return a `Quire` pointing at it.
 ///
 /// Creates a fresh tempdir under `std::env::temp_dir()`, inserts a fixed corpus
-/// of runs covering every interesting state (complete, failed, active, pending,
-/// superseded) with matching on-disk log artifacts. Idempotent — same input,
+/// of runs covering every interesting state (succeeded, failed, active, queued,
+/// canceled) with matching on-disk log artifacts. Idempotent — same input,
 /// same output.
 pub fn seed() -> Result<Quire> {
     Seeder::new()?.run()
@@ -133,7 +133,7 @@ impl Seeder {
             .into_diagnostic()?;
 
         let Some(run_started_at) = started_at else {
-            return Ok(()); // pending run; no jobs to insert.
+            return Ok(()); // queued run; no jobs to insert.
         };
 
         let logs_base = self
@@ -198,9 +198,9 @@ impl Seeder {
 
 fn build_runs() -> Vec<SeedRun> {
     vec![
-        // Run 1 — complete, all jobs passed.
+        // Run 1 — succeeded, all jobs passed.
         SeedRun {
-            state: "complete",
+            state: "succeeded",
             sha: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
             ref_name: "refs/heads/main",
             pushed_delta_ms: 0,
@@ -209,7 +209,7 @@ fn build_runs() -> Vec<SeedRun> {
             jobs: vec![
                 SeedJob {
                     job_id: "build",
-                    state: "complete",
+                    state: "succeeded",
                     exit_code: Some(0),
                     started_delta_ms: 0,
                     duration_ms: Some(2000),
@@ -232,7 +232,7 @@ fn build_runs() -> Vec<SeedRun> {
                 },
                 SeedJob {
                     job_id: "test",
-                    state: "complete",
+                    state: "succeeded",
                     exit_code: Some(0),
                     started_delta_ms: 2000,
                     duration_ms: Some(2000),
@@ -257,7 +257,7 @@ fn build_runs() -> Vec<SeedRun> {
             jobs: vec![
                 SeedJob {
                     job_id: "build",
-                    state: "complete",
+                    state: "succeeded",
                     exit_code: Some(0),
                     started_delta_ms: 0,
                     duration_ms: Some(2000),
@@ -294,9 +294,9 @@ fn build_runs() -> Vec<SeedRun> {
                 },
             ],
         },
-        // Run 3 — superseded, pushed then rebased.
+        // Run 3 — canceled, pushed then rebased.
         SeedRun {
-            state: "superseded",
+            state: "canceled",
             sha: "1111111111111111111111111111111111111111",
             ref_name: "refs/heads/feature",
             pushed_delta_ms: -1_200_000,
@@ -304,7 +304,7 @@ fn build_runs() -> Vec<SeedRun> {
             duration_ms: Some(1000),
             jobs: vec![SeedJob {
                 job_id: "build",
-                state: "complete",
+                state: "succeeded",
                 exit_code: Some(0),
                 started_delta_ms: 0,
                 duration_ms: Some(1000),
@@ -349,9 +349,9 @@ fn build_runs() -> Vec<SeedRun> {
                 ],
             }],
         },
-        // Run 5 — pending, queued but not started.
+        // Run 5 — queued but not started.
         SeedRun {
-            state: "pending",
+            state: "queued",
             sha: "3333333333333333333333333333333333333333",
             ref_name: "refs/heads/main",
             pushed_delta_ms: -1000,
@@ -359,9 +359,9 @@ fn build_runs() -> Vec<SeedRun> {
             duration_ms: None,
             jobs: vec![],
         },
-        // Run 6 — complete, multi-job: lint + build + test.
+        // Run 6 — succeeded, multi-job: lint + build + test.
         SeedRun {
-            state: "complete",
+            state: "succeeded",
             sha: "4444444444444444444444444444444444444444",
             ref_name: "refs/heads/v2",
             pushed_delta_ms: -3_600_000,
@@ -370,7 +370,7 @@ fn build_runs() -> Vec<SeedRun> {
             jobs: vec![
                 SeedJob {
                     job_id: "lint",
-                    state: "complete",
+                    state: "succeeded",
                     exit_code: Some(0),
                     started_delta_ms: 0,
                     duration_ms: Some(2000),
@@ -384,7 +384,7 @@ fn build_runs() -> Vec<SeedRun> {
                 },
                 SeedJob {
                     job_id: "build",
-                    state: "complete",
+                    state: "succeeded",
                     exit_code: Some(0),
                     started_delta_ms: 2000,
                     duration_ms: Some(4000),
@@ -398,7 +398,7 @@ fn build_runs() -> Vec<SeedRun> {
                 },
                 SeedJob {
                     job_id: "test",
-                    state: "complete",
+                    state: "succeeded",
                     exit_code: Some(0),
                     started_delta_ms: 6000,
                     duration_ms: Some(4000),
@@ -423,7 +423,7 @@ fn build_runs() -> Vec<SeedRun> {
             jobs: vec![
                 SeedJob {
                     job_id: "build",
-                    state: "complete",
+                    state: "succeeded",
                     exit_code: Some(0),
                     started_delta_ms: 0,
                     duration_ms: Some(3000),
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index 9277ed7..f7097c5 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -428,7 +428,7 @@ while [ $# -gt 0 ]; do
   esac
 done
 if [ -n "$events" ] && [ "$events" != "null" ]; then
-  printf '{"at_ms":0,"type":"run_finished","outcome":"success"}\n' > "$events"
+  printf '{"at_ms":0,"type":"run_finished","outcome":"succeeded"}\n' > "$events"
 fi
 exit 0
 "#
@@ -477,7 +477,7 @@ exit 0
     }
 
     #[test]
-    fn run_ref_inner_drives_run_to_complete_with_fake_quire_ci() {
+    fn run_ref_inner_drives_run_to_succeeded_with_fake_quire_ci() {
         let source = r#"(local ci (require :quire.ci))
 (ci.job :build [:quire/push] (fn [] nil))"#;
         let (_dir, quire, name) = bare_repo_with_ci(source);
@@ -506,7 +506,7 @@ exit 0
 
         trigger_result.expect("trigger_ref should succeed with fake quire-ci");
 
-        // The run should have reached complete.
+        // The run should have reached succeeded.
         let conn = crate::db::open(&quire.db_path()).expect("db");
         let state: String = conn
             .query_row(
@@ -516,19 +516,19 @@ exit 0
             )
             .expect("should have a run");
         assert_eq!(
-            state, "complete",
-            "run should be complete after fake quire-ci exits 0"
+            state, "succeeded",
+            "run should be succeeded after fake quire-ci exits 0"
         );
 
-        // No pending or active rows left behind.
+        // No queued or active rows left behind.
         let count: i64 = conn
             .query_row(
-                "SELECT COUNT(*) FROM runs WHERE state IN ('pending', 'active')",
+                "SELECT COUNT(*) FROM runs WHERE state IN ('queued', 'active')",
                 [],
                 |row| row.get(0),
             )
             .expect("count");
-        assert_eq!(count, 0, "run should be complete, not orphaned");
+        assert_eq!(count, 0, "run should be succeeded, not orphaned");
     }
 
     #[test]
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index 38a04d7..90e3caf 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -30,21 +30,21 @@ pub enum Executor {
 /// The state of a CI run.
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 pub enum RunState {
-    Pending,
+    Queued,
     Active,
-    Complete,
+    Succeeded,
     Failed,
-    Superseded,
+    Canceled,
 }
 
 impl RunState {
     pub fn as_str(&self) -> &'static str {
         match self {
-            RunState::Pending => "pending",
+            RunState::Queued => "queued",
             RunState::Active => "active",
-            RunState::Complete => "complete",
+            RunState::Succeeded => "succeeded",
             RunState::Failed => "failed",
-            RunState::Superseded => "superseded",
+            RunState::Canceled => "canceled",
         }
     }
 }
@@ -54,11 +54,11 @@ impl std::str::FromStr for RunState {
 
     fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
         match s {
-            "pending" => Some(RunState::Pending),
+            "queued" => Some(RunState::Queued),
             "active" => Some(RunState::Active),
-            "complete" => Some(RunState::Complete),
+            "succeeded" => Some(RunState::Succeeded),
             "failed" => Some(RunState::Failed),
-            "superseded" => Some(RunState::Superseded),
+            "canceled" => Some(RunState::Canceled),
             _ => None,
         }
         .ok_or(())
@@ -86,9 +86,9 @@ impl Runs {
         }
     }
 
-    /// Create a new run record in the `pending` state.
+    /// Create a new run record in the `queued` state.
     ///
-    /// Before inserting, supersedes any existing `pending` 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
@@ -109,14 +109,14 @@ impl Runs {
 
         let db = crate::db::open(&self.db_path)?;
 
-        // Supersede any existing pending or active run for (repo, ref).
+        // Cancel any existing queued or active run for (repo, ref).
         // Do this before inserting the new run so the new run is never
-        // caught by its own supersede query.
-        self.supersede_existing(&db, &meta.r#ref)?;
+        // caught by its own cancel query.
+        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, 'pending', ?6, ?7)",
+             VALUES (?1, ?2, ?3, ?4, ?5, 'queued', ?6, ?7)",
             rusqlite::params![
                 &id,
                 &self.repo,
@@ -135,14 +135,14 @@ impl Runs {
         Ok(Run {
             db_path: self.db_path.clone(),
             id,
-            state: RunState::Pending,
+            state: RunState::Queued,
             base_dir: self.base_dir.clone(),
         })
     }
 
-    /// Supersede any existing `pending` or `active` run for
+    /// Cancel any existing `queued` or `active` run for
     /// `(repo, ref)`. Different refs are unaffected.
-    fn supersede_existing(&self, db: &rusqlite::Connection, ref_name: &str) -> Result<()> {
+    fn cancel_existing(&self, db: &rusqlite::Connection, ref_name: &str) -> Result<()> {
         let now = Timestamp::now().as_millisecond();
 
         let active_ids: Vec<String> = db
@@ -155,26 +155,26 @@ impl Runs {
 
         for run_id in &active_ids {
             db.execute(
-                "UPDATE runs SET state = 'superseded', finished_at_ms = ?1 WHERE id = ?2",
+                "UPDATE runs SET state = 'canceled', finished_at_ms = ?1 WHERE id = ?2",
                 rusqlite::params![now, run_id],
             )?;
-            tracing::info!(run_id = %run_id, "superseded active run");
+            tracing::info!(run_id = %run_id, "canceled active run");
         }
 
-        let pending_count = db.execute(
-            "UPDATE runs SET state = 'superseded', finished_at_ms = ?1
-             WHERE repo = ?2 AND ref_name = ?3 AND state = 'pending'",
+        let queued_count = db.execute(
+            "UPDATE runs SET state = 'canceled', finished_at_ms = ?1
+             WHERE repo = ?2 AND ref_name = ?3 AND state = 'queued'",
             rusqlite::params![now, &self.repo, ref_name],
         )?;
-        if pending_count > 0 {
-            tracing::info!(count = pending_count, "superseded pending run(s)");
+        if queued_count > 0 {
+            tracing::info!(count = queued_count, "canceled queued run(s)");
         }
 
         Ok(())
     }
 }
 
-/// Move every `pending` or `active` run to `failed` with
+/// 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.
@@ -183,7 +183,7 @@ pub fn reconcile_orphans(db_path: &Path) -> Result<()> {
     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 ('pending', 'active')",
+         WHERE state IN ('queued', 'active')",
         rusqlite::params![now],
     )?;
     if count > 0 {
@@ -252,7 +252,7 @@ impl Run {
     /// * `jobs/<job>/sh-<n>.log` — per-sh CRI logs, written by quire-ci
     ///   via `--out-dir`.
     ///
-    /// Run finishes `Complete` 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,11 +264,11 @@ impl Run {
         session: Option<&ApiSession>,
     ) -> Result<()> {
         // For API runs the GET /api/run/bootstrap endpoint owns the
-        // pending → active transition (it sets started_at_ms when quire-ci
+        // 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(Complete/Failed) call passes the state-machine check.
+        // transition(Succeeded/Failed) call passes the state-machine check.
         if session.is_some() {
             self.state = RunState::Active;
         } else {
@@ -349,8 +349,8 @@ impl Run {
         // quire-ci exited cleanly but never reached the terminal event —
         // treat that as a crash too.
         match run_outcome {
-            Some(quire_core::ci::event::RunOutcome::Success) => {
-                self.transition(RunState::Complete, None)?;
+            Some(quire_core::ci::event::RunOutcome::Succeeded) => {
+                self.transition(RunState::Succeeded, None)?;
             }
             Some(quire_core::ci::event::RunOutcome::PipelineFailure) => {
                 self.transition(RunState::Failed, Some("pipeline-failure"))?;
@@ -392,17 +392,17 @@ impl Run {
         let db = crate::db::open(&self.db_path)?;
 
         // Pass 1: jobs rows. Pair JobStarted with JobFinished by job_id.
-        let mut pending_jobs: HashMap<&str, i64> = HashMap::new();
+        let mut inflight_jobs: HashMap<&str, i64> = HashMap::new();
         let mut run_outcome: Option<RunOutcome> = None;
         for event in &events {
             match &event.kind {
                 EventKind::JobStarted { job_id } => {
-                    pending_jobs.insert(job_id.as_str(), event.at_ms);
+                    inflight_jobs.insert(job_id.as_str(), event.at_ms);
                 }
                 EventKind::JobFinished { job_id, outcome } => {
-                    let started_at = pending_jobs.remove(job_id.as_str()).unwrap_or(event.at_ms);
+                    let started_at = inflight_jobs.remove(job_id.as_str()).unwrap_or(event.at_ms);
                     let state = match outcome {
-                        JobOutcome::Complete => "complete",
+                        JobOutcome::Succeeded => "succeeded",
                         JobOutcome::Failed => "failed",
                     };
                     db.execute(
@@ -421,14 +421,14 @@ impl Run {
         // Pass 2: sh rows. Pair ShStarted with ShFinished by job_id
         // (sequential within a run-fn, so a single buffer slot per job
         // is enough).
-        let mut pending_sh: HashMap<&str, (i64, &str)> = HashMap::new();
+        let mut inflight_sh: HashMap<&str, (i64, &str)> = HashMap::new();
         for event in &events {
             match &event.kind {
                 EventKind::ShStarted { job_id, cmd } => {
-                    pending_sh.insert(job_id.as_str(), (event.at_ms, cmd.as_str()));
+                    inflight_sh.insert(job_id.as_str(), (event.at_ms, cmd.as_str()));
                 }
                 EventKind::ShFinished { job_id, exit_code } => {
-                    let Some((started_at, cmd)) = pending_sh.remove(job_id.as_str()) else {
+                    let Some((started_at, cmd)) = inflight_sh.remove(job_id.as_str()) else {
                         continue;
                     };
                     db.execute(
@@ -470,12 +470,12 @@ impl Run {
     ///
     /// Allowed edges (see `docs/CI-STATE.md`):
     ///
-    /// * `Pending → Active`
-    /// * `Pending → Complete`
-    /// * `Pending → Superseded`
-    /// * `Active  → Complete`
-    /// * `Active  → Failed`
-    /// * `Active  → Superseded`
+    /// * `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
@@ -487,12 +487,12 @@ impl Run {
         use RunState::*;
         let allowed = matches!(
             (self.state, to),
-            (Pending, Active)
-                | (Pending, Complete)
-                | (Pending, Superseded)
-                | (Active, Complete)
+            (Queued, Active)
+                | (Queued, Succeeded)
+                | (Queued, Canceled)
+                | (Active, Succeeded)
                 | (Active, Failed)
-                | (Active, Superseded)
+                | (Active, Canceled)
         );
         if !allowed {
             return Err(Error::InvalidTransition {
@@ -512,7 +512,7 @@ impl Run {
                     rusqlite::params![now, &self.id],
                 )?;
             }
-            Complete | Superseded => {
+            Succeeded | Canceled => {
                 db.execute(
                     "UPDATE runs SET state = ?1, \
                         started_at_ms = COALESCE(started_at_ms, ?2), \
@@ -531,7 +531,7 @@ impl Run {
                     rusqlite::params![now, now, failure_kind, &self.id],
                 )?;
             }
-            Pending => unreachable!("transition to Pending is not valid"),
+            Queued => unreachable!("transition to Queued is not valid"),
         }
 
         self.state = to;
@@ -759,11 +759,11 @@ mod tests {
     #[test]
     fn run_state_round_trips() {
         for state in [
-            RunState::Pending,
+            RunState::Queued,
             RunState::Active,
-            RunState::Complete,
+            RunState::Succeeded,
             RunState::Failed,
-            RunState::Superseded,
+            RunState::Canceled,
         ] {
             assert!(state.as_str().parse::<RunState>().is_ok());
         }
@@ -825,14 +825,14 @@ mod tests {
     }
 
     #[test]
-    fn create_writes_row_in_pending_state() {
+    fn create_writes_row_in_queued_state() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
         let run = runs
             .create(&test_meta(), Some(&test_session()))
             .expect("create");
 
-        assert_eq!(run.state(), RunState::Pending);
+        assert_eq!(run.state(), RunState::Queued);
 
         // Verify workspace directory was created.
         let workspace = run.path().join("workspace");
@@ -887,7 +887,7 @@ mod tests {
     }
 
     #[test]
-    fn transition_stamps_finished_at_on_complete_and_failed() {
+    fn transition_stamps_finished_at_on_succeeded_and_failed() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
 
@@ -898,8 +898,8 @@ mod tests {
             .transition(RunState::Active, None)
             .expect("to active");
         completed
-            .transition(RunState::Complete, None)
-            .expect("to complete");
+            .transition(RunState::Succeeded, None)
+            .expect("to succeed");
         assert!(completed.read_finished_at().expect("read").is_some());
 
         let mut failed = runs
@@ -966,7 +966,7 @@ mod tests {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
 
-        // Pending -> Failed is not allowed (must go via Active).
+        // Queued -> Failed is not allowed (must go via Active).
         let mut run = runs
             .create(&test_meta(), Some(&test_session()))
             .expect("create");
@@ -980,8 +980,8 @@ mod tests {
             .transition(RunState::Active, None)
             .expect("to active");
         completed
-            .transition(RunState::Complete, None)
-            .expect("to complete");
+            .transition(RunState::Succeeded, None)
+            .expect("to succeed");
         assert!(completed.transition(RunState::Active, None).is_err());
         assert!(completed.transition(RunState::Failed, None).is_err());
     }
@@ -997,8 +997,8 @@ mod tests {
         run.transition(RunState::Active, None).expect("to active");
         let started = run.read_started_at().expect("read started_at");
 
-        run.transition(RunState::Complete, None)
-            .expect("to complete");
+        run.transition(RunState::Succeeded, None)
+            .expect("to succeed");
         assert_eq!(
             run.read_started_at().expect("read"),
             started,
@@ -1015,14 +1015,14 @@ mod tests {
             .expect("create");
 
         run.transition(RunState::Active, None).expect("to active");
-        run.transition(RunState::Complete, None)
-            .expect("to complete");
+        run.transition(RunState::Succeeded, None)
+            .expect("to succeed");
 
-        assert_eq!(run.state(), RunState::Complete);
+        assert_eq!(run.state(), RunState::Succeeded);
     }
 
     #[test]
-    fn reconcile_fails_pending_orphans() {
+    fn reconcile_fails_queued_orphans() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
         let run = runs
@@ -1053,21 +1053,21 @@ mod tests {
     }
 
     #[test]
-    fn reconcile_leaves_complete_runs_alone() {
+    fn reconcile_leaves_succeeded_runs_alone() {
         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::Complete, None)
-            .expect("to complete");
+        run.transition(RunState::Succeeded, None)
+            .expect("to succeed");
         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::Complete);
+        assert_eq!(reopened.state(), RunState::Succeeded);
     }
 
     #[test]
@@ -1112,7 +1112,7 @@ mod tests {
                 at_ms: 200,
                 kind: EventKind::JobFinished {
                     job_id: "build".into(),
-                    outcome: JobOutcome::Complete,
+                    outcome: JobOutcome::Succeeded,
                 },
             },
             Event {
@@ -1163,7 +1163,7 @@ mod tests {
         assert_eq!(
             jobs,
             vec![
-                ("build".to_string(), "complete".to_string(), 100, 200),
+                ("build".to_string(), "succeeded".to_string(), 100, 200),
                 ("test".to_string(), "failed".to_string(), 210, 220),
             ]
         );
@@ -1218,7 +1218,7 @@ mod tests {
     }
 
     #[test]
-    fn create_supersedes_pending_run_on_same_ref() {
+    fn create_cancels_queued_run_on_same_ref() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
 
@@ -1227,9 +1227,9 @@ mod tests {
             .create(&test_meta(), Some(&test_session()))
             .expect("create run1");
         let run1_id = run1.id().to_string();
-        assert_eq!(run1.state(), RunState::Pending);
+        assert_eq!(run1.state(), RunState::Queued);
 
-        // Create second run for same (repo, ref) — should supersede the first.
+        // Create second run for same (repo, ref) — should cancel the first.
         let meta2 = RunMeta {
             sha: "def456".to_string(),
             r#ref: "refs/heads/main".to_string(),
@@ -1238,19 +1238,19 @@ mod tests {
         let run2 = runs
             .create(&meta2, Some(&test_session()))
             .expect("create run2");
-        assert_eq!(run2.state(), RunState::Pending);
+        assert_eq!(run2.state(), RunState::Queued);
 
-        // First run should now be superseded.
+        // First run should now be canceled.
         let reopened = Run::open(quire.db_path(), run1_id, runs.base_dir.clone()).expect("reopen");
-        assert_eq!(reopened.state(), RunState::Superseded);
+        assert_eq!(reopened.state(), RunState::Canceled);
         assert!(
             reopened.read_finished_at().expect("read").is_some(),
-            "superseded run should have finished_at"
+            "canceled run should have finished_at"
         );
     }
 
     #[test]
-    fn create_supersedes_active_run_on_same_ref() {
+    fn create_cancels_active_run_on_same_ref() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
 
@@ -1270,19 +1270,19 @@ mod tests {
         let run2 = runs
             .create(&meta2, Some(&test_session()))
             .expect("create run2");
-        assert_eq!(run2.state(), RunState::Pending);
+        assert_eq!(run2.state(), RunState::Queued);
 
-        // First run should be superseded.
+        // First run should be canceled.
         let reopened = Run::open(quire.db_path(), run1_id, runs.base_dir.clone()).expect("reopen");
-        assert_eq!(reopened.state(), RunState::Superseded);
+        assert_eq!(reopened.state(), RunState::Canceled);
         assert!(
             reopened.read_finished_at().expect("read").is_some(),
-            "superseded run should have finished_at"
+            "canceled run should have finished_at"
         );
     }
 
     #[test]
-    fn create_does_not_supersede_different_ref() {
+    fn create_does_not_cancel_different_ref() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
 
@@ -1302,24 +1302,24 @@ mod tests {
             .create(&meta2, Some(&test_session()))
             .expect("create run2");
 
-        // First run should still be pending.
+        // First run should still be queued.
         let reopened = Run::open(quire.db_path(), run1_id, runs.base_dir.clone()).expect("reopen");
-        assert_eq!(reopened.state(), RunState::Pending);
+        assert_eq!(reopened.state(), RunState::Queued);
     }
 
     #[test]
-    fn create_does_not_supersede_complete_or_failed_runs() {
+    fn create_does_not_cancel_succeeded_or_failed_runs() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
 
-        // Create and complete first run.
+        // Drive first run to succeeded.
         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.transition(RunState::Complete, None)
-            .expect("to complete");
+        run1.transition(RunState::Succeeded, None)
+            .expect("to succeed");
 
         // Create second run for same (repo, ref).
         let meta2 = RunMeta {
@@ -1331,38 +1331,36 @@ mod tests {
             .create(&meta2, Some(&test_session()))
             .expect("create run2");
 
-        // First run should still be complete.
+        // 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::Complete);
+        assert_eq!(reopened.state(), RunState::Succeeded);
     }
 
     #[test]
-    fn transition_allows_pending_to_superseded() {
+    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::Superseded, None)
-            .expect("to superseded");
-        assert_eq!(run.state(), RunState::Superseded);
+        run.transition(RunState::Canceled, None).expect("to cancel");
+        assert_eq!(run.state(), RunState::Canceled);
     }
 
     #[test]
-    fn transition_allows_active_to_superseded() {
+    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::Superseded, None)
-            .expect("to superseded");
-        assert_eq!(run.state(), RunState::Superseded);
+        run.transition(RunState::Canceled, None).expect("to cancel");
+        assert_eq!(run.state(), RunState::Canceled);
     }
 
     #[test]
-    fn supersede_sets_finished_at() {
+    fn cancel_sets_finished_at() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
         let mut run = runs
@@ -1372,14 +1370,13 @@ mod tests {
 
         assert!(
             run.read_finished_at().expect("read").is_none(),
-            "should not have finished_at before supersede"
+            "should not have finished_at before cancel"
         );
 
-        run.transition(RunState::Superseded, None)
-            .expect("to superseded");
+        run.transition(RunState::Canceled, None).expect("to cancel");
         assert!(
             run.read_finished_at().expect("read").is_some(),
-            "superseded run should have finished_at"
+            "canceled run should have finished_at"
         );
     }
 }
diff --git a/quire-server/src/db.rs b/quire-server/src/db.rs
index fa79e34..b7a047d 100644
--- a/quire-server/src/db.rs
+++ b/quire-server/src/db.rs
@@ -20,6 +20,7 @@ static MIGRATIONS: std::sync::LazyLock<Migrations<'static>> = std::sync::LazyLoc
         M::up(include_str!("../migrations/0006_traceparent.sql")),
         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")),
     ])
 });
 
diff --git a/quire-server/src/quire/web/api.rs b/quire-server/src/quire/web/api.rs
index a18e6cf..ac8a879 100644
--- a/quire-server/src/quire/web/api.rs
+++ b/quire-server/src/quire/web/api.rs
@@ -168,7 +168,7 @@ async fn get_bootstrap(
                 .next()
                 .ok_or(rusqlite::Error::QueryReturnedNoRows)??;
 
-            if row.state != "pending" {
+            if row.state != "queued" {
                 return Err(ApiError::Gone);
             }
 
diff --git a/quire-server/src/quire/web/format.rs b/quire-server/src/quire/web/format.rs
index 920260f..e2ee112 100644
--- a/quire-server/src/quire/web/format.rs
+++ b/quire-server/src/quire/web/format.rs
@@ -83,7 +83,7 @@ fn format_ms_duration(ms: i64) -> String {
 /// don't each carry their own identical match.
 pub fn state_class(state: &str) -> &'static str {
     match state {
-        "complete" => "c-ok",
+        "succeeded" => "c-ok",
         "failed" => "c-bad",
         _ => "c-muted",
     }
@@ -171,8 +171,8 @@ mod tests {
     }
 
     #[test]
-    fn state_class_complete() {
-        assert_eq!(state_class("complete"), "c-ok");
+    fn state_class_succeeded() {
+        assert_eq!(state_class("succeeded"), "c-ok");
     }
 
     #[test]
@@ -182,7 +182,7 @@ mod tests {
 
     #[test]
     fn state_class_unknown_falls_through() {
-        assert_eq!(state_class("pending"), "c-muted");
+        assert_eq!(state_class("queued"), "c-muted");
         assert_eq!(state_class("active"), "c-muted");
         assert_eq!(state_class(""), "c-muted");
     }
diff --git a/quire-server/src/quire/web/handlers.rs b/quire-server/src/quire/web/handlers.rs
index 1198460..59e6066 100644
--- a/quire-server/src/quire/web/handlers.rs
+++ b/quire-server/src/quire/web/handlers.rs
@@ -393,7 +393,7 @@ mod tests {
         let env = TestEnv::new();
         env.insert_run(
             UUID1,
-            "complete",
+            "succeeded",
             SHA1,
             "refs/heads/main",
             1000,
@@ -426,14 +426,14 @@ mod tests {
         let env = TestEnv::new();
         env.insert_run(
             UUID1,
-            "complete",
+            "succeeded",
             SHA1,
             "refs/heads/main",
             1000,
             Some(2000),
             Some(3000),
         );
-        env.insert_job(UUID1, "build", "complete", Some(0), Some(2000), Some(3000));
+        env.insert_job(UUID1, "build", "succeeded", Some(0), Some(2000), Some(3000));
         let app = env.app();
         let req = Request::builder()
             .uri(&format!("/example/ci/{UUID1}"))
diff --git a/quire-server/src/quire/web/templates.rs b/quire-server/src/quire/web/templates.rs
index c72e957..fc0fc90 100644
--- a/quire-server/src/quire/web/templates.rs
+++ b/quire-server/src/quire/web/templates.rs
@@ -166,7 +166,7 @@ impl DetailRun {
     }
 
     pub fn is_terminal(&self) -> bool {
-        self.state == "complete" || self.state == "failed"
+        self.state == "succeeded" || self.state == "failed"
     }
 
     pub fn duration_display(&self) -> String {