Phase 1.A: webhook foundation for quire-ci service extraction
- Lift PushEvent/PushRef into quire-core; rename the ref field to
  ref_name to match the webhook contract. quire-server re-exports
  from quire-core. Update all construction sites and tests.

- Add quire-ci/migrations/0001_initial.sql: schema with lifecycle
  derived from timestamp columns (no state column), outcome enum,
  and pending/repo indexes.

- Add quire-ci/src/db.rs: open + migrate plumbing, test-only
  open_in_memory.

- Flesh out quire-ci/src/quire.rs: GlobalConfig gains webhook_secret
  and server_url; QuireCi owns the db path and runs migrations at
  startup.

- Add POST /webhook to quire-ci/src/server.rs: HMAC-SHA256
  verification (skipped when no secret configured), PushEvent
  deserialization, one runs row per updated ref. Tests cover valid
  HMAC, unsigned-with-no-secret, and wrong-HMAC paths.

https://claude.ai/code/session_01YXVu67hVnQSAY8wzmfmRHw
change
commit e3d210a31ea1ce4a10a8c68dae72c3b74229cb99
author Claude <noreply@anthropic.com>
date
parent xvonkttx
diff --git a/Cargo.lock b/Cargo.lock
index e87f313..2d0d6ae 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -485,6 +485,15 @@ dependencies = [
  "generic-array",
 ]
 
+[[package]]
+name = "block-buffer"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
+dependencies = [
+ "hybrid-array",
+]
+
 [[package]]
 name = "block2"
 version = "0.6.2"
@@ -641,6 +650,12 @@ dependencies = [
  "cc",
 ]
 
+[[package]]
+name = "cmov"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
+
 [[package]]
 name = "colorchoice"
 version = "1.0.5"
@@ -663,6 +678,12 @@ version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca"
 
+[[package]]
+name = "const-oid"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
+
 [[package]]
 name = "convert_case"
 version = "0.10.0"
@@ -697,6 +718,15 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "crc32fast"
 version = "1.5.0"
@@ -722,6 +752,24 @@ dependencies = [
  "typenum",
 ]
 
+[[package]]
+name = "crypto-common"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "ctutils"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
+dependencies = [
+ "cmov",
+]
+
 [[package]]
 name = "debugid"
 version = "0.8.0"
@@ -776,8 +824,20 @@ version = "0.10.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
 dependencies = [
- "block-buffer",
- "crypto-common",
+ "block-buffer 0.10.4",
+ "crypto-common 0.1.7",
+]
+
+[[package]]
+name = "digest"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
+dependencies = [
+ "block-buffer 0.12.0",
+ "const-oid",
+ "crypto-common 0.2.2",
+ "ctutils",
 ]
 
 [[package]]
@@ -1407,6 +1467,15 @@ version = "0.4.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
 
+[[package]]
+name = "hmac"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
+dependencies = [
+ "digest 0.11.3",
+]
+
 [[package]]
 name = "hostname"
 version = "0.4.2"
@@ -1474,6 +1543,15 @@ version = "1.0.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
 
+[[package]]
+name = "hybrid-array"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
+dependencies = [
+ "typenum",
+]
+
 [[package]]
 name = "hyper"
 version = "1.9.0"
