Back out quire-ci webserver: remove server, db, migrations
The webserver approach turned out to be unnecessary. Remove:
- server.rs (axum HTTP server with webhook handler)
- db.rs (SQLite wrapper for quire-ci runs)
- migrations/0001_initial.sql
- Commands::Serve from main.rs
- All webserver dependencies from Cargo.toml

The mod quire is retained (pre-webserver version): config loading
without the webhook_secret or port fields added for the server.

Also delete the design and deployment docs for the split architecture.

https://claude.ai/code/session_0171wuJy2GRJZuj2oVYZ2iKe
change
commit 6fb88648a6c4f0a1413e937a1fb7e6cd1b667f86
author Claude <noreply@anthropic.com>
date
parent mumrzstk
diff --git a/Cargo.lock b/Cargo.lock
index 87263bb..49bd370 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -485,15 +485,6 @@ 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"
@@ -650,12 +641,6 @@ 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"
@@ -678,12 +663,6 @@ 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"
@@ -718,15 +697,6 @@ 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"
@@ -752,24 +722,6 @@ 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"
@@ -824,20 +776,8 @@ version = "0.10.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
 dependencies = [
- "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",
+ "block-buffer",
+ "crypto-common",
 ]
 
 [[package]]
@@ -1467,15 +1407,6 @@ 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"
@@ -1543,15 +1474,6 @@ 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"
@@ -2626,14 +2548,9 @@ dependencies = [
 name = "quire-ci"
 version = "0.1.0"
 dependencies = [
- "axum",
- "axum-extra",
  "facet",
  "figue",
  "fs-err",
- "hex",
- "hmac",
- "http-body-util",
  "jiff",
  "miette",
  "mlua",
@@ -2641,22 +2558,15 @@ dependencies = [
  "opentelemetry_sdk",
  "quire-core",
  "reqwest",
- "rusqlite",
- "rusqlite_migration",
  "sentry",
  "serde",
  "serde_json",
- "sha2",
- "subtle",
  "tempfile",
  "thiserror",
  "tokio",
- "tower",
- "tower-http",
  "tracing",
  "tracing-opentelemetry",
  "tracing-subscriber",
- "uuid",
 ]
 
 [[package]]
@@ -3323,19 +3233,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
 dependencies = [
  "cfg-if",
- "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",
+ "cpufeatures",
+ "digest",
 ]
 
 [[package]]
diff --git a/docs/plans/2026-05-23-quire-ci-split-design.md b/docs/plans/2026-05-23-quire-ci-split-design.md
deleted file mode 100644
index c5aa2ef..0000000
--- a/docs/plans/2026-05-23-quire-ci-split-design.md
+++ /dev/null
@@ -1,372 +0,0 @@
-# Split quire-ci from quire-server
-
-Make CI a standalone service. quire-server keeps the git wire protocol,
-repo browsing, and push detection. A new `quire-ci serve` mode owns the
-webhook receiver, SQLite, runner, run/log web UI, and per-run workspace
-materialization.
-
-## Scope
-
-In scope:
-
-- A standalone `quire-ci serve` long-running process.
-- A webhook from quire-server to quire-ci on push.
-- A fresh `0001` migration for quire-ci's SQLite, post-split shape.
-- One Dockerfile with two final-stage targets (`server`, `ci`).
-- A staged cutover with quire-server intact through phases 1 and 2.
-
-Out of scope:
-
-- Schema or vocabulary reshape beyond what the split forces (deferred to
-  a separate pass once quire-ci is standing on its own).
-- A `repos` or `pushes` table. Add when a concrete need lands.
-- Per-repo webhook secrets. One shared secret is the right grain.
-- The in-pipeline `quire-ci run` subprocess dispatch path. Collapses to
-  in-thread execution.
-
-## Service shape
-
-```
-                  push                         webhook POST
-client (jj/git) ────────► quire-server ────────────────────► quire-ci
-                          │                                  │
-                          │ git wire, repo browse UI,        │ webhook endpoint,
-                          │ post-receive hook,               │ SQLite, runner,
-                          │ internal git-http endpoint       │ run/log web UI
-                          │                                  │
-                          ▼                                  ▼
-                    /var/quire/repos          git clone over HTTP from quire-server,
-                    (bare repos)              materialize workspace under /var/quire-ci
-                                                            │
-                                                            ▼
-                                                  runner task
-                                                  (in-process Fennel VM)
-                                                            │
-                                       ┌────────────────────┴────────────────────┐
-                                       ▼                                         ▼
-                                (sh ...) → child process              (sh ...) → docker exec
-                                (subprocess phase, now)               (per-run container, later)
-```
-
-Push lifecycle:
-
-1. Client pushes. `git-receive-pack` accepts. The `post-receive` hook fires.
-2. The hook POSTs JSON to `ci_webhook_url`. Auth via shared secret HMAC
-   header. The active span's `traceparent` is propagated as a header.
-3. quire-ci's webhook handler inserts a `runs` row, extracts the
-   traceparent, and notifies the runner via `tokio::sync::Notify`.
-4. The runner picks up the run, clones the repo from quire-server's
-   internal git-http endpoint at the SHA in the payload, and materializes
-   a workspace under `/var/quire-ci/runs/<id>/workspace/`.
-5. The runner calls into the pipeline runtime via
-   `tokio::task::spawn_blocking` (Lua is not `Send`-friendly across
-   `.await` points).
-6. The runtime evaluates `ci.fnl`, walks the DAG, dispatches each
-   `(sh ...)`:
-   - Subprocess phase (now): spawn a child process directly.
-   - Docker phase (later): `docker exec <per-run-container> sh -c "..."`.
-7. Events flow back over an in-process `mpsc` channel. The runner
-   persists them to SQLite (`jobs`, `sh`) and rebroadcasts via
-   `tokio::sync::broadcast` to any web subscribers tailing logs.
-8. Secrets stay in-process. The runtime resolves them against quire-ci's
-   `SecretRegistry` directly.
-
-What goes away from the current shape:
-
-- `quire-ci run` subprocess dispatch and its JSONL event stream.
-- The `/api/run/bootstrap` and `/api/run/secrets/:name` endpoints.
-- `run_token` minting and verification.
-- `QUIRE__SERVER_URL` / `QUIRE__RUN_TOKEN` env plumbing.
-- The musl-static build target. quire-ci is dynamic against glibc.
-
-What stays as a thin CLI:
-
-- `quire-ci validate <path>` and `quire-ci run --local <path>` wrap the
-  same in-process pipeline runtime for debugging `ci.fnl` outside the
-  orchestrator. One subcommand each, no orchestrator state.
-
-## Repository access
-
-quire-ci clones from quire-server per run. Per-run clone is wasteful but
-buys isolation; the clone-and-discard cost is acceptable for v1.
-
-quire-server gains an **internal git-over-HTTP endpoint** for this.
-Smart HTTP via `git http-backend`, bound to a port reachable only by
-quire-ci. Auth: a shared token in an Authorization header, or network
-isolation (docker network, loopback), or both.
-
-Clone URL: configured on quire-ci's side, not in the webhook payload.
-One quire-ci binds to one quire-server.
-
-Future optimization (when measured): cache a bare clone at
-`/var/quire-ci/repos/<name>.git`, update via `git fetch` on each
-webhook, reify per-run workspaces via `git worktree add` or
-`jj workspace add`. Avoids the full re-clone per push without coupling
-through a shared mount.
-
-## Schema (new quire-ci/migrations/0001)
-
-Lifecycle is derived from column population, not stored. No `state`
-column. A row's stage is read off the timestamps and outcome:
-
-| `dispatched_at` | `outcome` | Stage |
-|---|---|---|
-| NULL | NULL | queued |
-| set  | NULL | active |
-| set  | set  | resolved (specific kind in `outcome`) |
-
-```sql
-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);
-```
-
-A Rust-side `enum RunStage { Queued, Active, Resolved(Outcome) }` is
-computed from the columns rather than stored. The mapping is the table
-above.
-
-`jobs` and `sh` follow the same convention (drop state column, encode
-lifecycle through timestamp population, add an `outcome` enum where
-relevant). Spelling out their CHECK constraints can wait for the
-migration itself.
-
-Outcome values:
-
-| Value | Meaning |
-|---|---|
-| `succeeded` | Pipeline ran, all jobs passed. |
-| `failed-pipeline` | Pipeline ran, a job or `(sh ...)` reported failure. |
-| `failed-orphaned` | Runner restart found an unresolved row with no live runner — `reconcile_orphans` marked it. |
-| `failed-internal` | Runner task panicked or hit an unexpected error before the pipeline could report. |
-| `superseded` | A later push for the same `(repo, ref)` displaced this one. |
-
-Dropped from the current schema:
-
-- `runs.state` (derived from timestamps + `outcome` now).
-- `runs.failure_kind` (folded into `outcome` as `failed-*` variants).
-- `runs.run_token` (no API callback to authenticate).
-- `runs.git_dir` (derivable from `repo` plus a known base, and the base
-  is per-process anyway).
-- `runs.pushed_at_ms` (the receive time on quire-ci's clock is the
-  honest field; renamed to `created_at`).
-- `runs.started_at_ms` / `finished_at_ms` (renamed to `dispatched_at` /
-  `resolved_at` — neutral terms that don't overclaim like "finished"
-  does for a superseded run).
-
-Kept: `runs.traceparent`, populated from the webhook header rather than
-env vars.
-
-## Vocabulary
-
-Leave `runs`, `jobs`, `sh` as they are. `sh` is honest about being the
-host-effect chokepoint and avoids overclaiming "step" when a job's
-Fennel logic between `(sh ...)` calls is not recorded as a row.
-Generalize the name when a second host primitive lands.
-
-## Webhook contract
-
-`POST {ci_webhook_url}` with body:
-
-```json
-{
-  "repo": "foo",
-  "refs": [
-    { "ref_name": "refs/heads/main",
-      "old_sha":  "...",
-      "new_sha":  "..." }
-  ]
-}
-```
-
-Headers:
-
-- `Authorization: HMAC-SHA256 <hex>` — signature over the raw body
-  using `ci_webhook_secret`.
-- `traceparent: <w3c>` — propagated from the post-receive span.
-
-One webhook per push. A push can touch multiple refs; the receiver
-inserts one `runs` row per ref.
-
-## Binary
-
-One `quire-ci` binary with three subcommands:
-
-- `serve` — the orchestrator (webhook + DB + runner + web UI).
-- `validate <path>` — compile-only check of a `ci.fnl`.
-- `run --local <path>` — execute a `ci.fnl` against a local checkout
-  for debugging, no orchestrator state involved.
-
-The server-dispatched, subprocess form of `quire-ci run` goes away.
-
-## Dockerfile
-
-One Dockerfile with two final-stage targets, sharing the cargo-chef
-cook stage:
-
-```dockerfile
-FROM debian:trixie-slim AS git-builder
-# builds git 2.54 from source; only the server target consumes it
-...
-
-FROM rust:1.95-trixie AS chef
-RUN cargo install --locked cargo-chef
-WORKDIR /build
-
-FROM chef AS planner
-COPY . .
-RUN cargo chef prepare --recipe-path recipe.json
-
-FROM chef AS builder
-COPY --from=planner /build/recipe.json recipe.json
-RUN --mount=type=cache,target=/usr/local/cargo/registry \
-    --mount=type=cache,target=/build/target \
-    cargo chef cook --release --recipe-path recipe.json
-COPY . .
-RUN --mount=type=cache,target=/usr/local/cargo/registry \
-    --mount=type=cache,target=/build/target \
-    cargo build --release --bin quire --bin quire-ci && \
-    mkdir -p /build/bin && \
-    cp target/release/quire    /build/bin/quire && \
-    cp target/release/quire-ci /build/bin/quire-ci
-
-FROM debian:trixie-slim AS server
-RUN apt-get update && apt-get install -y --no-install-recommends \
-        ca-certificates libcurl4 libexpat1 \
-    && rm -rf /var/lib/apt/lists/*
-COPY --from=git-builder /usr/local/bin/git /usr/local/bin/git
-COPY --from=git-builder /usr/local/libexec/git-core/ /usr/local/libexec/git-core/
-COPY --from=builder    /build/bin/quire /usr/local/bin/quire
-RUN git config --system hook.quire.event   "post-receive" \
- && git config --system hook.quire.command "quire hook post-receive"
-RUN mkdir -p /var/quire/repos
-WORKDIR /var/quire
-EXPOSE 3000
-ENTRYPOINT ["quire"]
-CMD ["serve"]
-
-FROM debian:trixie-slim AS ci
-RUN apt-get update && apt-get install -y --no-install-recommends \
-        ca-certificates git \
-    && rm -rf /var/lib/apt/lists/*
-COPY --from=builder /build/bin/quire-ci /usr/local/bin/quire-ci
-RUN mkdir -p /var/quire-ci
-WORKDIR /var/quire-ci
-EXPOSE 3001
-ENTRYPOINT ["quire-ci"]
-CMD ["serve"]
-```
-
-Build:
-
-```
-docker build --target server -t quire-server:$VER .
-docker build --target ci     -t quire-ci:$VER     .
-```
-
-Notes:
-
-- The `builder` stage builds both binaries on every target. The cook
-  stage dominates; per-bin compilation is cheap. Split into per-bin
-  builder stages only if it shows up in CI time.
-- No `docker:cli` copy. The subprocess-first phase does not need it.
-  Add one apt line to the ci target when the docker-exec phase lands.
-- `Dockerfile.gitweb` is unrelated and stays as-is.
-
-## Code moves
-
-### Phase 1 — grow quire-ci in parallel
-
-quire-server is **not touched** in this phase. quire-ci grows from a
-stub into a working orchestrator alongside the existing in-process
-runner.
-
-- `quire-ci/migrations/0001_initial.sql` — the schema above.
-- `quire-ci/src/orchestrator/` — webhook handler, `Runs`/`Run`/`Executor`,
-  runner task. Crib from `quire-server/src/ci/`, then trim.
-- `quire-ci/src/web/` — run/log views. Port templates and Askama setup
-  from quire-server.
-- `quire-ci/src/db.rs` — DB plumbing, cribbed from quire-server.
-- `quire-ci/src/server.rs` (existing stub) — fleshed out into the real
-  axum router: `/webhook`, `/runs/*`, `/runs/:id/jobs/:id/logs/stream`,
-  static assets.
-- `quire-ci/src/quire.rs` — `QuireCi` grows to own the DB pool,
-  `SecretRegistry`, and runner handle.
-- `quire-core::PushEvent` / `PushRef` — lifted out of
-  `quire-server/src/event.rs`, since both processes need them.
-- `quire-server`: add a new `webhook_client` module (signs and POSTs the
-  event) with tests, but **do not wire it into the hook yet**.
-- `quire-server`: add the internal git-http endpoint. quire-ci needs
-  something to clone from during parallel development.
-
-At the end of phase 1, both processes work. quire-server still handles
-pushes via the in-process runner. quire-ci runs standalone, exercised
-via direct webhook calls.
-
-### Phase 2 — cutover
-
-A single small change:
-
-- Switch `quire hook post-receive` from "notify in-process listener" to
-  "POST to `ci_webhook_url`".
-- Add `ci_webhook_url` and `ci_webhook_secret` to quire-server's config.
-- Deploy quire-ci into production alongside quire-server.
-
-### Phase 3 — server cleanup
-
-A separate commit, once production has been on quire-ci for long enough
-to trust it:
-
-- Delete `quire-server/src/ci/`, `src/quire/web/api.rs`,
-  `src/quire/web/db.rs`, and the run/log handlers and templates.
-- Delete all of `quire-server/migrations/` and the `rusqlite*` deps.
-- Drop `quire-core::api::SecretResponse`, `ci::bootstrap`,
-  `ci::run::{ApiSession, RunMeta}`. They have no consumers after this.
-
-quire-server ends the phase stateless with respect to CI.
-
-## Open questions
-
-- HMAC scheme: SHA-256 is the obvious default; revisit if anything
-  pushes against it.
-- Whether to fold the existing `/api` mount path into quire-ci's web
-  router under the same prefix, or pick a fresh layout. Worth a look
-  when porting the templates.
-- Health-check shape for quire-ci. The current stub has `/health`; keep
-  that and add `/ready` if any deployment infra needs the distinction.
-
-## Sequencing
-
-1. Phase 1 lands as one or more PRs. Tests at the webhook boundary
-   (post a synthesized push to quire-ci, assert a `runs` row appears).
-2. Phase 2 is one small PR plus a deploy.
-3. Phase 3 is the cleanup PR.
diff --git a/docs/quire-ci-deployment.md b/docs/quire-ci-deployment.md
deleted file mode 100644
index 7bf737e..0000000
--- a/docs/quire-ci-deployment.md
+++ /dev/null
@@ -1,78 +0,0 @@
-# quire-ci deployment
-
-`quire-ci` is the webhook receiver / CI dispatcher. It runs as a separate
-container from `quire`, sharing the same image but with a different entrypoint.
-
-## Data layout
-
-```
-/var/quire-ci/
-  config.fnl     operator-created; required before first start
-  quire-ci.db    SQLite database; created automatically on first start
-```
-
-## Config
-
-Create `/var/quire-ci/config.fnl` on the host before starting the container.
-Minimal config:
-
-```fennel
-{:webhook-secret "change-me"}
-```
-
-| Key               | Required | Default | Purpose                                      |
-|-------------------|----------|---------|----------------------------------------------|
-| `:webhook-secret` | yes      | —       | Shared HMAC-SHA256 secret with quire-server. |
-| `:port`           | no       | `3000`  | TCP port to listen on.                       |
-| `:sentry :dsn`    | no       | —       | Sentry DSN for error reporting.              |
-
-`:webhook-secret` accepts a plain string or a Docker secret reference:
-
-```fennel
-{:webhook-secret {:file "/run/secrets/webhook_secret"}}
-```
-
-## Docker Compose
-
-```yaml
-services:
-  quire-ci:
-    image: quire
-    entrypoint: quire-ci
-    command: serve
-    volumes:
-      - quire-ci-data:/var/quire-ci
-    ports:
-      - "3001:3000"
-    restart: unless-stopped
-    secrets:
-      - webhook_secret   # optional; only if using {:file ...} in config.fnl
-
-volumes:
-  quire-ci-data:
-
-secrets:
-  webhook_secret:
-    file: ./secrets/webhook_secret
-```
-
-## Wiring to quire-server
-
-`quire-server` POSTs push events to `quire-ci` over HTTP. Set
-`:quire-ci-url` in `/var/quire/config.fnl` to point at the `quire-ci`
-container:
-
-```fennel
-{:quire-ci-url "http://quire-ci:3000/webhook"
- :webhook-secret "change-me"}
-```
-
-Both sides must share the same `:webhook-secret`.
-
-## Health check
-
-```
-GET /health  →  200 "ok"
-```
-
-Suitable for a Docker healthcheck or reverse-proxy probe.
diff --git a/quire-ci/Cargo.toml b/quire-ci/Cargo.toml
index 4281481..27580fe 100644
--- a/quire-ci/Cargo.toml
+++ b/quire-ci/Cargo.toml
@@ -4,36 +4,22 @@ version = "0.1.0"
 edition = "2024"
 
 [dependencies]
-axum = { workspace = true }
-axum-extra = { workspace = true }
-tower-http = { 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"] }
+tokio = { workspace = true, features = ["rt-multi-thread"] }
 opentelemetry = { workspace = true }
 opentelemetry_sdk = { workspace = true }
 tracing = { workspace = true }
 tracing-opentelemetry = { workspace = true }
 tracing-subscriber = { workspace = true }
-uuid = { version = "*", features = ["v7"] }
-hex = "*"
-
-[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
deleted file mode 100644
index f72449c..0000000
--- a/quire-ci/migrations/0001_initial.sql
+++ /dev/null
@@ -1,33 +0,0 @@
-CREATE TABLE runs (
-  id             TEXT    PRIMARY KEY,
-  repo           TEXT    NOT NULL,
-  ref            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
deleted file mode 100644
index abc5a4a..0000000
--- a/quire-ci/src/db.rs
+++ /dev/null
@@ -1,94 +0,0 @@
-use std::path::Path;
-use std::sync::{Arc, Mutex, MutexGuard};
-
-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 Error {
-    #[error(transparent)]
-    Sqlite(#[from] rusqlite::Error),
-    #[error("migration error: {0}")]
-    Migration(#[from] rusqlite_migration::Error),
-}
-
-/// An opened, migrated SQLite database.
-#[derive(Clone)]
-pub struct Db {
-    conn: Arc<Mutex<Connection>>,
-}
-
-impl Db {
-    pub fn open(path: &Path) -> Result<Self, Error> {
-        tracing::debug!(path = %path.display(), "opening database");
-        let mut conn = Connection::open(path)?;
-        conn.execute_batch(
-            "PRAGMA journal_mode = WAL;
-             PRAGMA foreign_keys = ON;
-             PRAGMA busy_timeout = 5000;",
-        )?;
-        MIGRATIONS.to_latest(&mut conn)?;
-        Ok(Self {
-            conn: Arc::new(Mutex::new(conn)),
-        })
-    }
-
-    pub fn lock(&self) -> MutexGuard<'_, Connection> {
-        self.conn.lock().unwrap()
-    }
-}
-
-#[cfg(test)]
-pub fn open_in_memory() -> Result<Db, Error> {
-    let mut conn = Connection::open_in_memory()?;
-    conn.execute_batch("PRAGMA foreign_keys = ON;")?;
-    MIGRATIONS.to_latest(&mut conn)?;
-    Ok(Db {
-        conn: Arc::new(Mutex::new(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 db = open_in_memory().expect("open_in_memory");
-        let conn = db.lock();
-        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",
-            "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 61cce60..9bf86bf 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -1,6 +1,4 @@
-mod db;
 mod quire;
-mod server;
 mod sink;
 
 use std::cell::RefCell;
@@ -127,9 +125,6 @@ enum Commands {
         #[facet(args::named, default)]
         git_dir: Option<PathBuf>,
     },
-
-    /// Start the HTTP server.
-    Serve,
 }
 
 /// RAII wrapper around a tempdir holding captured sh logs. On drop,
@@ -298,14 +293,6 @@ fn main() -> Result<()> {
 
     match cli.command {
         Commands::Validate => validate(workspace),
-        Commands::Serve => {
-            let quire = quire::QuireCi::new(cli.base_dir)?;
-            let rt = tokio::runtime::Builder::new_multi_thread()
-                .enable_all()
-                .build()
-                .into_diagnostic()?;
-            rt.block_on(server::run(quire)).into_diagnostic()
-        }
         Commands::Run {
             events,
             out_dir,
diff --git a/quire-ci/src/quire.rs b/quire-ci/src/quire.rs
index 2521b4d..83b27f1 100644
--- a/quire-ci/src/quire.rs
+++ b/quire-ci/src/quire.rs
@@ -1,11 +1,8 @@
 use std::path::PathBuf;
 
-use miette::{IntoDiagnostic, Result, bail};
+use miette::{IntoDiagnostic, Result};
 
 use quire_core::fennel::Fennel;
-use quire_core::secret::SecretString;
-
-use crate::db::Db;
 
 /// Parsed global configuration (`<base-dir>/config.fnl`).
 #[derive(serde::Deserialize, Debug, Clone)]
@@ -16,7 +13,6 @@ pub struct GlobalConfig {
     /// TCP port the HTTP server binds to on all interfaces (`0.0.0.0`).
     #[serde(default = "default_port")]
     pub port: u16,
-    pub webhook_secret: SecretString,
 }
 
 fn default_port() -> u16 {
@@ -26,45 +22,37 @@ fn default_port() -> u16 {
 pub use quire_core::telemetry::SentryConfig;
 
 /// Application runtime context.
+///
+/// Loads config at construction time so callers don't have to thread
+/// Results around.
 #[derive(Clone)]
 pub struct QuireCi {
     config: GlobalConfig,
-    db: Db,
-    hmac_key: Vec<u8>,
 }
 
 impl QuireCi {
     pub fn new(base_dir: PathBuf) -> Result<Self> {
         let config_path = base_dir.join("config.fnl");
-        if !config_path.exists() {
-            bail!("config file not found: {}", config_path.display());
-        }
-        let fennel = Fennel::new().into_diagnostic()?;
-        let config: GlobalConfig = fennel.load_file(&config_path).into_diagnostic()?;
-        let hmac_key = config
-            .webhook_secret
-            .reveal()
-            .into_diagnostic()?
-            .as_bytes()
-            .to_vec();
-        let db = Db::open(&base_dir.join("quire-ci.db")).into_diagnostic()?;
-        Ok(Self {
-            config,
-            db,
-            hmac_key,
-        })
+        let config = if config_path.exists() {
+            let fennel = Fennel::new().into_diagnostic()?;
+            fennel.load_file(&config_path).into_diagnostic()?
+        } else {
+            GlobalConfig::default()
+        };
+        Ok(Self { config })
     }
 
     pub fn config(&self) -> &GlobalConfig {
         &self.config
     }
+}
 
-    pub fn db(&self) -> &Db {
-        &self.db
-    }
-
-    pub fn hmac_key(&self) -> &[u8] {
-        &self.hmac_key
+impl Default for GlobalConfig {
+    fn default() -> Self {
+        Self {
+            sentry: None,
+            port: default_port(),
+        }
     }
 }
 
@@ -72,18 +60,11 @@ impl QuireCi {
 mod tests {
     use super::*;
 
-    fn write_config(dir: &tempfile::TempDir, content: &str) {
-        fs_err::write(dir.path().join("config.fnl"), content).expect("write config");
-    }
-
-    fn minimal_config(secret: &str) -> String {
-        format!(r#"{{:webhook-secret "{secret}"}}"#)
-    }
-
     #[test]
     fn global_config_defaults() {
         let dir = tempfile::tempdir().expect("tempdir");
-        write_config(&dir, &minimal_config("s3cret"));
+        let config_path = dir.path().join("config.fnl");
+        fs_err::write(&config_path, "{}").expect("write");
 
         let q = QuireCi::new(dir.path().to_path_buf()).expect("should load");
         assert_eq!(q.config().port, 3000);
@@ -92,26 +73,31 @@ mod tests {
     #[test]
     fn global_config_parses_custom_port() {
         let dir = tempfile::tempdir().expect("tempdir");
-        write_config(&dir, r#"{:webhook-secret "s3cret" :port 4000}"#);
+        let config_path = dir.path().join("config.fnl");
+        fs_err::write(&config_path, r#"{:port 4000}"#).expect("write");
 
         let q = QuireCi::new(dir.path().to_path_buf()).expect("should load");
         assert_eq!(q.config().port, 4000);
     }
 
     #[test]
-    fn global_config_missing_file_errors() {
+    fn global_config_missing_file_uses_defaults() {
         let dir = tempfile::tempdir().expect("tempdir");
-        let result = QuireCi::new(dir.path().to_path_buf());
-        assert!(result.is_err(), "should error when config file is missing");
+
+        let q = QuireCi::new(dir.path().to_path_buf()).expect("should load");
+        assert_eq!(q.config().port, 3000);
+        assert!(q.config().sentry.is_none());
     }
 
     #[test]
     fn global_config_loads_sentry() {
         let dir = tempfile::tempdir().expect("tempdir");
-        write_config(
-            &dir,
-            r#"{:webhook-secret "s3cret" :sentry {:dsn "https://key@sentry.io/123"}}"#,
-        );
+        let config_path = dir.path().join("config.fnl");
+        fs_err::write(
+            &config_path,
+            r#"{:sentry {:dsn "https://key@sentry.io/123"}}"#,
+        )
+        .expect("write");
 
         let q = QuireCi::new(dir.path().to_path_buf()).expect("should load");
         let sentry = q
@@ -121,15 +107,4 @@ 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");
-        write_config(&dir, &minimal_config("s3cret"));
-        QuireCi::new(dir.path().to_path_buf()).expect("should load");
-        assert!(
-            dir.path().join("quire-ci.db").exists(),
-            "db file should exist after new()"
-        );
-    }
 }
diff --git a/quire-ci/src/server.rs b/quire-ci/src/server.rs
deleted file mode 100644
index d072f95..0000000
--- a/quire-ci/src/server.rs
+++ /dev/null
@@ -1,306 +0,0 @@
-use std::net::SocketAddr;
-use std::time::Duration;
-
-use axum::Router;
-use axum::extract::{MatchedPath, State};
-use axum::http::{HeaderMap, Request, StatusCode};
-use axum::response::{IntoResponse, Response};
-use axum::routing::{get, post};
-use axum_extra::TypedHeader;
-use axum_extra::headers::Authorization;
-use axum_extra::headers::authorization::Credentials;
-use hmac::{Hmac, KeyInit, Mac};
-use quire_core::event::PushEvent;
-use quire_core::telemetry::{self, FmtMode};
-use sha2::Sha256;
-use tower_http::trace::TraceLayer;
-use tracing::info_span;
-
-use crate::quire::QuireCi;
-
-const VERSION: &str = env!("QUIRE_VERSION");
-
-async fn health() -> &'static str {
-    "ok"
-}
-
-async fn index() -> String {
-    format!("quire-ci {VERSION}\n")
-}
-
-#[derive(Debug, thiserror::Error)]
-enum WebhookError {
-    #[error("missing or malformed Authorization header")]
-    MissingSignature,
-    #[error("signature mismatch")]
-    InvalidSignature(#[from] hmac::digest::MacError),
-    #[error(transparent)]
-    InvalidPayload(#[from] serde_json::Error),
-    #[error(transparent)]
-    Db(#[from] rusqlite::Error),
-}
-
-/// Carries an error message through response extensions so TraceLayer can log it.
-#[derive(Clone)]
-struct RequestError(String);
-
-impl IntoResponse for WebhookError {
-    fn into_response(self) -> Response {
-        let status = match &self {
-            WebhookError::MissingSignature | WebhookError::InvalidSignature(_) => {
-                StatusCode::UNAUTHORIZED
-            }
-            WebhookError::InvalidPayload(_) => StatusCode::BAD_REQUEST,
-            WebhookError::Db(_) => StatusCode::INTERNAL_SERVER_ERROR,
-        };
-        // Auth failures are intentionally quiet; other errors get logged by TraceLayer.
-        if matches!(
-            self,
-            WebhookError::MissingSignature | WebhookError::InvalidSignature(_)
-        ) {
-            return status.into_response();
-        }
-        let mut response = status.into_response();
-        response
-            .extensions_mut()
-            .insert(RequestError(self.to_string()));
-        response
-    }
-}
-
-struct HmacSha256Sig(Vec<u8>);
-
-impl Credentials for HmacSha256Sig {
-    const SCHEME: &'static str = "HMAC-SHA256";
-
-    fn decode(value: &axum::http::HeaderValue) -> Option<Self> {
-        let hex_str = value.to_str().ok()?.strip_prefix("HMAC-SHA256 ")?;
-        hex::decode(hex_str).ok().map(Self)
-    }
-
-    fn encode(&self) -> axum::http::HeaderValue {
-        axum::http::HeaderValue::from_str(&format!("HMAC-SHA256 {}", hex::encode(&self.0)))
-            .expect("hex is always a valid header value")
-    }
-}
-
-struct HmacSha256Auth(Vec<u8>);
-
-impl<S: Send + Sync> axum::extract::FromRequestParts<S> for HmacSha256Auth {
-    type Rejection = WebhookError;
-
-    async fn from_request_parts(
-        parts: &mut axum::http::request::Parts,
-        state: &S,
-    ) -> std::result::Result<Self, WebhookError> {
-        use axum::extract::FromRequestParts;
-
-        let Some(TypedHeader(Authorization(sig))) =
-            <TypedHeader<Authorization<HmacSha256Sig>> as FromRequestParts<S>>::from_request_parts(
-                parts, state,
-            )
-            .await
-            .ok()
-        else {
-            return Err(WebhookError::MissingSignature);
-        };
-        Ok(Self(sig.0))
-    }
-}
-
-async fn webhook(
-    State(quire): State<QuireCi>,
-    HmacSha256Auth(provided_bytes): HmacSha256Auth,
-    headers: HeaderMap,
-    body: axum::body::Bytes,
-) -> std::result::Result<StatusCode, WebhookError> {
-    let mut mac =
-        Hmac::<Sha256>::new_from_slice(quire.hmac_key()).expect("HMAC accepts any key length");
-    mac.update(&body);
-    mac.verify_slice(&provided_bytes)?;
-
-    let event: PushEvent = serde_json::from_slice(&body)?;
-
-    let traceparent = headers
-        .get("traceparent")
-        .and_then(|v| v.to_str().ok())
-        .map(|s| s.to_string());
-
-    let conn = quire.db().lock();
-    let now_ms = jiff::Timestamp::now().as_millisecond();
-
-    for push_ref in event.updated_refs() {
-        let id = uuid::Uuid::now_v7().to_string();
-        conn.execute(
-            r#"INSERT INTO runs (id, repo, "ref", 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,
-            ],
-        )?;
-    }
-
-    Ok(StatusCode::NO_CONTENT)
-}
-
-#[derive(Debug, thiserror::Error, miette::Diagnostic)]
-pub enum Error {
-    #[error("io error: {0}")]
-    Io(#[from] std::io::Error),
-
-    #[error(transparent)]
-    Secret(#[from] quire_core::secret::Error),
-
-    #[error(transparent)]
-    Telemetry(#[from] quire_core::telemetry::Error),
-}
-
-pub type Result<T> = std::result::Result<T, Error>;
-
-pub async fn run(quire: QuireCi) -> Result<()> {
-    let port = quire.config().port;
-
-    let miette_layer = telemetry::MietteLayer::new();
-    let _guard = telemetry::init_telemetry(
-        miette_layer,
-        FmtMode::AutoJson,
-        quire.config().sentry.as_ref(),
-        VERSION,
-    )?;
-
-    let app = Router::new()
-        .route("/health", get(health))
-        .route("/", get(index))
-        .route("/webhook", post(webhook))
-        .layer(
-            TraceLayer::new_for_http()
-                .make_span_with(|request: &Request<_>| {
-                    let matched_path = request
-                        .extensions()
-                        .get::<MatchedPath>()
-                        .map(MatchedPath::as_str);
-                    info_span!("http_request", method = ?request.method(), matched_path)
-                })
-                .on_response(|response: &Response, _: Duration, _: &tracing::Span| {
-                    if let Some(RequestError(error)) = response.extensions().get::<RequestError>() {
-                        if response.status().is_server_error() {
-                            tracing::error!(%error);
-                        } else {
-                            tracing::warn!(%error);
-                        }
-                    }
-                }),
-        )
-        .with_state(quire);
-
-    let addr = SocketAddr::from(([0, 0, 0, 0], port));
-    tracing::info!(%addr, "starting HTTP server");
-
-    let listener = tokio::net::TcpListener::bind(addr).await?;
-
-    axum::serve(listener, app).await?;
-
-    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_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": "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 = quire.db().clone();
-        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 count: i64 = db
-            .lock()
-            .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);
-    }
-}