docs: reconcile documentation with the shipped code
The docs had drifted in two ways: PLAN.md mixed a finished build
sequence with live architecture, and several docs described a schema
and CI shape the code has since moved past.
- Replace docs/PLAN.md with docs/ARCHITECTURE.md, dropping the
completed build sequence (tracked in ranger now) and keeping the
architecture, locked-in decisions, and open questions. Correct the
mirroring decision, which is server-side rather than a CI job.
- Rewrite CI-STATE.md against migration 0010: runs no longer have
state/failure_kind columns, the lifecycle is derived from
created_at/dispatched_at/resolved_at, and terminal runs carry an
outcome enum.
- Mark CI.md as the target design, point at the re-architecture plan
for the three places it is superseded, replace the abandoned :needs
DAG schema with a pointer to the real inputs-list spec, and stop
presenting :allow-failure as implemented.
- Note in CI-FENNEL.md that no container executor exists yet and that
(ci.image ...) is slated for removal.
- Fix config.md and fennel.md: document the :host key, correct the
Fennel API contract (on_unknown callback, typed FennelError,
load_config wrappers), and fix module paths left over from the
workspace split.
- Flag the host README's docker-out-of-docker section as not yet in
effect.
- Add docs/README.md indexing the tree and separating current docs from
dated historical records, which is the distinction PLAN.md blurred.
Delete the stray superpowers/ copy of the SQLite migration plan; the
design doc it duplicates already lives in docs/plans/.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S32jQrcKrNTDnPGnJ3msYG
diff --git a/AGENTS.md b/AGENTS.md
index 2ed64fc..cd1e606 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -33,11 +33,14 @@ Always run `just all` and verify everything passes before committing. No excepti
## Updating docs
-When changing behavior (mirroring, config, hook dispatch, Docker layout, CI workflows), update the corresponding docs in the same commit. The docs to check:
+When changing behavior (mirroring, config, hook dispatch, Docker layout, CI workflows), update the corresponding docs in the same commit. `docs/README.md` indexes the whole tree; the docs to check:
- `README.md` — feature descriptions and project status.
-- `docs/PLAN.md` — build sequence, architecture, and locked-in design decisions.
+- `docs/ARCHITECTURE.md` — architecture and locked-in design decisions.
+- `docs/CI.md`, `docs/CI-STATE.md`, `docs/CI-FENNEL.md` — CI design, run/job lifecycle, and the pipeline DSL. `CI-STATE.md` tracks what the code actually does; keep it honest when CI behavior changes.
- `docs/config.md` — config file schemas and how they're loaded.
- `.github/workflows/` — workflow changes must be self-documenting (comments on permissions, triggers).
+Documents under `docs/plans/` and `docs/notes/` are dated historical records. Don't retro-edit them to match new behavior; write a new one instead.
+
If you're unsure whether a doc needs updating, it probably does.
diff --git a/Dockerfile b/Dockerfile
index 0a165f0..bde324d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -97,7 +97,7 @@ COPY --from=docker:cli /usr/local/bin/docker /usr/local/bin/docker
RUN git config --system hook.quire.event "post-receive" \
&& git config --system hook.quire.command "quire hook post-receive"
-# Volume layout per PLAN.md. Ownership is set on the host; the container
+# Volume layout per docs/ARCHITECTURE.md. Ownership is set on the host; the container
# runs as the host uid/gid passed via `docker exec --user`, so no user
# is created in the image.
RUN mkdir -p /var/quire/repos /var/quire/runs
diff --git a/README.md b/README.md
index 63852f8..cf82123 100644
--- a/README.md
+++ b/README.md
@@ -10,9 +10,9 @@ A Rust binary that runs in a Docker container, fronted by the host's sshd and a
- **Git hosting over SSH**, via the host's sshd dispatching into the container. Explicit repo creation (`ssh git@host quire repo new <name>`).
- **A read-only web view** for browsing README, tree, history, blame, diffs, and refs.
-- **Fennel-based CI** (Fennel is a Lisp that compiles to Lua), with pipelines defined in `.quire/ci.fnl`. Unsandboxed by default since every pipeline is code I've written; a bubblewrap-based opt-in is available for the day quire ever runs code I haven't.
-- **Automatic mirroring** to GitHub, Gitea, or any HTTPS remote on every push. Map each remote URL to a push token in `.quire/config.fnl` under `:mirrors` (the value names an entry in the global `:secrets`); quire force-pushes every updated ref to all of them independently of CI.
-- **Email notifications** for CI failures and recoveries. SMTP via `msmtp`; plain text; per-repo config for what to send and to whom.
+- **Fennel-based CI** (Fennel is a Lisp that compiles to Lua), with pipelines defined in `.quire/ci.fnl`. Unsandboxed since every pipeline is code I've written; a sandboxed opt-in is planned for the day quire ever runs code I haven't.
+- **Automatic mirroring** to GitHub, Gitea, or any HTTPS remote on every push. Map each remote URL to a push token in `.quire/config.fnl` under `:mirrors` (the value names an entry in the global `:secrets`); quire pushes every updated ref to all of them independently of CI.
+- **Email notifications** (planned) for CI failures and recoveries. SMTP via `msmtp`; plain text; per-repo config for what to send and to whom.
No issues, no PRs, no user management, no webhooks. Use the GitHub mirror for the social stuff; quire is your forge.
@@ -48,7 +48,7 @@ Quire holds to a few principles:
- **The container is pure quire.** SSH auth and TLS/web auth both live on the host (host sshd, reverse proxy). The container runs `quire`, git, and msmtp. One job per surface.
- **Don't own ssh.** The host's sshd handles auth, channels, and key management; `ForceCommand` dispatches authenticated invocations into the container via `docker exec`. Quire's integration point is git hooks and the `quire exec` dispatch target.
- **Web auth at the reverse proxy.** The proxy (Caddy or equivalent) handles authentication and injects a trusted identity header. Quire reads the header and applies per-repo visibility: public repos are world-readable, private repos and CI logs require auth. Any auth mechanism the proxy supports (basic, OAuth, SSO) Just Works — quire stays scheme-agnostic.
-- **Git's filesystem is the source of truth.** Bare repos under `/var/quire/repos/` are the primary artifact. CI run history is directories on disk, not a database. A database comes back only if the filesystem approach visibly fails.
+- **Git's filesystem is the source of truth for repos.** Bare repos under `/var/quire/repos/` are the primary artifact. CI run and job state lives in SQLite at `/var/quire/quire.db`; the filesystem holds per-run workspaces and logs only.
- **Built for jj.** The primary client is Jujutsu, which means routine force-pushes, short-lived refs, and unstable SHAs. No git-flow-shaped assumptions in the UI or CI.
- **Push should fail fast, loudly, and correctly.** No silent drift between quire and GitHub. No accepted-but-unreplicated state.
- **Config is code.** Global config is Fennel. CI pipelines are Fennel. If you're going to have a scripting language, have one.
@@ -59,13 +59,14 @@ Quire's data lives under one volume:
```
/var/quire/
+ quire.db SQLite database: CI run, job, and sh state
repos/ bare git repos; per-repo config lives in-tree at .quire/config.fnl
- runs/ CI run metadata, artifacts, and logs; retention-policied
+ runs/ per-run workspaces and CI logs
config.fnl global config; see docs/config.md for the schema
```
-Host-side config (sshd_config block, Caddyfile, docker-compose file) lives on the host, version-controlled separately. See `docs/PLAN.md` for the reference layout, `docs/config.md` for the global and per-repo Fennel schemas.
+Host-side config (sshd_config block, Caddyfile, docker-compose file) lives on the host, version-controlled separately. See `docs/host/` for reference configs, `docs/ARCHITECTURE.md` for the overall shape, and `docs/config.md` for the global and per-repo Fennel schemas.
## Status
-Early development. SSH dispatch, repo management, Fennel config loading, and mirror push via event socket work; web view, CI, and notifications are still ahead. See `docs/PLAN.md` for the build sequence and open questions.
+Early development. SSH dispatch, repo management, server-side mirroring, the web view, and the Fennel CI MVP work; CI currently runs pipelines as an unsandboxed host subprocess — per-run containers are the next step (see `docs/plans/2026-08-12-ci-rearchitecture.md`) — and email notifications are still ahead. See `docs/ARCHITECTURE.md` for the design and open questions.
diff --git a/docs/PLAN.md b/docs/ARCHITECTURE.md
similarity index 50%
rename from docs/PLAN.md
rename to docs/ARCHITECTURE.md
index 09be7d6..8cccd2f 100644
--- a/docs/PLAN.md
+++ b/docs/ARCHITECTURE.md
@@ -1,6 +1,10 @@
-# quire — plan
+# quire — architecture
-A build order, architectural notes, and a list of open questions. Living document.
+The system shape, the design decisions that are locked in, and the durable
+design intent. This replaces the old `PLAN.md`: the build sequence that
+lived there is done or superseded, and remaining work is tracked in the
+`ranger` backlog. Dated design documents for individual changes live in
+[`plans/`](./plans/).
## Architecture at a glance
@@ -14,7 +18,7 @@ The **host** does auth and network plumbing. The **container** is pure quire.
**Container-side:**
1. **`quire` binary** — serves as both the HTTP server (`quire serve`) and the dispatch target (`quire exec <cmd>` invoked from the host's ForceCommand via `docker exec`). No sshd inside. Git hooks installed in each repo via `hook.<n>.command` config call back into the binary as `quire hook <n>`.
-2. **CI runner** — a long-lived tokio task inside `quire serve` (not a separate process or subcommand). Wakes on a `tokio::sync::Notify` from the push-event listener and reads pending runs from SQLite. Sandboxing (via bubblewrap) is optional and deferred — single-user personal use doesn't warrant it; revisit if CI ever runs code I haven't written.
+2. **CI** — triggered from the push-event listener inside `quire serve`; the pipeline itself runs in a separate `quire-ci` subprocess. See [`CI.md`](./CI.md) for the design, [`CI-STATE.md`](./CI-STATE.md) for what the code does today, and [`plans/2026-08-12-ci-rearchitecture.md`](./plans/2026-08-12-ci-rearchitecture.md) for where it's headed (per-run containers, a real queue).
3. **Git** — invoked as a subprocess by both the dispatch path (`git-receive-pack`, `git-upload-pack` invoked from `quire exec`) and the hooks.
**Access matrix:**
@@ -56,22 +60,15 @@ One volume mounted into the container:
config.fnl # global config
```
-Per-repo config (`public_runs`, etc.) is checked into the repo at `.quire/config.fnl`, not stored in the bare repo's `quire/` directory. The `quire/` directory holds only generated artifacts.
+Per-repo config (`mirrors`, etc.) is checked into the repo at `.quire/config.fnl`, not stored in the bare repo's `quire/` directory. The `quire/` directory holds only generated artifacts.
-No SSH config or host keys in this volume — those live on the host. The container image brings the `quire` binary and git; the volume brings repos, runs, and per-repo state. Bubblewrap is only needed if CI sandboxing is enabled (it isn't by default).
+No SSH config or host keys in this volume — those live on the host. The container image brings the `quire` binary and git; the volume brings repos, runs, and per-repo state.
`docker compose down && up` loses nothing in the volume. Host identity (ssh host keys, reverse-proxy certs and state) persists on the host.
## Host configuration
-The container expects a specific host setup. Ship reference configs in `docs/host/` alongside the image:
-
-- **sshd_config snippet** — the `Match User git` block with AuthorizedKeysFile, ForceCommand, restrictions (`no-port-forwarding`, `no-agent-forwarding`, `no-X11-forwarding`, `no-pty`).
-- **quire-dispatch** — the small script that ForceCommand invokes. In the simplest deployment this is a one-liner: `exec docker exec -i quire-container quire exec "$SSH_ORIGINAL_COMMAND"`. Inlining it directly into `ForceCommand` works too; a script file is only worth having if host-side logic accumulates (rate limiting, logging, per-key policy).
-- **Caddyfile** — single vhost, terminates TLS, runs authentication (via `forward_auth` to an auth service, basic auth, or whatever's appropriate), strips any client-supplied `Remote-User` header, injects the proxy's own, reverse-proxies to the container's HTTP port.
-- **systemd unit or compose file** — starts the container with the volume mount, publishes the HTTP port to loopback only (Caddy reverse-proxies), restarts on failure.
-
-The host config is documented and version-controlled, not pretending to be handled by the container. This is a real cost — it's more moving parts than "one container does everything" — but it's the honest shape of the problem.
+The container expects a specific host setup: an sshd `Match User git` block dispatching into the container, a reverse proxy terminating TLS and injecting `Remote-User`, and a container start command that mounts the volume and publishes the HTTP port to loopback only. Reference configs and setup steps live in [`host/`](./host/README.md), version-controlled with the code rather than pretending to be handled by the container. This is a real cost — more moving parts than "one container does everything" — but it's the honest shape of the problem.
## Future: all-in-one image variant (not building)
@@ -87,82 +84,22 @@ The shape, sketched: `quire:standalone` extends `quire:latest` with:
Everything downstream of `quire exec` is identical to the host-mediated path — same allowlist, same dispatch logic — so there's no divergent code to maintain.
-Flagging the possibility now because it costs nothing at design time (the `quire exec` dispatch boundary is already the right shape for either deployment), and it'd be a thoughtful contribution from someone who wants it later. Not building it for v1 — I don't need it, and the base image plus reference host configs cover the deployment story I actually want.
+Flagging the possibility now because it costs nothing at design time (the `quire exec` dispatch boundary is already the right shape for either deployment), and it'd be a thoughtful contribution from someone who wants it later. Not building it — the base image plus reference host configs cover the deployment story I actually want.
## Client assumptions
The primary client is **jj** (Jujutsu), not git directly. In practice this changes very little server-side — `jj git push` speaks the git wire protocol, so `git-receive-pack` and `git-upload-pack` handle it transparently. A few things are still worth keeping in mind because they shape UX defaults, not protocol handling:
-- **Force-pushes are routine, not exceptional.** jj users rebase and amend constantly; force-pushing a bookmark is part of the normal flow. CI needs a policy for what happens when a new push supersedes an in-flight run for the same ref. Leaning: cancel the in-flight run, start the new one, log the cancellation in the run history.
+- **Force-pushes are routine, not exceptional.** jj users rebase and amend constantly; force-pushing a bookmark is part of the normal flow. CI cancels an in-flight run when a new push supersedes it for the same ref, and records the cancellation in the run history.
- **Short-lived refs are common.** jj's push-anywhere workflows can produce refs like `push-xxxxxxxx` that exist only to move work around. The web UI shouldn't give every ref equal prominence — surface branches the operator has opinions about (main, plus anything pinned in per-repo config), fold the rest into a "see all" affordance.
- **Commit SHAs aren't stable identities.** Don't build URLs or features that assume a given SHA will exist forever. Prefer refs where possible; accept that deep-linking to a SHA may 404 after a rebase.
- **No assumption of linear history.** Even post-rebase, merge commits and non-linear shapes show up. The log view shouldn't require linearity.
Nothing here requires jj-specific code. It's all just "don't make git-flow-shaped assumptions."
-## Build sequence
-
-The build sequence is ordered by integration risk, not feature priority — the unfamiliar plumbing comes first so the rest can be built on solid ground.
-
-### 1. Host-mediated dispatch to a pushable repo
-
-This is the step with real integration risk — getting host sshd to dispatch authenticated connections into the container cleanly, and making sure stdio is preserved end-to-end. Do it before anything else.
-
-Minimal Dockerfile: `quire` user, git installed, a bare repo pre-created at `/var/quire/repos/foo.git`. No sshd in the container, no quire binary yet. `ENTRYPOINT` is a shell that handles `docker exec` invocations.
-
-On the host: create a `git` user, put your pubkey in its `~/.ssh/authorized_keys`, add the `Match User git` block with `ForceCommand /usr/local/bin/quire-dispatch`, write the quire-dispatch script (parses `$SSH_ORIGINAL_COMMAND`, execs `docker exec -i quire-container /bin/sh -c "cd /var/quire/repos/$REPO && git-receive-pack ."` or similar).
-
-Verify with `git push git@host:foo main`. Push a commit, confirm it lands in the bare repo.
-
-Things most likely to go wrong here:
-
-- Stdio buffering between ssh → docker exec → git-receive-pack.
-- Argument quoting through three layers of shell.
-- `docker exec -i` vs `-it` (no TTY when invoked from ForceCommand).
-
-### 2. `quire exec` dispatch subcommand
-
-Replace the ad-hoc shell dispatch from step 1 with `docker exec -i quire-container quire exec "$SSH_ORIGINAL_COMMAND"`. The `quire exec` subcommand takes the original command string, parses it properly (shell-style with a real parser, not regex), validates it against a strict allowlist — `git-receive-pack`, `git-upload-pack`, `git-upload-archive`, and a specific set of `quire` subcommands (`repo new`, `repo list`, `repo rm`, `mirror push`) — and execs the appropriate binary.
-
-**This is the only dispatch surface into the container.** There's no sshd in the container to backstop a permissive parser; anything that gets past `quire exec` runs as trusted. The allowlist is the security boundary — not a UX convenience, the actual boundary. Treat it that way: explicit enumeration, reject by default, no regex-based "looks safe enough," tests for the rejection paths as well as the accept paths.
-
-### 3. Hook plumbing
-
-Write the quire binary's `hook` subcommand as a no-op that logs what it was invoked with. Install hooks into the test repo via `git config hook.pre-receive.command "quire hook pre-receive"` etc. Push to the repo, see the log lines. Proves the hook path works.
-
-### 4. Explicit repo creation
-
-`ssh git@host quire repo new <name>` → `quire exec` → `quire repo new <name>`. Creates a bare repo under `repos/`, validates the name (no `..`, one level of grouping max, no reserved names), sets `hook.<name>.command` configs. Also: `quire repo list`, `quire repo rm`, basic ops. All accessed via the same ssh-dispatch path.
+## Planned: email notifications
-### 5. GitHub mirror via CI job
-
-Mirroring to GitHub is expressed as an ordinary CI job in `.quire/ci.fnl`. The job shells out `git push` with a token from the global `:secrets` map. No per-repo deploy keys, no agent socket plumbing. Mirror failures surface in the CI run logs.
-
-### 6. Web view, minimum viable
-
-`quire serve` starts an HTTP server bound to a container-internal port (published to the host on loopback only). Caddy on the host terminates TLS, handles auth, and reverse-proxies. Repo list, repo home (README + recent commits + refs), tree browser, file view with syntax highlighting, commit view with diff. No JS required. Reads repos from disk on each request (no caching yet).
-
-Quire reads the `Remote-User` header (injected by Caddy). If present, the request is "authenticated" and the full UI is visible. If absent, only public paths serve content: public repos show, private repos 404, `/runs/*` always 404. The policy lives in quire — Caddy's job is just to handle the auth handshake and inject the header correctly. Belt-and-suspenders: if Caddy is misconfigured and fails to strip a client-supplied header, quire has no way to detect that. Document the header-stripping requirement loudly in the reference Caddyfile.
-
-### 7. Web view, nicer
-
-Per-file history following renames (`git log --follow`), compare-between-refs, blame, submodule-aware tree browsing. Skip branch-graph viz. Measure before caching anything — likely candidates if it's needed are rendered READMEs and syntax-highlighted blobs.
-
-### 8. Fennel CI MVP
-
-Embed Lua via `mlua`, ship Fennel compiler as a Lua module. Compile-and-eval `.quire/ci.fnl` at run-trigger time. The pipeline DSL is `(ci.image ...)` and `(ci.job ...)` — see `docs/CI-FENNEL.md` for the spec and `docs/CI.md` for the runtime. Each `(sh ...)` call `docker exec`s into a per-run container; per-run network policy is whatever the image's network namespace allows.
-
-Sandboxing of `ci.fnl` evaluation is deliberately not in the MVP. Since every pipeline is code I'm writing for my own projects, "the CI step can do anything a logged-in me can do in the container" is the right threat model. If that changes (running untrusted forks, for example, or sharing the instance), re-introduce bubblewrap wrapping behind a per-pipeline opt-in.
-
-The post-receive hook sends a JSON push event over `/var/quire/server.sock`; a listener task in `quire serve` parses the event, inserts a `pending` row into `runs`, and signals the runner. The hook never touches `runs/` directly.
-
-### 9. Run history + artifacts
-
-Run state lives in the `runs` and `jobs` tables in `quire.db`; the filesystem holds the per-run workspace and per-`(sh ...)` log files only. Artifact retention policy: last 10 runs per repo, or 30 days, whichever is longer. Web UI for run list and run detail with streaming log. Run list reads from SQLite; log streaming tails the per-`(sh ...)` CRI log files on disk.
-
-### 10. Email notifications
-
-Shell out to `msmtp` (or `sendmail`-compatible) as a subprocess — the container ships `msmtp`, and global config (`config.fnl`) specifies SMTP server + credentials once. Quire builds the message, pipes it to `msmtp -t`, done. No native SMTP library, no retry queue, no HTML templates; a plain-text email with subject and body is the whole thing.
+Not built yet. The design intent: shell out to `msmtp` (or `sendmail`-compatible) as a subprocess — the container ships `msmtp`, and global config (`config.fnl`) specifies SMTP server + credentials once. Quire builds the message, pipes it to `msmtp -t`, done. No native SMTP library, no retry queue, no HTML templates; a plain-text email with subject and body is the whole thing.
What triggers a notification, per-repo-configurable in `.quire/config.fnl`:
@@ -181,37 +118,28 @@ Global config has the SMTP connection details and a default `:to` list that per-
Send failures (SMTP down, auth rejected, etc.) are logged but don't block anything else — a failed notification shouldn't fail a push or a CI run. Logged to quire's own log so there's a place to notice drift.
-### 11. Polish
-
-Keyboard navigation in the web UI. Atom feeds for recent commits (public, subject to per-repo visibility) and CI runs (auth-gated, same as the log views). `quire` CLI rounded out (prune runs, re-run a CI job).
-
## Key design decisions locked in
- **Host mediates SSH; container is quire-only.** Host sshd authenticates, `ForceCommand` dispatches into the container via `docker exec`, container has no sshd. One auth layer, on the host, where the keys belong.
- **TLS and web auth on the reverse proxy.** Caddy (or equivalent) terminates TLS, handles authentication, and injects a trusted identity header. Quire reads the header and makes visibility decisions. Auth mechanism is the proxy's problem; quire stays scheme-agnostic.
-- **Mirror to GitHub via CI job.** Mirroring is expressed as a `.quire/ci.fnl` job that shells out `git push` with a token from the global `:secrets` map. No per-repo deploy keys, no in-process mirror logic.
+- **Mirroring is server-side, not a CI job.** On every push event, `quire serve` pushes each updated ref (non-force) to the remotes in the repo's `:mirrors` map, authenticating with tokens from the global `:secrets`. Independent of CI; mirror failures are logged, they don't fail the push — `ssh git@host quire mirror push <repo> <ref>` re-triggers a ref by hand after a mirror-side failure. (An earlier design routed mirroring through a CI job; [`plans/2026-08-12-ci-rearchitecture.md`](./plans/2026-08-12-ci-rearchitecture.md) removed it as a duplicate path.)
- **Web visibility: public by default, per-repo opt-outs.** Repos are public (they go to GitHub anyway); CI logs require auth. Per-repo `(private true)` and `(public_runs true)` flags cover the exceptions.
- **Trust the proxy-injected identity header.** `Remote-User` is trusted because the reverse proxy is the only ingress. Proxy must strip any client-supplied version before injecting its own — this is the security-critical invariant.
- **Explicit repo creation, not implicit on first push.** `ssh git@host quire repo new <name>`. No magic, no shims parsing first pushes.
- **Hooks via `hook.<n>.command` config.** Git 2.54+ (the version we build into the container image). No shim scripts on disk; `hook.<n>.command = /usr/local/bin/quire hook <n>`. Set at creation time.
-- **Post-receive hook sends push events over Unix socket.** The post-receive hook sends a JSON push event over a Unix domain socket (`/var/quire/server.sock`) to `quire serve`. The server dispatches CI triggers. The hook exits fast. When the server isn't running, the hook prints a warning and exits cleanly.
+- **Post-receive hook sends push events over Unix socket.** The post-receive hook sends a JSON push event over a Unix domain socket (`/var/quire/server.sock`) to `quire serve`. The server dispatches CI triggers and mirror pushes. The hook exits fast. When the server isn't running, the hook prints a warning and exits cleanly.
- **No reverse-direction mirroring.** quire is the source of truth; GitHub is the replica.
-- **CI pipelines are Fennel macros, not data tables.** The whole point is real code. Shared steps can be factored into `.quire/lib/*.fnl` and `require`'d.
+- **CI pipelines are Fennel code, not data tables.** The whole point is real code. Shared steps can be factored into `.quire/lib/*.fnl` and `require`'d.
- **One level of repo grouping max.** `foo.git` and `work/foo.git` are fine. `a/b/c.git` is rejected.
- **Read-only web UI.** No write operations from the browser, ever.
-- **One container, multiple processes inside.** `quire serve` and the CI runner are long-running; `quire exec` and `quire hook *` are short-lived subprocess invocations. Supervised via a minimal init (tini + a simple supervisor, or s6-overlay).
-- **`quire exec` is the only SSH-originated entry point.** Strict allowlist, explicit rejection, security-sensitive parser. No sshd in the container means no fallback if the parser is too loose.
+- **`quire exec` is the only SSH-originated entry point.** The command string is parsed shell-style (a real parser, not regex) and validated against a strict allowlist: `git-receive-pack`, `git-upload-pack`, `git-upload-archive`, plus the `quire repo` subcommands and `quire mirror push`. Reject by default, explicit enumeration, tests for the rejection paths as well as the accept paths. No sshd in the container means no fallback if the parser is too loose — the allowlist is the actual security boundary, not a UX convenience.
## Open questions
-- **Inline `ForceCommand` vs. quire-dispatch script.** Simplest is inlining: `ForceCommand docker exec -i quire-container quire exec "$SSH_ORIGINAL_COMMAND"`. No script file, no intermediate layer. The reason to add a `quire-dispatch` script would be host-side logic that needs to run before dispatch (rate limiting, per-key policy, audit logging). Lean: inline, add a script only when a need appears.
-- **Host config bundle.** The reference sshd_config block, Caddyfile, and docker-compose file should be in the quire repo itself, versioned with the code. Ideally a `quire install-host-config` command that writes them interactively. Or just a `docs/host/` directory with copy-paste instructions. Lean toward the latter — interactive installers that touch host config are scope creep.
-- **Public SSH port.** Host's sshd runs on 22. No conflict now — one sshd on the host does everything. Stay on 22.
-- **CI network policy.** Default on (you'll want it for `cargo`, `npm`), with a per-pipeline `(network false)` opt-out. Or default off with explicit `(network true)`? Default on is more ergonomic; default off is more principled.
+- **CI network policy.** Default on (you'll want it for `cargo`, `npm`), with a per-pipeline `(network false)` opt-out. Or default off with explicit `(network true)`? Default on is more ergonomic; default off is more principled. Becomes real when CI runs in containers.
- **Artifact size limits.** Probably want a per-run cap (1 GB?) and a per-repo cap (10 GB?). Values TBD after real use.
- **Push-time feedback for CI.** When post-receive kicks off CI, should the push block until the run starts (not completes)? Probably yes, so the client sees "CI run #42 queued" in push output.
-- **Secrets for CI.** Declared in the global `:secrets` map, exposed to jobs via `(runtime.secret :name)`. Each value is either a plain string or a `{:file "/run/secrets/<name>"}` reference (Docker-secrets convention; one trailing newline stripped on read). Resolved values are redacted from CI output surfaces — run logs, recorded command strings, the `sh.cmd` column — by a per-run registry that replaces matches with `{{ name }}`. Values shorter than 8 bytes are not registered (false-positive risk; a `WARN` trace event names the skip). Tracing/application logs are not covered in v1 — audit existing trace call sites instead. Encrypted-at-rest for the secrets file is deferred until there's a reason.
-- **Backup story.** `tar` the data volume. Deploy keys are in the volume, so they travel with the backup — convenient but also means the backup is sensitive. Worth thinking about encryption-at-rest for the backup, not just the source volume. Defer, but don't forget.
+- **Backup story.** `tar` the data volume. Secrets referenced from the volume travel with the backup — convenient but also means the backup is sensitive. Worth thinking about encryption-at-rest for the backup, not just the source volume. Defer, but don't forget.
- **`docker exec` performance.** Each git push spawns a new `docker exec`. Container startup is not involved (the container is already running), but there's still some latency — tens to hundreds of milliseconds. Probably fine for interactive use, possibly noticeable if something scripts many pushes. Measure, don't optimize preemptively.
- **Reverse-proxy auth scheme.** Which auth mechanism does the proxy actually run? Candidates:
- HTTP basic — simplest, but the login UI is the browser's ugly default dialog.
@@ -220,9 +148,8 @@ Keyboard navigation in the web UI. Atom feeds for recent commits (public, subjec
- GitHub OAuth via oauth2-proxy — nice "sign in with GitHub" flow, ties identity to something real.
Leaning basic auth for v1 — it's ugly but trivial, and "my password is a 40-character string in 1Password" is fine at single-user scale. Can swap to OAuth later without changing quire at all.
-- **Identity header name.** Various proxies use various names (`Remote-User`, `X-Remote-User`, `X-Forwarded-User`, `X-Auth-User`). Quire should pick one and the reference Caddyfile should match. `Remote-User` is short and traditional; `X-Remote-User` signals "this is a custom header, not the standard CGI one." Lean: `Remote-User`, matches the CGI convention and nginx/apache ecosystem.
-- **SMTP credentials.** Global config holds SMTP user + password. Storing in `config.fnl` plain-text is fine for a personal instance where the volume is trusted, but worth noting: anyone who reads the volume can read the password. Alternatives: env var (fine, same trust boundary), file outside the volume that the container reads on startup (marginal), actually encrypt (overkill for this). Lean: plain in `config.fnl`, document the trust assumption.
-- **Notification deduplication.** If CI is flaky and the same build fails twice in a row, that's two emails. If it fails ten times, that's ten. Probably fine at personal scale (flaky CI is itself a problem worth noticing), but if it becomes annoying, add simple per-event throttling ("don't send the same event for this repo more than once per N minutes"). Defer; fix if it's actually a nuisance.
+- **SMTP credentials** (when notifications land). Global config holds SMTP user + password. Storing in `config.fnl` plain-text is fine for a personal instance where the volume is trusted, but worth noting: anyone who reads the volume can read the password. Alternatives: env var (fine, same trust boundary), file outside the volume that the container reads on startup (marginal), actually encrypt (overkill for this). Lean: plain in `config.fnl`, document the trust assumption.
+- **Notification deduplication** (when notifications land). If CI is flaky and the same build fails twice in a row, that's two emails. If it fails ten times, that's ten. Probably fine at personal scale (flaky CI is itself a problem worth noticing), but if it becomes annoying, add simple per-event throttling ("don't send the same event for this repo more than once per N minutes"). Defer; fix if it's actually a nuisance.
## Post-baseline wishlist
@@ -230,7 +157,7 @@ Things to build after v1 is stable.
### Richer line/file history view
-Tracing "where did this code come from, where did it go" is the thing every forge does poorly and every developer wants. The baseline plan (step 7) gets us `git log --follow` and basic blame. This is about going materially beyond that:
+Tracing "where did this code come from, where did it go" is the thing every forge does poorly and every developer wants. The baseline gets us `git log --follow` and basic blame. This is about going materially beyond that:
- **Blame ladder.** Start on blame for a file at HEAD. Click any line → jump to the commit that last touched it and show blame at that commit's parent. Keep climbing. Turns blame from a point-in-time snapshot into navigable history. The UI affordance is "click to ascend" — like `tig`'s blame navigation but in a browser.
- **Range follow.** Select contiguous lines in the file view, get `git log -L` for that range, rendered as commits with diffs scoped to those lines. Much more useful than full-file history when you're asking about a specific function.
@@ -245,7 +172,7 @@ None of these should require a database. All are expressible as git subprocess i
- Multi-user anything
- Web-based repo creation or deletion
- Branch protection, required reviews, merge queues
-- Webhooks out (but see email notifications in the build sequence)
+- Webhooks out (but see the planned email notifications above)
- Pulling from external sources (quire is push-only from the operator's side)
- LFS
- Wiki, pages, packages
diff --git a/docs/CI-FENNEL.md b/docs/CI-FENNEL.md
index 6d3ea86..cc8e8a5 100644
--- a/docs/CI-FENNEL.md
+++ b/docs/CI-FENNEL.md
@@ -27,9 +27,9 @@ Top-level form, called once before any `(ci.job ...)`. Declares the image used t
Calling `ci.image` more than once errors with the same shape as other duplicate-registration errors.
-A pipeline can also build its image from a checked-in `.quire/Dockerfile` instead of declaring a public image. The resolution order is `(ci.image ...)` → `.quire/Dockerfile` → error.
+A pipeline can also build its image from a checked-in `.quire/Dockerfile` instead of declaring a public image.
-> **v0 status:** the docker executor only honors `.quire/Dockerfile` today; `(ci.image ...)` is parsed and validated but not yet wired into the executor. Pipelines targeting docker need a `.quire/Dockerfile` until the declared-image path lands.
+> **v0 status:** no container executor exists yet — `(sh ...)` runs on the host, and neither `(ci.image ...)` nor `.quire/Dockerfile` is consumed by anything. `(ci.image ...)` is parsed and validated but slated for removal: the decided design sources the image from `.quire/Dockerfile` with a default-image fallback, because the declaration can't survive containerized eval (see [`plans/2026-08-12-ci-rearchitecture.md`](./plans/2026-08-12-ci-rearchitecture.md), decision 3).
## The `job` primitive
@@ -215,7 +215,7 @@ Each of these blocks the Fennel function until it returns. Multi-`sh`-call paral
`sh` is the only host-effect channel. There is no `(container ...)` primitive — the run's container is started by the runner before any run-fn executes (with the image declared via `(ci.image ...)` at the pipeline level), and every `sh` call execs into it via `docker exec`. Stdout and stderr stay separated (no TTY); ordering is approximate but each chunk has its own timestamp in the JSONL log.
-> **v0 status:** `sh`, `secret`, and `jobs` are bound today. `sh` currently shells out on the host; the per-run container + `docker exec` tunneling is planned (see backlog `lpmoszxo`, `knmkqkvx`). `read-file`/`read-json`/`write-file`, `log`, and `env` are planned and tracked separately.
+> **v0 status:** `sh`, `secret`, and `jobs` are bound today. `sh` currently shells out on the host. Under the decided re-architecture, `quire-ci` itself runs inside the per-run container and `sh` stays a local subprocess — the `docker exec` tunneling described in this doc's prose is the older design. `read-file`/`read-json`/`write-file`, `log`, and `env` are planned and tracked separately.
The execute VM is sandboxed (no `io`/`os`/`debug`), so `runtime.sh` is the documented chokepoint for any host effect — `os.execute` and `io.open` are not available alternates. See CI.md for the full sandbox shape and the bwrap opt-in for the untrusted-code threat model.
diff --git a/docs/CI-STATE.md b/docs/CI-STATE.md
index 30c6698..37cff1e 100644
--- a/docs/CI-STATE.md
+++ b/docs/CI-STATE.md
@@ -11,89 +11,84 @@ A run owns its jobs; jobs FK on `(run_id, job_id)` and cascade delete.
## Run state machine
+Since migration 0010 there is no `state` column. A run's lifecycle stage is **derived from two timestamps**, and terminal runs carry an **`outcome`** string:
+
+| Stage | `dispatched_at` | `resolved_at` | `outcome` |
+| --- | --- | --- | --- |
+| queued | NULL | NULL | NULL |
+| active | set | NULL | NULL |
+| resolved | (any) | set | set |
+
### Diagram
```mermaid
stateDiagram-v2
[*] --> queued : Runs.create
- queued --> active : bootstrap endpoint
- queued --> canceled : cancel_existing
- queued --> failed : reconcile_orphans
+ queued --> active : bootstrap endpoint / dispatch()
+ queued --> resolved : cancel_existing (superseded)
+ queued --> resolved : reconcile_orphans (failed-orphaned)
- active --> succeeded : transition Succeeded
- active --> failed : pipeline-failure
- active --> failed : process-crashed
- active --> canceled : cancel_existing
- active --> failed : reconcile_orphans
+ active --> resolved : resolve (succeeded)
+ active --> resolved : resolve (failed-pipeline)
+ active --> resolved : resolve (failed-internal)
+ active --> resolved : cancel_existing (superseded)
+ active --> resolved : reconcile_orphans (failed-orphaned)
- succeeded --> [*]
- failed --> [*]
- canceled --> [*]
+ resolved --> [*]
note right of queued
- started_at_ms IS NULL
- finished_at_ms IS NULL
+ created_at stamped on insert
end note
note right of active
- started_at_ms stamped on entry
- finished_at_ms still NULL
+ dispatched_at stamped on entry
end note
- note right of succeeded
- started_at_ms, finished_at_ms set
+ note right of resolved
+ resolved_at and outcome stamped together
end note
```
### Transitions in code
-| From → To | Where | When | `failure_kind` |
+| Transition | Where | When | `outcome` |
| --- | --- | --- | --- |
-| `[*] → queued` | `Runs::create` (`quire-server/src/ci/run.rs`) | A push event arrives and a `runs` row is inserted. | — |
-| `queued → active` | Bootstrap endpoint (`api.rs`), called when `quire-ci` fetches bootstrap data | `quire-ci` connects to the server and marks the run active. Stamps `started_at_ms`. | — |
-| `active → succeeded` | `Run::transition`, called from `Run::execute` | `quire-ci` exited 0 and `RunFinished { outcome: Succeeded }` was ingested. Stamps `finished_at_ms`. | — |
-| `active → failed` | `Run::execute` | `quire-ci` exited 0 and `RunFinished { outcome: PipelineFailure }` was ingested — a job's run-fn returned an error. | `"pipeline-failure"` |
-| `active → failed` | `Run::execute` | `quire-ci` exited non-zero, or exited 0 but emitted no `RunFinished` event (process crash or panic). | `"process-crashed"` |
-| `{queued, active} → canceled` | `Runs::cancel_existing` via raw SQL, **bypassing `transition`** | A new `Runs::create` for the same `(repo, ref)` arrived. Both queued and active rows are flipped directly. | — |
-| `{queued, active} → failed` | `reconcile_orphans` via raw SQL, **bypassing `transition`** | Startup-time cleanup of rows left behind by a previous `quire serve` instance. | `"orphaned"` |
-
-`Run::transition(to, failure_kind)`'s allowed-transition match:
-
-```
-(Queued, Active) | (Queued, Succeeded) | (Queued, Canceled) |
-(Active, Succeeded) | (Active, Failed) | (Active, Canceled)
-```
+| `[*] → queued` | `Runs::create` (`quire-server/src/ci/run.rs`) | A push event arrives and a `runs` row is inserted. Stamps `created_at`. | — |
+| `queued → active` | Bootstrap endpoint (`api.rs`) for API runs; `Run::dispatch` for local runs | `quire-ci` connects to the server and fetches the bootstrap payload. Stamps `dispatched_at`. | — |
+| `active → resolved` | `Run::resolve`, called from `Run::execute` | `quire-ci` exited 0 and `RunFinished { outcome: Succeeded }` was ingested. | `succeeded` |
+| `active → resolved` | `Run::execute` | `quire-ci` exited 0 and `RunFinished { outcome: PipelineFailure }` was ingested — a job's run-fn returned an error. Compile errors in `ci.fnl` also take this path (quire-ci emits `RunFinished(PipelineFailure)` and exits 0). | `failed-pipeline` |
+| `active → resolved` | `Run::execute` | `quire-ci` exited non-zero, or exited 0 but emitted no `RunFinished` event (process crash or panic). | `failed-internal` |
+| `{queued, active} → resolved` | `Runs::cancel_existing` | A new `Runs::create` for the same `(repo, ref)` arrived. Both queued and active rows are resolved directly. | `superseded` |
+| `{queued, active} → resolved` | `reconcile_orphans` | Startup-time cleanup of rows left behind by a previous `quire serve` instance (`WHERE resolved_at IS NULL`). | `failed-orphaned` |
-In practice only `(Active, Succeeded)` and `(Active, Failed)` are exercised via `transition` — the `Queued → Active` edge is owned by the bootstrap endpoint (api.rs), and the cancel edges go through raw SQL (`cancel_existing`), not `transition`. The other edges are gated for defensive consistency, in case a future caller routes cancellation through the typed API. Anything else — `Queued → Failed`, `Active → Queued`, or any transition out of a terminal state — returns `InvalidTransition`.
+There is no typed allowed-transition table anymore. The guards are:
-`failure_kind` is recorded only when `to == Failed`; it's ignored for `Active`, `Succeeded`, and `Canceled`.
+* `Run::dispatch` and `Run::resolve` reject re-entry via in-memory flags (`AlreadyDispatched`, `AlreadyResolved`).
+* Every `UPDATE` stamps `dispatched_at`, `resolved_at`, and `outcome` through `COALESCE`, so each is written at most once — a later writer can never overwrite an earlier stamp.
+* The DB `CHECK` constraints below reject inconsistent rows outright.
### Database invariants
-The DB enforces shape per state via a `CHECK` constraint (see `migrations/0009_rename_ci_vocab.sql`):
-
-| State | `started_at_ms` | `finished_at_ms` |
-| --- | --- | --- |
-| `queued` | NULL | NULL |
-| `active` | set | NULL |
-| `succeeded` | set | set |
-| `failed` | (any) | set |
-| `canceled` | (any) | set |
+Enforced by `CHECK` constraints (see `migrations/0010_outcome_schema.sql`):
-Plus monotonicity: `started_at_ms >= queued_at_ms`, `finished_at_ms >= started_at_ms`. `started_at_ms`, `finished_at_ms`, and `failure_kind` are stamped at most once each, via `COALESCE` in the `UPDATE`.
+* `(resolved_at IS NULL) = (outcome IS NULL)` — resolution and outcome arrive together.
+* `outcome IN ('succeeded', 'failed-pipeline', 'failed-orphaned', 'failed-internal', 'superseded')`.
+* Monotonicity: `dispatched_at >= created_at`, `resolved_at >= created_at`, and `resolved_at >= dispatched_at` when both are set.
-### `failure_kind`
+A queued run superseded before dispatch resolves with `dispatched_at` still NULL — `superseded` and `failed-orphaned` do not require the run to have started.
-Nullable column populated by `Run::transition` when entering `Failed`, plus `reconcile_orphans` (raw SQL). Each transition sets it at most once via `COALESCE`. The values written today:
+### Outcomes
| Value | Producer |
| --- | --- |
-| `"pipeline-failure"` | `Run::execute`: `quire-ci` exited 0 and reported `RunFinished { outcome: PipelineFailure }` — a job's run-fn returned an error. Compile errors in `ci.fnl` also produce this outcome (quire-ci emits `RunFinished(PipelineFailure)` and exits 0). |
-| `"process-crashed"` | `Run::execute`: `quire-ci` exited non-zero, or exited 0 but never emitted a `RunFinished` event (panic or unexpected termination). |
-| `"orphaned"` | `reconcile_orphans` on startup. |
+| `succeeded` | `Run::execute`: exit 0 + `RunFinished(Succeeded)`. |
+| `failed-pipeline` | `Run::execute`: exit 0 + `RunFinished(PipelineFailure)` — a job's run-fn returned an error, or `ci.fnl` failed to compile. |
+| `failed-internal` | `Run::execute`: non-zero exit, or exit 0 with no `RunFinished` event (panic or unexpected termination). |
+| `failed-orphaned` | `reconcile_orphans` on startup. |
+| `superseded` | `Runs::cancel_existing` when a newer push to the same `(repo, ref)` displaces the run. Slated for renaming to `replaced` (see [`plans/2026-08-12-ci-rearchitecture.md`](./plans/2026-08-12-ci-rearchitecture.md), decision 7). |
-Succeeded and canceled runs leave `failure_kind` NULL. The set is open — UI consumers should not assume it's exhaustive.
+The set is open — UI consumers should not assume it's exhaustive.
## Job state machine
@@ -159,8 +154,8 @@ sequenceDiagram
Trigger->>Run: execute()
Run->>CI: spawn (QUIRE__SERVER_URL, QUIRE__RUN_TOKEN, --events, --out-dir)
CI->>Bootstrap: GET /api/run/bootstrap (bearer token)
- Bootstrap->>DB: UPDATE runs SET state='active', started_at_ms=now
- Bootstrap-->>CI: meta, sentry_trace_id
+ Bootstrap->>DB: UPDATE runs SET dispatched_at=now
+ Bootstrap-->>CI: meta, traceparent
CI->>CI: compile .quire/ci.fnl
loop per job in topo order
CI->>CI: enter_job / run-fn / leave_job
@@ -171,11 +166,11 @@ sequenceDiagram
Run->>DB: INSERT jobs (pass 1)
Run->>DB: INSERT sh (pass 2)
alt RunFinished(Succeeded) + exit 0
- Run->>DB: UPDATE runs SET state='succeeded'
+ Run->>DB: UPDATE runs SET resolved_at, outcome='succeeded'
else RunFinished(PipelineFailure) + exit 0
- Run->>DB: UPDATE runs SET state='failed' (failure_kind='pipeline-failure')
+ Run->>DB: UPDATE runs SET resolved_at, outcome='failed-pipeline'
else exit nonzero or no RunFinished
- Run->>DB: UPDATE runs SET state='failed' (failure_kind='process-crashed')
+ Run->>DB: UPDATE runs SET resolved_at, outcome='failed-internal'
end
```
@@ -184,6 +179,7 @@ Wire events (`quire-core/src/ci/event.rs`):
* `JobStarted { job_id }`
* `JobFinished { job_id, outcome: succeeded | failed }` — `JobOutcome` is the closed set, not the full job-state enum.
* `ShStarted { job_id, cmd }` / `ShFinished { job_id, exit_code }`
+* `RunFinished { outcome: succeeded | pipeline-failure }`
`Run::ingest_events` reads the file in two passes (jobs first to satisfy the FK on `(run_id, job_id)`, then sh). Ingest failures are logged but never demote the run's own outcome — a partial DB write is preferable to losing the pass/fail signal.
@@ -203,19 +199,19 @@ sequenceDiagram
Hook->>Listener: PushEvent JSON over /var/quire/server.sock
Listener->>Trigger: trigger(quire, &event)
loop per updated ref
- Trigger->>DB: cancel_existing (Queued|Active → Canceled for same repo/ref)
- Trigger->>DB: INSERT runs (state=queued)
+ Trigger->>DB: cancel_existing (queued|active → superseded for same repo/ref)
+ Trigger->>DB: INSERT runs (created_at)
Trigger->>FS: create run dir + workspace
Trigger->>FS: git archive | tar -x (materialize workspace)
Trigger->>Exec: execute()
- Exec->>DB: active → succeeded|failed (via bootstrap endpoint + ingest_events)
+ Exec->>DB: dispatched_at via bootstrap endpoint; resolved_at + outcome after ingest
end
```
-Two things in `CI.md` that the code does *not* yet implement at this layer:
+Two things in `CI.md` that the code does *not* yet implement at this layer (both are decided targets in [`plans/2026-08-12-ci-rearchitecture.md`](./plans/2026-08-12-ci-rearchitecture.md)):
* **Queue + Notify wakeup.** `CI.md` describes a separate runner task pulled from a SQLite queue via `tokio::sync::Notify`. Today `ci::trigger` is called **synchronously** on the listener's tokio task — one push at a time, no queue, no separate runner. Max-concurrency-1 falls out of this trivially, but it isn't the architecture in `CI.md`.
-* **Per-run container.** `CI.md` says `docker run` at run start, `docker exec` per `(sh …)`, `docker stop` at end. `quire-ci` invokes `(sh …)` directly on the host process. The Docker-executor schema columns (`container_id`, `image_tag`, build/container timestamps) have been removed in migration 0007.
+* **Per-run container.** `CI.md` says `docker run` at run start and container teardown at run end. Today `quire-ci` runs as a host subprocess and invokes `(sh …)` directly on the host. The Docker-executor schema columns (`container_id`, `image_tag`, build/container timestamps) were removed in migration 0007.
## Schema column inventory
@@ -228,15 +224,14 @@ Two things in `CI.md` that the code does *not* yet implement at this layer:
| `ref_name` | `Runs::create` | `cancel_existing`, web handlers, bootstrap response |
| `sha` | `Runs::create` | `read_meta`, bootstrap response, web handlers |
| `pushed_at_ms` | `Runs::create` | `read_meta`, web handlers |
-| `state` | `Runs::create` (→ `queued`) + every transition | everywhere |
-| `failure_kind` | `Run::transition(Failed, …)`, `reconcile_orphans` | web handlers |
-| `queued_at_ms` | `Runs::create` | web handlers |
-| `started_at_ms` | `transition(Active)`, also stamped as fallback in `Succeeded/Failed/Canceled` | `read_started_at`, web handlers |
-| `finished_at_ms` | `transition(Succeeded/Failed/Canceled)` | `read_finished_at`, web handlers |
+| `created_at` | `Runs::create` | web handlers |
+| `dispatched_at` | bootstrap endpoint / `Run::dispatch`, also stamped as fallback by `cancel_existing` (active rows), `reconcile_orphans`, and `resolve` | `read_dispatched_at`, web handlers |
+| `resolved_at` | `Run::resolve`, `cancel_existing`, `reconcile_orphans` | `read_resolved_at`, web handlers |
+| `outcome` | `Run::resolve`, `cancel_existing`, `reconcile_orphans` | `read_outcome`, web handlers |
| `run_token` | `Runs::create` (API sessions only) | `verify_run_token` middleware |
| `traceparent` | `Run::store_bootstrap_data` (API sessions only) | bootstrap endpoint |
-Migration 0007 dropped eight columns that carried no live data with the Process executor: `container_id`, `workspace_path`, `image_tag`, `build_started_at_ms`, `build_finished_at_ms`, `container_started_at_ms`, `container_stopped_at_ms`, and `sentry_trace_id`. The first five were Docker-executor placeholders; `workspace_path` was written at create time but reconstructable from `<base_dir>/<run_id>/workspace`; `sentry_trace_id` was added in migration 0004 and superseded by `traceparent` before it was ever used. Migration 0011 dropped `git_dir`, whose only consumer was the CI mirror helper removed in favor of server-side mirroring.
+Migration 0007 dropped eight columns that carried no live data with the Process executor: `container_id`, `workspace_path`, `image_tag`, `build_started_at_ms`, `build_finished_at_ms`, `container_started_at_ms`, `container_stopped_at_ms`, and `sentry_trace_id`. Migration 0010 replaced `state`/`failure_kind` with the derived-lifecycle columns above. Migration 0011 dropped `git_dir`, whose only consumer was the CI mirror helper removed in favor of server-side mirroring.
### `jobs` table
@@ -257,11 +252,12 @@ States the schema admits — or `CI.md` commits to — that no code path produce
| Job `active` rows during execution | Schema-allowed | `ingest_events` inserts one row per job at JobFinished time. While `quire-ci` is running, the `jobs` table has nothing for this run. Live UI of "currently running job" needs an active-row writer — either eager ingest, or a separate writer inside `quire-ci`. |
| Job `skipped` outcome for dependents of a failed job | Tracked in ranger `wwpxzuvq` | `quire-ci`'s loop `break`s on first failure and emits no events for downstream jobs. Would need `skipped` re-added to the jobs CHECK constraint; producer would emit `JobSkipped` events from `quire-ci` or compute them in the ingester from the pipeline graph. |
| `:allow-failure` job flag | Documented in `CI.md` as v1 | Not implemented anywhere in `quire-core`, `quire-ci`, or `quire-server`. The structural validator doesn't recognize the key; the executor treats every job error as fatal. |
-| Queue + Notify wakeup | `CI.md` "Communication" section | `trigger` runs synchronously on the listener task. No queue scan, no Notify, no separate runner task. |
+| Queue + Notify wakeup | `CI.md` "Communication" section | `trigger` runs synchronously on the listener task. No queue scan, no Notify, no separate runner task. Decided design in `plans/2026-08-12-ci-rearchitecture.md`, decision 5. |
## Cross-references
* Architecture and rationale: [`CI.md`](./CI.md).
+* Where the implementation is headed: [`plans/2026-08-12-ci-rearchitecture.md`](./plans/2026-08-12-ci-rearchitecture.md).
* Pipeline DSL: [`CI-FENNEL.md`](./CI-FENNEL.md).
-* DB shape: [`quire-server/migrations/0001_initial.sql`](../quire-server/migrations/0001_initial.sql).
+* DB shape: [`quire-server/migrations/`](../quire-server/migrations/), especially `0009_rename_ci_vocab.sql` (jobs) and `0010_outcome_schema.sql` (runs).
* Code: `quire-server/src/ci/run.rs`, `quire-server/src/ci/mod.rs`, `quire-core/src/ci/event.rs`, `quire-ci/src/main.rs`.
diff --git a/docs/CI.md b/docs/CI.md
index a9f4a75..2bd8f4e 100644
--- a/docs/CI.md
+++ b/docs/CI.md
@@ -1,6 +1,8 @@
# quire — CI design
-How CI works in quire. Slots alongside PLAN.md; will likely fold in once the open questions settle. For the run/job state machines and what each state means in the database, see [CI-STATE.md](./CI-STATE.md).
+How CI works in quire. Slots alongside [ARCHITECTURE.md](./ARCHITECTURE.md). For the run/job state machines and what each state means in the database, see [CI-STATE.md](./CI-STATE.md).
+
+> **Status:** this is the target design; the implementation is partway there. Today the pipeline runs in a `quire-ci` host subprocess with no container, no queue, and `(sh …)` executing directly on the host — [CI-STATE.md](./CI-STATE.md) documents that current state. [`plans/2026-08-12-ci-rearchitecture.md`](./plans/2026-08-12-ci-rearchitecture.md) is the decided path to the containerized shape described here, and supersedes this doc on a few points: the image comes from `.quire/Dockerfile` (not `(ci.image …)`), quire-ci ↔ server communication moves to a per-run Unix socket (not HTTP), and mirroring is server-side rather than a CI concern.
## Shape
@@ -80,38 +82,15 @@ Cheap to get right *if* the run record stores the ref it's building from the sta
## The job DAG
-Jobs declare dependencies via `:needs`. Missing `:needs` means no dependencies — ready immediately. Failure of a job marks all transitive dependents as `skipped`, unless the failing job has `:allow-failure true` (in which case dependents proceed normally).
-
-```
-{:jobs
- [{:id "setup"
- :image "rust:1.75"
- :run "rustup component add clippy rustfmt"}
-
- {:id "lint"
- :image "rust:1.75"
- :needs ["setup"]
- :allow-failure true
- :run "cargo clippy -- -D warnings"}
-
- {:id "test"
- :image "rust:1.75"
- :needs ["setup"]
- :run "cargo test"}
-
- {:id "deploy"
- :image "alpine"
- :needs ["test"]
- :run "scp target/release/quire host:/usr/local/bin/"}]}
-```
+Jobs form a dependency graph derived from each job's **inputs list** — the concrete syntax and semantics live in [CI-FENNEL.md](./CI-FENNEL.md). (Earlier drafts of this section used a `:needs` field on data-table jobs; that schema was abandoned when the spec settled on `(job id inputs run)`.)
-With max-concurrency 1, executor topo-sorts and picks one ready job at a time (FIFO among ready jobs = spec order). `lint` and `test` are both ready after `setup`; lint runs first, then test, then deploy. If `setup` fails, all three skip.
+With max-concurrency 1, the executor topo-sorts and picks one ready job at a time (FIFO among ready jobs = spec order). Failure of a job should mark all transitive dependents as `skipped` — today the executor simply stops at the first failure and downstream jobs record nothing (see the Gaps table in CI-STATE.md).
-Schema decisions baked in:
+Decisions baked in:
-* `:needs` is `needs-all` (job runs only when *all* listed jobs succeed). `needs-any` is a real but rare want; the schema can grow `:needs-any` later without breaking existing specs.
-* Job ids are arbitrary non-empty strings. Cycle detection at parse time via Kahn's algorithm — fails closed, error message names the cycle.
-* `:allow-failure` exists from v1. Without it, the only way to express "lint can fail and we still want to deploy" is to remove the dependency, which loses the ordering signal.
+* Dependencies are needs-all (a job runs only when *all* its input jobs succeed). A needs-any variant is a real but rare want; the spec can grow one later without breaking existing pipelines.
+* Cycle detection at registration via Kahn's algorithm — fails closed, error message names the cycle.
+* `:allow-failure` — "lint can fail and we still want to deploy" without dropping the ordering edge — is designed but **not implemented**; today every job failure is fatal to the run.
## Fennel evaluation
@@ -152,20 +131,19 @@ The reason this is the chosen path rather than "subprocess + rlimit, no bwrap"
2. **Runner picks up** the entry from the queue. Single `UPDATE runs SET state = 'active'` in SQLite.
3. **Materialize workspace.** `git --git-dir=repos/foo.git archive <sha> | tar -x -C workspace/`. No worktree, no checkout state on the bare repo. Workspace is throwaway; deleted at end of run.
4. **Evaluate `.quire/ci.fnl`** in the host process (see above). Pipeline image is read from the `(ci.image ...)` registration; jobs are registered via `(ci.job ...)`; the run-fns are not yet invoked.
-5. **Start the run container.** `docker run -d --rm --mount type=bind,src=<run-dir>,dst=/work -w /work <image> sleep infinity`. Container ID written to the `runs` row. The run's container hosts every `(sh ...)` call from every job in the run.
+5. **Start the run container.** `docker run -d --rm --mount type=bind,src=<run-dir>,dst=/work -w /work <image> sleep infinity`. The run's container hosts every `(sh ...)` call from every job in the run.
6. **Per ready job:** invoke its run-fn in topological order. Each `(sh ...)` call inside the run-fn issues `docker exec` (no TTY) into the run container, captures stdout/stderr and exit code, and returns `{exit, stdout, stderr, cmd}` to Lua.
-7. **Tear down the run container.** `docker stop` + `docker rm`. Even on error paths — no orphaned containers if a run-fn errors. `container_stopped_at_ms` written to the `runs` row.
-8. **Aggregate.** Write final status via `UPDATE runs SET state = 'complete'` (or `'failed'`). Per-`(sh ...)` log files are written to `jobs/<job-id>/sh-<n>.log` on disk before the final transition.
+7. **Tear down the run container.** `docker stop` + `docker rm`. Even on error paths — no orphaned containers if a run-fn errors.
+8. **Aggregate.** Resolve the run: stamp `resolved_at` and an `outcome` (`succeeded` or a `failed-*` value — see CI-STATE.md for the taxonomy). Per-`(sh ...)` log files are written to `jobs/<job-id>/sh-<n>.log` on disk before the final transition.
## Run record schema
```
quire.db
- runs table: id, repo, ref_name, sha, pushed_at_ms, state, failure_kind,
- queued_at_ms, started_at_ms, finished_at_ms, container_id,
- image_tag, build_started_at_ms, build_finished_at_ms,
- container_started_at_ms, container_stopped_at_ms, workspace_path
- jobs table: run_id, job_id, state, exit_code, started_at_ms, finished_at_ms
+ runs table: id, repo, ref_name, sha, pushed_at_ms, created_at,
+ dispatched_at, resolved_at, outcome, run_token, traceparent
+ jobs table: run_id, job_id, state, exit_code, started_at_ms, finished_at_ms
+ sh table: run_id, job_id, started_at_ms, finished_at_ms, exit_code, cmd
runs/<repo>/<run-id>/
workspace/ # materialized checkout
@@ -255,12 +233,12 @@ Punt on cache invalidation until it actually annoys. "Delete the cache dir" is a
* **SQLite is the primary store for run and job state.** Migrations under `migrations/`, embedded into the binary. The filesystem holds workspaces and per-job log files only.
* **Per-run container**, not per-job and not long-lived runners. One `docker run` at run start, `docker exec` per `(sh ...)` call from each job, `docker stop` at run end. Per-job container differentiation is a deferred extension.
* **`(sh ...)` is the only host-effect primitive in the Lua VM.** No `(container ...)` primitive. The execute VM is hardened (no `io`/`os`/`debug`) so `sh` becomes the documented chokepoint — every effect is auditable, persistable, redactable in one place.
-* **Pipeline-level image declaration via `(ci.image ...)`.** Single image per pipeline; per-job override deferred until pipelines actually need heterogeneity.
+* **Pipeline image from `.quire/Dockerfile`, falling back to a default image.** Single image per pipeline; per-job override deferred until pipelines actually need heterogeneity. (An earlier `(ci.image ...)` declaration is being removed — it can't survive containerized eval; see the re-architecture plan, decision 3.)
* **DooD for v1**; OCI+bwrap as planned migration path.
* **Workspace materialized via `git archive`**, not worktree.
* **Max concurrency 1** across the whole forge. Escape valve is `max_concurrent_runs` config + per-repo cache file lock; not building it now.
* **Jobs are a DAG** with `:needs` (needs-all). Executor schedules serially in topological order under max-concurrency 1; lifting that constraint changes the executor, not the spec.
-* **`:allow-failure`** flag exists from v1.
+* **`:allow-failure`** flag is part of the design; not yet implemented (see CI-STATE.md Gaps).
* **Supersede on same `(repo, ref)`**: replace queued, kill running.
* **`.quire/ci.fnl` is executed**, returns the DAG.
* **Eval runs in-process; the execute VM is sandboxed.** Compile VM keeps full Lua 5.4 (Fennel macroexpand/traceback need `debug`); execute VM removes `io`/`os`/`debug` and exposes only `{sh, secret, jobs, string, table, math}`. Trusted-code threat model — no external isolation. Bwrap-based eval sandbox stays available as an opt-in for the day quire runs `ci.fnl` from someone other than the operator. Not built; not v1.
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..1a27794
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,41 @@
+# quire docs
+
+## Current
+
+Living documents. Keep these true as behavior changes.
+
+| Doc | Covers |
+|---|---|
+| [`ARCHITECTURE.md`](./ARCHITECTURE.md) | System shape, host/container split, access model, locked-in decisions, open questions. |
+| [`CI.md`](./CI.md) | CI design: runner shape, storage, concurrency, sandbox backends. Target design — see the status note at the top. |
+| [`CI-STATE.md`](./CI-STATE.md) | Run and job lifecycle **as the code implements it today**, plus the gaps against `CI.md`. |
+| [`CI-FENNEL.md`](./CI-FENNEL.md) | The `.quire/ci.fnl` pipeline DSL: `(job id inputs run)`, sources, runtime primitives. |
+| [`config.md`](./config.md) | Global and per-repo config schemas, secrets, redaction. |
+| [`fennel.md`](./fennel.md) | How `.fnl` files are loaded into typed Rust structs. |
+| [`STYLE_GUIDE.md`](./STYLE_GUIDE.md) | Product personality, vocabulary, typography, color, web UI components. |
+| [`host/`](./host/README.md) | Reference host configs — sshd block, container start, docker-out-of-docker prerequisites. |
+
+When behavior changes, update the relevant doc in the same commit. `AGENTS.md`
+lists which docs to check.
+
+## Historical
+
+Dated records of decisions as they were made. **Not maintained** — a plan
+describes what was intended on its date, not necessarily what the code does
+now. Read them for rationale and context; trust the current docs and the code
+for present behavior.
+
+- [`plans/`](./plans/) — design documents and implementation plans, one per
+ change. Newest first is roughly the story of the project. Two are worth
+ knowing about because they still describe where things are going:
+ - [`2026-08-12-ci-rearchitecture.md`](./plans/2026-08-12-ci-rearchitecture.md)
+ — the decided path for CI: per-run containers, a real queue, a per-run
+ Unix socket replacing HTTP, mirroring moved out of CI. Supersedes parts of
+ `CI.md` and `CI-FENNEL.md`; those docs point at it where they diverge.
+ - [`2026-05-08-workspace-split.md`](./plans/2026-05-08-workspace-split.md)
+ — the crate split into `quire-core` / `quire-server` / `quire-ci`.
+- [`notes/`](./notes/) — smaller scoped design notes that didn't warrant a
+ full plan.
+
+A stale reference inside a dated document is expected and not a bug. If you
+find one in a **current** doc, fix it.
diff --git a/docs/config.md b/docs/config.md
index 036f578..3366a41 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -13,6 +13,7 @@ pick up changes.
| Key | Type | Required | Purpose |
|---------------------------|----------------|----------|----------------------------------------------------------|
| `:port` | integer | no | TCP port the HTTP server binds to (on `0.0.0.0`). Default: `3000`. |
+| `:host` | string | no | Hostname used in the clone URL shown in the repo sidebar, e.g. `"quire.local"` renders `https://quire.local/{repo}.git`. |
| `:sentry :dsn` | `SecretString` | no | Sentry DSN for error reporting from both `quire` and `quire-ci`. Omit to disable. |
| `:secrets` | table | no | Named secrets exposed to `ci.fnl` jobs as `(secret :name)` and referenced by per-repo mirror targets. |
@@ -45,8 +46,9 @@ Files quire reads from a checked-in `.quire/` directory in the working
tree:
- `.quire/ci.fnl` — pipeline definition (jobs, image).
-- `.quire/Dockerfile` — image built per run when the CI executor is
- `docker` and no other image is supplied.
+- `.quire/Dockerfile` — will supply the per-run container image once CI
+ runs in containers (see `plans/2026-08-12-ci-rearchitecture.md`); not
+ consumed yet.
- `.quire/config.fnl` — per-repo settings; read at the pushed commit's
SHA on every push.
@@ -115,5 +117,5 @@ Limits worth knowing:
## See also
- [`fennel.md`](fennel.md) — how Fennel files are loaded into Rust structs.
-- `src/quire.rs` — `GlobalConfig` definition.
-- `src/secret.rs` — `SecretString` implementation and tests.
+- `quire-server/src/quire/mod.rs` — `GlobalConfig` and `RepoConfig` definitions.
+- `quire-core/src/secret.rs` — `SecretString` implementation and tests.
diff --git a/docs/fennel.md b/docs/fennel.md
index 69ebf68..8ed256c 100644
--- a/docs/fennel.md
+++ b/docs/fennel.md
@@ -3,8 +3,9 @@
How quire loads `.fnl` config files into typed Rust structs. Covers the
global config at `/var/quire/config.fnl` and the per-repo config checked
in at `.quire/config.fnl` (read from the bare repo via
-`git show HEAD:.quire/config.fnl`). CI pipeline support will reuse this
-machinery later, but its design is out of scope here.
+`git show <sha>:.quire/config.fnl`). CI pipelines reuse the same
+embedding (see [`CI-FENNEL.md`](CI-FENNEL.md)), but their design is out
+of scope here.
## Components
@@ -22,34 +23,40 @@ machinery later, but its design is out of scope here.
## Decisions
Files evaluate to a single Lua table literal. Pure data, not a
-DSL. PLAN.md sketches `(notifications :to [...] :on [...])` which reads
-as a function call, but a DSL adds parser machinery for no v1 win. Move
-to a DSL when CI lands and there's a real reason.
+DSL. Earlier sketches had forms like `(notifications :to [...] :on [...])`
+which read as function calls, but a DSL adds parser machinery for no
+win — config stays data; only CI pipelines get real code.
-A representative per-repo config (reserved for future use):
+A representative per-repo config:
```fennel
-{:notifications {:to ["alpha@example.com"]
- :on [:ci-failed]}}
+{:mirrors {"https://github.com/user/repo.git" :github-mirror}}
```
-Today each call site (`Quire::global_config`, `Repo::config`)
-constructs a fresh `Fennel`. Cheap enough at current call volume.
-Reusing a single instance across loads is a planned optimization for
-when `quire serve` lands and starts loading per-request.
+Today each call site constructs a fresh `Fennel` — that's what the
+`load_config` / `load_config_str` associated functions do. Cheap enough
+at current call volume; reusing one instance across loads is available
+(construct a `Fennel` and call the methods directly) but not yet needed.
`load_string` is the primitive; `load_file` wraps it. Per-repo config
comes from `git show` stdout, not a path on disk, so the string form is
load-bearing. The `name` argument is for error messages — a filename
or a synthetic label like `HEAD:.quire/config.fnl`.
-Errors flow through miette. Wrap `mlua::Error` with the source name
-and any line/column info Lua surfaces. Hook log lines should point at
-the offending file and line, not just "syntax error."
+Unknown fields don't fail the load. Deserialization runs through
+`serde_ignored`, and every key the target struct doesn't consume is
+reported to an `on_unknown` callback. The `load_config*` wrappers
+collect them into a single `tracing::warn!` so a typo'd config key is
+visible without being fatal.
-Lives in `src/fennel.rs`. Used by `Quire::global_config` and
-`Repo::config` in `src/quire.rs`, which also define the `GlobalConfig`
-and `RepoConfig` schemas.
+Errors are a typed `FennelError` enum (`thiserror`) that also derives
+`miette::Diagnostic`, so CLI callers get source labels and line/column
+info from Lua via `?`. Hook log lines should point at the offending
+file and line, not just "syntax error."
+
+Lives in `quire-core/src/fennel.rs`. Used by `Quire::global_config` and
+`Repo::config` in `quire-server/src/quire/mod.rs`, which also define the
+`GlobalConfig` and `RepoConfig` schemas.
## Contracts
@@ -57,27 +64,49 @@ and `RepoConfig` schemas.
pub struct Fennel { /* private */ }
impl Fennel {
- pub fn new() -> Result<Self>;
- pub fn load_string<T: DeserializeOwned>(&self, source: &str, name: &str) -> Result<T>;
- pub fn load_file<T: DeserializeOwned>(&self, path: &Path) -> Result<T>;
+ pub fn new() -> Result<Self, FennelError>;
+
+ pub fn load_string<T: DeserializeOwned>(
+ &self,
+ source: &str,
+ name: &str,
+ on_unknown: impl FnMut(&serde_ignored::Path<'_>),
+ ) -> Result<T, FennelError>;
+
+ pub fn load_file<T: DeserializeOwned>(
+ &self,
+ path: &Path,
+ on_unknown: impl FnMut(&serde_ignored::Path<'_>),
+ ) -> Result<T, FennelError>;
+
+ // Fresh-VM convenience wrappers; warn on unknown fields.
+ pub fn load_config<T: DeserializeOwned>(path: &Path) -> Result<T, FennelError>;
+ pub fn load_config_str<T: DeserializeOwned>(source: &str, name: &str) -> Result<T, FennelError>;
}
```
Errors: file-not-found, parse error, eval error, type mismatch — all
-`miette::Result` with named source labels where Lua provides them.
+`FennelError` variants carrying named source labels where Lua provides
+them.
## Related modules
-- `src/secret.rs` — `SecretString` wraps Fennel-loaded strings that
- resolve from a file or shell command on access.
-- `src/quire.rs` — `Quire::global_config` reads global config from disk.
-
-## Test plan
-
-- `load_string` round-trip on a representative table → struct.
-- `load_file` reads from disk and behaves the same as `load_string`.
-- File-not-found surfaces as a distinct error.
-- Malformed Fennel → error mentions the source name.
-- Type mismatch (string where number expected) → error mentions the
- field.
-- Empty file → error. An empty config file is almost always a mistake.
+- `quire-core/src/secret.rs` — `SecretString` wraps Fennel-loaded
+ strings that resolve from a file on access.
+- `quire-server/src/quire/mod.rs` — `Quire::global_config` reads global
+ config from disk.
+
+## Covered behavior
+
+The tests in `quire-core/src/fennel.rs` pin: round-tripping flat and
+nested tables into structs, `load_file` matching `load_string`,
+file-not-found as a distinct error, malformed Fennel and type
+mismatches naming their source, the unknown-field callback firing at
+both top level and nested depth, error labels surviving a `:` in the
+source name, line/column extraction from Lua errors, and the stdlib
+and `quire.ci` placeholder modules being preloaded at construction.
+
+Two adjacent cases worth not confusing: an *empty file* is an error
+(`invalid type: nil, expected table` — the chunk returns nil), while an
+*empty table* `{}` deserializes to a struct of defaults and is the
+valid minimal global config.
diff --git a/docs/host/README.md b/docs/host/README.md
index b256381..b79bcc6 100644
--- a/docs/host/README.md
+++ b/docs/host/README.md
@@ -81,12 +81,19 @@ should reach it directly.
## CI: docker-out-of-docker
-The CI runner shells out to docker against the **host** daemon —
-`docker run` to start a per-run container with the pipeline's image,
-`docker exec` for each `(sh ...)` call, `docker stop` at the end.
+> **Not yet in effect.** CI currently runs the pipeline as a plain host
+> subprocess inside the quire container — no docker involved, so the
+> socket mount and `docker` group membership below aren't required to
+> run CI today. They become required when per-run containers land (see
+> [the re-architecture plan](../plans/2026-08-12-ci-rearchitecture.md));
+> the setup is documented here because it's the deployment shape being
+> built toward.
+
+The CI runner will shell out to docker against the **host** daemon,
+starting a per-run container that `quire-ci` itself runs inside.
Architecture and trade-offs in [docs/CI.md](../CI.md).
-For this to work the quire container needs:
+For this the quire container needs:
- The docker CLI (baked into the image; the host daemon does the work).
- The host's `/var/run/docker.sock` bind-mounted in.
diff --git a/docs/superpowers/plans/2026-05-06-ci-sqlite-migration.md b/docs/superpowers/plans/2026-05-06-ci-sqlite-migration.md
deleted file mode 100644
index 3c32c05..0000000
--- a/docs/superpowers/plans/2026-05-06-ci-sqlite-migration.md
+++ /dev/null
@@ -1,913 +0,0 @@
-# CI SQLite Migration Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Replace the filesystem-backed CI run store with a single SQLite database, preserving the existing CI lifecycle (trigger → execute → complete/fail).
-
-**Architecture:** A `src/db.rs` module owns the SQLite connection, migrations, and schema. `Runs` and `Run` structs query and mutate the DB instead of managing directories. State directories (`pending/`, `active/`, `complete/`, `failed/`) and per-run YAML sidecars are removed. Run directories persist only for workspace materialization and log storage.
-
-**Tech Stack:** `rusqlite`, `rusqlite_migration`, `include_str!` for embedded SQL migrations.
-
-**Design doc:** `docs/plans/2026-05-06-ci-sqlite-migration-design.md`
-
----
-
-## File structure
-
-| Action | File | Responsibility |
-|--------|------|---------------|
-| Create | `migrations/0001_initial.sql` | Schema DDL for `runs` and `jobs` tables |
-| Create | `src/db.rs` | Connection management, WAL mode, migration runner |
-| Modify | `Cargo.toml` | Add `rusqlite`, `rusqlite_migration` dependencies |
-| Modify | `src/lib.rs` | Export `db` module |
-| Modify | `src/ci/run.rs` | Rewrite `Runs` and `Run` to use SQLite; remove `write_yaml`/`read_yaml` helpers |
-| Modify | `src/ci/mod.rs` | Update `trigger_ref` to pass DB conn; remove old filesystem paths |
-| Modify | `src/ci/error.rs` | Add `Sql` error variant, remove `Yaml` variant |
-| Modify | `src/ci/runtime.rs` | Update `DockerLifecycle` to use DB for container record writes |
-| Modify | `src/ci/docker.rs` | No changes expected (shell-out layer) |
-| Modify | `src/quire.rs` | Add `db()` method returning a DB handle; remove `Runs` convenience methods that are now DB-scoped |
-| Modify | `src/bin/quire/server.rs` | Open DB on startup; pass to orphan reconciliation |
-| Modify | `src/bin/quire/commands/ci.rs` | Use DB for `ci run` command |
-| Modify | `docs/CI.md` | Update storage section, layout, lifecycle description |
-
----
-
-## Task 1: Add dependencies and create migration file
-
-**Files:**
-- Create: `migrations/0001_initial.sql`
-- Modify: `Cargo.toml`
-
-- [ ] **Step 1: Add rusqlite and rusqlite_migration to Cargo.toml**
-
-Add to `[dependencies]` in `Cargo.toml`:
-
-```toml
-rusqlite = { version = "*", features = ["bundled"] }
-rusqlite_migration = "*"
-```
-
-- [ ] **Step 2: Create the initial migration file**
-
-Create `migrations/0001_initial.sql` with the schema from the design doc:
-
-```sql
-CREATE TABLE runs (
- id TEXT PRIMARY KEY,
- repo TEXT NOT NULL,
- ref_name TEXT NOT NULL,
- sha TEXT NOT NULL,
- pushed_at_ms INTEGER NOT NULL,
- state TEXT NOT NULL,
- failure_kind TEXT,
- queued_at_ms INTEGER NOT NULL,
- started_at_ms INTEGER,
- finished_at_ms INTEGER,
- container_id TEXT,
- workspace_path TEXT NOT NULL,
-
- CHECK (state IN ('pending', 'active', 'complete', 'failed', 'superseded')),
-
- CHECK (started_at_ms IS NULL OR started_at_ms >= queued_at_ms),
- CHECK (finished_at_ms IS NULL OR finished_at_ms >= queued_at_ms),
- CHECK (finished_at_ms IS NULL OR started_at_ms IS NULL
- OR finished_at_ms >= started_at_ms),
-
- CHECK (CASE state
- WHEN 'pending' THEN started_at_ms IS NULL AND finished_at_ms IS NULL AND container_id IS NULL
- WHEN 'active' THEN started_at_ms IS NOT NULL AND finished_at_ms IS NULL
- WHEN 'complete' THEN started_at_ms IS NOT NULL AND finished_at_ms IS NOT NULL AND container_id IS NULL
- WHEN 'failed' THEN finished_at_ms IS NOT NULL AND container_id IS NULL
- WHEN 'superseded' THEN finished_at_ms IS NOT NULL AND container_id IS NULL
- END)
-);
-
-CREATE INDEX runs_repo_pushed_at ON runs(repo, pushed_at_ms DESC);
-CREATE INDEX runs_state ON runs(state);
-
-CREATE TABLE jobs (
- run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
- job_id TEXT NOT NULL,
- state TEXT NOT NULL,
- exit_code INTEGER,
- started_at_ms INTEGER,
- finished_at_ms INTEGER,
-
- CHECK (state IN ('pending', 'active', 'complete', 'failed', 'skipped', 'aborted')),
-
- CHECK (started_at_ms IS NULL OR finished_at_ms IS NULL
- OR finished_at_ms >= started_at_ms),
-
- CHECK (CASE state
- WHEN 'pending' THEN started_at_ms IS NULL AND finished_at_ms IS NULL
- WHEN 'active' THEN started_at_ms IS NOT NULL AND finished_at_ms IS NULL
- WHEN 'complete' THEN started_at_ms IS NOT NULL AND finished_at_ms IS NOT NULL
- WHEN 'failed' THEN started_at_ms IS NOT NULL AND finished_at_ms IS NOT NULL
- WHEN 'skipped' THEN started_at_ms IS NULL AND finished_at_ms IS NOT NULL
- WHEN 'aborted' THEN finished_at_ms IS NOT NULL
- END),
-
- PRIMARY KEY (run_id, job_id)
-);
-```
-
-- [ ] **Step 3: Verify it compiles**
-
-Run: `cargo check --workspace`
-Expected: compiles (dependencies resolve; no code uses them yet)
-
-- [ ] **Step 4: Commit**
-
-```
-Add rusqlite dependencies and initial schema migration
-```
-
----
-
-## Task 2: Create `src/db.rs` — connection management and migration runner
-
-**Files:**
-- Create: `src/db.rs`
-- Modify: `src/lib.rs`
-
-- [ ] **Step 1: Create `src/db.rs`**
-
-```rust
-//! Database connection management and migration runner.
-//!
-//! Owns the SQLite connection, WAL mode pragma, foreign key enforcement,
-//! and the ordered list of migrations. Callers borrow a connection handle
-//! from [`open`] rather than opening their own.
-
-use std::path::Path;
-use std::sync::LazyLock;
-
-use rusqlite::Connection;
-use rusqlite_migration::{Migrations, M};
-
-use crate::error::Error;
-
-/// The ordered set of schema migrations. Append-only — never edit
-/// a migration that has already shipped.
-static MIGRATIONS: LazyLock<Migrations<'static>> = LazyLock::new(|| {
- Migrations::new(vec![
- M::up(include_str!("../migrations/0001_initial.sql")),
- ])
-});
-
-/// Open the database at `path`, enable WAL mode and foreign keys,
-/// and run any pending migrations. Creates the file if it doesn't
-/// exist.
-pub fn open(path: &Path) -> Result<Connection, Error> {
- let mut conn = Connection::open(path)?;
- conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;")?;
- MIGRATIONS.to_latest(&mut conn)?;
- Ok(conn)
-}
-
-/// Open an in-memory database (for tests). Same pragmas and
-/// migrations as the on-disk version.
-#[cfg(test)]
-pub fn open_in_memory() -> Result<Connection, Error> {
- let mut conn = Connection::open_in_memory()?;
- conn.execute_batch("PRAGMA foreign_keys = ON;")?;
- MIGRATIONS.to_latest(&mut conn)?;
- Ok(conn)
-}
-```
-
-- [ ] **Step 2: Export the module from `src/lib.rs`**
-
-Add `pub mod db;` to `src/lib.rs`.
-
-- [ ] **Step 3: Add `Sql` error variant to `src/error.rs`**
-
-Add `rusqlite` error conversion. In `src/error.rs`:
-
-```rust
-#[error(transparent)]
-Sql(#[from] rusqlite::Error),
-```
-
-And in `src/ci/error.rs`:
-
-```rust
-#[error(transparent)]
-Sql(#[from] rusqlite::Error),
-```
-
-Also remove the `Yaml` and `Utf8` variants from `src/ci/error.rs` once nothing uses them (will clean up in Task 3).
-
-- [ ] **Step 4: Verify it compiles**
-
-Run: `cargo check --workspace`
-
-- [ ] **Step 5: Commit**
-
-```
-Add db module with SQLite connection management and migrations
-```
-
----
-
-## Task 3: Rewrite `Runs` and `Run` to use SQLite
-
-This is the core of the migration. The `Runs` struct owns a DB connection (or a path to the DB file) and a base path for run directories (workspace + logs). `Run` owns a connection and a run ID.
-
-**Files:**
-- Modify: `src/ci/run.rs`
-- Modify: `src/ci/error.rs`
-
-### Key changes to `src/ci/run.rs`
-
-**Struct changes:**
-
-- `Runs` now holds: `db: rusqlite::Connection`, `repo: String`, `base_dir: PathBuf` (for run directories)
-- `Run` now holds: `db: rusqlite::Connection`, `id: String`, `repo: String`, `base_dir: PathBuf`
-- `RunState` gains `Superseded` variant
-- Remove `RunMeta`, `RunTimes`, `ContainerRecord` as persistence types — their fields map to columns
-- Keep `RunMeta` as an in-memory input type for `Runs::create` (callers still pass sha/ref/pushed_at)
-- Remove `write_yaml` / `read_yaml` helpers
-
-**`Runs::create` changes:**
-
-```sql
-INSERT INTO runs (id, repo, ref_name, sha, pushed_at_ms, state,
- queued_at_ms, workspace_path)
-VALUES (?, ?, ?, ?, ?, 'pending', ?, ?);
-```
-
-The `workspace_path` is `<base_dir>/<id>/workspace`. The run directory is created at create time.
-
-**`Run::transition` changes:**
-
-```sql
-UPDATE runs SET state = ?, started_at_ms = ?, finished_at_ms = ?, container_id = NULL
-WHERE id = ?;
-```
-
-Single UPDATE in a transaction. No directory renames. Timestamps stamped as in the current code.
-
-**`Run::read_meta` / `read_times` / `write_times` changes:**
-
-Replaced by direct column reads from the `runs` row. Expose accessor methods instead of returning structs:
-
-- `Run::sha()`, `Run::ref_name()`, `Run::pushed_at_ms()` — read from DB
-- `Run::started_at_ms()`, `Run::finished_at_ms()` — read from DB
-- `Run::state()` — cached from last query or read fresh
-
-**`Run::read_container_record` / `write_container_record` changes:**
-
-`container_id` is a column on `runs`. The container timestamps (build_started_at, etc.) are not in the current schema — they can be added to the `runs` table in a follow-up migration. For now, keep writing `container.yml` as a file in the run directory for the container lifecycle timestamps, and only track `container_id` in the DB. This is consistent with the design doc's schema which only has `container_id`.
-
-**`DockerLifecycle` changes:**
-
-`record_path` still points to `<run-dir>/container.yml` for the container timestamps. The `container_id` is also written to the DB when it's set. The `Drop` impl continues to write `container_stopped_at` to the YAML file.
-
-**`Runs::scan_orphans` changes:**
-
-```sql
-SELECT id, state FROM runs WHERE state IN ('pending', 'active') AND repo = ?;
-```
-
-No more directory scanning. Quarantine concept goes away (unreadable runs were a filesystem artifact).
-
-**`Runs::reconcile_orphans` changes:**
-
-```sql
-UPDATE runs SET state = 'failed', finished_at_ms = ?, container_id = NULL, failure_kind = 'orphaned'
-WHERE state = 'active' AND repo = ?;
-```
-
-For pending orphans, the design doc says `umykvluw` lands separately. Current behavior transitions them to `complete`. Keep that behavior for now but use the DB:
-
-```sql
-UPDATE runs SET state = 'complete', finished_at_ms = ?, container_id = NULL
-WHERE state = 'pending' AND repo = ?;
-```
-
-**`Run::path` changes:**
-
-Returns `<base_dir>/<id>/` — the run directory for workspace and logs. No state subdirectory.
-
-**`Run::update_latest` changes:**
-
-Removed entirely. The `latest` symlink was a filesystem workaround; the DB query `SELECT id FROM runs WHERE repo = ? ORDER BY queued_at_ms DESC LIMIT 1` replaces it.
-
-**`Run::write_all_logs` changes:**
-
-Stays the same — writes YAML log files under `<run-dir>/jobs/<job-id>/log.yml`. Logs live on disk per the design doc.
-
-- [ ] **Step 1: Write the new `RunState` with `Superseded` variant and accessor methods**
-
-Update `RunState` to include `Superseded`:
-
-```rust
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum RunState {
- Pending,
- Active,
- Complete,
- Failed,
- Superseded,
-}
-
-impl RunState {
- pub fn as_str(&self) -> &'static str {
- match self {
- RunState::Pending => "pending",
- RunState::Active => "active",
- RunState::Complete => "complete",
- RunState::Failed => "failed",
- RunState::Superseded => "superseded",
- }
- }
-
- pub fn from_str(s: &str) -> Option<Self> {
- match s {
- "pending" => Some(RunState::Pending),
- "active" => Some(RunState::Active),
- "complete" => Some(RunState::Complete),
- "failed" => Some(RunState::Failed),
- "superseded" => Some(RunState::Superseded),
- _ => None,
- }
- }
-}
-```
-
-Remove `dir_name()`.
-
-- [ ] **Step 2: Rewrite `Runs` struct**
-
-```rust
-pub struct Runs {
- db: rusqlite::Connection,
- repo: String,
- base_dir: PathBuf,
-}
-```
-
-`base_dir` is `<quire-root>/runs/<repo>/`. Run directories live at `<base_dir>/<id>/`.
-
-Update constructor:
-
-```rust
-impl Runs {
- pub fn new(db: rusqlite::Connection, repo: String, base_dir: PathBuf) -> Self {
- Self { db, repo, base_dir }
- }
-}
-```
-
-- [ ] **Step 3: Rewrite `Runs::create`**
-
-```rust
-pub fn create(&self, meta: &RunMeta) -> Result<Run> {
- let id = uuid::Uuid::now_v7().to_string();
- let workspace_path = self.base_dir.join(&id).join("workspace");
-
- self.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)",
- rusqlite::params![
- &id,
- &self.repo,
- &meta.r#ref,
- &meta.sha,
- meta.pushed_at.as_millisecond(),
- jiff::Timestamp::now().as_millisecond(),
- workspace_path.to_str().ok_or_else(|| std::io::Error::new(
- std::io::ErrorKind::InvalidData,
- "workspace path is not valid UTF-8",
- ))?,
- ],
- )?;
-
- // Create run directory for workspace and logs.
- fs_err::create_dir_all(&workspace_path)?;
-
- Ok(Run {
- id,
- repo: self.repo.clone(),
- base_dir: self.base_dir.clone(),
- state: RunState::Pending,
- })
-}
-```
-
-Note: `Run` caches `state` in memory to avoid a round-trip after creation. It also needs a way to execute DB statements. Two options:
-- (A) `Run` clones the connection or holds a reference
-- (B) `Run` takes `&Connection` on each method call
-
-Option (B) is cleaner for borrowing but makes `Run::execute` harder since it consumes `self`. Go with option (A): `Run` holds its own `rusqlite::Connection`. SQLite allows multiple connections to the same file in WAL mode. Alternatively, since `rusqlite::Connection` is not `Clone`, `Run` can take ownership of the connection from `Runs`.
-
-Actually, the simplest approach: `Runs` holds the DB path (not a connection), and each method opens a short-lived connection. Or better: pass `&Connection` to each method. Since `Run::execute` needs to do many operations, it should hold its own connection.
-
-Let me reconsider: `Runs` creates `Run` objects. The caller (trigger_ref, server startup) has a connection. Let's make `Runs` hold a `&Connection` lifetime... but that gets messy with ownership.
-
-Simplest correct approach: `Runs` owns a `Connection`. `Run` borrows `&Connection`. But `Run::execute` consumes `self` and the pipeline, and the runtime needs its own state...
-
-Final decision: `Run` holds its own `rusqlite::Connection`. It opens a new connection to the same DB file. This is standard SQLite practice — multiple connections in WAL mode are fine. `Runs` holds the DB path and opens connections as needed.
-
-Update:
-
-```rust
-pub struct Runs {
- db_path: PathBuf,
- repo: String,
- base_dir: PathBuf,
-}
-
-impl Runs {
- pub fn new(db_path: PathBuf, repo: String, base_dir: PathBuf) -> Self {
- Self { db_path, repo, base_dir }
- }
-
- fn conn(&self) -> Result<rusqlite::Connection> {
- let conn = rusqlite::Connection::open(&self.db_path)?;
- conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;")?;
- Ok(conn)
- }
-
- pub fn create(&self, meta: &RunMeta) -> Result<Run> {
- let conn = self.conn()?;
- // ... insert ...
- Run::open(conn, self.repo.clone(), self.base_dir.clone(), &id)
- }
-}
-```
-
-Hmm, but opening a new connection per operation is wasteful. Let me think about this differently.
-
-Better approach: `Runs` holds a `rusqlite::Connection`. `Run::execute` is where the long-lived operation happens. Before `execute`, `Run` can get its own connection (or we pass one in). For the simpler methods (transition, read_meta, etc.), `Run` can borrow from... somewhere.
-
-Actually, the cleanest solution: make `Run` hold a `rusqlite::Connection` that it receives at construction. `Runs` opens a connection in `create` and transfers it to the new `Run`. For methods that don't consume `self` (like `read_meta`, `transition`), `Run` uses its owned connection. For `execute`, it already owns one.
-
-But then `Runs` needs a new connection for each `create` call. Unless `Runs` doesn't hold a connection at all — it holds the db path.
-
-Let me look at how `Runs` is used:
-
-1. `Runs::new(base)` — constructed with a path
-2. `runs.create(&meta)` — returns a `Run`
-3. `runs.scan_orphans()` — returns `Vec<Run>`
-4. `runs.reconcile_orphans()` — internally iterates
-
-And `Run` is used:
-1. `run.id()`, `run.state()`, `run.path()`
-2. `run.transition(RunState::Active)`
-3. `run.read_meta()`, `run.read_times()`, `run.write_times()`
-4. `run.read_container_record()`, `run.write_container_record()`
-5. `run.execute(pipeline, secrets, git_dir, workspace, executor)` — consumes self
-
-The pattern is: `Runs` creates `Run` objects, and callers work with `Run` objects. Both need DB access.
-
-Simplest clean approach: both `Runs` and `Run` hold `rusqlite::Connection`. Since SQLite supports multiple connections in WAL mode, `Runs::create` opens a new connection for the new `Run`. For `Runs` methods like `scan_orphans` and `reconcile_orphans`, it uses its own connection.
-
-- [ ] **Step 4: Rewrite `Run` struct**
-
-```rust
-pub struct Run {
- db: rusqlite::Connection,
- id: String,
- state: RunState,
- base_dir: PathBuf,
-}
-```
-
-Methods use `self.db` for all reads/writes. No more filesystem state management.
-
-- [ ] **Step 5: Rewrite `Run::transition`**
-
-```rust
-pub fn transition(&mut self, to: RunState) -> Result<()> {
- let allowed = matches!(
- (self.state, to),
- (RunState::Pending, RunState::Active)
- | (RunState::Pending, RunState::Complete)
- | (RunState::Active, RunState::Complete)
- | (RunState::Active, RunState::Failed)
- );
- if !allowed {
- return Err(Error::InvalidTransition { from: self.state, to });
- }
-
- let now = jiff::Timestamp::now().as_millisecond();
-
- self.db.execute(
- "UPDATE runs SET state = ?1,
- started_at_ms = CASE WHEN ?2 = 'active' AND started_at_ms IS NULL THEN ?3 ELSE started_at_ms END,
- finished_at_ms = CASE WHEN ?2 IN ('complete', 'failed') AND finished_at_ms IS NULL THEN ?3 ELSE finished_at_ms END,
- container_id = CASE WHEN ?2 IN ('complete', 'failed') THEN NULL ELSE container_id END
- WHERE id = ?4",
- rusqlite::params![to.as_str(), to.as_str(), now, &self.id],
- )?;
-
- self.state = to;
- Ok(())
-}
-```
-
-- [ ] **Step 6: Rewrite `Run::read_meta` as column accessors**
-
-```rust
-pub fn read_meta(&self) -> Result<RunMeta> {
- let (sha, ref_name, pushed_at_ms) = self.db.query_row(
- "SELECT sha, ref_name, pushed_at_ms FROM runs WHERE id = ?1",
- rusqlite::params![&self.id],
- |row| Ok((row.get(0)?, row.get(1)?, row.get::<_, i64>(2)?)),
- )?;
- Ok(RunMeta {
- sha,
- r#ref: ref_name,
- pushed_at: jiff::Timestamp::from_millisecond(pushed_at_ms).expect("valid timestamp"),
- })
-}
-```
-
-- [ ] **Step 7: Remove `write_yaml` / `read_yaml`, `RunTimes`, `ContainerRecord` persistence**
-
-`RunTimes` and `ContainerRecord` as separate YAML-backed types go away. Timestamps are columns on `runs`. `container_id` is a column. Container lifecycle timestamps (build_started_at etc.) can stay as a `container.yml` file in the run dir for now, since the schema only tracks `container_id`.
-
-Keep `RunMeta` as an in-memory struct passed to `Runs::create`. Remove `RunTimes` as a public type — callers use `run.started_at()` etc.
-
-Actually, keep `ContainerRecord` around for the file-based container timestamps, since the DB schema only has `container_id`. The `DockerLifecycle` still writes `container.yml`.
-
-- [ ] **Step 8: Rewrite `Run::path`**
-
-```rust
-pub fn path(&self) -> PathBuf {
- self.base_dir.join(&self.id)
-}
-```
-
-No state subdirectory. The run dir is always `<base_dir>/<id>/`.
-
-- [ ] **Step 9: Remove `update_latest`**
-
-No more symlink. Remove the method entirely.
-
-- [ ] **Step 10: Rewrite `Runs::scan_orphans` and `reconcile_orphans`**
-
-```rust
-pub fn scan_orphans(&self) -> Result<Vec<Run>> {
- let mut stmt = self.db.prepare(
- "SELECT id, state FROM runs WHERE state IN ('pending', 'active') AND repo = ?1"
- )?;
- let rows = stmt.query_map(rusqlite::params![&self.repo], |row| {
- let id: String = row.get(0)?;
- let state_str: String = row.get(1)?;
- let state = RunState::from_str(&state_str).expect("DB enforces valid states");
- Ok((id, state))
- })?;
-
- let mut orphans = Vec::new();
- for row in rows {
- let (id, state) = row?;
- let db = self.conn()?; // each Run gets its own connection
- orphans.push(Run { db, id, state, base_dir: self.base_dir.clone() });
- }
- Ok(orphans)
-}
-```
-
-```rust
-pub fn reconcile_orphans(&self) -> Result<()> {
- let now = jiff::Timestamp::now().as_millisecond();
-
- // Active orphans → failed
- self.db.execute(
- "UPDATE runs SET state = 'failed', finished_at_ms = ?1, container_id = NULL, failure_kind = 'orphaned'
- WHERE state = 'active' AND repo = ?2",
- rusqlite::params![now, &self.repo],
- )?;
-
- // Pending orphans → complete (matching current behavior; umykvluw changes this to failed)
- self.db.execute(
- "UPDATE runs SET state = 'complete', finished_at_ms = ?1, container_id = NULL
- WHERE state = 'pending' AND repo = ?2",
- rusqlite::params![now, &self.repo],
- )?;
-
- Ok(())
-}
-```
-
-- [ ] **Step 11: Rewrite `Run::execute` to use DB**
-
-The execute method is the most complex. Key changes:
-- `self.transition(RunState::Active)` works as before (now DB-backed)
-- `build_executor_runtime` writes `container_id` to the DB instead of (or in addition to) `container.yml`
-- `write_all_logs` stays file-based (logs on disk per design doc)
-- The `DockerLifecycle.record_path` stays for container timestamps, but `container_id` is tracked in the DB
-
-- [ ] **Step 12: Update `Run::build_executor_runtime`**
-
-After building the container and getting the session, write `container_id` to the DB:
-
-```rust
-self.db.execute(
- "UPDATE runs SET container_id = ?1 WHERE id = ?2",
- rusqlite::params![&session.container_id, &self.id],
-)?;
-```
-
-Keep writing `container.yml` for the build/container timestamps.
-
-- [ ] **Step 13: Remove unused error variants from `src/ci/error.rs`**
-
-Remove `Yaml` and `Utf8` variants if nothing uses them. Add `Sql` variant.
-
-- [ ] **Step 14: Run tests and fix compilation errors**
-
-Run: `cargo check --workspace`
-Then: `cargo test --workspace -q`
-
-Fix any compilation errors. The tests in `run.rs` will need updating since they construct `Runs` with the old API.
-
-- [ ] **Step 15: Update tests in `src/ci/run.rs`**
-
-Key test changes:
-- `tmp_quire()` helpers create an in-memory DB via `db::open_in_memory()` or open a temp file DB
-- `test_runs()` creates `Runs::new(db_path, "test.git".to_string(), base_dir)`
-- Tests no longer check for state directories (`pending/`, `active/`, etc.)
-- Tests check run directories at `<base_dir>/<id>/`
-- `scan_orphans` tests verify DB queries instead of directory scans
-- Remove `create_symlinks_latest` test (no more symlink)
-- Remove `scan_orphans_quarantines_unreadable_runs` test (no more quarantine — that was a filesystem artifact)
-- Update `transition_errors_on_missing_source` — no more missing directory, but could test with a run ID that doesn't exist in the DB
-
-- [ ] **Step 16: Run full test suite**
-
-Run: `cargo test --workspace -q`
-
-- [ ] **Step 17: Commit**
-
-```
-Rewrite Runs and Run to use SQLite for state storage
-```
-
----
-
-## Task 4: Update `src/ci/mod.rs` — trigger path
-
-**Files:**
-- Modify: `src/ci/mod.rs`
-
-The `trigger_ref` function currently calls `ci.runs(repo.runs_base()).create(&meta)`. After the migration, it needs to pass the DB connection/path.
-
-- [ ] **Step 1: Update `Ci::runs` signature**
-
-Change from:
-
-```rust
-pub fn runs(&self, runs_base: PathBuf) -> Runs
-```
-
-To:
-
-```rust
-pub fn runs(&self, db_path: &Path, repo: &str, runs_base: PathBuf) -> Runs
-```
-
-Or, better: pass through the Quire-level DB path. The `trigger` function has access to `quire`, so it can pass `quire.db_path()`.
-
-- [ ] **Step 2: Update `trigger` and `trigger_ref` functions**
-
-```rust
-pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
- // ... existing repo resolution ...
- let db_path = quire.db_path();
- for push_ref in event.updated_refs() {
- if let Err(e) = trigger_ref(&repo, &db_path, event.pushed_at, push_ref, &secrets) {
- // ... error handling ...
- }
- }
-}
-
-fn trigger_ref(
- repo: &Repo,
- db_path: &Path,
- pushed_at: jiff::Timestamp,
- push_ref: &PushRef,
- secrets: &HashMap<String, crate::secret::SecretString>,
-) -> error::Result<()> {
- // ... existing code ...
- let mut run = ci.runs(db_path, repo.name(), repo.runs_base()).create(&meta)?;
- // ... rest stays largely the same ...
-}
-```
-
-- [ ] **Step 3: Update tests in `src/ci/mod.rs`**
-
-Tests that create `Runs` need the new signature. Use `db::open_in_memory()` or a temp file for the DB.
-
-- [ ] **Step 4: Run tests**
-
-Run: `cargo test --workspace -q`
-
-- [ ] **Step 5: Commit**
-
-```
-Update CI trigger path to use SQLite
-```
-
----
-
-## Task 5: Update `src/quire.rs` — DB path accessor
-
-**Files:**
-- Modify: `src/quire.rs`
-
-- [ ] **Step 1: Add `db_path` method to `Quire`**
-
-```rust
-pub fn db_path(&self) -> PathBuf {
- self.base_dir.join("quire.db")
-}
-```
-
-- [ ] **Step 2: Update `Repo::runs` and `Repo::runs_base`**
-
-`Repo::runs` currently returns `Runs::new(self.runs_base())`. Update to pass the DB path:
-
-```rust
-pub fn runs(&self, db_path: &Path) -> Runs {
- Runs::new(
- db_path.to_path_buf(),
- self.name().to_string(),
- self.runs_base(),
- )
-}
-```
-
-Or remove the convenience method and let callers construct `Runs` directly with the right params.
-
-- [ ] **Step 3: Run tests**
-
-Run: `cargo test --workspace -q`
-
-- [ ] **Step 4: Commit**
-
-```
-Add DB path accessor to Quire and update Repo::runs
-```
-
----
-
-## Task 6: Update server startup — open DB and reconcile orphans
-
-**Files:**
-- Modify: `src/bin/quire/server.rs`
-
-- [ ] **Step 1: Open the database on startup**
-
-Add after the socket setup, before orphan reconciliation:
-
-```rust
-let db_path = quire.db_path();
-tracing::info!(path = %db_path.display(), "opening database");
-let db = crate::db::open(&db_path)?;
-```
-
-- [ ] **Step 2: Update orphan reconciliation to use DB**
-
-```rust
-for repo in quire.repos().context("failed to list repos")? {
- let runs = repo.runs(&db_path);
- runs.reconcile_orphans()?;
-}
-```
-
-Note: if `Runs` holds a connection (not a path), the server would pass a reference or the path. If `Runs` takes a path and opens its own connection, this is straightforward.
-
-- [ ] **Step 3: Run tests**
-
-Run: `cargo test --workspace -q`
-
-- [ ] **Step 4: Commit**
-
-```
-Open SQLite database on server startup for orphan reconciliation
-```
-
----
-
-## Task 7: Update `ci run` CLI command
-
-**Files:**
-- Modify: `src/bin/quire/commands/ci.rs`
-
-- [ ] **Step 1: Update `ci::run` to use DB**
-
-The `run` function creates a `Runs` with a tempdir. Now it needs a DB path. Use a temp file for the DB:
-
-```rust
-let db_path = tmp.path().join("quire.db");
-let db = quire::db::open(&db_path)?;
-let runs = Runs::new(db_path, "local".to_string(), tmp.path().to_path_buf());
-```
-
-- [ ] **Step 2: Run tests**
-
-Run: `cargo test --workspace -q`
-
-- [ ] **Step 3: Commit**
-
-```
-Update ci run command to use SQLite
-```
-
----
-
-## Task 8: Update `src/ci/runtime.rs` — DockerLifecycle DB writes
-
-**Files:**
-- Modify: `src/ci/runtime.rs`
-
-The `DockerLifecycle` currently writes `container_stopped_at` to a YAML file. After the migration:
-- `container_id` is tracked in the DB
-- Container lifecycle timestamps can stay in `container.yml` for now (the DB schema only has `container_id`)
-- The `Drop` impl for `DockerLifecycle` needs a DB connection to clear `container_id`
-
-The challenge: `DockerLifecycle` needs DB access in its `Drop` impl. Options:
-- (A) Give `DockerLifecycle` the DB path so it can open a connection in `Drop`
-- (B) Keep writing container timestamps to `container.yml` only; the DB `container_id` is managed by `Run::build_executor_runtime` and `Run::transition` (which already clears it)
-
-Go with (B): `Run::transition` already sets `container_id = NULL` when transitioning to Complete/Failed. The `DockerLifecycle` only needs to write `container_stopped_at` to the YAML file. No changes to `DockerLifecycle` needed beyond what's already handled by the transition logic.
-
-- [ ] **Step 1: Verify `DockerLifecycle` Drop still works**
-
-The `Drop` impl writes to `container.yml` at `self.record_path`. This path is still valid since run directories still exist at `<base_dir>/<id>/`. No changes needed.
-
-- [ ] **Step 2: Verify container_id is cleared on state transition**
-
-The `Run::transition` SQL already sets `container_id = NULL` for complete/failed states. Confirm this is working.
-
-- [ ] **Step 3: Commit (if changes needed, otherwise skip)**
-
----
-
-## Task 9: Update `docs/CI.md`
-
-**Files:**
-- Modify: `docs/CI.md`
-
-- [ ] **Step 1: Update the "Storage" section**
-
-Remove "No SQLite in v1" and the secondary-index-only commitment. Replace with the SQLite-as-primary-store description from the design doc.
-
-- [ ] **Step 2: Update the volume layout**
-
-Replace the directory-based run layout with:
-
-```
-/var/quire/
- quire.db # SQLite database
- repos/<name>.git/ # bare repos, unchanged
- runs/<repo>/<run-id>/ # per-run workspace
- workspace/ # materialized checkout
- jobs/<job-id>/
- log.yml # per-job sh output logs
-```
-
-- [ ] **Step 3: Update the lifecycle description**
-
-Replace the directory-rename lifecycle with SQL state transitions.
-
-- [ ] **Step 4: Remove the in-memory queue / mpsc references**
-
-The design doc says SQLite is the queue. The `mpsc` references in CI.md should be updated (or noted as "replaced by DB queries" — the actual queue replacement is a follow-up since the runner isn't built yet).
-
-- [ ] **Step 5: Commit**
-
-```
-Update CI docs for SQLite migration
-```
-
----
-
-## Task 10: Clean up and final verification
-
-- [ ] **Step 1: Remove dead code**
-
-- Remove `write_yaml` and `read_yaml` helpers from `run.rs`
-- Remove `Yaml` error variant from `ci/error.rs` if unused
-- Remove `repo_segment` function if unused (was for Docker image tags from path — check if still needed)
-- Remove any `serde_yaml_ng` usage in `run.rs` that's no longer needed
-
-- [ ] **Step 2: Run `just all`**
-
-Run: `just all`
-Expected: all checks pass (fmt, clippy, test)
-
-- [ ] **Step 3: Run coverage**
-
-Run: `just coverage`
-Expected: 100% coverage maintained
-
-- [ ] **Step 4: Commit**
-
-```
-Clean up dead code from filesystem run store
-```
diff --git a/quire-server/src/quire/web/auth.rs b/quire-server/src/quire/web/auth.rs
index e9c68f3..9e80e81 100644
--- a/quire-server/src/quire/web/auth.rs
+++ b/quire-server/src/quire/web/auth.rs
@@ -41,7 +41,7 @@ pub async fn inject_dev_user(mut request: axum::extract::Request, next: Next) ->
/// Middleware that rejects unauthenticated requests.
///
-/// CI routes require auth per the access matrix in PLAN.md.
+/// CI routes require auth per the access matrix in docs/ARCHITECTURE.md.
/// Returns 401 so the client knows auth is required.
pub async fn require_auth(request: axum::extract::Request, next: Next) -> Response {
let user = request