Evaluate ci.fnl and validate the job graph
Adds eval_ci which loads ci.fnl in a fresh Lua VM with a `job` global
that accumulates into a registration table, and validate which checks
four structural rules: acyclic, non-empty inputs, reachability from
a source ref, and no `/` in user job ids. dispatch_push now evals
and validates ci.fnl on push, transitioning the run to complete on
success or failed on eval/validation error.

Assisted-by: GLM-5.1 via pi
change lvlyknkmkxykyovvoutluukxsqzwmqzz
commit 139baa7abd6218687ba4ab5204139cf637d8d42b
author Alpha Chen <alpha@kejadlen.dev>
date
parent ptwsplwu
diff --git a/docs/notes/2026-04-28-ci-registration.md b/docs/notes/2026-04-28-ci-registration.md
new file mode 100644
index 0000000..270a6cc
--- /dev/null
+++ b/docs/notes/2026-04-28-ci-registration.md
@@ -0,0 +1,62 @@
+# Fennel registration + structural validation
+
+## Scope
+
+Evaluate `.quire/ci.fnl` via the existing Fennel integration and register jobs. Validate the resulting job graph. No execution — that's a future task.
+
+## Capabilities
+
+1. **Evaluate `ci.fnl`** in a fresh Lua VM with `job` in scope.
+2. **`job` registers into a table** — `(job :test [:quire/push] (fn [_] nil))` records `{id: "test", inputs: ["quire/push"], run: <function>}`.
+4. **Four structural validations** run after eval:
+   - Acyclic (Kahn's algorithm)
+   - Non-empty inputs
+   - Reachability from a source ref
+   - No `/` in user job ids
+5. **Errors produce failed runs**
+
+## Components
+
+**`ci::EvalResult`** — what comes back from evaluating `ci.fnl`:
+```
+jobs: Vec<JobDef>   // id, inputs, run_fn (kept as mlua::Function for later)
+```
+
+**`ci::eval_ci`** — takes a `Fennel` instance and a source string, returns `EvalResult`. Creates a fresh VM, injects `job` global, evals the source, extracts the registration table.
+
+**`ci::validate`** — takes `&[JobDef]`, returns `Result<(), Vec<ValidationError>>`. Runs the four rules. Pure function, no I/O.
+
+**Integration point** — `dispatch_push` in `event.rs` currently does `runs.create(&meta)` then immediately completes the run. After this change: create the run, transition to `Active`, eval `ci.fnl`, validate, then either complete (success) or fail with the validation error.
+
+## Contracts
+
+```rust
+struct JobDef {
+    id: String,
+    inputs: Vec<String>,
+    run_fn: mlua::Function,  // kept for future execution, not called here
+}
+
+struct ValidationError {
+    message: String,
+}
+
+fn eval_ci(fennel: &Fennel, source: &str, name: &str) -> Result<EvalResult>;
+fn validate(jobs: &[JobDef]) -> Result<(), Vec<ValidationError>>;
+```
+
+## Key decisions
+
+- **One eval context** — registration and "run start" collapse into a single eval per run, per the v0 note in CI-FENNEL.md.
+- **`job` accumulates into a registration table** — a Lua table in the VM that `job` pushes into. After eval, we extract it from the globals.
+- **`mlua::Function` stored but not called** — the `run_fn` field preserves the function for the future execution task. We don't call it here.
+- **Validation errors are batched** — collect all violations, return them together. The run's state file records all of them, not just the first.
+- **Fennel eval errors → failed run** — same path as validation failures. The caller (dispatch_push) catches either and transitions accordingly.
+- **`container` deferred** — not even a marker for now. A `ci.fnl` that references `container` will get a Fennel "unknown global" error. That's acceptable until the execution task adds it.
+
+## Out of scope
+
+- Container execution (separate task)
+- Per-job eval / run-fn invocation
+- Multiple source types (just `:quire/push` for now)
+- Job outputs, artifacts, caching
diff --git a/src/ci.rs b/src/ci.rs
index ac7f032..9074b9c 100644
--- a/src/ci.rs
+++ b/src/ci.rs
@@ -1,5 +1,7 @@
 use std::path::{Path, PathBuf};
 
+use mlua::Lua;
+
 use crate::Result;
 
 /// The state of a CI run.
@@ -298,7 +300,219 @@ pub struct OrphanedRun {
     pub state: RunStateFile,
 }
 
-/// Write a value to a YAML file atomically.
+/// A registered job definition extracted from ci.fnl.
+pub struct JobDef {
+    pub id: String,
+    pub inputs: Vec<String>,
+}
+
+/// The result of evaluating a ci.fnl file.
+pub struct EvalResult {
+    pub jobs: Vec<JobDef>,
+}
+
+/// Evaluate a ci.fnl source string, registering jobs via the `job` macro.
+///
+/// Creates a fresh Lua VM with Fennel loaded, injects a `job` global
+/// that accumulates into a registration table, evaluates the source,
+/// and extracts the registered jobs.
+pub fn eval_ci(
+    _fennel: &crate::fennel::Fennel,
+    source: &str,
+    name: &str,
+) -> crate::Result<EvalResult> {
+    fn lua_err(e: mlua::Error) -> crate::Error {
+        crate::Error::Lua(e.to_string())
+    }
+
+    // Create a fresh VM with Fennel loaded.
+    let lua = unsafe { Lua::unsafe_new() };
+    let fennel_lua: &str = include_str!("../vendor/fennel.lua");
+    let fennel_module: mlua::Table = lua
+        .load(fennel_lua)
+        .set_name("fennel.lua")
+        .eval()
+        .map_err(lua_err)?;
+    lua.globals()
+        .set("fennel", fennel_module)
+        .map_err(lua_err)?;
+
+    // Create a registration table. `job` will push into this.
+    let registry: mlua::Table = lua.create_table().map_err(lua_err)?;
+    lua.globals()
+        .set("_quire_jobs", registry)
+        .map_err(lua_err)?;
+
+    // Define the `job` global: (job id inputs run-fn)
+    let job_fn = lua
+        .create_function(
+            |lua, (id, inputs, run_fn): (mlua::String, mlua::Table, mlua::Function)| {
+                let registry: mlua::Table = lua.globals().get("_quire_jobs")?;
+                let entry = lua.create_table()?;
+                entry.set("id", id)?;
+                entry.set("inputs", inputs)?;
+                entry.set("run", run_fn)?;
+                registry.push(entry)?;
+                Ok(())
+            },
+        )
+        .map_err(lua_err)?;
+    lua.globals().set("job", job_fn).map_err(lua_err)?;
+
+    // Eval the ci.fnl source via Fennel.
+    let fennel: mlua::Table = lua.globals().get("fennel").map_err(lua_err)?;
+    let eval: mlua::Function = fennel.get("eval").map_err(lua_err)?;
+    let opts = lua.create_table().map_err(lua_err)?;
+    opts.set("filename", name).map_err(lua_err)?;
+    eval.call::<mlua::MultiValue>((source, opts))
+        .map_err(lua_err)?;
+
+    // Extract the registration table.
+    let registry: mlua::Table = lua.globals().get("_quire_jobs").map_err(lua_err)?;
+    let mut jobs = Vec::new();
+    for entry in registry.sequence_values::<mlua::Table>() {
+        let entry = entry.map_err(lua_err)?;
+        let id: String = entry.get("id").map_err(lua_err)?;
+        let inputs_table: mlua::Table = entry.get("inputs").map_err(lua_err)?;
+        let mut inputs = Vec::new();
+        for input in inputs_table.sequence_values::<String>() {
+            inputs.push(input.map_err(lua_err)?);
+        }
+        jobs.push(JobDef { id, inputs });
+    }
+
+    Ok(EvalResult { jobs })
+}
+
+/// A validation error found in the job graph.
+#[derive(Debug)]
+pub struct ValidationError {
+    pub message: String,
+}
+
+/// Validate the structural rules of a job graph.
+///
+/// Returns `Ok(())` if all four rules pass, or `Err` with all violations found.
+pub fn validate(jobs: &[JobDef]) -> std::result::Result<(), Vec<ValidationError>> {
+    let mut errors = Vec::new();
+
+    // Build a set of known job ids.
+    let job_ids: std::collections::HashSet<&str> = jobs.iter().map(|j| j.id.as_str()).collect();
+
+    // Rule 4: no '/' in user job ids.
+    for job in jobs {
+        if job.id.contains('/') {
+            errors.push(ValidationError {
+                message: format!(
+                    "Job id '{}' contains '/', which is reserved for the 'quire/' source namespace.",
+                    job.id
+                ),
+            });
+        }
+    }
+
+    // Rule 2: non-empty inputs.
+    for job in jobs {
+        if job.inputs.is_empty() {
+            errors.push(ValidationError {
+                message: format!(
+                    "Job '{}' has empty inputs. Pass [:quire/push] (or another input) so it has something to fire it.",
+                    job.id
+                ),
+            });
+        }
+    }
+
+    // Rule 1: acyclic (Kahn's algorithm).
+    let mut in_degree: std::collections::HashMap<&str, usize> =
+        jobs.iter().map(|j| (j.id.as_str(), 0)).collect();
+    let mut adjacency: std::collections::HashMap<&str, Vec<&str>> =
+        jobs.iter().map(|j| (j.id.as_str(), Vec::new())).collect();
+
+    for job in jobs {
+        for input in &job.inputs {
+            if job_ids.contains(input.as_str()) {
+                *in_degree.entry(job.id.as_str()).or_insert(0) += 1;
+                adjacency
+                    .entry(input.as_str())
+                    .or_default()
+                    .push(job.id.as_str());
+            }
+        }
+    }
+
+    let mut queue: Vec<&str> = in_degree
+        .iter()
+        .filter(|&(_, &deg)| deg == 0)
+        .map(|(&id, _)| id)
+        .collect();
+    let mut sorted = Vec::new();
+
+    while let Some(id) = queue.pop() {
+        sorted.push(id);
+        if let Some(dependents) = adjacency.get(id) {
+            for &dep in dependents {
+                if let Some(deg) = in_degree.get_mut(dep) {
+                    *deg -= 1;
+                    if *deg == 0 {
+                        queue.push(dep);
+                    }
+                }
+            }
+        }
+    }
+
+    if sorted.len() != jobs.len() {
+        let cycle_jobs: Vec<&str> = jobs
+            .iter()
+            .map(|j| j.id.as_str())
+            .filter(|id| !sorted.contains(id))
+            .collect();
+        errors.push(ValidationError {
+            message: format!("Cycle detected among jobs: {}", cycle_jobs.join(", ")),
+        });
+    }
+
+    // Rule 3: reachability — every job's transitive inputs must include a source ref.
+    let is_source = |name: &str| name.starts_with("quire/");
+
+    for job in jobs {
+        let mut visited = std::collections::HashSet::new();
+        let mut stack: Vec<&str> = job.inputs.iter().map(|s| s.as_str()).collect();
+        let mut found_source = false;
+
+        while let Some(name) = stack.pop() {
+            if !visited.insert(name) {
+                continue;
+            }
+            if is_source(name) {
+                found_source = true;
+                break;
+            }
+            if let Some(upstream) = jobs.iter().find(|j| j.id == name) {
+                for input in &upstream.inputs {
+                    stack.push(input.as_str());
+                }
+            }
+        }
+
+        if !found_source {
+            errors.push(ValidationError {
+                message: format!(
+                    "Job '{}' is not reachable from any source ref (e.g. :quire/push).",
+                    job.id
+                ),
+            });
+        }
+    }
+
+    if errors.is_empty() {
+        Ok(())
+    } else {
+        Err(errors)
+    }
+}
+
 fn write_yaml<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
     let tmp_path = path.with_extension("yml.tmp");
     let f = fs_err::File::create(&tmp_path)?;
@@ -481,4 +695,121 @@ mod tests {
         let loaded_meta = run.read_meta().expect("read meta");
         assert_eq!(loaded_meta, test_meta());
     }
+
+    // --- eval_ci tests ---
+
+    fn fennel() -> crate::fennel::Fennel {
+        crate::fennel::Fennel::new().expect("Fennel::new() should succeed")
+    }
+
+    #[test]
+    fn eval_ci_registers_a_job() {
+        let f = fennel();
+        let source = r#"(job :test [:quire/push] (fn [_] nil))"#;
+        let result = eval_ci(&f, source, "ci.fnl").expect("eval should succeed");
+        assert_eq!(result.jobs.len(), 1);
+        assert_eq!(result.jobs[0].id, "test");
+        assert_eq!(result.jobs[0].inputs, vec!["quire/push"]);
+    }
+
+    #[test]
+    fn eval_ci_registers_multiple_jobs() {
+        let f = fennel();
+        let source = r#"
+(job :build [:quire/push] (fn [_] nil))
+(job :test [:build] (fn [_] nil))
+"#;
+        let result = eval_ci(&f, source, "ci.fnl").expect("eval should succeed");
+        assert_eq!(result.jobs.len(), 2);
+        assert_eq!(result.jobs[0].id, "build");
+        assert_eq!(result.jobs[0].inputs, vec!["quire/push"]);
+        assert_eq!(result.jobs[1].id, "test");
+        assert_eq!(result.jobs[1].inputs, vec!["build"]);
+    }
+
+    #[test]
+    fn eval_ci_errors_on_bad_fennel() {
+        let f = fennel();
+        let result = eval_ci(&f, "{:bad {:}", "ci.fnl");
+        assert!(result.is_err(), "malformed Fennel should fail");
+    }
+
+    // --- validate tests ---
+
+    #[test]
+    fn validate_accepts_valid_config() {
+        let jobs = vec![
+            JobDef {
+                id: "build".into(),
+                inputs: vec!["quire/push".into()],
+            },
+            JobDef {
+                id: "test".into(),
+                inputs: vec!["build".into(), "quire/push".into()],
+            },
+        ];
+        assert!(validate(&jobs).is_ok());
+    }
+
+    #[test]
+    fn validate_rejects_cycle() {
+        let jobs = vec![
+            JobDef {
+                id: "a".into(),
+                inputs: vec!["b".into()],
+            },
+            JobDef {
+                id: "b".into(),
+                inputs: vec!["a".into()],
+            },
+        ];
+        let errs = validate(&jobs).unwrap_err();
+        assert!(
+            errs.iter()
+                .any(|e| e.message.to_lowercase().contains("cycle")),
+            "should report a cycle: {errs:?}"
+        );
+    }
+
+    #[test]
+    fn validate_rejects_empty_inputs() {
+        let jobs = vec![JobDef {
+            id: "setup".into(),
+            inputs: vec![],
+        }];
+        let errs = validate(&jobs).unwrap_err();
+        assert!(
+            errs.iter()
+                .any(|e| e.message.contains("setup") && e.message.contains("empty inputs")),
+            "should report empty inputs for 'setup': {errs:?}"
+        );
+    }
+
+    #[test]
+    fn validate_rejects_unreachable_jobs() {
+        let jobs = vec![JobDef {
+            id: "orphan".into(),
+            inputs: vec!["orphan".into()],
+        }];
+        let errs = validate(&jobs).unwrap_err();
+        assert!(
+            errs.iter()
+                .any(|e| e.message.contains("orphan") && e.message.contains("source")),
+            "should report unreachable job 'orphan': {errs:?}"
+        );
+    }
+
+    #[test]
+    fn validate_rejects_slash_in_job_id() {
+        let jobs = vec![JobDef {
+            id: "foo/bar".into(),
+            inputs: vec!["quire/push".into()],
+        }];
+        let errs = validate(&jobs).unwrap_err();
+        assert!(
+            errs.iter()
+                .any(|e| e.message.contains("foo/bar") && e.message.contains("'/'")),
+            "should report slash in job id: {errs:?}"
+        );
+    }
 }
