Replace destructured runtime handle with ambient runtime accessor
Run-fns are now zero-arg (fn [] …) instead of (fn [{: sh : secret}] …).
The runtime is installed as a global Lua table whose __index metatable
dispatches runtime.sh, runtime.secret, and runtime.jobs to closures
over the active Runtime. One-arg run-fns are rejected at registration
with a clear error message. This lets helpers compose without threading
a handle through every call, and the runtime. prefix makes effect sites
grep-able. Foundation for runtime.mirror and future runtime-bound
primitives.

Assisted-by: GLM-5.1 via pi
change typmlxwvwxqnskvzurmpxynztvortsrl
commit 5c095b9bbec59ad307355277367159c04a396ec5
author Alpha Chen <alpha@kejadlen.dev>
date
parent pmwonymx
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 480f22b..d6a7827 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -9,7 +9,6 @@ use std::rc::Rc;
 
 use clap::Parser;
 use miette::IntoDiagnostic;
-use mlua::IntoLua;
 use quire_core::ci::pipeline::{self, Pipeline, RunFn};
 use quire_core::ci::run::RunMeta;
 use quire_core::ci::runtime::{Runtime, RuntimeError, RuntimeEvent, RuntimeHandle};
@@ -229,11 +228,10 @@ fn run_pipeline(
         }));
     }
 
-    // Install the runtime handle on the Lua VM once for the whole run;
-    // each job's run-fn receives `rt_value` as its sole argument.
+    // Install the ambient runtime on the Lua VM once for the whole run.
     let lua = runtime.lua();
-    let rt_value = RuntimeHandle(runtime.clone())
-        .into_lua(lua)
+    RuntimeHandle(runtime.clone())
+        .install(lua)
         .expect("install runtime on Lua VM");
 
     let mut failed_job: Option<(String, RuntimeError)> = None;
@@ -255,7 +253,7 @@ fn run_pipeline(
         runtime.enter_job(job_id);
         let result: Result<(), RuntimeError> = match run_fn {
             RunFn::Lua(f) => f
-                .call::<mlua::Value>(rt_value.clone())
+                .call::<mlua::Value>(())
                 .map(|_| ())
                 .map_err(RuntimeError::from),
             RunFn::Rust(f) => f(&runtime),
diff --git a/quire-core/src/ci/mirror.rs b/quire-core/src/ci/mirror.rs
index 04c214e..8d3da78 100644
--- a/quire-core/src/ci/mirror.rs
+++ b/quire-core/src/ci/mirror.rs
@@ -231,7 +231,6 @@ impl MirrorJob {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use mlua::IntoLua;
 
     use crate::ci::pipeline::{Diagnostic, RustRunFn, compile};
     use crate::ci::run::RunMeta;
@@ -321,7 +320,7 @@ mod tests {
     #[test]
     fn mirror_after_appends_to_inputs() {
         let source = r#"(local ci (require :quire.ci))
-(ci.job :build [:quire/push] (fn [_] nil))
+(ci.job :build [:quire/push] (fn [] nil))
 (ci.mirror "https://github.com/example/repo.git"
   {:secret :github_token :tag (fn [_] "v1") :after [:build]})"#;
         let inputs = mirror_job_inputs(source);
@@ -448,8 +447,8 @@ mod tests {
             std::env::current_dir().expect("cwd"),
             log_dir,
         ));
-        let _ = RuntimeHandle(runtime.clone())
-            .into_lua(runtime.lua())
+        RuntimeHandle(runtime.clone())
+            .install(runtime.lua())
             .expect("install runtime");
         (runtime, run_fn)
     }
diff --git a/quire-core/src/ci/pipeline.rs b/quire-core/src/ci/pipeline.rs
index 42b9971..b43e7ae 100644
--- a/quire-core/src/ci/pipeline.rs
+++ b/quire-core/src/ci/pipeline.rs
@@ -53,6 +53,15 @@ pub enum DefinitionError {
         #[label("here")]
         span: SourceSpan,
     },
+
+    #[error(
+        "Job '{job_id}' run-fn takes an argument — run-fns must be zero-arg `(fn [] …)`; use `runtime.sh`, `runtime.secret`, and `runtime.jobs` instead of destructuring a handle."
+    )]
+    ArityViolation {
+        job_id: String,
+        #[label("here")]
+        span: SourceSpan,
+    },
 }
 
 /// A post-graph structural error found after all jobs have been
