Add transport foundation for quire-ci ↔ quire-server API
Thread --run-id, --server-url, and QUIRE_CI_TOKEN through the spawn
path. Add TransportMode enum (filesystem/api) to GlobalConfig under
:ci, default filesystem. Mint a per-run auth token stored in the DB
when transport is api. Add reqwest to quire-ci. Separate config
(TransportMode) from runtime (Transport) enums. Use clap::ValueEnum
for quire-ci CLI transport arg. No behavior change yet — filesystem
path is still the default.

Assisted-by: Owl Alpha via pi
change pqlyworuxzxxlolkltummkmqrustnzqp
commit 8dd64d95a223b05cb9287cb8f687a47f19da18c7
author Alpha Chen <alpha@kejadlen.dev>
date
parent qtrtnqkn
diff --git a/Cargo.lock b/Cargo.lock
index 5888d7a..cab18e2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2133,6 +2133,7 @@ dependencies = [
  "miette",
  "mlua",
  "quire-core",
+ "reqwest",
  "sentry",
  "serde",
  "serde_json",
@@ -2180,6 +2181,7 @@ dependencies = [
  "petgraph",
  "predicates",
  "quire-core",
+ "rand",
  "regex",
  "rusqlite",
  "rusqlite_migration",
diff --git a/quire-ci/Cargo.toml b/quire-ci/Cargo.toml
index 2392f00..41e3bfc 100644
--- a/quire-ci/Cargo.toml
+++ b/quire-ci/Cargo.toml
@@ -10,6 +10,7 @@ jiff = { workspace = true }
 miette = { workspace = true, features = ["fancy"] }
 mlua = { workspace = true }
 quire-core = { path = "../quire-core" }
+reqwest = { version = "*", features = ["rustls"], default-features = false }
 sentry = { workspace = true }
 serde = { workspace = true }
 serde_json = { workspace = true }
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 9e05335..bd4fbc5 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -82,9 +82,52 @@ enum Commands {
         /// secrets).
         #[arg(long)]
         dispatch: Option<PathBuf>,
+
+        #[command(flatten)]
+        transport: TransportFlags,
     },
 }
 
+/// CLI flags for the CI ↔ server transport. Grouped so the related
+/// args travel together and `Transport == Api` can pull its required
+/// peers in via `required_if_eq`.
+#[derive(clap::Args, Debug)]
+struct TransportFlags {
+    /// Transport for CI ↔ server communication.
+    #[arg(long, default_value = "filesystem", value_enum)]
+    transport: Transport,
+
+    /// Run ID assigned by the orchestrator.
+    /// Required when `--transport api`.
+    #[arg(long, required_if_eq("transport", "api"))]
+    run_id: Option<String>,
+
+    /// Base URL of quire-server (e.g. `http://127.0.0.1:3000`).
+    /// Required when `--transport api`.
+    #[arg(long, required_if_eq("transport", "api"))]
+    server_url: Option<String>,
+}
+
+impl TransportFlags {
+    /// Promote into the resolved [`TransportArgs`], folding in the
+    /// `QUIRE_CI_TOKEN` env var. clap's `required_if_eq` guarantees
+    /// the unwraps are safe for `Transport::Api`.
+    fn resolve(self, auth_token: Option<String>) -> TransportArgs {
+        match self.transport {
+            Transport::Filesystem => TransportArgs::Filesystem,
+            Transport::Api => TransportArgs::Api {
+                run_id: self
+                    .run_id
+                    .expect("clap requires --run-id when --transport api"),
+                server_url: self
+                    .server_url
+                    .expect("clap requires --server-url when --transport api"),
+                auth_token,
+            },
+        }
+    }
+}
+
 /// RAII wrapper around the tempdir that holds a `quire-ci run`'s
 /// captured sh logs when no `--out-dir` was passed. On drop, prints
 /// each log file's contents to stdout, then lets the underlying
@@ -157,6 +200,31 @@ fn parse_events_target(s: &str) -> Result<EventsTarget, String> {
     }
 }
 