@@ -2552,6 +2630,9 @@ dependencies = [
  "facet",
  "figue",
  "fs-err",
+ "hex",
+ "hmac",
+ "http-body-util",
  "jiff",
  "miette",
  "mlua",
@@ -2559,15 +2640,21 @@ dependencies = [
  "opentelemetry_sdk",
  "quire-core",
  "reqwest",
+ "rusqlite",
+ "rusqlite_migration",
  "sentry",
  "serde",
  "serde_json",
+ "sha2",
+ "subtle",
  "tempfile",
  "thiserror",
  "tokio",
+ "tower",
  "tracing",
  "tracing-opentelemetry",
  "tracing-subscriber",
+ "uuid",
 ]
 
 [[package]]
@@ -3233,8 +3320,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
 dependencies = [
  "cfg-if",
- "cpufeatures",
- "digest",
+ "cpufeatures 0.2.17",
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "sha2"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "digest 0.11.3",
 ]
 
 [[package]]
diff --git a/quire-ci/Cargo.toml b/quire-ci/Cargo.toml
index 5fedb85..689fd96 100644
--- a/quire-ci/Cargo.toml
+++ b/quire-ci/Cargo.toml
@@ -8,14 +8,19 @@ axum = { workspace = true }
 facet = { workspace = true }
 figue = "*"
 fs-err = { workspace = true }
+hmac = "*"
 jiff = { workspace = true }
 miette = { workspace = true, features = ["fancy"] }
 mlua = { workspace = true }
 quire-core = { path = "../quire-core" }
 reqwest = { version = "*", features = ["blocking", "json", "rustls"], default-features = false }
+rusqlite = { version = "*", features = ["bundled"] }
+rusqlite_migration = "*"
 sentry = { workspace = true }
 serde = { workspace = true }
 serde_json = { workspace = true }
+sha2 = "*"
+subtle = "*"
 tempfile = { workspace = true }
 thiserror = { workspace = true }
 tokio = { workspace = true, features = ["full"] }
@@ -24,3 +29,9 @@ opentelemetry_sdk = { workspace = true }
 tracing = { workspace = true }
 tracing-opentelemetry = { workspace = true }
 tracing-subscriber = { workspace = true }
+uuid = { version = "*", features = ["v7"] }
+hex = "0.4.3"
+
+[dev-dependencies]
+http-body-util = "*"
+tower = { version = "*", features = ["util"] }
diff --git a/quire-ci/migrations/0001_initial.sql b/quire-ci/migrations/0001_initial.sql
new file mode 100644
index 0000000..771144c
--- /dev/null
+++ b/quire-ci/migrations/0001_initial.sql
@@ -0,0 +1,33 @@
+CREATE TABLE runs (
+  id             TEXT    PRIMARY KEY,
+  repo           TEXT    NOT NULL,
+  ref_name       TEXT    NOT NULL,
+  sha            TEXT    NOT NULL,
+  created_at     INTEGER NOT NULL,
+  dispatched_at  INTEGER,
+  resolved_at    INTEGER,
+  outcome        TEXT,
+  traceparent    TEXT,
+
+  -- timestamps move forward
+  CHECK (dispatched_at IS NULL OR dispatched_at >= created_at),
+  CHECK (resolved_at   IS NULL OR resolved_at   >= created_at),
+  CHECK (resolved_at   IS NULL OR dispatched_at IS NULL
+         OR resolved_at >= dispatched_at),
+
+  -- resolved_at and outcome travel together
+  CHECK ((resolved_at IS NULL) = (outcome IS NULL)),
+
+  -- outcome enum
+  CHECK (outcome IS NULL OR outcome IN (
+    'succeeded',
+    'failed-pipeline', 'failed-orphaned', 'failed-internal',
+    'superseded'
+  ))
+);
+
+-- Pending work: queue scans only touch unresolved rows.
+CREATE INDEX runs_pending ON runs(created_at) WHERE outcome IS NULL;
+
+-- Listing runs per repo, most recent first.
+CREATE INDEX runs_repo_created_at ON runs(repo, created_at DESC);
diff --git a/quire-ci/src/db.rs b/quire-ci/src/db.rs
new file mode 100644
index 0000000..fabfada
--- /dev/null
+++ b/quire-ci/src/db.rs
@@ -0,0 +1,79 @@
+use std::path::Path;
+
+use rusqlite::Connection;
+use rusqlite_migration::{M, Migrations};
+
+static MIGRATIONS: std::sync::LazyLock<Migrations<'static>> = std::sync::LazyLock::new(|| {
+    Migrations::new(vec![M::up(include_str!("../migrations/0001_initial.sql"))])
+});
+
+#[derive(Debug, thiserror::Error)]
+pub enum MigrationError {
+    #[error(transparent)]
+    Sqlite(#[from] rusqlite::Error),
+    #[error("migration error: {0}")]
+    Migration(#[from] rusqlite_migration::Error),
+}
+
+pub fn open(path: &Path) -> Result<Connection, rusqlite::Error> {
+    let conn = Connection::open(path)?;
+    conn.execute_batch(
+        "PRAGMA journal_mode = WAL;
+         PRAGMA foreign_keys = ON;
+         PRAGMA busy_timeout = 5000;",
+    )?;
+    Ok(conn)
+}
+
+pub fn migrate(conn: &mut Connection) -> Result<(), MigrationError> {
+    MIGRATIONS.to_latest(conn)?;
+    Ok(())
+}
+
+#[cfg(test)]
+pub fn open_in_memory() -> Result<Connection, MigrationError> {
+    let mut conn = Connection::open_in_memory()?;
+    conn.execute_batch("PRAGMA foreign_keys = ON;")?;
+    MIGRATIONS.to_latest(&mut conn)?;
+    Ok(conn)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn migrations_apply_without_panicking() {
+        open_in_memory().expect("migrations should apply cleanly");
+    }
+
+    #[test]
+    fn runs_table_has_expected_columns() {
+        let conn = open_in_memory().expect("open_in_memory");
+        let mut stmt = conn
+            .prepare("PRAGMA table_info(runs)")
+            .expect("prepare pragma");
+        let columns: Vec<String> = stmt
+            .query_map([], |row| row.get(1))
+            .expect("query")
+            .map(|r| r.expect("column name"))
+            .collect();
+
+        for expected in &[
+            "id",
+            "repo",
+            "ref_name",
+            "sha",
+            "created_at",
+            "dispatched_at",
+            "resolved_at",
+            "outcome",
+            "traceparent",
+        ] {
+            assert!(
+                columns.contains(&expected.to_string()),
+                "missing column: {expected}"
+            );
+        }
+    }
+}
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 88c6b06..61cce60 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -1,3 +1,4 @@
+mod db;
 mod quire;
 mod server;
 mod sink;
diff --git a/quire-ci/src/quire.rs b/quire-ci/src/quire.rs
index 83b27f1..80d090c 100644
--- a/quire-ci/src/quire.rs
+++ b/quire-ci/src/quire.rs
@@ -1,9 +1,12 @@
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
 
 use miette::{IntoDiagnostic, Result};
+use rusqlite::Connection;
 
 use quire_core::fennel::Fennel;
 
+use crate::db;
+
 /// Parsed global configuration (`<base-dir>/config.fnl`).
 #[derive(serde::Deserialize, Debug, Clone)]
 #[serde(rename_all = "kebab-case")]
@@ -13,6 +16,12 @@ pub struct GlobalConfig {
     /// TCP port the HTTP server binds to on all interfaces (`0.0.0.0`).
     #[serde(default = "default_port")]
     pub port: u16,
+    #[serde(default)]
+    pub webhook_secret: Option<quire_core::secret::SecretString>,
+    // Reserved for future use: quire-ci will clone from this URL.
+    #[allow(dead_code)]
+    #[serde(default)]
+    pub server_url: Option<String>,
 }
 
 fn default_port() -> u16 {
@@ -28,6 +37,7 @@ pub use quire_core::telemetry::SentryConfig;
 #[derive(Clone)]
 pub struct QuireCi {
     config: GlobalConfig,
+    db_path: PathBuf,
 }
 
 impl QuireCi {
@@ -39,12 +49,23 @@ impl QuireCi {
         } else {
             GlobalConfig::default()
         };
-        Ok(Self { config })
+        let db_path = base_dir.join("quire-ci.db");
+        let mut conn = db::open(&db_path).into_diagnostic()?;
+        db::migrate(&mut conn).into_diagnostic()?;
+        Ok(Self { config, db_path })
     }
 
     pub fn config(&self) -> &GlobalConfig {
         &self.config
     }
+
+    pub fn db_path(&self) -> &Path {
+        &self.db_path
+    }
+
+    pub fn open_db(&self) -> Result<Connection, rusqlite::Error> {
+        db::open(self.db_path())
+    }
 }
 
 impl Default for GlobalConfig {
@@ -52,6 +73,8 @@ impl Default for GlobalConfig {
         Self {
             sentry: None,
             port: default_port(),
+            webhook_secret: None,
+            server_url: None,
         }
     }
 }
@@ -107,4 +130,12 @@ mod tests {
             .expect("sentry should be present");
         assert_eq!(sentry.dsn.reveal().unwrap(), "https://key@sentry.io/123");
     }
+
+    #[test]
+    fn db_is_created_at_expected_path() {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let q = QuireCi::new(dir.path().to_path_buf()).expect("should load");
+        assert_eq!(q.db_path(), dir.path().join("quire-ci.db"));
+        assert!(q.db_path().exists(), "db file should exist after new()");
+    }
 }
diff --git a/quire-ci/src/server.rs b/quire-ci/src/server.rs
index ac37287..ded931d 100644
--- a/quire-ci/src/server.rs
+++ b/quire-ci/src/server.rs
@@ -1,8 +1,13 @@
 use std::net::SocketAddr;
 
 use axum::Router;
-use axum::routing::get;
+use axum::extract::State;
+use axum::http::{HeaderMap, StatusCode};
+use axum::routing::{get, post};
+use hmac::{Hmac, KeyInit, Mac};
+use quire_core::event::PushEvent;
 use quire_core::telemetry::{self, FmtMode};
+use sha2::Sha256;
 
 use crate::quire::QuireCi;
 
@@ -16,6 +21,78 @@ async fn index() -> String {
     format!("quire-ci {VERSION}\n")
 }
 
+async fn webhook(
+    State(quire): State<QuireCi>,
+    headers: HeaderMap,
+    body: axum::body::Bytes,
+) -> StatusCode {
+    if let Some(secret) = quire.config().webhook_secret.as_ref() {
+        let secret_bytes = match secret.reveal() {
+            Ok(s) => s.as_bytes().to_vec(),
+            Err(_) => return StatusCode::INTERNAL_SERVER_ERROR,
+        };
+
+        let auth_header = match headers
+            .get("Authorization")
+            .and_then(|v| v.to_str().ok())
+            .and_then(|s| s.strip_prefix("HMAC-SHA256 "))
+        {
+            Some(hex) => hex.to_string(),
+            None => return StatusCode::UNAUTHORIZED,
+        };
+
+        let provided_bytes = match hex::decode(&auth_header) {
+            Ok(b) => b,
+            Err(_) => return StatusCode::UNAUTHORIZED,
+        };
+
+        let mut mac =
+            Hmac::<Sha256>::new_from_slice(&secret_bytes).expect("HMAC accepts any key length");
+        mac.update(&body);
+        if mac.verify_slice(&provided_bytes).is_err() {
+            return StatusCode::UNAUTHORIZED;
+        }
+    }
+
+    let event: PushEvent = match serde_json::from_slice(&body) {
+        Ok(e) => e,
+        Err(_) => return StatusCode::BAD_REQUEST,
+    };
+
+    let traceparent = headers
+        .get("traceparent")
+        .and_then(|v| v.to_str().ok())
+        .map(|s| s.to_string());
+
+    let conn = match quire.open_db() {
+        Ok(c) => c,
+        Err(_) => return StatusCode::INTERNAL_SERVER_ERROR,
+    };
+
+    let now_ms = jiff::Timestamp::now().as_millisecond();
+
+    for push_ref in event.updated_refs() {
+        let id = uuid::Uuid::now_v7().to_string();
+        let result = conn.execute(
+            "INSERT INTO runs (id, repo, ref_name, sha, created_at, traceparent)
+             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
+            rusqlite::params![
+                id,
+                event.repo,
+                push_ref.ref_name,
+                push_ref.new_sha,
+                now_ms,
+                traceparent,
+            ],
+        );
+        if result.is_err() {
+            return StatusCode::INTERNAL_SERVER_ERROR;
+        }
+    }
+
+    StatusCode::NO_CONTENT
+}
+
 #[derive(Debug, thiserror::Error, miette::Diagnostic)]
 pub enum Error {
     #[error("io error: {0}")]
@@ -43,7 +120,9 @@ pub async fn run(quire: QuireCi) -> Result<()> {
 
     let app = Router::new()
         .route("/health", get(health))
-        .route("/", get(index));
+        .route("/", get(index))
+        .route("/webhook", post(webhook))
+        .with_state(quire);
 
     let addr = SocketAddr::from(([0, 0, 0, 0], port));
     tracing::info!(%addr, "starting HTTP server");
@@ -54,3 +133,131 @@ pub async fn run(quire: QuireCi) -> Result<()> {
 
     Ok(())
 }
+
+#[cfg(test)]
+mod tests {
+    use axum::body::Body;
+    use axum::http::{Request, StatusCode};
+    use hmac::{Hmac, KeyInit, Mac};
+    use sha2::Sha256;
+    use tower::ServiceExt;
+
+    use crate::quire::QuireCi;
+
+    fn make_app(quire: QuireCi) -> axum::Router {
+        axum::Router::new()
+            .route("/webhook", axum::routing::post(super::webhook))
+            .with_state(quire)
+    }
+
+    fn quire_without_secret() -> (tempfile::TempDir, QuireCi) {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let quire = QuireCi::new(dir.path().to_path_buf()).expect("QuireCi::new");
+        (dir, quire)
+    }
+
+    fn quire_with_secret(secret: &str) -> (tempfile::TempDir, QuireCi) {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let config_path = dir.path().join("config.fnl");
+        fs_err::write(&config_path, format!(r#"{{:webhook-secret "{secret}"}}"#))
+            .expect("write config");
+        let quire = QuireCi::new(dir.path().to_path_buf()).expect("QuireCi::new");
+        (dir, quire)
+    }
+
+    fn push_event_body() -> Vec<u8> {
+        serde_json::to_vec(&serde_json::json!({
+            "type": "push",
+            "repo": "test/repo.git",
+            "pushed_at": "2026-05-01T00:00:00Z",
+            "refs": [
+                {
+                    "ref_name": "refs/heads/main",
+                    "old_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+                    "new_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+                }
+            ]
+        }))
+        .expect("serialize")
+    }
+
+    fn hmac_header(secret: &str, body: &[u8]) -> String {
+        let mut mac =
+            Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
+        mac.update(body);
+        let result = mac.finalize();
+        format!("HMAC-SHA256 {}", hex::encode(result.into_bytes()))
+    }
+
+    #[tokio::test]
+    async fn valid_hmac_creates_run_row() {
+        let secret = "test-secret-key";
+        let (_dir, quire) = quire_with_secret(secret);
+        let db_path = quire.db_path().to_path_buf();
+        let app = make_app(quire);
+
+        let body = push_event_body();
+        let auth = hmac_header(secret, &body);
+
+        let req = Request::builder()
+            .method("POST")
+            .uri("/webhook")
+            .header("Authorization", auth)
+            .header("content-type", "application/json")
+            .body(Body::from(body))
+            .unwrap();
+
+        let resp = app.oneshot(req).await.unwrap();
+        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
+
+        let conn = crate::db::open(&db_path).expect("open db");
+        let count: i64 = conn
+            .query_row("SELECT COUNT(*) FROM runs", [], |row| row.get(0))
+            .expect("count");
+        assert_eq!(count, 1);
+    }
+
+    #[tokio::test]
+    async fn no_secret_configured_allows_unsigned_post() {
+        let (_dir, quire) = quire_without_secret();
+        let db_path = quire.db_path().to_path_buf();
+        let app = make_app(quire);
+
+        let body = push_event_body();
+
+        let req = Request::builder()
+            .method("POST")
+            .uri("/webhook")
+            .header("content-type", "application/json")
+            .body(Body::from(body))
+            .unwrap();
+
+        let resp = app.oneshot(req).await.unwrap();
+        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
+
+        let conn = crate::db::open(&db_path).expect("open db");
+        let count: i64 = conn
+            .query_row("SELECT COUNT(*) FROM runs", [], |row| row.get(0))
+            .expect("count");
+        assert_eq!(count, 1);
+    }
+
+    #[tokio::test]
+    async fn wrong_hmac_returns_401() {
+        let (_dir, quire) = quire_with_secret("correct-secret");
+        let app = make_app(quire);
+
+        let body = push_event_body();
+
+        let req = Request::builder()
+            .method("POST")
+            .uri("/webhook")
+            .header("Authorization", "HMAC-SHA256 deadbeefdeadbeef")
+            .header("content-type", "application/json")
+            .body(Body::from(body))
+            .unwrap();
+
+        let resp = app.oneshot(req).await.unwrap();
+        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
+    }
+}
diff --git a/quire-core/src/event.rs b/quire-core/src/event.rs
new file mode 100644
index 0000000..55adfcd
--- /dev/null
+++ b/quire-core/src/event.rs
@@ -0,0 +1,104 @@
+/// A single ref update from a push.
+#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
+pub struct PushRef {
+    pub ref_name: String,
+    pub old_sha: String,
+    pub new_sha: String,
+}
+
+/// A push event sent from hook to server over the event socket, and
+/// from quire-server to quire-ci over the webhook.
+#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq)]
+pub struct PushEvent {
+    // Kept for backward compat with the unix socket protocol.
+    pub r#type: String,
+    pub repo: String,
+    pub pushed_at: jiff::Timestamp,
+    pub refs: Vec<PushRef>,
+}
+
+impl PushEvent {
+    pub fn new(repo: String, refs: Vec<PushRef>) -> Self {
+        Self {
+            r#type: "push".to_string(),
+            repo,
+            pushed_at: jiff::Timestamp::now(),
+            refs,
+        }
+    }
+
+    /// Refs that are not deletions (non-zero new sha).
+    pub fn updated_refs(&self) -> Vec<&PushRef> {
+        self.refs
+            .iter()
+            .filter(|r| r.new_sha != "0000000000000000000000000000000000000000")
+            .collect()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn push_event_new_populates_fields() {
+        let refs = vec![PushRef {
+            old_sha: "a".to_string(),
+            new_sha: "b".to_string(),
+            ref_name: "refs/heads/main".to_string(),
+        }];
+        let event = PushEvent::new("foo.git".to_string(), refs.clone());
+
+        assert_eq!(event.r#type, "push");
+        assert_eq!(event.repo, "foo.git");
+        assert_eq!(event.refs, refs);
+        assert!(event.pushed_at > jiff::Timestamp::UNIX_EPOCH);
+    }
+
+    #[test]
+    fn updated_refs_filters_deletions() {
+        let refs = vec![
+            PushRef {
+                old_sha: "aaa".to_string(),
+                new_sha: "bbb".to_string(),
+                ref_name: "refs/heads/main".to_string(),
+            },
+            PushRef {
+                old_sha: "ccc".to_string(),
+                new_sha: "0000000000000000000000000000000000000000".to_string(),
+                ref_name: "refs/heads/feature".to_string(),
+            },
+        ];
+        let event = PushEvent::new("foo.git".to_string(), refs);
+
+        let updated = event.updated_refs();
+        assert_eq!(updated.len(), 1);
+        assert_eq!(updated[0].ref_name, "refs/heads/main");
+    }
+
+    #[test]
+    fn push_event_round_trips_json() {
+        let refs = vec![
+            PushRef {
+                old_sha: "aaa".to_string(),
+                new_sha: "bbb".to_string(),
+                ref_name: "refs/heads/main".to_string(),
+            },
+            PushRef {
+                old_sha: "ccc".to_string(),
+                new_sha: "ddd".to_string(),
+                ref_name: "refs/heads/feature".to_string(),
+            },
+        ];
+        let event = PushEvent::new("work/foo.git".to_string(), refs);
+
+        let json = serde_json::to_string(&event).expect("serialize");
+        let parsed: PushEvent = serde_json::from_str(&json).expect("deserialize");
+
+        assert_eq!(parsed.r#type, "push");
+        assert_eq!(parsed.repo, "work/foo.git");
+        assert_eq!(parsed.refs.len(), 2);
+        assert_eq!(parsed.refs[0].ref_name, "refs/heads/main");
+        assert_eq!(parsed.refs[1].ref_name, "refs/heads/feature");
+    }
+}
diff --git a/quire-core/src/lib.rs b/quire-core/src/lib.rs
index e967709..df3d6a5 100644
--- a/quire-core/src/lib.rs
+++ b/quire-core/src/lib.rs
@@ -3,6 +3,7 @@
 
 pub mod api;
 pub mod ci;
+pub mod event;
 pub mod fennel;
 pub mod secret;
 pub mod telemetry;
diff --git a/quire-server/src/bin/quire/commands/hook.rs b/quire-server/src/bin/quire/commands/hook.rs
index 76a5c27..000d1b6 100644
--- a/quire-server/src/bin/quire/commands/hook.rs
+++ b/quire-server/src/bin/quire/commands/hook.rs
@@ -70,7 +70,7 @@ fn post_receive(quire: &Quire) -> Result<()> {
         refs.push(quire::event::PushRef {
             old_sha: parts[0].to_string(),
             new_sha: parts[1].to_string(),
-            r#ref: parts[2].to_string(),
+            ref_name: parts[2].to_string(),
         });
     }
 
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index f7097c5..6570021 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -219,7 +219,7 @@ fn run_ref_inner(
 
     let meta = RunMeta {
         sha: push_ref.new_sha.clone(),
-        r#ref: push_ref.r#ref.clone(),
+        r#ref: push_ref.ref_name.clone(),
         pushed_at,
     };
 
@@ -233,7 +233,7 @@ fn run_ref_inner(
     tracing::info!(
         run_id = %run.id(), // cov-excl-line
         sha = %push_ref.new_sha,
-        r#ref = %push_ref.r#ref,
+        ref_name = %push_ref.ref_name,
         "created CI run"
     );
 
@@ -487,7 +487,7 @@ exit 0
         let push_ref = PushRef {
             old_sha: "0000000000000000000000000000000000000000".to_string(),
             new_sha: sha.clone(),
-            r#ref: "refs/heads/main".to_string(),
+            ref_name: "refs/heads/main".to_string(),
         };
 
         let (_fake_dir, fake_path) = fake_quire_ci(0);
@@ -542,7 +542,7 @@ exit 0
         let push_ref = PushRef {
             old_sha: "0000000000000000000000000000000000000000".to_string(),
             new_sha: sha.clone(),
-            r#ref: "refs/heads/main".to_string(),
+            ref_name: "refs/heads/main".to_string(),
         };
 
         let (_fake_dir, fake_path) = fake_quire_ci(1);
@@ -588,7 +588,7 @@ exit 0
         let push_ref = PushRef {
             old_sha: "0000000000000000000000000000000000000000".to_string(),
             new_sha: sha,
-            r#ref: "refs/heads/main".to_string(),
+            ref_name: "refs/heads/main".to_string(),
         };
 
         let db_path = quire.db_path();
@@ -610,7 +610,7 @@ exit 0
             vec![PushRef {
                 old_sha: "0000000000000000000000000000000000000000".to_string(),
                 new_sha: sha.to_string(),
-                r#ref: "refs/heads/main".to_string(),
+                ref_name: "refs/heads/main".to_string(),
             }],
         )
     }
@@ -658,12 +658,12 @@ exit 0
                 PushRef {
                     old_sha: "0000000000000000000000000000000000000000".to_string(),
                     new_sha: sha.clone(),
-                    r#ref: "refs/heads/main".to_string(),
+                    ref_name: "refs/heads/main".to_string(),
                 },
                 PushRef {
                     old_sha: "0000000000000000000000000000000000000000".to_string(),
                     new_sha: sha.clone(),
-                    r#ref: "refs/tags/v1".to_string(),
+                    ref_name: "refs/tags/v1".to_string(),
                 },
             ],
         );
diff --git a/quire-server/src/event.rs b/quire-server/src/event.rs
index 217c8b1..ef63d21 100644
--- a/quire-server/src/event.rs
+++ b/quire-server/src/event.rs
@@ -1,105 +1 @@
-/// A single ref update from a push.
-#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
-pub struct PushRef {
-    pub r#ref: String,
-    pub old_sha: String,
-    pub new_sha: String,
-}
-
-/// A push event sent from hook to serve over the event socket.
-#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq)]
-pub struct PushEvent {
-    pub r#type: String,
-    pub repo: String,
-    pub pushed_at: jiff::Timestamp,
-    pub refs: Vec<PushRef>,
-}
-
-impl PushEvent {
-    /// Build a push event from the repo name and updated refs.
-    ///
-    /// `repo` is the repo name relative to the repos dir (e.g. "foo.git").
-    pub fn new(repo: String, refs: Vec<PushRef>) -> Self {
-        Self {
-            r#type: "push".to_string(),
-            repo,
-            pushed_at: jiff::Timestamp::now(),
-            refs,
-        }
-    }
-
-    /// Refs that are not deletions (non-zero new sha).
-    pub fn updated_refs(&self) -> Vec<&PushRef> {
-        self.refs
-            .iter()
-            .filter(|r| r.new_sha != "0000000000000000000000000000000000000000")
-            .collect()
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn push_event_new_populates_fields() {
-        let refs = vec![PushRef {
-            old_sha: "a".to_string(),
-            new_sha: "b".to_string(),
-            r#ref: "refs/heads/main".to_string(),
-        }];
-        let event = PushEvent::new("foo.git".to_string(), refs.clone());
-
-        assert_eq!(event.r#type, "push");
-        assert_eq!(event.repo, "foo.git");
-        assert_eq!(event.refs, refs);
-        assert!(event.pushed_at > jiff::Timestamp::UNIX_EPOCH);
-    }
-
-    #[test]
-    fn updated_refs_filters_deletions() {
-        let refs = vec![
-            PushRef {
-                old_sha: "aaa".to_string(),
-                new_sha: "bbb".to_string(),
-                r#ref: "refs/heads/main".to_string(),
-            },
-            PushRef {
-                old_sha: "ccc".to_string(),
-                new_sha: "0000000000000000000000000000000000000000".to_string(),
-                r#ref: "refs/heads/feature".to_string(),
-            },
-        ];
-        let event = PushEvent::new("foo.git".to_string(), refs);
-
-        let updated = event.updated_refs();
-        assert_eq!(updated.len(), 1);
-        assert_eq!(updated[0].r#ref, "refs/heads/main");
-    }
-
-    #[test]
-    fn push_event_round_trips_json() {
-        let refs = vec![
-            PushRef {
-                old_sha: "aaa".to_string(),
-                new_sha: "bbb".to_string(),
-                r#ref: "refs/heads/main".to_string(),
-            },
-            PushRef {
-                old_sha: "ccc".to_string(),
-                new_sha: "ddd".to_string(),
-                r#ref: "refs/heads/feature".to_string(),
-            },
-        ];
-        let event = PushEvent::new("work/foo.git".to_string(), refs);
-
-        let json = serde_json::to_string(&event).expect("serialize");
-        let parsed: PushEvent = serde_json::from_str(&json).expect("deserialize");
-
-        assert_eq!(parsed.r#type, "push");
-        assert_eq!(parsed.repo, "work/foo.git");
-        assert_eq!(parsed.refs.len(), 2);
-        assert_eq!(parsed.refs[0].r#ref, "refs/heads/main");
-        assert_eq!(parsed.refs[1].r#ref, "refs/heads/feature");
-    }
-}
+pub use quire_core::event::{PushEvent, PushRef};
diff --git a/quire-server/tests/cli.rs b/quire-server/tests/cli.rs
index 6862ac6..f6c3ca6 100644
--- a/quire-server/tests/cli.rs
+++ b/quire-server/tests/cli.rs
@@ -34,7 +34,7 @@ fn push_event_round_trips_through_socket() {
         refs: vec![quire::event::PushRef {
             old_sha: "0000000000000000000000000000000000000000".to_string(),
             new_sha: "abc123".to_string(),
-            r#ref: "refs/heads/main".to_string(),
+            ref_name: "refs/heads/main".to_string(),
         }],
     };
 
@@ -63,7 +63,7 @@ fn push_event_round_trips_through_socket() {
     assert_eq!(parsed.r#type, "push");
     assert_eq!(parsed.repo, "test.git");
     assert_eq!(parsed.refs.len(), 1);
-    assert_eq!(parsed.refs[0].r#ref, "refs/heads/main");
+    assert_eq!(parsed.refs[0].ref_name, "refs/heads/main");
     assert_eq!(parsed.refs[0].new_sha, "abc123");
 }
 
@@ -82,12 +82,12 @@ fn push_event_multiple_refs_round_trip() {
             quire::event::PushRef {
                 old_sha: "aaa".to_string(),
                 new_sha: "bbb".to_string(),
-                r#ref: "refs/heads/main".to_string(),
+                ref_name: "refs/heads/main".to_string(),
             },
             quire::event::PushRef {
                 old_sha: "ccc".to_string(),
                 new_sha: "0000000000000000000000000000000000000000".to_string(),
-                r#ref: "refs/heads/feature".to_string(),
+                ref_name: "refs/heads/feature".to_string(),
             },
         ],
     };
@@ -111,8 +111,8 @@ fn push_event_multiple_refs_round_trip() {
     let parsed: quire::event::PushEvent = serde_json::from_str(&buf).expect("deserialize");
 
     assert_eq!(parsed.refs.len(), 2);
-    assert_eq!(parsed.refs[0].r#ref, "refs/heads/main");
-    assert_eq!(parsed.refs[1].r#ref, "refs/heads/feature");
+    assert_eq!(parsed.refs[0].ref_name, "refs/heads/main");
+    assert_eq!(parsed.refs[1].ref_name, "refs/heads/feature");
     // Deletion ref included in event (server decides how to handle).
     assert_eq!(
         parsed.refs[1].new_sha,
diff --git a/quire-server/tests/property.rs b/quire-server/tests/property.rs
index 837f7d0..b4a9d2e 100644
--- a/quire-server/tests/property.rs
+++ b/quire-server/tests/property.rs
@@ -13,7 +13,7 @@ const MIN_REDACT_LEN: usize = 8;
 #[hegel::composite]
 fn push_ref(tc: TestCase) -> PushRef {
     PushRef {
-        r#ref: tc.draw(text()),
+        ref_name: tc.draw(text()),
         old_sha: tc.draw(text()),
         // Mix in zero-shas so updated_refs() actually exercises its filter.
         new_sha: tc.draw(one_of![text(), just(ZERO_SHA.to_string())]),
@@ -113,7 +113,7 @@ fn push_event_updated_refs_is_subtractive(tc: TestCase) {
             event
                 .refs
                 .iter()
-                .any(|r| r.r#ref == kept_ref.r#ref && r.new_sha == kept_ref.new_sha)
+                .any(|r| r.ref_name == kept_ref.ref_name && r.new_sha == kept_ref.new_sha)
         );
     }
 }