@@ -480,7 +489,7 @@ mod tests {
     #[test]
     fn compile_registers_a_job() {
         let source = r#"(local ci (require :quire.ci))
-(ci.job :test [:quire/push] (fn [_] nil))"#;
+(ci.job :test [:quire/push] (fn [] nil))"#;
         let pipeline = compile(source, "ci.fnl").expect("compile should succeed");
         let jobs = pipeline.jobs();
         assert_eq!(jobs.len(), 1);
@@ -492,8 +501,8 @@ mod tests {
     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))
+(ci.job :build [:quire/push] (fn [] nil))
+(ci.job :test [:build] (fn [] nil))
 "#;
         let pipeline = compile(source, "ci.fnl").expect("compile should succeed");
         let jobs = pipeline.jobs();
@@ -507,11 +516,11 @@ mod tests {
     #[test]
     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 :first [:quire/push] (fn [] nil))
+(ci.job :second [:quire/push] (fn [] nil))
 
 
-(ci.job :sixth [:quire/push] (fn [_] nil))";
+(ci.job :sixth [:quire/push] (fn [] nil))";
         let pipeline = compile(source, "ci.fnl").expect("compile should succeed");
         let lines: Vec<usize> = pipeline
             .jobs()
@@ -568,8 +577,8 @@ mod tests {
         let jobs = registered_jobs(
             r#"
 (local ci (require :quire.ci))
-(ci.job :build [:quire/push] (fn [_] nil))
-(ci.job :test [:build :quire/push] (fn [_] nil))
+(ci.job :build [:quire/push] (fn [] nil))
+(ci.job :test [:build :quire/push] (fn [] nil))
 "#,
         );
         assert!(validate(&jobs).is_ok());
@@ -580,8 +589,8 @@ mod tests {
         let jobs = registered_jobs(
             r#"
 (local ci (require :quire.ci))
-(ci.job :a [:b] (fn [_] nil))
-(ci.job :b [:a] (fn [_] nil))
+(ci.job :a [:b] (fn [] nil))
+(ci.job :b [:a] (fn [] nil))
 "#,
         );
         let errs = validate(&jobs).unwrap_err();
@@ -596,9 +605,9 @@ mod tests {
         let jobs = registered_jobs(
             r#"
 (local ci (require :quire.ci))
-(ci.job :a [:b :quire/push] (fn [_] nil))
-(ci.job :b [:a :quire/push] (fn [_] nil))
-(ci.job :clean [:quire/push] (fn [_] nil))
+(ci.job :a [:b :quire/push] (fn [] nil))
+(ci.job :b [:a :quire/push] (fn [] nil))
+(ci.job :clean [:quire/push] (fn [] nil))
 "#,
         );
         let errs = validate(&jobs).unwrap_err();
@@ -622,10 +631,10 @@ mod tests {
         let jobs = registered_jobs(
             r#"
 (local ci (require :quire.ci))
-(ci.job :a [:b :quire/push] (fn [_] nil))
-(ci.job :b [:a :quire/push] (fn [_] nil))
-(ci.job :c [:d :quire/push] (fn [_] nil))
-(ci.job :d [:c :quire/push] (fn [_] nil))
+(ci.job :a [:b :quire/push] (fn [] nil))
+(ci.job :b [:a :quire/push] (fn [] nil))
+(ci.job :c [:d :quire/push] (fn [] nil))
+(ci.job :d [:c :quire/push] (fn [] nil))
 "#,
         );
         let errs = validate(&jobs).unwrap_err();
@@ -640,7 +649,7 @@ mod tests {
     fn register_rejects_empty_inputs() {
         let errors = registration_errors(
             r#"(local ci (require :quire.ci))
-(ci.job :setup [] (fn [_] nil))"#,
+(ci.job :setup [] (fn [] nil))"#,
         );
         assert!(
             errors.iter().any(
@@ -654,7 +663,7 @@ mod tests {
     fn register_rejects_slash_in_job_id() {
         let errors = registration_errors(
             r#"(local ci (require :quire.ci))
-(ci.job :foo/bar [:quire/push] (fn [_] nil))"#,
+(ci.job :foo/bar [:quire/push] (fn [] nil))"#,
         );
         assert!(
             errors.iter().any(
@@ -668,8 +677,8 @@ mod tests {
     fn register_rejects_duplicate_job_id() {
         let errors = registration_errors(
             r#"(local ci (require :quire.ci))
-(ci.job :build [:quire/push] (fn [_] nil))
-(ci.job :build [:quire/push] (fn [_] nil))"#,
+(ci.job :build [:quire/push] (fn [] nil))
+(ci.job :build [:quire/push] (fn [] nil))"#,
         );
         assert!(
             errors.iter().any(
@@ -686,8 +695,8 @@ mod tests {
         let jobs = registered_jobs(
             r#"
 (local ci (require :quire.ci))
-(ci.job :a [:b] (fn [_] nil))
-(ci.job :b [:a] (fn [_] nil))
+(ci.job :a [:b] (fn [] nil))
+(ci.job :b [:a] (fn [] nil))
 "#,
         );
         let errs = validate(&jobs).unwrap_err();
@@ -708,7 +717,7 @@ mod tests {
         // reaches the post-graph reachability check.
         let jobs = registered_jobs(
             r#"(local ci (require :quire.ci))
-(ci.job :orphan [:does-not-exist] (fn [_] nil))"#,
+(ci.job :orphan [:does-not-exist] (fn [] nil))"#,
         );
         let errs = validate(&jobs).unwrap_err();
         assert!(
@@ -727,10 +736,10 @@ mod tests {
         let jobs = registered_jobs(
             r#"
 (local ci (require :quire.ci))
-(ci.job :a [:quire/push] (fn [_] nil))
-(ci.job :b [:a] (fn [_] nil))
-(ci.job :c [:a] (fn [_] nil))
-(ci.job :d [:b :c] (fn [_] nil))"#,
+(ci.job :a [:quire/push] (fn [] nil))
+(ci.job :b [:a] (fn [] nil))
+(ci.job :c [:a] (fn [] nil))
+(ci.job :d [:b :c] (fn [] nil))"#,
         );
         assert!(validate(&jobs).is_ok());
     }
@@ -744,7 +753,7 @@ mod tests {
         let jobs = registered_jobs(
             r#"
 (local ci (require :quire.ci))
-(ci.job :orphan [:a :a] (fn [_] nil))"#,
+(ci.job :orphan [:a :a] (fn [] nil))"#,
         );
         let errs = validate(&jobs).unwrap_err();
         assert!(
@@ -758,9 +767,9 @@ mod tests {
     fn transitive_inputs_collects_direct_and_indirect() {
         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.job :setup [:quire/push] (fn [] nil))
+(ci.job :build [:setup] (fn [] nil))
+(ci.job :test [:build :setup] (fn [] nil))"#,
             "ci.fnl",
         )
         .expect("compile should succeed");
@@ -791,7 +800,7 @@ mod tests {
     fn transitive_inputs_excludes_self() {
         let pipeline = compile(
             r#"(local ci (require :quire.ci))
-(ci.job :only [:quire/push] (fn [_] nil))"#,
+(ci.job :only [:quire/push] (fn [] nil))"#,
             "ci.fnl",
         )
         .expect("compile should succeed");
@@ -804,7 +813,7 @@ mod tests {
     fn compile_registers_pipeline_image() {
         let source = r#"(local ci (require :quire.ci))
 (ci.image "alpine")
-(ci.job :build [:quire/push] (fn [_] nil))"#;
+(ci.job :build [:quire/push] (fn [] nil))"#;
         let pipeline = compile(source, "ci.fnl").expect("compile should succeed");
         assert_eq!(pipeline.image(), Some("alpine"));
     }
@@ -812,7 +821,7 @@ mod tests {
     #[test]
     fn compile_succeeds_without_image() {
         let source = r#"(local ci (require :quire.ci))
-(ci.job :build [:quire/push] (fn [_] nil))"#;
+(ci.job :build [:quire/push] (fn [] nil))"#;
         let pipeline = compile(source, "ci.fnl").expect("compile should succeed");
         assert_eq!(pipeline.image(), None);
     }
@@ -833,8 +842,8 @@ mod tests {
         // errors short-circuit before structure checks run.
         let result = compile(
             r#"(local ci (require :quire.ci))
-(ci.job :setup [] (fn [_] nil))
-(ci.job :orphan [:does-not-exist] (fn [_] nil))"#,
+(ci.job :setup [] (fn [] nil))
+(ci.job :orphan [:does-not-exist] (fn [] nil))"#,
             "ci.fnl",
         );
         let Err(CompileError::Pipeline(pe)) = result else {
@@ -861,7 +870,7 @@ mod tests {
         let source = r#"(local ci (require :quire.ci))
 (ci.image "alpine")
 (ci.image "ubuntu")
-(ci.job :build [:quire/push] (fn [_] nil))"#;
+(ci.job :build [:quire/push] (fn [] nil))"#;
         let result = compile(source, "ci.fnl");
         assert!(result.is_err(), "duplicate image should fail");
         let Err(e) = result else { unreachable!() };
diff --git a/quire-core/src/ci/registration.rs b/quire-core/src/ci/registration.rs
index d973f62..f0d7a14 100644
--- a/quire-core/src/ci/registration.rs
+++ b/quire-core/src/ci/registration.rs
@@ -52,7 +52,13 @@ pub fn register(fennel: &Fennel, source: &str, name: &str) -> CompileResult<Regi
                 image: image.clone(),
                 source: src.clone(),
             },
-        )
+        )?;
+        // Declare `runtime` as a known global so Fennel compilation
+        // doesn't reject `runtime.sh` / `runtime.secret` / `runtime.jobs`
+        // as unknown identifiers. The real value is installed at execution
+        // time by `RuntimeHandle::install`.
+        lua.globals().set("runtime", lua.create_table()?)?;
+        Ok(())
     })?;
 
     // Remove the Registration app data so `ci.image`/`ci.job` calls at
@@ -187,6 +193,26 @@ fn register_job(
         return Ok(());
     }
 
+    // Arity check: one-arg run-fns use the old `(fn [{: sh}] …)`
+    // pattern. Reject them so users get a clear message instead of
+    // a runtime-nil panic when the ambient runtime is absent.
+    //
+    // Fail open on `debug.getinfo` errors — if the debug library is
+    // unavailable or the call shape changes, treat the function as
+    // zero-arg and let the user surface any real arity mismatch at
+    // execution time.
+    let nparams: u32 = lua
+        .load("return debug.getinfo(...).nparams")
+        .call(&run_fn)
+        .unwrap_or(0);
+    if nparams != 0 {
+        let span = pipeline::span_for_line(&r.source, line);
+        r.errors
+            .borrow_mut()
+            .push(DefinitionError::ArityViolation { job_id: id, span });
+        return Ok(());
+    }
+
     match Job::new(id, inputs, RunFn::Lua(run_fn), line, &r.source) {
         Ok(job) => r.add_job(job, line),
         Err(e) => r.errors.borrow_mut().push(e),
diff --git a/quire-core/src/ci/runtime.rs b/quire-core/src/ci/runtime.rs
index 1f0a60e..0a3c7bd 100644
--- a/quire-core/src/ci/runtime.rs
+++ b/quire-core/src/ci/runtime.rs
@@ -364,33 +364,29 @@ impl Runtime {
     }
 }
 
-/// `IntoLua` carrier for an `Rc<Runtime>`. Stows the Rc on the VM as
-/// app data and returns the handle table — `{sh, secret, jobs}`.
+/// Install the ambient `runtime` global on the Lua VM.
+///
+/// Stows the `Rc<Runtime>` as app data and sets a `runtime` global
+/// table holding `sh`, `secret`, and `jobs` — closures over the
+/// active runtime. An `__index` metatable raises a clear error for
+/// any other key. The active job slot is set/cleared by the executor
+/// around each run-fn invocation via [`Runtime::enter_job`] /
+/// [`Runtime::leave_job`].
 pub struct RuntimeHandle(pub Rc<Runtime>);
 
-impl IntoLua for RuntimeHandle {
-    // Errors raised by the closures below cross the mlua boundary via
-    // `Error::external`, which erases them to
-    // `Box<dyn Error + Send + Sync>`. The `std::error::Error` source
-    // chain is preserved, but miette `Diagnostic` metadata (codes,
-    // labels, source spans) does not survive the round trip — the
-    // resulting `mlua::Error` becomes the `#[source]` of
-    // `Error::JobFailed` at the executor, which only renders the chain
-    // as plain `Display`. Don't reach for richer error types here
-    // expecting them to render: rephrase the Display string to carry
-    // what the user needs to see.
-    fn into_lua(self, lua: &Lua) -> mlua::Result<mlua::Value> {
-        // Pull the installed runtime out of `lua`'s app data, or
-        // surface a Lua error. Every adapter below needs this.
+impl RuntimeHandle {
+    /// Install the ambient runtime on the Lua VM. Call once before
+    /// executing any run-fns.
+    pub fn install(self, lua: &Lua) -> mlua::Result<()> {
         fn runtime(lua: &Lua) -> mlua::Result<mlua::AppDataRef<'_, Rc<Runtime>>> {
             lua.app_data_ref::<Rc<Runtime>>()
                 .ok_or_else(|| mlua::Error::external("runtime not installed on Lua VM"))
         }
 
         lua.set_app_data(self.0);
-        let table = lua.create_table()?;
+        let rt = lua.create_table()?;
 
-        table.set(
+        rt.set(
             "sh",
             lua.create_function(|lua, (cmd, opts): (Cmd, Option<ShOpts>)| {
                 let rt = runtime(lua)?;
@@ -401,7 +397,7 @@ impl IntoLua for RuntimeHandle {
             })?,
         )?;
 
-        table.set(
+        rt.set(
             "secret",
             lua.create_function(|lua, name: String| {
                 let rt = runtime(lua)?;
@@ -409,13 +405,15 @@ impl IntoLua for RuntimeHandle {
             })?,
         )?;
 
-        table.set(
+        rt.set(
             "jobs",
             lua.create_function(|lua, name: String| {
                 let rt = runtime(lua)?;
                 let calling = rt.current_job.borrow();
                 let calling = calling.as_ref().ok_or_else(|| {
-                    mlua::Error::external("(jobs ...) called outside a job's run-fn")
+                    mlua::Error::external(
+                        "runtime accessed outside a job — primitives are only available while a run-fn is executing",
+                    )
                 })?;
                 // Runtime::new builds a view for every job and
                 // enter_job is the only setter for current_job, so a
@@ -438,7 +436,22 @@ impl IntoLua for RuntimeHandle {
             })?,
         )?;
 
-        table.into_lua(lua)
+        // Catch typos: any key other than sh/secret/jobs raises
+        // a clear error instead of returning nil.
+        let mt = lua.create_table()?;
+        mt.set(
+            "__index",
+            lua.create_function(
+                |_lua, (_table, key): (mlua::Table, String)| -> mlua::Result<mlua::Value> {
+                    Err(mlua::Error::external(format!(
+                        "unknown runtime primitive '{key}' — expected sh, secret, or jobs"
+                    )))
+                },
+            )?,
+        )?;
+        rt.set_metatable(Some(mt))?;
+        lua.globals().set("runtime", rt)?;
+        Ok(())
     }
 }
 
@@ -608,8 +621,8 @@ mod tests {
             RunFn::Rust(_) => panic!("expected RunFn::Lua for test setup"),
         };
         let runtime = Rc::new(Runtime::for_test(pipeline, secrets));
-        let _ = RuntimeHandle(runtime.clone())
-            .into_lua(runtime.lua())
+        RuntimeHandle(runtime.clone())
+            .install(runtime.lua())
             .expect("install runtime");
         (runtime, run_fn)
     }
@@ -622,13 +635,10 @@ mod tests {
             SecretString::from("ghp_test_value"),
         );
         let source = r#"(local ci (require :quire.ci))
-(ci.job :grab [:quire/push] (fn [{: secret}] (secret :github_token)))"#;
-        let (runtime, run_fn) = rt(source, secrets);
-        let handle = RuntimeHandle(runtime.clone())
-            .into_lua(runtime.lua())
-            .expect("install runtime");
+(ci.job :grab [:quire/push] (fn [] (runtime.secret :github_token)))"#;
+        let (_runtime, run_fn) = rt(source, secrets);
         let token: String = run_fn
-            .call(handle)
+            .call::<String>(())
             .expect("run_fn should return the secret value");
         assert_eq!(token, "ghp_test_value");
     }
@@ -636,12 +646,9 @@ mod tests {
     #[test]
     fn secret_errors_for_unknown_name() {
         let source = r#"(local ci (require :quire.ci))
-(ci.job :grab [:quire/push] (fn [{: secret}] (secret :missing)))"#;
-        let (runtime, run_fn) = rt(source, HashMap::new());
-        let handle = RuntimeHandle(runtime.clone())
-            .into_lua(runtime.lua())
-            .expect("install runtime");
-        let err = run_fn.call::<mlua::Value>(handle).unwrap_err();
+(ci.job :grab [:quire/push] (fn [] (runtime.secret :missing)))"#;
+        let (_runtime, run_fn) = rt(source, HashMap::new());
+        let err = run_fn.call::<mlua::Value>(()).unwrap_err();
         let msg = err.to_string();
         assert!(
             msg.contains("unknown secret") && msg.contains("missing"),
@@ -656,7 +663,7 @@ mod tests {
 
         let (runtime, run_fn) = rt(
             r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [{: sh}] (sh ["echo" "hi"])))"#,
+(ci.job :go [:quire/push] (fn [] (runtime.sh ["echo" "hi"])))"#,
             HashMap::new(),
         );
         runtime.set_event_callback(Box::new(move |event| match event {
@@ -669,10 +676,7 @@ mod tests {
         }));
         *runtime.current_job.borrow_mut() = Some("go".to_string());
 
-        let handle = RuntimeHandle(runtime.clone())
-            .into_lua(runtime.lua())
-            .expect("install runtime");
-        let _: mlua::Value = run_fn.call(handle).expect("sh call");
+        let _: mlua::Value = run_fn.call(()).expect("sh call");
 
         let calls = received.borrow();
         assert_eq!(calls.len(), 2, "expected 2 events, got: {calls:?}");
@@ -688,16 +692,13 @@ mod tests {
     fn sh_writes_cri_log_inline() {
         let (runtime, run_fn) = rt(
             r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [{: sh}] (sh ["echo" "hi"])))"#,
+(ci.job :go [:quire/push] (fn [] (runtime.sh ["echo" "hi"])))"#,
             HashMap::new(),
         );
         let log_dir = runtime.log_dir().to_path_buf();
         *runtime.current_job.borrow_mut() = Some("go".to_string());
 
-        let handle = RuntimeHandle(runtime.clone())
-            .into_lua(runtime.lua())
-            .expect("install runtime");
-        let _: mlua::Value = run_fn.call(handle).expect("sh call");
+        let _: mlua::Value = run_fn.call(()).expect("sh call");
 
         let log_path = log_dir.join("jobs").join("go").join("sh-1.log");
         assert!(log_path.exists(), "expected sh-1.log at {log_path:?}");
@@ -715,7 +716,7 @@ mod tests {
 
         let (runtime, run_fn) = rt(
             r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [{: sh}] (sh ["echo" "hi"])))"#,
+(ci.job :go [:quire/push] (fn [] (runtime.sh ["echo" "hi"])))"#,
             HashMap::new(),
         );
         runtime.set_event_callback(Box::new(move |_event| {
@@ -723,23 +724,17 @@ mod tests {
         }));
         // No enter_job — current_job stays None.
 
-        let handle = RuntimeHandle(runtime.clone())
-            .into_lua(runtime.lua())
-            .expect("install runtime");
-        let _: mlua::Value = run_fn.call(handle).expect("sh call");
+        let _: mlua::Value = run_fn.call(()).expect("sh call");
 
         assert_eq!(*count.borrow(), 0, "callback should not fire outside a job");
     }
 
     /// Build a pipeline whose single job's run-fn invokes `(sh …)`,
-    /// invoke it with the runtime handle, and decode the resulting Lua
+    /// invoke it with the ambient runtime, and decode the resulting Lua
     /// table as ShOutput.
     fn run_sh_via_job(source: &str) -> ShOutput {
         let (runtime, run_fn) = rt(source, HashMap::new());
-        let handle = RuntimeHandle(runtime.clone())
-            .into_lua(runtime.lua())
-            .expect("install runtime");
-        let value: mlua::Value = run_fn.call(handle).expect("sh call should return a value");
+        let value: mlua::Value = run_fn.call(()).expect("sh call should return a value");
         runtime.lua().from_value(value).expect("decode ShOutput")
     }
 
@@ -752,20 +747,17 @@ mod tests {
         );
         let source = r#"(local ci (require :quire.ci))
 (ci.job :go [:quire/push]
-  (fn [{: sh : secret}]
-    (let [tok (secret :github_token)]
-      (sh ["echo" tok]))))"#;
+  (fn []
+    (let [tok (runtime.secret :github_token)]
+      (runtime.sh ["echo" tok]))))"#;
         let (runtime, run_fn) = rt(source, secrets);
-        let handle = RuntimeHandle(runtime.clone())
-            .into_lua(runtime.lua())
-            .expect("install runtime");
 
         // Mark a current job so sh records into outputs. for_test seeds
         // an empty inputs map, so enter_job would panic; bypass the
         // assertion by writing the field directly.
         *runtime.current_job.borrow_mut() = Some("go".to_string());
 
-        let value: mlua::Value = run_fn.call(handle).expect("sh call");
+        let value: mlua::Value = run_fn.call(()).expect("sh call");
         let returned: ShOutput = runtime.lua().from_value(value).expect("decode");
 
         // The Lua caller still sees the raw value — echo printed it.
@@ -798,7 +790,7 @@ mod tests {
     fn sh_runs_argv_and_captures_stdout() {
         let r = run_sh_via_job(
             r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [{: sh}] (sh ["echo" "hello"])))"#,
+(ci.job :go [:quire/push] (fn [] (runtime.sh ["echo" "hello"])))"#,
         );
         assert_eq!(r.exit, 0);
         assert_eq!(r.stdout, "hello\n");
@@ -809,7 +801,7 @@ mod tests {
     fn sh_runs_string_under_shell() {
         let r = run_sh_via_job(
             r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [{: sh}] (sh "echo hello | tr a-z A-Z")))"#,
+(ci.job :go [:quire/push] (fn [] (runtime.sh "echo hello | tr a-z A-Z")))"#,
         );
         assert_eq!(r.exit, 0);
         assert_eq!(r.stdout, "HELLO\n");
@@ -819,7 +811,7 @@ mod tests {
     fn sh_reports_nonzero_exit_without_erroring() {
         let r = run_sh_via_job(
             r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [{: sh}] (sh "exit 7")))"#,
+(ci.job :go [:quire/push] (fn [] (runtime.sh "exit 7")))"#,
         );
         assert_eq!(r.exit, 7);
     }
@@ -833,8 +825,8 @@ mod tests {
         let r = run_sh_via_job(
             r#"(local ci (require :quire.ci))
 (ci.job :go [:quire/push]
-  (fn [{: sh}]
-    (sh "echo $CI_SH_INHERITED_TEST $CI_SH_OVERRIDE_TEST"
+  (fn []
+    (runtime.sh "echo $CI_SH_INHERITED_TEST $CI_SH_OVERRIDE_TEST"
         {:env {:CI_SH_OVERRIDE_TEST "from-opts"}})))"#,
         );
         assert_eq!(r.exit, 0);
@@ -843,15 +835,12 @@ mod tests {
 
     #[test]
     fn sh_rejects_unknown_opt_key() {
-        let (runtime, run_fn) = rt(
+        let (_runtime, run_fn) = rt(
             r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [{: sh}] (sh "echo hi" {:cwdir "/tmp"})))"#,
+(ci.job :go [:quire/push] (fn [] (runtime.sh "echo hi" {:cwdir "/tmp"})))"#,
             HashMap::new(),
         );
-        let handle = RuntimeHandle(runtime.clone())
-            .into_lua(runtime.lua())
-            .expect("install runtime");
-        let err = run_fn.call::<mlua::Value>(handle).unwrap_err();
+        let err = run_fn.call::<mlua::Value>(()).unwrap_err();
         let msg = err.to_string();
         assert!(
             msg.contains("unknown field") && msg.contains("cwdir"),
@@ -861,15 +850,12 @@ mod tests {
 
     #[test]
     fn sh_rejects_non_sequence_table_as_cmd() {
-        let (runtime, run_fn) = rt(
+        let (_runtime, run_fn) = rt(
             r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [{: sh}] (sh {:env {:FOO "bar"}})))"#,
+(ci.job :go [:quire/push] (fn [] (runtime.sh {:env {:FOO "bar"}})))"#,
             HashMap::new(),
         );
-        let handle = RuntimeHandle(runtime.clone())
-            .into_lua(runtime.lua())
-            .expect("install runtime");
-        let err = run_fn.call::<mlua::Value>(handle).unwrap_err();
+        let err = run_fn.call::<mlua::Value>(()).unwrap_err();
         let msg = err.to_string();
         assert!(
             msg.contains("sequence"),
@@ -879,15 +865,12 @@ mod tests {
 
     #[test]
     fn sh_rejects_empty_argv() {
-        let (runtime, run_fn) = rt(
+        let (_runtime, run_fn) = rt(
             r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [{: sh}] (sh [])))"#,
+(ci.job :go [:quire/push] (fn [] (runtime.sh [])))"#,
             HashMap::new(),
         );
-        let handle = RuntimeHandle(runtime.clone())
-            .into_lua(runtime.lua())
-            .expect("install runtime");
-        let err = run_fn.call::<mlua::Value>(handle).unwrap_err();
+        let err = run_fn.call::<mlua::Value>(()).unwrap_err();
         let msg = err.to_string();
         assert!(
             msg.contains("empty"),
@@ -897,15 +880,12 @@ mod tests {
 
     #[test]
     fn sh_rejects_number_as_cmd() {
-        let (runtime, run_fn) = rt(
+        let (_runtime, run_fn) = rt(
             r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [{: sh}] (sh 42)))"#,
+(ci.job :go [:quire/push] (fn [] (runtime.sh 42)))"#,
             HashMap::new(),
         );
-        let handle = RuntimeHandle(runtime.clone())
-            .into_lua(runtime.lua())
-            .expect("install runtime");
-        let err = run_fn.call::<mlua::Value>(handle).unwrap_err();
+        let err = run_fn.call::<mlua::Value>(()).unwrap_err();
         let msg = err.to_string();
         assert!(
             msg.contains("string or sequence"),
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index 922ee8c..f7471fb 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -310,7 +310,7 @@ mod tests {
     #[test]
     fn ci_pipeline_returns_pipeline_when_ci_fnl_present() {
         let source = r#"(local ci (require :quire.ci))
-(ci.job :build [:quire/push] (fn [_] nil))"#;
+(ci.job :build [:quire/push] (fn [] nil))"#;
         let (_dir, quire, name) = bare_repo_with_ci(source);
         let repo = quire.repo(&name).expect("repo");
         let ci = repo.ci();
@@ -344,7 +344,7 @@ mod tests {
 
     #[test]
     fn ci_source_reads_file_at_sha() {
-        let source = "(local ci (require :quire.ci))\n(ci.job :x [:quire/push] (fn [_] nil))";
+        let source = "(local ci (require :quire.ci))\n(ci.job :x [:quire/push] (fn [] nil))";
         let (_dir, quire, name) = bare_repo_with_ci(source);
         let repo = quire.repo(&name).expect("repo");
         let ci = repo.ci();
@@ -359,7 +359,7 @@ mod tests {
     #[test]
     fn trigger_creates_run_and_completes() {
         let source = r#"(local ci (require :quire.ci))
-(ci.job :build [:quire/push] (fn [_] nil))"#;
+(ci.job :build [:quire/push] (fn [] nil))"#;
         let (_dir, quire, name) = bare_repo_with_ci(source);
         let repo = quire.repo(&name).expect("repo");
         let sha = head_sha(&repo);
@@ -417,7 +417,7 @@ mod tests {
 
     #[test]
     fn trigger_errors_on_invalid_pipeline() {
-        let source = "(local ci (require :quire.ci))\n(ci.job :a [] (fn [_] nil))";
+        let source = "(local ci (require :quire.ci))\n(ci.job :a [] (fn [] nil))";
         let (_dir, quire, name) = bare_repo_with_ci(source);
         let repo = quire.repo(&name).expect("repo");
         let sha = head_sha(&repo);
@@ -480,7 +480,7 @@ mod tests {
     #[test]
     fn trigger_processes_multiple_refs() {
         let source = r#"(local ci (require :quire.ci))
-(ci.job :build [:quire/push] (fn [_] nil))"#;
+(ci.job :build [:quire/push] (fn [] nil))"#;
         let (_dir, quire, name) = bare_repo_with_ci(source);
         let repo = quire.repo(&name).expect("repo");
         let sha = head_sha(&repo);
@@ -509,7 +509,7 @@ mod tests {
     #[test]
     fn ci_source_errors_on_invalid_sha() {
         let source = r#"(local ci (require :quire.ci))
-(ci.job :build [:quire/push] (fn [_] nil))"#;
+(ci.job :build [:quire/push] (fn [] nil))"#;
         let (_dir, quire, name) = bare_repo_with_ci(source);
         let repo = quire.repo(&name).expect("repo");
         let ci = repo.ci();
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index c150876..bc05621 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -10,13 +10,12 @@ use std::path::{Path, PathBuf};
 use std::rc::Rc;
 
 use jiff::Timestamp;
-use mlua::IntoLua;
-
-use super::error::{Error, Result};
 use quire_core::ci::pipeline::{Pipeline, RunFn};
 use quire_core::ci::runtime::{Runtime, RuntimeHandle, ShOutput};
 use quire_core::secret::SecretString;
 
+use super::error::{Error, Result};
+
 pub use quire_core::ci::run::RunMeta;
 
 /// How a run dispatches its pipeline.
@@ -235,8 +234,8 @@ impl Run {
         ));
 
         let lua = runtime.lua();
-        let rt_value = RuntimeHandle(runtime.clone())
-            .into_lua(lua)
+        RuntimeHandle(runtime.clone())
+            .install(lua)
             .expect("install runtime on Lua VM");
 
         let mut failed_job: Option<(String, Error)> = None;
@@ -260,7 +259,7 @@ impl Run {
             runtime.enter_job(job_id);
             let result: Result<()> = (|| match run_fn {
                 RunFn::Lua(f) => {
-                    let _: mlua::Value = f.call(rt_value.clone())?;
+                    f.call::<mlua::Value>(())?;
                     Ok(())
                 }
                 RunFn::Rust(f) => f(&runtime).map_err(Into::into),
@@ -298,7 +297,6 @@ impl Run {
         // Drop the runtime *before* the final transition. In docker
         // mode this fires `DockerLifecycle::drop`, which stamps
         // `container_stopped_at` in the database.
-        drop(rt_value);
         let _ = lua; // release the Lua borrow tied to `runtime`.
         drop(runtime);
 
@@ -887,7 +885,7 @@ mod tests {
 
         let pipeline = load(
             r#"(local ci (require :quire.ci))
-(ci.job :pwd [:quire/push] (fn [{: sh}] (sh ["ls"])))"#,
+(ci.job :pwd [:quire/push] (fn [] (runtime.sh ["ls"])))"#,
         );
 
         let outputs = run
@@ -915,8 +913,8 @@ mod tests {
 
         let pipeline = load(
             r#"(local ci (require :quire.ci))
-(ci.job :a [:quire/push] (fn [{: sh}] (sh ["echo" "from-a"])))
-(ci.job :b [:a] (fn [{: sh}] (sh ["echo" "from-b"])))"#,
+(ci.job :a [:quire/push] (fn [] (runtime.sh ["echo" "from-a"])))
+(ci.job :b [:a] (fn [] (runtime.sh ["echo" "from-b"])))"#,
         );
 
         let run_id = run.id().to_string();
@@ -951,8 +949,8 @@ mod tests {
         let log_str = log.to_string_lossy();
         let source = format!(
             r#"(local ci (require :quire.ci))
-(ci.job :b [:a] (fn [{{: sh}}] (sh (.. "echo b >> {log}"))))
-(ci.job :a [:quire/push] (fn [{{: sh}}] (sh (.. "echo a >> {log}"))))"#,
+(ci.job :b [:a] (fn [] (runtime.sh (.. "echo b >> {log}"))))
+(ci.job :a [:quire/push] (fn [] (runtime.sh (.. "echo a >> {log}"))))"#,
             log = log_str
         );
         let pipeline = load(&source);
@@ -977,8 +975,8 @@ mod tests {
 
         let pipeline = load(
             r#"(local ci (require :quire.ci))
-(ci.job :a [:quire/push] (fn [_] (error "boom")))
-(ci.job :b [:a] (fn [{: sh}] (sh ["echo" "should-not-run"])))"#,
+(ci.job :a [:quire/push] (fn [] (error "boom")))
+(ci.job :b [:a] (fn [] (runtime.sh ["echo" "should-not-run"])))"#,
         );
 
         let run_id = run.id().to_string();
@@ -1006,9 +1004,9 @@ mod tests {
         let pipeline = load(
             r#"(local ci (require :quire.ci))
 (ci.job :grab [:quire/push]
-  (fn [{: sh : jobs}]
-    (let [push (jobs :quire/push)]
-      (sh ["echo" push.sha push.ref]))))"#,
+  (fn []
+    (let [push (runtime.jobs :quire/push)]
+      (runtime.sh ["echo" push.sha push.ref]))))"#,
         );
 
         let outputs = run
@@ -1033,11 +1031,11 @@ mod tests {
 
         let pipeline = load(
             r#"(local ci (require :quire.ci))
-(ci.job :a [:quire/push] (fn [_] nil))
+(ci.job :a [:quire/push] (fn [] nil))
 (ci.job :b [:a]
-  (fn [{: sh : jobs}]
-    (let [push (jobs :quire/push)]
-      (sh ["echo" push.sha]))))"#,
+  (fn []
+    (let [push (runtime.jobs :quire/push)]
+      (runtime.sh ["echo" push.sha]))))"#,
         );
 
         let outputs = run
@@ -1062,7 +1060,7 @@ mod tests {
 
         let pipeline = load(
             r#"(local ci (require :quire.ci))
-(ci.job :grab [:quire/push] (fn [{: jobs}] (jobs :nope)))"#,
+(ci.job :grab [:quire/push] (fn [] (runtime.jobs :nope)))"#,
         );
 
         let err = run
@@ -1092,8 +1090,8 @@ mod tests {
 
         let pipeline = load(
             r#"(local ci (require :quire.ci))
-(ci.job :peer [:quire/push] (fn [_] nil))
-(ci.job :grab [:quire/push] (fn [{: jobs}] (jobs :peer)))"#,
+(ci.job :peer [:quire/push] (fn [] nil))
+(ci.job :grab [:quire/push] (fn [] (runtime.jobs :peer)))"#,
         );
 
         let err = run
@@ -1122,7 +1120,7 @@ mod tests {
 
         let pipeline = load(
             r#"(local ci (require :quire.ci))
-(ci.job :grab [:quire/push] (fn [{: jobs}] (jobs :grab)))"#,
+(ci.job :grab [:quire/push] (fn [] (runtime.jobs :grab)))"#,
         );
 
         let err = run
@@ -1151,11 +1149,11 @@ mod tests {
 
         let pipeline = load(
             r#"(local ci (require :quire.ci))
-(ci.job :a [:quire/push] (fn [_] nil))
+(ci.job :a [:quire/push] (fn [] nil))
 (ci.job :b [:a]
-  (fn [{: sh : jobs}]
-    (let [a-outputs (jobs :a)]
-      (sh ["echo" (tostring a-outputs)]))))"#,
+  (fn []
+    (let [a-outputs (runtime.jobs :a)]
+      (runtime.sh ["echo" (tostring a-outputs)]))))"#,
         );
 
         let outputs = run
@@ -1179,7 +1177,7 @@ mod tests {
 
         let pipeline = load(
             r#"(local ci (require :quire.ci))
-(ci.job :greet [:quire/push] (fn [{: sh}] (sh ["echo" "hello"])))"#,
+(ci.job :greet [:quire/push] (fn [] (runtime.sh ["echo" "hello"])))"#,
         );
 
         let run_id = run.id().to_string();
@@ -1224,8 +1222,8 @@ mod tests {
         // `a` succeeds, `b` fails — log for `a` should still be written.
         let pipeline = load(
             r#"(local ci (require :quire.ci))
-(ci.job :a [:quire/push] (fn [{: sh}] (sh ["echo" "from-a"])))
-(ci.job :b [:a] (fn [_] (error "boom")))"#,
+(ci.job :a [:quire/push] (fn [] (runtime.sh ["echo" "from-a"])))
+(ci.job :b [:a] (fn [] (error "boom")))"#,
         );
 
         let run_id = run.id().to_string();
@@ -1259,7 +1257,7 @@ mod tests {
             r#"(local ci (require :quire.ci))
 (ci.image "alpine")
 (ci.job :bad [:quire/push]
-  (fn [_]
+  (fn []
     (ci.image "sneaky")))"#,
         );
 
@@ -1292,7 +1290,7 @@ mod tests {
 
         let mut pipeline = load(
             r#"(local ci (require :quire.ci))
-(ci.job :only [:quire/push] (fn [_] nil))"#,
+(ci.job :only [:quire/push] (fn [] nil))"#,
         );
 
         let called = Rc::new(Cell::new(false));
@@ -1320,7 +1318,7 @@ mod tests {
 
         let mut pipeline = load(
             r#"(local ci (require :quire.ci))
-(ci.job :boom [:quire/push] (fn [_] nil))"#,
+(ci.job :boom [:quire/push] (fn [] nil))"#,
         );
 
         pipeline.replace_first_run_fn(RunFn::Rust(Rc::new(|_rt| {