+/// Transport for CI ↔ server communication.
+///
+/// CLI-shape only; the resolved variant (with run_id/server_url
+/// promoted out of their Options) is `TransportArgs`.
+#[derive(Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
+pub enum Transport {
+    Filesystem,
+    Api,
+}
+
+/// Resolved transport produced by [`TransportFlags::resolve`].
+///
+/// The `Api` variant's fields are wired up ahead of the HTTP client
+/// that will consume them.
+#[derive(Debug)]
+#[allow(dead_code)] // fields read by the upcoming API client
+enum TransportArgs {
+    Filesystem,
+    Api {
+        run_id: String,
+        server_url: String,
+        auth_token: Option<String>,
+    },
+}
+
 fn main() -> miette::Result<()> {
     miette::set_panic_hook();
     let cli = Cli::parse();
@@ -166,6 +234,7 @@ fn main() -> miette::Result<()> {
             events,
             out_dir,
             dispatch,
+            transport,
         } => {
             let sink: Box<dyn EventSink> = match events {
                 EventsTarget::Null => Box::new(NullSink),
@@ -186,6 +255,8 @@ fn main() -> miette::Result<()> {
                     (path, Some(DumpLogsOnDrop { dir }))
                 }
             };
+            let auth_token = std::env::var("QUIRE_CI_TOKEN").ok();
+            let transport = transport.resolve(auth_token);
             let (git_dir, meta, secrets, sentry_handoff) = match dispatch {
                 Some(path) => load_dispatch(&path)?,
                 None => (
@@ -219,7 +290,15 @@ fn main() -> miette::Result<()> {
             let miette_layer = MietteLayer::new();
             telemetry::init_tracing(miette_layer, FmtMode::Plain)?;
 
-            run_pipeline(cli.workspace, sink, log_dir, git_dir, meta, secrets)
+            run_pipeline(
+                cli.workspace,
+                sink,
+                log_dir,
+                git_dir,
+                meta,
+                secrets,
+                transport,
+            )
         }
     }
 }
@@ -349,6 +428,7 @@ fn run_pipeline(
     git_dir: PathBuf,
     meta: RunMeta,
     secrets: HashMap<String, quire_core::secret::SecretString>,
+    _transport: TransportArgs,
 ) -> miette::Result<()> {
     let pipeline = compile_at(&workspace)?;
 
diff --git a/quire-server/Cargo.toml b/quire-server/Cargo.toml
index 7ddf103..fd36998 100644
--- a/quire-server/Cargo.toml
+++ b/quire-server/Cargo.toml
@@ -34,6 +34,7 @@ askama = "*"
 axum = "*"
 clap = { workspace = true }
 clap_complete = "*"
+rand = "*"
 rusqlite = { version = "*", features = ["bundled"] }
 rusqlite_migration = "*"
 sentry = { workspace = true }
diff --git a/quire-server/migrations/0003_ci_api.sql b/quire-server/migrations/0003_ci_api.sql
new file mode 100644
index 0000000..fea5121
--- /dev/null
+++ b/quire-server/migrations/0003_ci_api.sql
@@ -0,0 +1 @@
+ALTER TABLE runs ADD COLUMN auth_token TEXT;
diff --git a/quire-server/src/bin/quire/commands/ci.rs b/quire-server/src/bin/quire/commands/ci.rs
index dbffad0..9aee15b 100644
--- a/quire-server/src/bin/quire/commands/ci.rs
+++ b/quire-server/src/bin/quire/commands/ci.rs
@@ -2,7 +2,7 @@ use std::path::PathBuf;
 
 use miette::{IntoDiagnostic, Result};
 use quire::Quire;
-use quire::ci::{Ci, CommitRef, RunMeta, Runs};
+use quire::ci::{Ci, CommitRef, RunMeta, Runs, Transport};
 
 /// Validate a repo's ci.fnl without executing any jobs.
 ///
@@ -74,7 +74,10 @@ pub async fn run(quire: &Quire, maybe_sha: Option<&str>) -> Result<()> {
         pushed_at: jiff::Timestamp::now(),
     };
 
-    let run = runs.create(&meta)?;
+    // Local `quire ci run` always uses filesystem transport; it never
+    // talks to the server, so no server_url is needed.
+    let transport = Transport::Filesystem;
+    let run = runs.create(&meta, &transport)?;
     let run_id = run.id().to_string();
     println!(
         "Run {}: executing at {} ({})",
@@ -86,8 +89,14 @@ pub async fn run(quire: &Quire, maybe_sha: Option<&str>) -> Result<()> {
     let workspace = tmp.path().join("workspace");
     quire::ci::materialize_workspace(&repo_path.join(".git"), &commit.sha, &workspace)
         .into_diagnostic()?;
-    let exec_result =
-        run.execute_via_quire_ci(&repo_path.join(".git"), &workspace, &meta, &secrets, None);
+    let exec_result = run.execute_via_quire_ci(
+        &repo_path.join(".git"),
+        &workspace,
+        &meta,
+        &secrets,
+        None,
+        &transport,
+    );
 
     // Print the combined quire-ci log regardless of outcome.
     let log_path = tmp.path().join(&run_id).join("quire-ci.log");
diff --git a/quire-server/src/ci/error.rs b/quire-server/src/ci/error.rs
index ca3238c..bd93717 100644
--- a/quire-server/src/ci/error.rs
+++ b/quire-server/src/ci/error.rs
@@ -60,6 +60,9 @@ pub enum Error {
     #[error("quire-ci exited with status {exit:?}")]
     QuireCiExit { exit: Option<i32> },
 
+    #[error("ci.transport=api requires ci.server-url to be set in config.fnl")]
+    ApiTransportMissingServerUrl,
+
     #[error("failed to parse quire-ci event stream at {}: {source}", path.display())]
     EventStreamParse {
         path: std::path::PathBuf,
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index 559a0c9..5ee76bb 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -12,7 +12,10 @@ pub use quire_core::ci::pipeline::{
 };
 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::{
+    ApiTransport, Executor, Run, RunState, Runs, Transport, TransportMode, materialize_workspace,
+    reconcile_orphans,
+};
 
 /// A resolved commit reference.
 ///
@@ -162,13 +165,27 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
                 );
             },
             || {
+                let transport =
+                    match Transport::for_new_run(config.ci.transport, config.server_url.as_deref())
+                    {
+                        Ok(t) => t,
+                        Err(e) => {
+                            tracing::error!(
+                                repo = %event.repo,
+                                error = &e as &(dyn std::error::Error + 'static),
+                                "CI transport misconfigured",
+                            );
+                            return;
+                        }
+                    };
                 if let Err(e) = trigger_ref(
                     &repo,
                     &db_path,
                     event.pushed_at,
                     push_ref,
                     &config.secrets,
-                    config.executor,
+                    config.ci.executor,
+                    &transport,
                     sentry_handoff.as_ref(),
                 ) {
                     // QuireCiExit means quire-ci itself ran and reported a
@@ -199,6 +216,7 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
 }
 
 /// Create and run CI for a single updated ref.
+#[allow(clippy::too_many_arguments)]
 fn trigger_ref(
     repo: &Repo,
     db_path: &Path,
@@ -206,6 +224,7 @@ fn trigger_ref(
     push_ref: &PushRef,
     secrets: &HashMap<String, quire_core::secret::SecretString>,
     executor: Executor,
+    transport: &Transport,
     sentry: Option<&quire_core::ci::dispatch::SentryHandoff>,
 ) -> error::Result<()> {
     let ci = repo.ci();
@@ -220,7 +239,7 @@ fn trigger_ref(
         pushed_at,
     };
 
-    let run = repo.runs(db_path).create(&meta)?;
+    let run = repo.runs(db_path).create(&meta, transport)?;
 
     tracing::info!(
         run_id = %run.id(), // cov-excl-line
@@ -235,7 +254,7 @@ fn trigger_ref(
         Executor::QuireCi => {
             // Compilation happens inside quire-ci so a malformed ci.fnl is
             // reported once, with the worker's trace context.
-            run.execute_via_quire_ci(&repo.path(), &workspace, &meta, secrets, sentry)?;
+            run.execute_via_quire_ci(&repo.path(), &workspace, &meta, secrets, sentry, transport)?;
         }
     }
     Ok(())
@@ -472,6 +491,7 @@ mod tests {
                 &push_ref,
                 &HashMap::new(),
                 Executor::QuireCi,
+                &Transport::Filesystem,
                 None,
             )
         });
@@ -526,6 +546,7 @@ mod tests {
                 &push_ref,
                 &HashMap::new(),
                 Executor::QuireCi,
+                &Transport::Filesystem,
                 None,
             )
         });
@@ -569,6 +590,7 @@ mod tests {
             &push_ref,
             &HashMap::new(),
             Executor::QuireCi,
+            &Transport::Filesystem,
             None,
         )
         .expect("should succeed without ci.fnl");
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index 7cf6de3..f8790fb 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -10,6 +10,7 @@ use std::path::{Path, PathBuf};
 
 use jiff::Timestamp;
 use quire_core::secret::SecretString;
+use rand::{Rng, distr::Alphanumeric};
 
 use super::error::{Error, Result};
 
@@ -28,6 +29,54 @@ pub enum Executor {
     QuireCi,
 }
 
+/// Transport for CI ↔ server communication.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum TransportMode {
+    #[default]
+    Filesystem,
+    Api,
+}
+
+/// Runtime transport for a single CI run. Built once per run from
+/// the config-shape [`TransportMode`] + the top-level `server_url`,
+/// then passed to `Runs::create` and `Run::execute_via_quire_ci`.
+#[derive(Clone, Debug, Default)]
+pub enum Transport {
+    #[default]
+    Filesystem,
+    Api(ApiTransport),
+}
+
+impl Transport {
+    /// Build a runtime transport for a new run. For `Api`, mints a
+    /// fresh CSPRNG bearer token and pairs it with `server_url`.
+    /// Errors if `mode == Api` and `server_url` is missing.
+    pub fn for_new_run(mode: TransportMode, server_url: Option<&str>) -> Result<Self> {
+        match mode {
+            TransportMode::Filesystem => Ok(Transport::Filesystem),
+            TransportMode::Api => {
+                let server_url = server_url
+                    .ok_or(Error::ApiTransportMissingServerUrl)?
+                    .to_string();
+                Ok(Transport::Api(ApiTransport {
+                    server_url,
+                    auth_token: mint_auth_token(),
+                }))
+            }
+        }
+    }
+}
+
+/// Runtime config for the API transport, produced at run creation.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ApiTransport {
+    /// Base URL of quire-server (e.g. `http://127.0.0.1:3000`).
+    pub server_url: String,
+    /// Bearer token for this run, minted at creation time.
+    pub auth_token: String,
+}
+
 /// The state of a CI run.
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 pub enum RunState {
@@ -96,9 +145,17 @@ impl Runs {
     ///
     /// Inserts a row into `runs` and creates the run directory for
     /// workspace materialization and log storage.
-    pub fn create(&self, meta: &RunMeta) -> Result<Run> {
+    ///
+    /// `transport` is built by the caller via [`Transport::for_new_run`];
+    /// its `Api` auth_token (if any) is persisted in the `auth_token`
+    /// column so server-side request handlers can look it up.
+    pub fn create(&self, meta: &RunMeta, transport: &Transport) -> Result<Run> {
         let id = uuid::Uuid::now_v7().to_string();
         let workspace_path = self.base_dir.join(&id).join("workspace");
+        let auth_token_str = match transport {
+            Transport::Filesystem => None,
+            Transport::Api(api) => Some(api.auth_token.as_str()),
+        };
 
         let db = crate::db::open(&self.db_path)?;
 
@@ -108,8 +165,8 @@ impl Runs {
         self.supersede_existing(&db, &meta.r#ref)?;
 
         db.execute(
-            "INSERT INTO runs (id, repo, ref_name, sha, pushed_at_ms, state, queued_at_ms, workspace_path)
-             VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7)",
+            "INSERT INTO runs (id, repo, ref_name, sha, pushed_at_ms, state, queued_at_ms, workspace_path, auth_token)
+             VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7, ?8)",
             rusqlite::params![
                 &id,
                 &self.repo,
@@ -121,6 +178,7 @@ impl Runs {
                     std::io::ErrorKind::InvalidData,
                     "workspace path is not valid UTF-8",
                 ))?,
+                auth_token_str,
             ],
         )?;
 
@@ -266,6 +324,10 @@ impl Run {
     /// * `jobs/<job>/sh-<n>.log` — per-sh CRI logs, written by quire-ci
     ///   via `--out-dir`.
     ///
+    /// When `transport` is `Api`, passes `--run-id`, `--server-url`,
+    /// and `QUIRE_CI_TOKEN` to the subprocess instead of the
+    /// filesystem-based flags.
+    ///
     /// Run finishes `Complete` on exit 0, `Failed` otherwise. The DB
     /// rows are written even on failure so the web UI can render
     /// partial progress.
@@ -276,6 +338,7 @@ impl Run {
         meta: &RunMeta,
         secrets: &HashMap<String, SecretString>,
         sentry: Option<&quire_core::ci::dispatch::SentryHandoff>,
+        transport: &Transport,
     ) -> Result<()> {
         self.transition(RunState::Active, None)?;
 
@@ -297,16 +360,28 @@ impl Run {
             "dispatching run to quire-ci",
         );
 
-        let status = std::process::Command::new("quire-ci")
-            .arg("run")
-            .arg("--workspace")
-            .arg(workspace)
-            .arg("--out-dir")
-            .arg(&run_dir)
-            .arg("--events")
-            .arg(&events_path)
-            .arg("--dispatch")
-            .arg(&dispatch_path)
+        let mut cmd = std::process::Command::new("quire-ci");
+        cmd.arg("run").arg("--workspace").arg(workspace);
+
+        match transport {
+            Transport::Filesystem => {
+                cmd.arg("--out-dir")
+                    .arg(&run_dir)
+                    .arg("--events")
+                    .arg(&events_path)
+                    .arg("--dispatch")
+                    .arg(&dispatch_path);
+            }
+            Transport::Api(api) => {
+                cmd.arg("--run-id")
+                    .arg(&self.id)
+                    .arg("--server-url")
+                    .arg(&api.server_url);
+                cmd.env("QUIRE_CI_TOKEN", &api.auth_token);
+            }
+        }
+
+        let status = cmd
             .stdout(std::process::Stdio::from(log))
             .stderr(std::process::Stdio::from(log_clone))
             .status()
@@ -630,6 +705,19 @@ pub fn materialize_workspace(git_dir: &Path, sha: &str, workspace: &Path) -> Res
     Ok(())
 }
 
+/// Mint a 32-character alphanumeric bearer token from the OS CSPRNG.
+///
+/// ~190 bits of entropy, opaque to the holder. Used as the per-run
+/// auth secret for the API transport; stored in the `runs.auth_token`
+/// column and passed to quire-ci via `QUIRE_CI_TOKEN`.
+fn mint_auth_token() -> String {
+    rand::rng()
+        .sample_iter(&Alphanumeric)
+        .take(32)
+        .map(char::from)
+        .collect()
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -805,16 +893,97 @@ mod tests {
     fn create_generates_uuidv7_id() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
-        let run = runs.create(&test_meta()).expect("create");
+        let run = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create");
         let parsed = uuid::Uuid::parse_str(run.id()).expect("should be valid UUID");
         assert_eq!(parsed.get_version(), Some(uuid::Version::SortRand));
     }
 
+    #[test]
+    fn create_with_filesystem_leaves_auth_token_null() {
+        let (_dir, quire) = tmp_quire();
+        let runs = test_runs(&quire);
+        let run = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create");
+
+        let conn = crate::db::open(&quire.db_path()).expect("db");
+        let token: Option<String> = conn
+            .query_row(
+                "SELECT auth_token FROM runs WHERE id = ?1",
+                rusqlite::params![run.id()],
+                |row| row.get(0),
+            )
+            .expect("row");
+        assert!(
+            token.is_none(),
+            "filesystem transport should not mint a token"
+        );
+    }
+
+    #[test]
+    fn create_with_api_persists_minted_auth_token() {
+        let (_dir, quire) = tmp_quire();
+        let runs = test_runs(&quire);
+        let transport = Transport::for_new_run(TransportMode::Api, Some("http://127.0.0.1:3000"))
+            .expect("for_new_run");
+        let run = runs.create(&test_meta(), &transport).expect("create");
+
+        let Transport::Api(api) = &transport else {
+            panic!("expected Api transport");
+        };
+
+        let conn = crate::db::open(&quire.db_path()).expect("db");
+        let stored: Option<String> = conn
+            .query_row(
+                "SELECT auth_token FROM runs WHERE id = ?1",
+                rusqlite::params![run.id()],
+                |row| row.get(0),
+            )
+            .expect("row");
+        assert_eq!(stored.as_deref(), Some(api.auth_token.as_str()));
+    }
+
+    #[test]
+    fn for_new_run_mints_alphanumeric_token() {
+        let transport = Transport::for_new_run(TransportMode::Api, Some("http://127.0.0.1:3000"))
+            .expect("for_new_run");
+        let Transport::Api(api) = transport else {
+            panic!("expected Api transport");
+        };
+        assert_eq!(api.server_url, "http://127.0.0.1:3000");
+        assert_eq!(api.auth_token.len(), 32);
+        assert!(
+            api.auth_token.chars().all(|c| c.is_ascii_alphanumeric()),
+            "token should be alphanumeric, got {:?}",
+            api.auth_token
+        );
+    }
+
+    #[test]
+    fn for_new_run_rejects_api_without_server_url() {
+        match Transport::for_new_run(TransportMode::Api, None) {
+            Ok(_) => panic!("for_new_run should fail without server_url"),
+            Err(Error::ApiTransportMissingServerUrl) => {}
+            Err(other) => panic!("unexpected error: {other}"),
+        }
+    }
+
+    #[test]
+    fn mint_auth_token_returns_unique_values() {
+        let a = mint_auth_token();
+        let b = mint_auth_token();
+        assert_ne!(a, b, "two mints should not collide");
+    }
+
     #[test]
     fn create_writes_row_in_pending_state() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
-        let run = runs.create(&test_meta()).expect("create");
+        let run = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create");
 
         assert_eq!(run.state(), RunState::Pending);
 
@@ -837,7 +1006,9 @@ mod tests {
     fn transition_updates_state_in_db() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
-        let mut run = runs.create(&test_meta()).expect("create");
+        let mut run = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create");
         let id = run.id().to_string();
 
         run.transition(RunState::Active, None).expect("transition");
@@ -858,7 +1029,9 @@ mod tests {
     fn transition_stamps_started_at_on_active() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
-        let mut run = runs.create(&test_meta()).expect("create");
+        let mut run = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create");
 
         run.transition(RunState::Active, None).expect("to active");
         let started = run.read_started_at().expect("read started_at");
@@ -871,7 +1044,9 @@ mod tests {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
 
-        let mut completed = runs.create(&test_meta()).expect("create");
+        let mut completed = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create");
         completed
             .transition(RunState::Active, None)
             .expect("to active");
@@ -880,7 +1055,9 @@ mod tests {
             .expect("to complete");
         assert!(completed.read_finished_at().expect("read").is_some());
 
-        let mut failed = runs.create(&test_meta()).expect("create");
+        let mut failed = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create");
         failed
             .transition(RunState::Active, None)
             .expect("to active");
@@ -894,7 +1071,9 @@ mod tests {
     fn transition_records_failure_kind_on_failed() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
-        let mut run = runs.create(&test_meta()).expect("create");
+        let mut run = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create");
         let id = run.id().to_string();
 
         run.transition(RunState::Active, None).expect("to active");
@@ -916,7 +1095,9 @@ mod tests {
     fn transition_skips_failure_kind_when_none() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
-        let mut run = runs.create(&test_meta()).expect("create");
+        let mut run = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create");
         let id = run.id().to_string();
 
         run.transition(RunState::Active, None).expect("to active");
@@ -939,11 +1120,15 @@ mod tests {
         let runs = test_runs(&quire);
 
         // Pending -> Failed is not allowed (must go via Active).
-        let mut run = runs.create(&test_meta()).expect("create");
+        let mut run = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create");
         assert!(run.transition(RunState::Failed, None).is_err());
 
         // Terminal -> anything is not allowed.
-        let mut completed = runs.create(&test_meta()).expect("create");
+        let mut completed = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create");
         completed
             .transition(RunState::Active, None)
             .expect("to active");
@@ -958,7 +1143,9 @@ mod tests {
     fn transition_preserves_started_at_through_completion() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
-        let mut run = runs.create(&test_meta()).expect("create");
+        let mut run = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create");
 
         run.transition(RunState::Active, None).expect("to active");
         let started = run.read_started_at().expect("read started_at");
@@ -976,7 +1163,9 @@ mod tests {
     fn transition_full_lifecycle() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
-        let mut run = runs.create(&test_meta()).expect("create");
+        let mut run = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create");
 
         run.transition(RunState::Active, None).expect("to active");
         run.transition(RunState::Complete, None)
@@ -989,7 +1178,9 @@ mod tests {
     fn reconcile_fails_pending_orphans() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
-        let run = runs.create(&test_meta()).expect("create");
+        let run = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create");
         let id = run.id().to_string();
 
         reconcile_orphans(&quire.db_path()).expect("reconcile");
@@ -1002,7 +1193,9 @@ mod tests {
     fn reconcile_fails_active_orphans() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
-        let mut run = runs.create(&test_meta()).expect("create");
+        let mut run = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create");
         run.transition(RunState::Active, None).expect("to active");
         let id = run.id().to_string();
 
@@ -1016,7 +1209,9 @@ mod tests {
     fn reconcile_leaves_complete_runs_alone() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
-        let mut run = runs.create(&test_meta()).expect("create");
+        let mut run = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create");
         run.transition(RunState::Active, None).expect("to active");
         run.transition(RunState::Complete, None)
             .expect("to complete");
@@ -1040,7 +1235,9 @@ mod tests {
 
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
-        let run = runs.create(&test_meta()).expect("create");
+        let run = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create");
         let run_id = run.id().to_string();
 
         let events = vec![
@@ -1145,7 +1342,9 @@ mod tests {
     fn ingest_events_treats_missing_file_as_empty() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
-        let run = runs.create(&test_meta()).expect("create");
+        let run = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create");
 
         let missing = run.path().join("events.jsonl");
         run.ingest_events(&missing)
@@ -1168,7 +1367,9 @@ mod tests {
         let runs = test_runs(&quire);
 
         // Create first run.
-        let run1 = runs.create(&test_meta()).expect("create run1");
+        let run1 = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create run1");
         let run1_id = run1.id().to_string();
         assert_eq!(run1.state(), RunState::Pending);
 
@@ -1178,7 +1379,9 @@ mod tests {
             r#ref: "refs/heads/main".to_string(),
             pushed_at: "2026-04-28T13:00:00Z".parse().unwrap(),
         };
-        let run2 = runs.create(&meta2).expect("create run2");
+        let run2 = runs
+            .create(&meta2, &Transport::Filesystem)
+            .expect("create run2");
         assert_eq!(run2.state(), RunState::Pending);
 
         // First run should now be superseded.
@@ -1196,7 +1399,9 @@ mod tests {
         let runs = test_runs(&quire);
 
         // Create and activate first run.
-        let mut run1 = runs.create(&test_meta()).expect("create run1");
+        let mut run1 = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create run1");
         let run1_id = run1.id().to_string();
         run1.transition(RunState::Active, None).expect("to active");
 
@@ -1206,7 +1411,9 @@ mod tests {
             r#ref: "refs/heads/main".to_string(),
             pushed_at: "2026-04-28T13:00:00Z".parse().unwrap(),
         };
-        let run2 = runs.create(&meta2).expect("create run2");
+        let run2 = runs
+            .create(&meta2, &Transport::Filesystem)
+            .expect("create run2");
         assert_eq!(run2.state(), RunState::Pending);
 
         // First run should be superseded.
@@ -1224,7 +1431,9 @@ mod tests {
         let runs = test_runs(&quire);
 
         // Create run for main.
-        let run1 = runs.create(&test_meta()).expect("create run1");
+        let run1 = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create run1");
         let run1_id = run1.id().to_string();
 
         // Create run for a different ref.
@@ -1233,7 +1442,9 @@ mod tests {
             r#ref: "refs/heads/feature".to_string(),
             pushed_at: "2026-04-28T13:00:00Z".parse().unwrap(),
         };
-        let _run2 = runs.create(&meta2).expect("create run2");
+        let _run2 = runs
+            .create(&meta2, &Transport::Filesystem)
+            .expect("create run2");
 
         // First run should still be pending.
         let reopened = Run::open(quire.db_path(), run1_id, runs.base_dir.clone()).expect("reopen");
@@ -1246,7 +1457,9 @@ mod tests {
         let runs = test_runs(&quire);
 
         // Create and complete first run.
-        let mut run1 = runs.create(&test_meta()).expect("create run1");
+        let mut run1 = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create run1");
         let run1_id = run1.id().to_string();
         run1.transition(RunState::Active, None).expect("to active");
         run1.transition(RunState::Complete, None)
@@ -1258,7 +1471,9 @@ mod tests {
             r#ref: "refs/heads/main".to_string(),
             pushed_at: "2026-04-28T13:00:00Z".parse().unwrap(),
         };
-        let _run2 = runs.create(&meta2).expect("create run2");
+        let _run2 = runs
+            .create(&meta2, &Transport::Filesystem)
+            .expect("create run2");
 
         // First run should still be complete.
         let reopened = Run::open(quire.db_path(), run1_id, runs.base_dir.clone()).expect("reopen");
@@ -1269,7 +1484,9 @@ mod tests {
     fn transition_allows_pending_to_superseded() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
-        let mut run = runs.create(&test_meta()).expect("create");
+        let mut run = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create");
         run.transition(RunState::Superseded, None)
             .expect("to superseded");
         assert_eq!(run.state(), RunState::Superseded);
@@ -1279,7 +1496,9 @@ mod tests {
     fn transition_allows_active_to_superseded() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
-        let mut run = runs.create(&test_meta()).expect("create");
+        let mut run = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create");
         run.transition(RunState::Active, None).expect("to active");
         run.transition(RunState::Superseded, None)
             .expect("to superseded");
@@ -1290,7 +1509,9 @@ mod tests {
     fn supersede_sets_finished_at() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
-        let mut run = runs.create(&test_meta()).expect("create");
+        let mut run = runs
+            .create(&test_meta(), &Transport::Filesystem)
+            .expect("create");
         run.transition(RunState::Active, None).expect("to active");
 
         assert!(
diff --git a/quire-server/src/db.rs b/quire-server/src/db.rs
index 7fca1ca..0b8770b 100644
--- a/quire-server/src/db.rs
+++ b/quire-server/src/db.rs
@@ -14,6 +14,7 @@ static MIGRATIONS: std::sync::LazyLock<Migrations<'static>> = std::sync::LazyLoc
     Migrations::new(vec![
         M::up(include_str!("../migrations/0001_initial.sql")),
         M::up(include_str!("../migrations/0002_sh_events.sql")),
+        M::up(include_str!("../migrations/0003_ci_api.sql")),
     ])
 });
 
diff --git a/quire-server/src/quire/mod.rs b/quire-server/src/quire/mod.rs
index 8a6e0a7..150017d 100644
--- a/quire-server/src/quire/mod.rs
+++ b/quire-server/src/quire/mod.rs
@@ -6,7 +6,7 @@ use miette::{Context, IntoDiagnostic, Result, ensure};
 
 pub mod web;
 
-use crate::ci::{Ci, Executor, Runs};
+use crate::ci::{Ci, Executor, Runs, TransportMode};
 use crate::{Error, Result as AppResult};
 use quire_core::fennel::Fennel;
 use quire_core::secret::SecretString;
@@ -15,6 +15,7 @@ use quire_core::secret::SecretString;
 ///
 /// Top-level stays open for future keys (notifications defaults, SMTP, etc.).
 #[derive(serde::Deserialize, Debug)]
+#[serde(rename_all = "kebab-case")]
 pub struct GlobalConfig {
     #[serde(default)]
     pub sentry: Option<SentryConfig>,
@@ -22,10 +23,30 @@ pub struct GlobalConfig {
     /// Each value is a `SecretString` (plain literal or `{:file "..."}`).
     #[serde(default)]
     pub secrets: HashMap<String, SecretString>,
+    /// Base URL the orchestrator advertises to quire-ci over the API
+    /// transport (e.g. `http://127.0.0.1:3000`). Top-level because it
+    /// describes the server itself, not CI; once the filesystem
+    /// transport is retired it stays put.
+    #[serde(default)]
+    pub server_url: Option<String>,
+    /// CI configuration.
+    #[serde(default)]
+    pub ci: CiConfig,
+}
+
+#[derive(serde::Deserialize, Debug, Default)]
+pub struct CiConfig {
     /// How the orchestrator dispatches CI runs. Defaults to shelling
     /// out to the `quire-ci` binary via `Executor::QuireCi`.
     #[serde(default)]
     pub executor: Executor,
+    /// Transport for CI ↔ server communication.
+    ///
+    /// `"filesystem"` (default) writes dispatch/events/logs to disk;
+    /// `"api"` uses the HTTP API with bearer-token auth (requires
+    /// the top-level `server-url`).
+    #[serde(default)]
+    pub transport: TransportMode,
 }
 
 #[derive(serde::Deserialize, Debug)]
@@ -387,6 +408,35 @@ mod tests {
         assert!(q.repo_from_path(&path).is_err());
     }
 
+    #[test]
+    fn global_config_ci_defaults_to_filesystem() {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let config_path = dir.path().join("config.fnl");
+        fs_err::write(&config_path, "{}").expect("write");
+
+        let q = Quire::new(dir.path().to_path_buf());
+        let config = q.global_config().expect("global_config should load");
+        assert_eq!(config.ci.transport, TransportMode::Filesystem);
+        assert_eq!(config.ci.executor, Executor::QuireCi);
+        assert!(config.server_url.is_none());
+    }
+
+    #[test]
+    fn global_config_parses_api_transport_and_server_url() {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let config_path = dir.path().join("config.fnl");
+        fs_err::write(
+            &config_path,
+            r#"{:server-url "http://127.0.0.1:3000" :ci {:transport :api}}"#,
+        )
+        .expect("write");
+
+        let q = Quire::new(dir.path().to_path_buf());
+        let config = q.global_config().expect("global_config should load");
+        assert_eq!(config.ci.transport, TransportMode::Api);
+        assert_eq!(config.server_url.as_deref(), Some("http://127.0.0.1:3000"));
+    }
+
     #[test]
     fn global_config_loads_from_fennel_file() {
         let dir = tempfile::tempdir().expect("tempdir");