diff --git a/src/error.rs b/src/error.rs
index 1d739ed..773410d 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -17,6 +17,12 @@ pub enum Error {
     #[error(transparent)]
     Fennel(#[from] crate::fennel::FennelError),
 
+    #[error("CI validation failed: {}", .0.iter().map(|e| e.message.clone()).collect::<Vec<_>>().join("; "))]
+    Validation(Vec<crate::ci::ValidationError>),
+
+    #[error("lua error: {0}")]
+    Lua(String),
+
     #[error("git error: {0}")]
     Git(String),
 
@@ -26,3 +32,9 @@ pub enum Error {
 }
 
 pub type Result<T> = std::result::Result<T, Error>;
+
+impl From<Vec<crate::ci::ValidationError>> for Error {
+    fn from(errs: Vec<crate::ci::ValidationError>) -> Self {
+        Error::Validation(errs)
+    }
+}
diff --git a/src/event.rs b/src/event.rs
index fb1d32b..ad2226b 100644
--- a/src/event.rs
+++ b/src/event.rs
@@ -53,13 +53,50 @@ pub async fn dispatch_push(quire: &crate::Quire, event: &PushEvent) {
                         "created CI run"
                     );
 
-                    // No eval yet — immediately complete.
-                    if let Err(e) = run.transition(crate::ci::RunState::Complete) {
-                        tracing::error!(
-                            run_id = %run.id(),
-                            %e,
-                            "failed to transition run to complete"
-                        );
+                    // Transition to active, eval ci.fnl, validate.
+                    run.transition(crate::ci::RunState::Active)
+                        .unwrap_or_else(|e| {
+                            tracing::error!(run_id = %run.id(), %e, "failed to transition run to active");
+                        });
+
+                    let result = (|| -> crate::Result<()> {
+                        let source = repo.ci_fnl_source(&push_ref.new_sha)?;
+                        let fennel = crate::fennel::Fennel::new()?;
+                        let eval_result = crate::ci::eval_ci(
+                            &fennel,
+                            &source,
+                            &format!("{}:.quire/ci.fnl", &push_ref.new_sha),
+                        )?;
+                        crate::ci::validate(&eval_result.jobs)?;
+                        Ok(())
+                    })();
+
+                    match result {
+                        Ok(()) => {
+                            if let Err(e) = run.transition(crate::ci::RunState::Complete) {
+                                tracing::error!(
+                                    run_id = %run.id(),
+                                    %e,
+                                    "failed to transition run to complete"
+                                );
+                            }
+                        }
+                        Err(e) => {
+                            tracing::error!(
+                                run_id = %run.id(),
+                                %e,
+                                "CI evaluation failed"
+                            );
+                            if let Err(te) = run.transition(crate::ci::RunState::Failed) {
+                                tracing::error!(run_id = %run.id(), %te, "failed to transition run to failed");
+                            } else if let Err(we) = run.write_state(&crate::ci::RunStateFile {
+                                status: crate::ci::RunState::Failed,
+                                started_at: None,
+                                finished_at: Some(jiff::Zoned::now().to_string()),
+                            }) {
+                                tracing::error!(run_id = %run.id(), %we, "failed to write state for failed run");
+                            }
+                        }
                     }
                 }
                 Err(e) => {
diff --git a/src/quire.rs b/src/quire.rs
index 1d8c6d7..d98b454 100644
--- a/src/quire.rs
+++ b/src/quire.rs
@@ -164,6 +164,26 @@ impl Repo {
             .unwrap_or(false)
     }
 
+    /// Read the contents of `.quire/ci.fnl` at a given commit SHA.
+    pub fn ci_fnl_source(&self, sha: &str) -> crate::Result<String> {
+        let output = self
+            .git(&["show", &format!("{sha}:.quire/ci.fnl")])
+            .stdout(std::process::Stdio::piped())
+            .stderr(std::process::Stdio::piped())
+            .output()
+            .map_err(|e| crate::Error::Git(format!("failed to read ci.fnl: {e}")))?;
+
+        if !output.status.success() {
+            let stderr = String::from_utf8_lossy(&output.stderr);
+            return Err(crate::Error::Git(format!(
+                "failed to read ci.fnl at {sha}: {stderr}"
+            )));
+        }
+
+        String::from_utf8(output.stdout)
+            .map_err(|e| crate::Error::Git(format!("ci.fnl is not valid UTF-8: {e}")))
+    }
+
     /// Push `main` to the configured mirror, injecting the GitHub token via
     /// `http.extraHeader` so it never appears in the URL or git's error output.
     ///