Rename and split CI loading types to match actual code structure
Split the monolithic ValidationError into DefinitionError
(registration-time: ReservedSlash, EmptyInputs, DuplicateImage)
and StructureError (post-graph: Cycle, Unreachable). Wrap both
in a Diagnostic enum for miette rendering. Rename LoadError to
PipelineError, Pipeline::load to compile, Ci::load to pipeline,
lua::parse to register, and ParseOutput to Registrations.

The verb chain now reads Ci::pipeline → pipeline::compile →
lua::register, where each verb describes its own layer. No
behavior changes.

Assisted-by: GLM-5.1 via pi
change xsuqylkouzssptsuwkzuzxnxmtunrkot
commit 372b84aac76b4bf48b19fed61b58b9e04695c19c
author Alpha Chen <alpha@kejadlen.dev>
date
parent oqotxqlx
diff --git a/docs/plans/2026-05-02-ci-loading-rename-design.md b/docs/plans/2026-05-02-ci-loading-rename-design.md
new file mode 100644
index 0000000..f16a014
--- /dev/null
+++ b/docs/plans/2026-05-02-ci-loading-rename-design.md
@@ -0,0 +1,94 @@
+# CI loading: rename and re-shape errors
+
+**Goal:** Rationalize the names and error categories around CI loading so the structure matches how the code actually thinks. No behavior changes.
+
+## What's wrong now
+
+Reading `src/ci/{mod,pipeline,lua}.rs` and `src/error.rs` back, three things feel arbitrary:
+
+1. **"load" is overloaded.** `Ci::load`, `Pipeline::load`, and `LoadError` all use the word but mean different things. The overloading is triggered by the error name — once that's gone, the methods read fine.
+2. **`ValidationError` is a grab bag.** Variants come from two distinct stages — registration-time rules (`ReservedSlash`, `EmptyInputs`, `DuplicateImage`) and post-graph rules (`Cycle`, `Unreachable`) — but share one enum, one suffix, and no structure to signal the difference.
+3. **`Error::Validation` names a stage, not a domain.** Compare with the sibling `Error::Fennel`, which names its source. "Validation failed" reads weirdly next to "fennel error."
+
+## New shape
+
+### Two error categories
+
+The split that already exists in the code (per the comment in `validate_post_graph`) is "caught at registration time" vs. "caught after the graph is built." Make that explicit:
+
+```rust
+pub enum DefinitionError {
+    ReservedSlash { job_id: String, span: SourceSpan },
+    EmptyInputs   { job_id: String, span: SourceSpan },
+    DuplicateImage { span: SourceSpan },
+}
+
+pub enum StructureError {
+    Cycle       { cycle_jobs: Vec<String>, spans: Vec<SourceSpan> },
+    Unreachable { job_id: String, span: SourceSpan },
+}
+
+pub enum Diagnostic {
+    Definition(DefinitionError),
+    Structure(StructureError),
+}
+```
+
+The per-job vs. pipeline-singleton distinction (DuplicateImage is the lone singleton today) is an implementation detail of detection, not a user-visible category, so two buckets is the right count.
+
+### Error bag and top-level variant
+
+```rust
+pub struct PipelineError {
+    pub src: NamedSource<String>,
+    pub diagnostics: Vec<Diagnostic>,
+}
+```
+
+At the top level: `Error::Pipeline(Box<PipelineError>)`. Replaces `Error::Validation(Box<LoadError>)`.
+
+### Method and module renames
+
+| Old | New | Reason |
+|---|---|---|
+| `Ci::load(commit)` | `Ci::pipeline(commit)` | Returns the pipeline at a commit; noun-style accessor reads more naturally than "load". |
+| `Pipeline::load(src, name)` (method) | `pipeline::compile(src, name)` (free fn) | Source → runnable artifact is compilation. Free fn avoids "the type compiles itself." |
+| `lua::parse(...)` | `lua::register(...)` | Phase naming. Fennel's actual parser runs inside `eval_raw`; this function performs the registration phase. |
+| `ParseOutput` | `Registrations` | Names what came out, not what step ran. |
+| `ValidationError` | split into `DefinitionError` + `StructureError` (see above) | Stage-aligned. |
+| `LoadError` | `PipelineError` | Domain-named bag. |
+| `Error::Validation` | `Error::Pipeline` | Domain-named, matches the bag. |
+
+The verb chain reads top-down:
+
+```
+Ci::pipeline → pipeline::compile → lua::register
+```
+
+Each verb describes its own layer. "Register" is imperfect (the Fennel script does the registering; the Rust function provides the sinks and harvests), but it names the phase clearly and avoids "evaluate" — which would collide with Lua's eval terminology.
+
+## Scope
+
+In-scope:
+
+- Renames listed above
+- Splitting the validation enum into two
+- Adding the `Diagnostic` wrapper for miette's `#[related]` iteration
+- Updating tests in `src/ci/pipeline.rs`, `src/ci/lua.rs`, `src/ci/mod.rs`, and `src/error.rs`
+- Updating call sites — at minimum `src/bin/quire/commands/ci.rs`
+
+Out of scope:
+
+- Behavioral changes to validation rules
+- Merging `Error::Fennel` and `Error::Pipeline` (the user-facing "your ci.fnl is bad" framing wasn't a concern in this pass)
+- Restructuring `lua.rs` runtime types (`Runtime`, `RuntimeHandle`, `ShOutput`)
+
+## Verification
+
+- `cargo test` passes with no behavior changes
+- Error rendering for a multi-error ci.fnl still produces the same miette output (modulo the type names in `Debug`)
+- No public API used outside `src/ci/` and `src/bin/` should change names
+
+## Open questions
+
+- "Register" is acceptable but not loved. If a better verb surfaces during implementation, revisit before committing.
diff --git a/src/bin/quire/commands/ci.rs b/src/bin/quire/commands/ci.rs
index 172b3cc..8f7ee97 100644
--- a/src/bin/quire/commands/ci.rs
+++ b/src/bin/quire/commands/ci.rs
@@ -6,7 +6,7 @@ use quire::ci::{Ci, CommitRef, RunMeta, Runs};
 
 /// Validate a repo's ci.fnl without executing any jobs.
 ///
-/// `Ci::load` parses the Fennel source and validates the resulting
+/// `Ci::pipeline` parses the Fennel source and validates the resulting
 /// job graph; this command surfaces the registered jobs and any
 /// validation errors via the standard miette diagnostic path.
 pub async fn validate(maybe_sha: Option<&str>) -> Result<()> {
@@ -14,7 +14,7 @@ pub async fn validate(maybe_sha: Option<&str>) -> Result<()> {
     let commit = resolve_commit(maybe_sha)?;
     let ci = Ci::new(repo_path);
 
-    let Some(pipeline) = ci.load(&commit)? else {
+    let Some(pipeline) = ci.pipeline(&commit)? else {
         println!("No ci.fnl found at {}.", commit.display);
         return Ok(());
     };
@@ -48,7 +48,7 @@ pub async fn run(quire: &Quire, maybe_sha: Option<&str>) -> Result<()> {
 
     // Pull secrets from the global config; absence is fine for local
     // testing. A broken-but-present config is a real error. Secrets
-    // are passed to `Run::execute` rather than `Ci::load` since they
+    // are passed to `Run::execute` rather than `Ci::pipeline` since they
     // only matter when the run-fns actually fire.
     let secrets = match quire.global_config() {
         Ok(c) => c.secrets,
@@ -56,7 +56,7 @@ pub async fn run(quire: &Quire, maybe_sha: Option<&str>) -> Result<()> {
         Err(e) => return Err(e).into_diagnostic(),
     };
 
-    let Some(pipeline) = ci.load(&commit)? else {
+    let Some(pipeline) = ci.pipeline(&commit)? else {
         println!("No ci.fnl found at {}.", commit.display);
         return Ok(());
     };
diff --git a/src/ci/lua.rs b/src/ci/lua.rs
index c1ea6f9..19686eb 100644
--- a/src/ci/lua.rs
+++ b/src/ci/lua.rs
@@ -3,7 +3,7 @@
 //! into each job's `run-fn`.
 //!
 //! All mlua/Fennel interaction lives here. The pipeline module calls
-//! [`parse`] to evaluate a script and collect the registered jobs;
+//! [`register`] to evaluate a script and collect the registered jobs;
 //! the run module installs a [`Runtime`] and threads its handle into
 //! each `run-fn` at execute time.
 
@@ -13,24 +13,24 @@ use std::rc::Rc;
 
 use mlua::{IntoLua, Lua, LuaSerdeExt};
 
-use super::pipeline::{Job, ValidationError};
+use super::pipeline::{DefinitionError, Job};
 use crate::Result;
 use crate::fennel::Fennel;
 use crate::secret::SecretString;
 
-/// Output of [`parse`]: registered jobs and the pipeline image (if
-/// declared).
-pub(super) struct ParseOutput {
+/// Output of [`register`]: registered jobs, definition-time errors,
+/// and the pipeline image (if declared).
+pub(super) struct Registrations {
     pub(super) jobs: Vec<Job>,
-    pub(super) errors: Vec<ValidationError>,
+    pub(super) errors: Vec<DefinitionError>,
     pub(super) image: Option<String>,
 }
 
 /// Evaluate `source` with the registration module bound and collect
-/// the registration results — one `Result` per `(ci.job …)` call.
+/// the registered jobs, image, and any definition-time errors.
 /// Pre-graph rules run inside the callback, so a single bad job does
 /// not abort the rest of the script.
-pub(super) fn parse(fennel: &Fennel, source: &str, name: &str) -> Result<ParseOutput> {
+pub(super) fn register(fennel: &Fennel, source: &str, name: &str) -> Result<Registrations> {
     let jobs: Rc<RefCell<Vec<Job>>> = Rc::new(RefCell::new(Vec::new()));
     let image = Rc::new(RefCell::new(None));
     let src = Rc::new(source.to_string());
@@ -55,7 +55,7 @@ pub(super) fn parse(fennel: &Fennel, source: &str, name: &str) -> Result<ParseOu
     fennel.lua().remove_app_data::<Registration>();
 
     let image_name = image.borrow().as_ref().map(|i| i.name.clone());
-    Ok(ParseOutput {
+    Ok(Registrations {
         jobs: jobs.take(),
         errors: errors.take(),
         image: image_name,
@@ -79,7 +79,7 @@ pub(super) fn parse(fennel: &Fennel, source: &str, name: &str) -> Result<ParseOu
 /// ```
 struct Registration {
     jobs: Rc<RefCell<Vec<Job>>>,
-    errors: Rc<RefCell<Vec<ValidationError>>>,
+    errors: Rc<RefCell<Vec<DefinitionError>>>,
     image: Rc<RefCell<Option<ImageRegistration>>>,
     source: Rc<String>,
 }
@@ -117,7 +117,7 @@ fn register_image(lua: &Lua, (name,): (String,)) -> mlua::Result<()> {
             let span = super::pipeline::span_for_line(&r.source, line);
             r.errors
                 .borrow_mut()
-                .push(ValidationError::DuplicateImage { span });
+                .push(DefinitionError::DuplicateImage { span });
         }
         None => {
             *img = Some(ImageRegistration { name, _line: line });
@@ -539,13 +539,13 @@ fn run_sh(lua: &Lua, (cmd, opts): (Cmd, Option<ShOpts>)) -> mlua::Result<mlua::V
 
 #[cfg(test)]
 mod tests {
-    use super::super::pipeline::Pipeline;
     use super::*;
 
     /// Consume the pipeline for its VM, build a minimal runtime,
     /// and return the runtime and first job's run_fn.
     fn rt(source: &str, secrets: HashMap<String, SecretString>) -> (Rc<Runtime>, mlua::Function) {
-        let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
+        let pipeline =
+            super::super::pipeline::compile(source, "ci.fnl").expect("compile should succeed");
         let run_fn = pipeline.jobs()[0].run_fn.clone();
         let runtime = Rc::new(Runtime::for_test(pipeline, secrets));
         let _ = RuntimeHandle(runtime.clone())
diff --git a/src/ci/mod.rs b/src/ci/mod.rs
index 6faa8bc..59216e2 100644
--- a/src/ci/mod.rs
+++ b/src/ci/mod.rs
@@ -6,7 +6,7 @@ mod lua;
 mod pipeline;
 mod run;
 
-pub use pipeline::{Job, LoadError, Pipeline, ValidationError};
+pub use pipeline::{DefinitionError, Diagnostic, Job, Pipeline, PipelineError, StructureError};
 pub use run::{Run, RunMeta, RunState, RunTimes, Runs};
 
 /// A resolved commit reference.
@@ -31,9 +31,9 @@ pub const CI_FNL: &str = ".quire/ci.fnl";
 
 /// Access to CI operations for a single repo.
 ///
-/// Provides load and validation methods scoped to a bare repo.
-/// Obtain one via `Repo::ci()`. Run lifecycle is on `Runs`, obtainable
-/// via `Repo::runs()`.
+/// Provides pipeline compilation and validation scoped to a bare
+/// repo. Obtain one via `Repo::ci()`. Run lifecycle is on `Runs`,
+/// obtainable via `Repo::runs()`.
 pub struct Ci {
     repo_path: PathBuf,
 }
@@ -48,21 +48,22 @@ impl Ci {
         Runs::new(runs_base)
     }
 
-    /// Load ci.fnl at a given SHA and return the validated pipeline.
+    /// Read and compile ci.fnl at a given SHA, returning the validated
+    /// pipeline.
     ///
-    /// Pure parse and structural validation. Secrets are not needed
+    /// Pure compilation and structural validation. Secrets are not needed
     /// here — they are passed to `Run::execute` since they only matter
     /// when the run-fns actually fire.
     ///
     /// Returns `Ok(None)` if the repo has no ci.fnl at that commit.
     /// Errors if the Fennel source fails to parse/evaluate or if the
     /// resulting job graph violates any structural rule.
-    pub fn load(&self, commit: &CommitRef) -> Result<Option<Pipeline>> {
+    pub fn pipeline(&self, commit: &CommitRef) -> Result<Option<Pipeline>> {
         let Some(source) = self.source(&commit.sha)? else {
             return Ok(None);
         };
         let name = CI_FNL.to_string();
-        let pipeline = Pipeline::load(&source, &name)?;
+        let pipeline = pipeline::compile(&source, &name)?;
         Ok(Some(pipeline))
     }
 
@@ -161,7 +162,7 @@ fn trigger_ref(
         "created CI run"
     );
 
-    let pipeline = match Pipeline::load(&source, CI_FNL) {
+    let pipeline = match pipeline::compile(&source, CI_FNL) {
         Ok(p) => p,
         Err(e) => {
             run.transition(RunState::Active)?;
@@ -265,7 +266,7 @@ mod tests {
     }
 
     #[test]
-    fn ci_load_returns_none_when_no_ci_fnl() {
+    fn ci_pipeline_returns_none_when_no_ci_fnl() {
         let (_dir, quire, name) = bare_repo_without_ci();
         let repo = quire.repo(&name).expect("repo");
         let ci = repo.ci();
@@ -274,12 +275,12 @@ mod tests {
             sha: sha.clone(),
             display: sha,
         };
-        let result = ci.load(&commit).expect("load should not error");
+        let result = ci.pipeline(&commit).expect("pipeline should not error");
         assert!(result.is_none(), "no ci.fnl should return None");
     }
 
     #[test]
-    fn ci_load_returns_pipeline_when_ci_fnl_present() {
+    fn ci_pipeline_returns_pipeline_when_ci_fnl_present() {
         let source = r#"(local ci (require :quire.ci))
 (ci.job :build [:quire/push] (fn [_] nil))"#;
         let (_dir, quire, name) = bare_repo_with_ci(source);
@@ -291,15 +292,15 @@ mod tests {
             display: sha,
         };
         let pipeline = ci
-            .load(&commit)
-            .expect("load should succeed")
+            .pipeline(&commit)
+            .expect("pipeline should succeed")
             .expect("should have pipeline");
         assert_eq!(pipeline.jobs().len(), 1);
         assert_eq!(pipeline.jobs()[0].id, "build");
     }
 
     #[test]
-    fn ci_load_errors_on_invalid_fennel() {
+    fn ci_pipeline_errors_on_invalid_fennel() {
         let source = "{:bad {:}";
         let (_dir, quire, name) = bare_repo_with_ci(source);
         let repo = quire.repo(&name).expect("repo");
@@ -309,7 +310,7 @@ mod tests {
             sha: sha.clone(),
             display: sha,
         };
-        let result = ci.load(&commit);
+        let result = ci.pipeline(&commit);
         assert!(result.is_err(), "bad Fennel should fail");
     }
 
diff --git a/src/ci/pipeline.rs b/src/ci/pipeline.rs
index 4580316..bda9f67 100644
--- a/src/ci/pipeline.rs
+++ b/src/ci/pipeline.rs
@@ -1,5 +1,5 @@
-//! CI job graph: validation rules and the `load` entry point that
-//! parses a `ci.fnl` source string into a `Pipeline`.
+//! CI job graph: validation rules and the [`compile`] entry point
+//! that turns a `ci.fnl` source string into a [`Pipeline`].
 //!
 //! Lua/Fennel evaluation lives in the sibling [`super::lua`] module;
 //! this module owns the domain types and the structural rules.
@@ -15,6 +15,66 @@ use super::lua;
 use crate::Result;
 use crate::fennel::Fennel;
 
+/// A registration-time error caught while individual `(ci.job …)` and
+/// `(ci.image …)` calls are being processed.
+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
+pub enum DefinitionError {
+    #[error(
+        "Job '{job_id}' has empty inputs. Pass [:quire/push] (or another input) so it has something to fire it."
+    )]
+    EmptyInputs {
+        job_id: String,
+        #[label("declared here")]
+        span: SourceSpan,
+    },
+
+    #[error("Job id '{job_id}' contains '/', which is reserved for the 'quire/' source namespace.")]
+    ReservedSlash {
+        job_id: String,
+        #[label("declared here")]
+        span: SourceSpan,
+    },
+
+    #[error("Pipeline image declared more than once.")]
+    DuplicateImage {
+        #[label("duplicate image declaration")]
+        span: SourceSpan,
+    },
+}
+
+/// A post-graph structural error found after all jobs have been
+/// registered and the dependency graph is built.
+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
+pub enum StructureError {
+    #[error("Cycle detected among jobs: {}", cycle_jobs.join(", "))]
+    Cycle {
+        cycle_jobs: Vec<String>,
+        #[label(collection, "in cycle")]
+        spans: Vec<SourceSpan>,
+    },
+
+    #[error("Job '{job_id}' is not reachable from any source ref (e.g. :quire/push).")]
+    Unreachable {
+        job_id: String,
+        #[label("declared here")]
+        span: SourceSpan,
+    },
+}
+
+/// A single diagnostic from pipeline compilation. Wraps the two
+/// error categories — definition-time and structure-time — so miette
+/// can iterate them via `#[related]`.
+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
+pub enum Diagnostic {
+    #[error(transparent)]
+    #[diagnostic(transparent)]
+    Definition(#[from] DefinitionError),
+
+    #[error(transparent)]
+    #[diagnostic(transparent)]
+    Structure(#[from] StructureError),
+}
+
 /// Edges point from dependency to dependent. Node weights are indices
 /// into `Pipeline::jobs`; source refs (e.g. `quire/push`) are not nodes.
 type JobGraph = Graph<usize, ()>;
@@ -52,15 +112,15 @@ impl Job {
         run_fn: mlua::Function,
         line: u32,
         source: &str,
-    ) -> std::result::Result<Self, ValidationError> {
+    ) -> std::result::Result<Self, DefinitionError> {
         let span = span_for_line(source, line);
 
         if id.contains('/') {
-            return Err(ValidationError::ReservedSlash { job_id: id, span });
+            return Err(DefinitionError::ReservedSlash { job_id: id, span });
         }
 
         if inputs.is_empty() {
-            return Err(ValidationError::EmptyInputs { job_id: id, span });
+            return Err(DefinitionError::EmptyInputs { job_id: id, span });
         }
 
         Ok(Self {
@@ -75,12 +135,12 @@ impl Job {
 /// A validated CI pipeline — a job graph that has passed all
 /// structural rules.
 ///
-/// Obtain via [`Pipeline::load`], which parses the Fennel source and
+/// Obtain via [`compile`], which evaluates the Fennel source and
 /// validates the result. Holding a `Pipeline` is proof that the graph
 /// is sound.
 ///
 /// Owns the Fennel/Lua VM so the registered `run_fn`s remain callable
-/// after `load` returns.
+/// after `compile` returns.
 pub struct Pipeline {
     jobs: Vec<Job>,
     graph: JobGraph,
@@ -159,43 +219,6 @@ impl Pipeline {
         }
         result
     }
-
-    /// Parse and validate a ci.fnl source string into a `Pipeline`.
-    ///
-    /// Delegates evaluation to [`lua::parse`] for the Fennel-side work,
-    /// then runs the post-graph rules over the surviving jobs. Any errors
-    /// found are gathered into a single `LoadError` carrying the source
-    /// for miette to render with inline labels.
-    pub(crate) fn load(source: &str, name: &str) -> Result<Pipeline> {
-        let fennel = Fennel::new()?;
-        let parsed = lua::parse(&fennel, source, name)?;
-
-        let mut errors = parsed.errors;
-        let jobs = parsed.jobs;
-        let image = parsed.image;
-
-        let (graph, node_index) = build_graph(&jobs);
-
-        if let Err(post) = validate_post_graph(&jobs, &graph) {
-            errors.extend(post);
-        }
-
-        if errors.is_empty() {
-            Ok(Self {
-                jobs,
-                graph,
-                node_index,
-                fennel,
-                image,
-            })
-        } else {
-            Err(LoadError {
-                src: NamedSource::new(name, source.to_string()),
-                errors,
-            }
-            .into())
-        }
-    }
 }
 
 /// Build the dependency graph for `jobs`. Inputs that don't match a
@@ -242,71 +265,71 @@ pub(super) fn span_for_line(source: &str, line: u32) -> SourceSpan {
     SourceSpan::from((source.len(), 0)) // cov-excl-line
 }
 
-/// A validation error found in the job graph.
-#[derive(Debug, thiserror::Error, miette::Diagnostic)]
-pub enum ValidationError {
-    #[error("Cycle detected among jobs: {}", cycle_jobs.join(", "))]
-    Cycle {
-        cycle_jobs: Vec<String>,
-        #[label(collection, "in cycle")]
-        spans: Vec<SourceSpan>,
-    },
-
-    #[error(
-        "Job '{job_id}' has empty inputs. Pass [:quire/push] (or another input) so it has something to fire it."
-    )]
-    EmptyInputs {
-        job_id: String,
-        #[label("declared here")]
-        span: SourceSpan,
-    },
-
-    #[error("Job '{job_id}' is not reachable from any source ref (e.g. :quire/push).")]
-    Unreachable {
-        job_id: String,
-        #[label("declared here")]
-        span: SourceSpan,
-    },
-
-    #[error("Job id '{job_id}' contains '/', which is reserved for the 'quire/' source namespace.")]
-    ReservedSlash {
-        job_id: String,
-        #[label("declared here")]
-        span: SourceSpan,
-    },
-
-    #[error("Pipeline image declared more than once.")]
-    DuplicateImage {
-        #[label("duplicate image declaration")]
-        span: SourceSpan,
-    },
-}
-
-/// All validation errors produced while loading a ci.fnl, paired with
-/// the source so miette can render inline labels for each per-job
-/// error.
+/// All diagnostics produced while compiling a ci.fnl, paired with
+/// the source so miette can render inline labels for each diagnostic.
 #[derive(Debug, thiserror::Error, miette::Diagnostic)]
-#[error("CI validation failed")]
-pub struct LoadError {
+#[error("ci.fnl has errors")]
+pub struct PipelineError {
     // Named `src` rather than `source` so thiserror doesn't auto-treat
     // it as the error chain.
     #[source_code]
     pub src: NamedSource<String>,
 
     #[related]
-    pub errors: Vec<ValidationError>,
+    pub diagnostics: Vec<Diagnostic>,
+}
+
+/// Parse and validate a ci.fnl source string into a [`Pipeline`].
+///
+/// Delegates evaluation to [`lua::register`] for the Fennel-side work,
+/// then runs the post-graph rules over the surviving jobs. Any errors
+/// found are gathered into a single [`PipelineError`] carrying the
+/// source for miette to render with inline labels.
+pub(crate) fn compile(source: &str, name: &str) -> Result<Pipeline> {
+    let fennel = Fennel::new()?;
+    let registrations = lua::register(&fennel, source, name)?;
+
+    let mut diagnostics: Vec<Diagnostic> = registrations
+        .errors
+        .into_iter()
+        .map(Diagnostic::Definition)
+        .collect();
+    let jobs = registrations.jobs;
+    let image = registrations.image;
+
+    let (graph, node_index) = build_graph(&jobs);
+
+    if let Err(post) = validate_post_graph(&jobs, &graph) {
+        diagnostics.extend(post.into_iter().map(Diagnostic::Structure));
+    }
+
+    if diagnostics.is_empty() {
+        Ok(Pipeline {
+            jobs,
+            graph,
+            node_index,
+            fennel,
+            image,
+        })
+    } else {
+        Err(PipelineError {
+            src: NamedSource::new(name, source.to_string()),
+            diagnostics,
+        }
+        .into())
+    }
 }
 
 /// Run the post-graph validation rules — cycle detection and source
-/// reachability — over the surviving jobs from parsing.
+/// reachability — over the surviving jobs from registration.
 ///
 /// Per-job pre-graph rules (slash-in-id, empty inputs) run inside the
-/// `(ci.job …)` callback during `lua::parse`, so they are not re-checked
-/// here.
+/// `(ci.job …)` callback during `lua::register`, so they are not
+/// re-checked here.
 fn validate_post_graph(
     jobs: &[Job],
     graph: &JobGraph,
-) -> std::result::Result<(), Vec<ValidationError>> {
+) -> std::result::Result<(), Vec<StructureError>> {
     let mut errors = Vec::new();
     let mut cycle_members: std::collections::HashSet<&str> = std::collections::HashSet::new();
 
@@ -325,7 +348,7 @@ fn validate_post_graph(
         }
         let cycle_jobs = members.iter().map(|j| j.id.clone()).collect();
         let spans = members.iter().map(|j| j.span).collect();
-        errors.push(ValidationError::Cycle { cycle_jobs, spans });
+        errors.push(StructureError::Cycle { cycle_jobs, spans });
     }
 
     // Rule 3: reachability — every job's transitive inputs must include a source ref.
@@ -361,7 +384,7 @@ fn validate_post_graph(
         }
 
         if !found_source {
-            errors.push(ValidationError::Unreachable {
+            errors.push(StructureError::Unreachable {
                 job_id: job.id.clone(),
                 span: job.span,
             });
@@ -380,10 +403,10 @@ mod tests {
     use super::*;
 
     #[test]
-    fn load_registers_a_job() {
+    fn compile_registers_a_job() {
         let source = r#"(local ci (require :quire.ci))
 (ci.job :test [:quire/push] (fn [_] nil))"#;
-        let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
+        let pipeline = compile(source, "ci.fnl").expect("compile should succeed");
         let jobs = pipeline.jobs();
         assert_eq!(jobs.len(), 1);
         assert_eq!(jobs[0].id, "test");
@@ -391,13 +414,13 @@ mod tests {
     }
 
     #[test]
-    fn load_registers_multiple_jobs() {
+    fn compile_registers_multiple_jobs() {
         let source = r#"
 (local ci (require :quire.ci))
 (ci.job :build [:quire/push] (fn [_] nil))
 (ci.job :test [:build] (fn [_] nil))
 "#;
-        let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
+        let pipeline = compile(source, "ci.fnl").expect("compile should succeed");
         let jobs = pipeline.jobs();
         assert_eq!(jobs.len(), 2);
         assert_eq!(jobs[0].id, "build");
@@ -407,14 +430,14 @@ mod tests {
     }
 
     #[test]
-    fn load_captures_source_line() {
+    fn compile_captures_source_line() {
         let source = "(local ci (require :quire.ci))
 (ci.job :first [:quire/push] (fn [_] nil))
 (ci.job :second [:quire/push] (fn [_] nil))
 
 
 (ci.job :sixth [:quire/push] (fn [_] nil))";
-        let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
+        let pipeline = compile(source, "ci.fnl").expect("compile should succeed");
         let lines: Vec<usize> = pipeline
             .jobs()
             .iter()
@@ -424,36 +447,36 @@ mod tests {
     }
 
     #[test]
-    fn load_errors_on_bad_fennel() {
-        let result = Pipeline::load("{:bad {:}", "ci.fnl");
+    fn compile_errors_on_bad_fennel() {
+        let result = compile("{:bad {:}", "ci.fnl");
         assert!(result.is_err(), "malformed Fennel should fail");
     }
 
-    /// Parse a Fennel source into jobs and errors. The local Fennel is
-    /// dropped on return, but the returned `Job`s only need their
-    /// non-VM fields here.
-    fn parse_jobs(source: &str) -> (Vec<Job>, Vec<ValidationError>) {
+    /// Register a Fennel source, returning the jobs and any
+    /// definition-time errors. The local Fennel is dropped on return,
+    /// but the returned `Job`s only need their non-VM fields here.
+    fn register_jobs(source: &str) -> (Vec<Job>, Vec<DefinitionError>) {
         let f = Fennel::new().expect("Fennel::new() should succeed");
-        let output = lua::parse(&f, source, "ci.fnl").expect("parse should succeed");
+        let output = lua::register(&f, source, "ci.fnl").expect("register should succeed");
         (output.jobs, output.errors)
     }
 
-    /// Discard parse errors and return only the successfully registered
-    /// jobs — for tests that exercise post-graph rules.
-    fn parsed_jobs(source: &str) -> Vec<Job> {
-        parse_jobs(source).0
+    /// Discard registration errors and return only the successfully
+    /// registered jobs — for tests that exercise post-graph rules.
+    fn registered_jobs(source: &str) -> Vec<Job> {
+        register_jobs(source).0
     }
 
     /// Run post-graph validation against `jobs`, building the dependency
-    /// graph the same way `load` does.
-    fn validate(jobs: &[Job]) -> std::result::Result<(), Vec<ValidationError>> {
+    /// graph the same way `compile` does.
+    fn validate(jobs: &[Job]) -> std::result::Result<(), Vec<StructureError>> {
         let (graph, _) = build_graph(jobs);
         validate_post_graph(jobs, &graph)
     }
 
     #[test]
     fn validate_accepts_valid_config() {
-        let jobs = parsed_jobs(
+        let jobs = registered_jobs(
             r#"
 (local ci (require :quire.ci))
 (ci.job :build [:quire/push] (fn [_] nil))
@@ -465,7 +488,7 @@ mod tests {
 
     #[test]
     fn validate_rejects_cycle() {
-        let jobs = parsed_jobs(
+        let jobs = registered_jobs(
             r#"
 (local ci (require :quire.ci))
 (ci.job :a [:b] (fn [_] nil))
@@ -474,14 +497,14 @@ mod tests {
         );
         let errs = validate(&jobs).unwrap_err();
         assert!(
-            errs.iter().any(|e| matches!(e, ValidationError::Cycle { cycle_jobs, .. } if cycle_jobs.contains(&"a".to_string()) && cycle_jobs.contains(&"b".to_string()))),
+            errs.iter().any(|e| matches!(e, StructureError::Cycle { cycle_jobs, .. } if cycle_jobs.contains(&"a".to_string()) && cycle_jobs.contains(&"b".to_string()))),
             "should report a cycle involving a and b: {errs:?}"
         );
     }
 
     #[test]
     fn validate_cycle_only_reports_cycle_members() {
-        let jobs = parsed_jobs(
+        let jobs = registered_jobs(
             r#"
 (local ci (require :quire.ci))
 (ci.job :a [:b :quire/push] (fn [_] nil))
@@ -493,7 +516,7 @@ mod tests {
         let cycle_errs: Vec<&Vec<String>> = errs
             .iter()
             .filter_map(|e| match e {
-                ValidationError::Cycle { cycle_jobs, .. } => Some(cycle_jobs),
+                StructureError::Cycle { cycle_jobs, .. } => Some(cycle_jobs),
                 _ => None, // cov-excl-line
             })
             .collect();
@@ -507,7 +530,7 @@ mod tests {
 
     #[test]
     fn validate_reports_disjoint_cycles_separately() {
-        let jobs = parsed_jobs(
+        let jobs = registered_jobs(
             r#"
 (local ci (require :quire.ci))
 (ci.job :a [:b :quire/push] (fn [_] nil))
@@ -519,34 +542,34 @@ mod tests {
         let errs = validate(&jobs).unwrap_err();
         let cycle_count = errs
             .iter()
-            .filter(|e| matches!(e, ValidationError::Cycle { .. }))
+            .filter(|e| matches!(e, StructureError::Cycle { .. }))
             .count();
         assert_eq!(cycle_count, 2, "expected two cycle errors: {errs:?}");
     }
 
     #[test]
-    fn parse_rejects_empty_inputs() {
-        let (_, errors) = parse_jobs(
+    fn register_rejects_empty_inputs() {
+        let (_, errors) = register_jobs(
             r#"(local ci (require :quire.ci))
 (ci.job :setup [] (fn [_] nil))"#,
         );
         assert!(
             errors.iter().any(
-                |e| matches!(e, ValidationError::EmptyInputs { job_id, .. } if job_id == "setup")
+                |e| matches!(e, DefinitionError::EmptyInputs { job_id, .. } if job_id == "setup")
             ),
             "should report empty inputs for 'setup': {errors:?}"
         );
     }
 
     #[test]
-    fn parse_rejects_slash_in_job_id() {
-        let (_, errors) = parse_jobs(
+    fn register_rejects_slash_in_job_id() {
+        let (_, errors) = register_jobs(
             r#"(local ci (require :quire.ci))
 (ci.job :foo/bar [:quire/push] (fn [_] nil))"#,
         );
         assert!(
             errors.iter().any(
-                |e| matches!(e, ValidationError::ReservedSlash { job_id, .. } if job_id == "foo/bar")
+                |e| matches!(e, DefinitionError::ReservedSlash { job_id, .. } if job_id == "foo/bar")
             ),
             "should report slash in job id: {errors:?}"
         );
@@ -556,7 +579,7 @@ mod tests {
     fn validate_does_not_double_report_cycle_as_unreachable() {
         // Jobs in a cycle are technically also unreachable from any
         // source ref, but reporting both is noise. Cycle alone is enough.
-        let jobs = parsed_jobs(
+        let jobs = registered_jobs(
             r#"
 (local ci (require :quire.ci))
 (ci.job :a [:b] (fn [_] nil))
@@ -566,7 +589,7 @@ mod tests {
         let errs = validate(&jobs).unwrap_err();
         let unreachable_count = errs
             .iter()
-            .filter(|e| matches!(e, ValidationError::Unreachable { .. }))
+            .filter(|e| matches!(e, StructureError::Unreachable { .. }))
             .count();
         assert_eq!(
             unreachable_count, 0,
@@ -579,14 +602,14 @@ mod tests {
         // A job whose only input names a non-existent job passes
         // pre-graph rules (inputs is non-empty, id has no slash) and
         // reaches the post-graph reachability check.
-        let jobs = parsed_jobs(
+        let jobs = registered_jobs(
             r#"(local ci (require :quire.ci))
 (ci.job :orphan [:does-not-exist] (fn [_] nil))"#,
         );
         let errs = validate(&jobs).unwrap_err();
         assert!(
             errs.iter().any(
-                |e| matches!(e, ValidationError::Unreachable { job_id, .. } if job_id == "orphan")
+                |e| matches!(e, StructureError::Unreachable { job_id, .. } if job_id == "orphan")
             ),
             "should report unreachable job 'orphan': {errs:?}"
         );
@@ -597,7 +620,7 @@ mod tests {
         // Diamond: push -> a -> b -> d, push -> a -> c -> d.
         // `d` is reachable and `a` is visited multiple times
         // through different paths.
-        let jobs = parsed_jobs(
+        let jobs = registered_jobs(
             r#"
 (local ci (require :quire.ci))
 (ci.job :a [:quire/push] (fn [_] nil))
@@ -614,7 +637,7 @@ mod tests {
         // stack ["a", "a"], pop a (visit), push nothing (a isn't a job),
         // stack ["a"], pop a → already visited → continue.
         // The dedup fires because `a` isn't a job and isn't a source.
-        let jobs = parsed_jobs(
+        let jobs = registered_jobs(
             r#"
 (local ci (require :quire.ci))
 (ci.job :orphan [:a :a] (fn [_] nil))"#,
@@ -622,21 +645,21 @@ mod tests {
         let errs = validate(&jobs).unwrap_err();
         assert!(
             errs.iter()
-                .any(|e| matches!(e, ValidationError::Unreachable { .. })),
+                .any(|e| matches!(e, StructureError::Unreachable { .. })),
             "expected unreachable: {errs:?}"
         );
     }
 
     #[test]
     fn transitive_inputs_collects_direct_and_indirect() {
-        let pipeline = Pipeline::load(
+        let pipeline = compile(
             r#"(local ci (require :quire.ci))
 (ci.job :setup [:quire/push] (fn [_] nil))
 (ci.job :build [:setup] (fn [_] nil))
 (ci.job :test [:build :setup] (fn [_] nil))"#,
             "ci.fnl",
         )
-        .expect("load should succeed");
+        .expect("compile should succeed");
 
         let map = pipeline.transitive_inputs();
 
@@ -662,47 +685,47 @@ mod tests {
 
     #[test]
     fn transitive_inputs_excludes_self() {
-        let pipeline = Pipeline::load(
+        let pipeline = compile(
             r#"(local ci (require :quire.ci))
 (ci.job :only [:quire/push] (fn [_] nil))"#,
             "ci.fnl",
         )
-        .expect("load should succeed");
+        .expect("compile should succeed");
 
         let map = pipeline.transitive_inputs();
         assert!(!map["only"].contains("only"), "self should not be in set");
     }
 
     #[test]
-    fn load_registers_pipeline_image() {
+    fn compile_registers_pipeline_image() {
         let source = r#"(local ci (require :quire.ci))
 (ci.image "alpine")
 (ci.job :build [:quire/push] (fn [_] nil))"#;
-        let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
+        let pipeline = compile(source, "ci.fnl").expect("compile should succeed");
         assert_eq!(pipeline.image(), Some("alpine"));
     }
 
     #[test]
-    fn load_succeeds_without_image() {
+    fn compile_succeeds_without_image() {
         let source = r#"(local ci (require :quire.ci))
 (ci.job :build [:quire/push] (fn [_] nil))"#;
-        let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
+        let pipeline = compile(source, "ci.fnl").expect("compile should succeed");
         assert_eq!(pipeline.image(), None);
     }
 
     #[test]
     fn duplicate_image_variant_exists() {
-        let err = ValidationError::DuplicateImage {
+        let err = DefinitionError::DuplicateImage {
             span: SourceSpan::from((0, 0)),
         };
         assert!(err.to_string().contains("image"));
     }
 
     #[test]
-    fn load_reports_both_parse_and_post_graph_errors() {
+    fn compile_reports_both_definition_and_structure_errors() {
         // `setup` has empty inputs (pre-graph error) and `orphan` is unreachable
         // (post-graph error). Both should be reported.
-        let result = Pipeline::load(
+        let result = compile(
             r#"(local ci (require :quire.ci))
 (ci.job :setup [] (fn [_] nil))
 (ci.job :orphan [:does-not-exist] (fn [_] nil))"#,
@@ -711,24 +734,24 @@ mod tests {
         let Err(e) = result else { unreachable!() };
         let msg = e.to_string();
         assert!(
-            msg.contains("CI validation failed"),
-            "expected validation error: {msg}"
+            msg.contains("ci.fnl has errors"),
+            "expected pipeline error: {msg}"
         );
     }
 
     #[test]
-    fn load_errors_on_duplicate_image() {
+    fn compile_errors_on_duplicate_image() {
         let source = r#"(local ci (require :quire.ci))
 (ci.image "alpine")
 (ci.image "ubuntu")
 (ci.job :build [:quire/push] (fn [_] nil))"#;
-        let result = Pipeline::load(source, "ci.fnl");
+        let result = compile(source, "ci.fnl");
         assert!(result.is_err(), "duplicate image should fail");
         let Err(e) = result else { unreachable!() };
         let msg = e.to_string();
         assert!(
-            msg.contains("CI validation failed"),
-            "expected validation error: {msg}"
+            msg.contains("ci.fnl has errors"),
+            "expected pipeline error: {msg}"
         );
     }
 }
diff --git a/src/ci/run.rs b/src/ci/run.rs
index 0e2ed8e..d23c967 100644
--- a/src/ci/run.rs
+++ b/src/ci/run.rs
@@ -786,7 +786,7 @@ mod tests {
     }
 
     fn load(source: &str) -> Pipeline {
-        super::super::pipeline::Pipeline::load(source, "ci.fnl").expect("load should succeed")
+        super::super::pipeline::compile(source, "ci.fnl").expect("compile should succeed")
     }
 
     #[test]
diff --git a/src/error.rs b/src/error.rs
index 3ace9e1..f7c7b3e 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -1,6 +1,6 @@
 use miette::Diagnostic;
 
-use crate::ci::{LoadError, RunState};
+use crate::ci::{PipelineError, RunState};
 use crate::fennel::FennelError;
 
 #[derive(Debug, thiserror::Error, Diagnostic)]
@@ -25,7 +25,7 @@ pub enum Error {
 
     #[error(transparent)]
     #[diagnostic(transparent)]
-    Validation(Box<LoadError>),
+    Pipeline(Box<PipelineError>),
 
     #[error("invalid run transition: {from:?} -> {to:?}")]
     InvalidTransition { from: RunState, to: RunState },
@@ -55,9 +55,9 @@ impl From<FennelError> for Error {
     }
 }
 
-impl From<LoadError> for Error {
-    fn from(err: LoadError) -> Self {
-        Error::Validation(Box::new(err))
+impl From<PipelineError> for Error {
+    fn from(err: PipelineError) -> Self {
+        Error::Pipeline(Box::new(err))
     }
 }
 
@@ -76,16 +76,18 @@ mod tests {
     }
 
     #[test]
-    fn from_load_error() {
+    fn from_pipeline_error() {
         let source = "(ci.job :a [] (fn [_] nil))";
-        let load_err = LoadError {
+        let pipeline_err = PipelineError {
             src: miette::NamedSource::new("ci.fnl", source.to_string()),
-            errors: vec![crate::ci::ValidationError::EmptyInputs {
-                job_id: "a".to_string(),
-                span: miette::SourceSpan::from((0, 0)),
-            }],
+            diagnostics: vec![crate::ci::Diagnostic::Definition(
+                crate::ci::DefinitionError::EmptyInputs {
+                    job_id: "a".to_string(),
+                    span: miette::SourceSpan::from((0, 0)),
+                },
+            )],
         };
-        let err: Error = load_err.into();
-        assert!(err.to_string().contains("CI validation failed"));
+        let err: Error = pipeline_err.into();
+        assert!(err.to_string().contains("ci.fnl has errors"));
     }
 }