Move runtime under quire.ci, mutate the stub in place
`RuntimeHandle::install` used to create a fresh table and assign it to
the `runtime` global, hiding that the global and
`package.loaded["quire.runtime"]` had drifted apart since registration.
Now one stub lives at `package.loaded["quire.ci"].runtime` — seeded as
a placeholder by `Fennel::new`, carried forward when registration
overwrites `quire.ci`, mutated by `install`, and cleared by `uninstall`.
Destructuring `(local {: runtime} (require :quire.ci))` works because
the captured reference is the same table the install populates. No
runtime global, no separate `quire.runtime` module; `defrun`, stdlib,
and the mirror shim all reach the runtime through `(require :quire.ci)`.

Assisted-by: Claude Opus 4.7 via Claude Code
change myvouvquvypsutlmylultluowyvzkoxm
commit 6ebf769e725d2a9c8bb9effd7eb142d1abe35355
author Alpha Chen <alpha@kejadlen.dev>
date
parent louwvnko
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index c4eda5b..818b79c 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -344,7 +344,7 @@ fn run_pipeline(
         }
     }
 
-    lua.remove_app_data::<Rc<Runtime>>();
+    RuntimeHandle::uninstall(lua).expect("uninstall runtime");
 
     if let Some((_, err)) = failed_job {
         return Err(err.into());
diff --git a/quire-core/src/ci/macros.fnl b/quire-core/src/ci/macros.fnl
index c7251df..831a3b5 100644
--- a/quire-core/src/ci/macros.fnl
+++ b/quire-core/src/ci/macros.fnl
@@ -2,9 +2,9 @@
 ;;
 ;; `defrun` is sugar for the common run-fn shape: a zero-arg function
 ;; whose body needs `sh` / `secret` / `jobs` / `mirror` from the
-;; ambient `runtime` global. Writing `(let [{: sh} runtime] …)` at the
-;; top of every job becomes the macro itself, with the destructure
-;; pattern as the apparent argument list.
+;; runtime. Writing `(let [{: sh} (. (require :quire.ci) :runtime)] …)`
+;; at the top of every job becomes the macro itself, with the
+;; destructure pattern as the apparent argument list.
 ;;
 ;;   (defrun [{: sh : jobs}]
 ;;     (let [push (jobs :quire/push)]
@@ -13,7 +13,7 @@
 ;; expands to:
 ;;
 ;;   (fn []
-;;     (let [{: sh : jobs} runtime]
+;;     (let [{: sh : jobs} (. (require :quire.ci) :runtime)]
 ;;       (let [push (jobs :quire/push)]
 ;;         (sh ["cargo" "test"]))))
 ;;
@@ -30,7 +30,7 @@
         `(fn []
            ,(unpack body))
         `(fn []
-           (let [,(. arglist 1) runtime]
+           (let [,(. arglist 1) (. (require :quire.ci) :runtime)]
              ,(unpack body))))))
 
 {: defrun}
diff --git a/quire-core/src/ci/mirror.rs b/quire-core/src/ci/mirror.rs
index 08b1582..d9f468a 100644
--- a/quire-core/src/ci/mirror.rs
+++ b/quire-core/src/ci/mirror.rs
@@ -95,14 +95,13 @@ pub fn register(lua: &Lua, (url, opts): (String, mlua::Table)) -> mlua::Result<(
     let refs = Rc::new(refs);
 
     let run_fn = lua.create_function(move |lua, ()| {
-        let mirror: mlua::Function = lua
+        let loaded: mlua::Table = lua
             .globals()
             .get::<mlua::Table>("package")?
-            .get::<mlua::Table>("loaded")?
-            .get::<mlua::Table>("quire.stdlib")?
-            .get("mirror")?;
+            .get::<mlua::Table>("loaded")?;
+        let mirror: mlua::Function = loaded.get::<mlua::Table>("quire.stdlib")?.get("mirror")?;
 
-        let runtime: mlua::Table = lua.globals().get("runtime")?;
+        let runtime: mlua::Table = loaded.get::<mlua::Table>("quire.ci")?.get("runtime")?;
         let push: mlua::Table = runtime.get::<mlua::Function>("jobs")?.call("quire/push")?;
         let pushed_ref: String = push.get("ref")?;
 
diff --git a/quire-core/src/ci/pipeline.rs b/quire-core/src/ci/pipeline.rs
index b43e7ae..140fb12 100644
--- a/quire-core/src/ci/pipeline.rs
+++ b/quire-core/src/ci/pipeline.rs
@@ -55,7 +55,7 @@ pub enum DefinitionError {
     },
 
     #[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."
+        "Job '{job_id}' run-fn takes an argument — run-fns must be zero-arg `(fn [] …)`; bind the runtime via `(local runtime (require :quire.runtime))` or destructure it from `(require :quire.ci)` instead of destructuring a handle."
     )]
     ArityViolation {
         job_id: String,
diff --git a/quire-core/src/ci/registration.rs b/quire-core/src/ci/registration.rs
index 3832410..ee84ead 100644
--- a/quire-core/src/ci/registration.rs
+++ b/quire-core/src/ci/registration.rs
@@ -53,11 +53,6 @@ pub fn register(fennel: &Fennel, source: &str, name: &str) -> CompileResult<Regi
                 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(())
     })?;
 
@@ -111,6 +106,18 @@ impl IntoLua for Registration {
         table.set("job", lua.create_function(register_job)?)?;
         table.set("image", lua.create_function(register_image)?)?;
         table.set("mirror", lua.create_function(mirror::register)?)?;
+        // Carry forward the runtime stub from the placeholder
+        // `quire.ci` table seeded by `Fennel::new`. `register_module`
+        // overwrites `package.loaded["quire.ci"]` with the value we
+        // return; reusing the existing stub keeps references captured
+        // before registration (and any held by previously preloaded
+        // modules like `quire.stdlib`) pointing at the same Lua table
+        // that `RuntimeHandle::install` mutates.
+        let package: mlua::Table = lua.globals().get("package")?;
+        let loaded: mlua::Table = package.get("loaded")?;
+        let placeholder: mlua::Table = loaded.get("quire.ci")?;
+        let runtime: mlua::Table = placeholder.get("runtime")?;
+        table.set("runtime", runtime)?;
         table.into_lua(lua)
     }
 }
diff --git a/quire-core/src/ci/runtime.rs b/quire-core/src/ci/runtime.rs
index 1234446..4fdb60a 100644
--- a/quire-core/src/ci/runtime.rs
+++ b/quire-core/src/ci/runtime.rs
@@ -364,19 +364,28 @@ impl Runtime {
     }
 }
 
-/// Install the ambient `runtime` global on the Lua VM.
+/// Install the runtime primitives 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`].
+/// Stows the `Rc<Runtime>` as app data and populates the stub seeded
+/// at `package.loaded["quire.runtime"]` by
+/// [`crate::fennel::Fennel::new`] with `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`].
+///
+/// The stub is mutated in place rather than replaced so that
+/// references captured during registration — e.g.
+/// `(require :quire.runtime)` directly, or
+/// `(local {: runtime} (require :quire.ci))` — see the populated
+/// table at call time. There is no `runtime` global; user code must
+/// reach the primitives via one of the require paths.
 pub struct RuntimeHandle(pub Rc<Runtime>);
 
 impl RuntimeHandle {
-    /// Install the ambient runtime on the Lua VM. Call once before
-    /// executing any run-fns.
+    /// Install the runtime on the Lua VM. Call once before executing
+    /// any run-fns; pair with [`RuntimeHandle::uninstall`] when the
+    /// run is done.
     pub fn install(self, lua: &Lua) -> mlua::Result<()> {
         fn runtime(lua: &Lua) -> mlua::Result<mlua::AppDataRef<'_, Rc<Runtime>>> {
             lua.app_data_ref::<Rc<Runtime>>()
@@ -384,7 +393,7 @@ impl RuntimeHandle {
         }
 
         lua.set_app_data(self.0);
-        let rt = lua.create_table()?;
+        let rt: mlua::Table = runtime_stub(lua)?;
 
         rt.set(
             "sh",
@@ -450,17 +459,38 @@ impl RuntimeHandle {
             )?,
         )?;
         rt.set_metatable(Some(mt))?;
-        lua.globals().set("runtime", rt.clone())?;
-        // Mirror into `package.loaded["quire.runtime"]` so library
-        // code can do `(let [{: sh} (require :quire.runtime)] …)`.
-        // Same table — destructures and ambient access stay in sync.
-        let package: mlua::Table = lua.globals().get("package")?;
-        let loaded: mlua::Table = package.get("loaded")?;
-        loaded.set("quire.runtime", rt)?;
+        Ok(())
+    }
+
+    /// Tear down the ambient runtime: clear `sh`/`secret`/`jobs` from
+    /// the runtime table, drop the metatable, and remove the
+    /// `Rc<Runtime>` app data. Idempotent — calling twice is a no-op.
+    ///
+    /// In practice the Lua VM is dropped right after a run, so this
+    /// is hygiene rather than necessity; pair it with `install` so
+    /// the install/uninstall lifecycle is explicit at the call site.
+    pub fn uninstall(lua: &Lua) -> mlua::Result<()> {
+        let rt: mlua::Table = runtime_stub(lua)?;
+        rt.set("sh", mlua::Value::Nil)?;
+        rt.set("secret", mlua::Value::Nil)?;
+        rt.set("jobs", mlua::Value::Nil)?;
+        rt.set_metatable(None)?;
+        lua.remove_app_data::<Rc<Runtime>>();
         Ok(())
     }
 }
 
+/// The runtime stub at `package.loaded["quire.ci"].runtime`. Seeded
+/// as a placeholder by `Fennel::new`, threaded through
+/// `registration::register`, mutated by `install`, cleared by
+/// `uninstall`.
+fn runtime_stub(lua: &Lua) -> mlua::Result<mlua::Table> {
+    let package: mlua::Table = lua.globals().get("package")?;
+    let loaded: mlua::Table = package.get("loaded")?;
+    let ci: mlua::Table = loaded.get("quire.ci")?;
+    ci.get::<mlua::Table>("runtime")
+}
+
 /// The two valid shapes of `cmd` for `(sh cmd …)`. A bare string
 /// runs under `sh -c`; a sequence runs as argv with no shell.
 ///
@@ -640,8 +670,8 @@ mod tests {
             "github_token".to_string(),
             SecretString::from("ghp_test_value"),
         );
-        let source = r#"(local ci (require :quire.ci))
-(ci.job :grab [:quire/push] (fn [] (runtime.secret :github_token)))"#;
+        let source = r#"(local {: job : runtime} (require :quire.ci))
+(job :grab [:quire/push] (fn [] (runtime.secret :github_token)))"#;
         let (_runtime, run_fn) = rt(source, secrets);
         let token: String = run_fn
             .call::<String>(())
@@ -649,10 +679,55 @@ mod tests {
         assert_eq!(token, "ghp_test_value");
     }
 
+    #[test]
+    fn runtime_destructured_from_quire_ci_resolves_after_install() {
+        // (local {: runtime} (require :quire.ci)) must bind to the
+        // same table that `RuntimeHandle::install` mutates in place,
+        // so `runtime.secret` works inside a run-fn.
+        let mut secrets = HashMap::new();
+        secrets.insert("k".to_string(), SecretString::from("v"));
+        let source = r#"(local {: job : runtime} (require :quire.ci))
+(job :grab [:quire/push] (fn [] (runtime.secret :k)))"#;
+        let (_runtime, run_fn) = rt(source, secrets);
+        let token: String = run_fn.call::<String>(()).expect("run_fn");
+        assert_eq!(token, "v");
+    }
+
+    #[test]
+    fn uninstall_clears_runtime_table_and_app_data() {
+        let source = r#"(local {: job : runtime} (require :quire.ci))
+(job :grab [:quire/push] (fn [] (runtime.secret :anything)))"#;
+        let (runtime, _run_fn) = rt(source, HashMap::new());
+        let lua = runtime.lua();
+        RuntimeHandle::uninstall(lua).expect("uninstall");
+
+        let rt: mlua::Table = runtime_stub(lua).expect("runtime stub");
+        assert!(matches!(
+            rt.get::<mlua::Value>("sh").expect("sh"),
+            mlua::Value::Nil
+        ));
+        assert!(matches!(
+            rt.get::<mlua::Value>("secret").expect("secret"),
+            mlua::Value::Nil
+        ));
+        assert!(matches!(
+            rt.get::<mlua::Value>("jobs").expect("jobs"),
+            mlua::Value::Nil
+        ));
+        assert!(rt.metatable().is_none(), "metatable should be cleared");
+        assert!(
+            lua.app_data_ref::<Rc<Runtime>>().is_none(),
+            "app data should be removed"
+        );
+
+        // Idempotent.
+        RuntimeHandle::uninstall(lua).expect("uninstall twice");
+    }
+
     #[test]
     fn secret_errors_for_unknown_name() {
-        let source = r#"(local ci (require :quire.ci))
-(ci.job :grab [:quire/push] (fn [] (runtime.secret :missing)))"#;
+        let source = r#"(local {: job : runtime} (require :quire.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();
@@ -668,8 +743,8 @@ mod tests {
         let received_clone = received.clone();
 
         let (runtime, run_fn) = rt(
-            r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [] (runtime.sh ["echo" "hi"])))"#,
+            r#"(local {: job : runtime} (require :quire.ci))
+(job :go [:quire/push] (fn [] (runtime.sh ["echo" "hi"])))"#,
             HashMap::new(),
         );
         runtime.set_event_callback(Box::new(move |event| match event {
@@ -697,8 +772,8 @@ mod tests {
     #[test]
     fn sh_writes_cri_log_inline() {
         let (runtime, run_fn) = rt(
-            r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [] (runtime.sh ["echo" "hi"])))"#,
+            r#"(local {: job : runtime} (require :quire.ci))
+(job :go [:quire/push] (fn [] (runtime.sh ["echo" "hi"])))"#,
             HashMap::new(),
         );
         let log_dir = runtime.log_dir().to_path_buf();
@@ -721,8 +796,8 @@ mod tests {
         let count_clone = count.clone();
 
         let (runtime, run_fn) = rt(
-            r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [] (runtime.sh ["echo" "hi"])))"#,
+            r#"(local {: job : runtime} (require :quire.ci))
+(job :go [:quire/push] (fn [] (runtime.sh ["echo" "hi"])))"#,
             HashMap::new(),
         );
         runtime.set_event_callback(Box::new(move |_event| {
@@ -751,8 +826,8 @@ mod tests {
             "github_token".to_string(),
             SecretString::from("ghp_long_secret_value"),
         );
-        let source = r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push]
+        let source = r#"(local {: job : runtime} (require :quire.ci))
+(job :go [:quire/push]
   (fn []
     (let [tok (runtime.secret :github_token)]
       (runtime.sh ["echo" tok]))))"#;
@@ -795,8 +870,8 @@ mod tests {
     #[test]
     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 [] (runtime.sh ["echo" "hello"])))"#,
+            r#"(local {: job : runtime} (require :quire.ci))
+(job :go [:quire/push] (fn [] (runtime.sh ["echo" "hello"])))"#,
         );
         assert_eq!(r.exit, 0);
         assert_eq!(r.stdout, "hello\n");
@@ -806,8 +881,8 @@ mod tests {
     #[test]
     fn sh_runs_string_under_shell() {
         let r = run_sh_via_job(
-            r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [] (runtime.sh "echo hello | tr a-z A-Z")))"#,
+            r#"(local {: job : runtime} (require :quire.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");
@@ -816,8 +891,8 @@ mod tests {
     #[test]
     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 [] (runtime.sh "exit 7")))"#,
+            r#"(local {: job : runtime} (require :quire.ci))
+(job :go [:quire/push] (fn [] (runtime.sh "exit 7")))"#,
         );
         assert_eq!(r.exit, 7);
     }
@@ -829,8 +904,8 @@ mod tests {
             std::env::set_var("CI_SH_INHERITED_TEST", "from-parent");
         }
         let r = run_sh_via_job(
-            r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push]
+            r#"(local {: job : runtime} (require :quire.ci))
+(job :go [:quire/push]
   (fn []
     (runtime.sh "echo $CI_SH_INHERITED_TEST $CI_SH_OVERRIDE_TEST"
         {:env {:CI_SH_OVERRIDE_TEST "from-opts"}})))"#,
@@ -842,8 +917,8 @@ mod tests {
     #[test]
     fn sh_rejects_unknown_opt_key() {
         let (_runtime, run_fn) = rt(
-            r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [] (runtime.sh "echo hi" {:cwdir "/tmp"})))"#,
+            r#"(local {: job : runtime} (require :quire.ci))
+(job :go [:quire/push] (fn [] (runtime.sh "echo hi" {:cwdir "/tmp"})))"#,
             HashMap::new(),
         );
         let err = run_fn.call::<mlua::Value>(()).unwrap_err();
@@ -857,8 +932,8 @@ mod tests {
     #[test]
     fn sh_rejects_non_sequence_table_as_cmd() {
         let (_runtime, run_fn) = rt(
-            r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [] (runtime.sh {:env {:FOO "bar"}})))"#,
+            r#"(local {: job : runtime} (require :quire.ci))
+(job :go [:quire/push] (fn [] (runtime.sh {:env {:FOO "bar"}})))"#,
             HashMap::new(),
         );
         let err = run_fn.call::<mlua::Value>(()).unwrap_err();
@@ -872,8 +947,8 @@ mod tests {
     #[test]
     fn sh_rejects_empty_argv() {
         let (_runtime, run_fn) = rt(
-            r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [] (runtime.sh [])))"#,
+            r#"(local {: job : runtime} (require :quire.ci))
+(job :go [:quire/push] (fn [] (runtime.sh [])))"#,
             HashMap::new(),
         );
         let err = run_fn.call::<mlua::Value>(()).unwrap_err();
@@ -887,8 +962,8 @@ mod tests {
     #[test]
     fn sh_rejects_number_as_cmd() {
         let (_runtime, run_fn) = rt(
-            r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [] (runtime.sh 42)))"#,
+            r#"(local {: job : runtime} (require :quire.ci))
+(job :go [:quire/push] (fn [] (runtime.sh 42)))"#,
             HashMap::new(),
         );
         let err = run_fn.call::<mlua::Value>(()).unwrap_err();
@@ -971,9 +1046,9 @@ mod tests {
         );
 
         let source = format!(
-            r#"(local ci (require :quire.ci))
+            r#"(local {{: job : runtime}} (require :quire.ci))
 (local {{: mirror}} (require :quire.stdlib))
-(ci.job :go [:quire/push]
+(job :go [:quire/push]
   (fn []
     (let [auth (runtime.secret :github_token)]
       (mirror {{:url "{url}"
@@ -996,9 +1071,9 @@ mod tests {
 
     #[test]
     fn stdlib_mirror_errors_on_missing_required_opt() {
-        let source = r#"(local ci (require :quire.ci))
+        let source = r#"(local {: job} (require :quire.ci))
 (local {: mirror} (require :quire.stdlib))
-(ci.job :go [:quire/push]
+(job :go [:quire/push]
   (fn []
     (mirror {:auth-header "x" :sha "x" :tag "v1" :git-dir "/tmp"})))"#;
         let (_runtime, run_fn) = rt(source, HashMap::new());
diff --git a/quire-core/src/ci/stdlib.fnl b/quire-core/src/ci/stdlib.fnl
index c028d12..c851c79 100644
--- a/quire-core/src/ci/stdlib.fnl
+++ b/quire-core/src/ci/stdlib.fnl
@@ -1,7 +1,7 @@
 ;; quire.stdlib — helpers callable from inside any run-fn via
 ;; `(require :quire.stdlib)`. Each function pulls its runtime
-;; primitives from `(require :quire.runtime)` at call time so the
-;; binding always tracks the currently-installed runtime.
+;; primitives from `(. (require :quire.ci) :runtime)` at call time so
+;; the binding always tracks the currently-installed runtime.
 
 (local M {})
 
@@ -34,7 +34,7 @@
 ;; non-zero git exits. `lambda` checks the required bindings for nil
 ;; at the call site.
 (λ M.mirror [{: url : auth-header : sha : tag : git-dir :refs ?refs}]
-  (let [{: sh} (require :quire.runtime)
+  (let [{: sh} (. (require :quire.ci) :runtime)
         refs (or ?refs [])
         ;; Pass http.extraHeader via GIT_CONFIG_* env (git 2.31+)
         ;; instead of `-c http.extraHeader=…` in argv. Keeps the auth
diff --git a/quire-core/src/fennel.rs b/quire-core/src/fennel.rs
index 1c1c991..ce141fd 100644
--- a/quire-core/src/fennel.rs
+++ b/quire-core/src/fennel.rs
@@ -83,17 +83,21 @@ impl Fennel {
 
         lua.globals().set("fennel", fennel_module)?;
 
-        // Stub `runtime` so the embedded stdlib (and user pipelines)
-        // compile under Fennel's strict-globals check. Mirror it into
-        // `package.loaded["quire.runtime"]` so library code can write
-        // `(require :quire.runtime)` instead of touching the global.
-        // Both slots are replaced at execution time by
-        // `RuntimeHandle::install`.
+        // Seed a placeholder `quire.ci` module exposing only an empty
+        // `runtime` stub. The stub is the canonical runtime table:
+        // `RuntimeHandle::install` mutates it in place and `uninstall`
+        // clears it. `registration::register` overwrites the rest of
+        // `quire.ci` (job/image/mirror) but carries this same stub
+        // forward as the new module's `runtime` field, so references
+        // captured before, during, and after registration all point at
+        // the same Lua table. There is no `quire.runtime` module — all
+        // access flows through `(require :quire.ci) → .runtime`.
         let stub: mlua::Table = lua.create_table()?;
-        lua.globals().set("runtime", stub.clone())?;
+        let placeholder: mlua::Table = lua.create_table()?;
+        placeholder.set("runtime", stub)?;
         let package: mlua::Table = lua.globals().get("package")?;
         let loaded: mlua::Table = package.get("loaded")?;
-        loaded.set("quire.runtime", stub)?;
+        loaded.set("quire.ci", placeholder)?;
 
         let f = Self { lua };
         f.preload_stdlib()?;
@@ -485,14 +489,19 @@ mod tests {
     }
 
     #[test]
-    fn defrun_destructures_from_ambient_runtime() {
+    fn defrun_destructures_from_quire_ci_runtime() {
         let f = fennel();
 
-        // Replace the stub with a runtime table whose `sh` records calls.
+        // Populate the runtime stub with a `sh` that records calls.
+        // defrun expands to `(let [<pat> (. (require :quire.ci) :runtime)] …)`,
+        // so the destructure pulls `sh` straight from this table.
         let calls: std::rc::Rc<std::cell::RefCell<Vec<String>>> =
             std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
         let cb_calls = calls.clone();
-        let rt = f.lua().create_table().expect("rt");
+        let package: mlua::Table = f.lua().globals().get("package").expect("package");
+        let loaded: mlua::Table = package.get("loaded").expect("package.loaded");
+        let ci: mlua::Table = loaded.get("quire.ci").expect("quire.ci placeholder");
+        let rt: mlua::Table = ci.get("runtime").expect("quire.ci.runtime stub");
         rt.set(
             "sh",
             f.lua()
@@ -503,7 +512,6 @@ mod tests {
                 .expect("create sh"),
         )
         .expect("set sh");
-        f.lua().globals().set("runtime", rt).expect("set runtime");
 
         let source = r#"
 (import-macros {: defrun} :quire.ci)
@@ -553,21 +561,30 @@ mod tests {
     }
 
     #[test]
-    fn quire_runtime_module_preloaded_to_stub() {
+    fn quire_ci_placeholder_exposes_empty_runtime_stub() {
         let f = fennel();
-        // Before install, `(require :quire.runtime)` returns the stub
-        // table — same reference as the `runtime` global.
-        let stub_via_require: mlua::Table = f
+        // Before registration runs, `(require :quire.ci)` returns a
+        // placeholder table with only an empty `runtime` stub —
+        // primitives are nil until `RuntimeHandle::install` populates
+        // them. There is no `quire.runtime` module and no `runtime`
+        // global; the placeholder is the only access path.
+        let stub: mlua::Table = f
             .lua()
-            .load(r#"return require("quire.runtime")"#)
+            .load(r#"return require("quire.ci").runtime"#)
             .eval()
-            .expect("require quire.runtime");
-        let stub_via_global: mlua::Table =
-            f.lua().globals().get("runtime").expect("runtime global");
-        assert!(
-            stub_via_require.equals(&stub_via_global).unwrap(),
-            "require and global should resolve to the same stub"
-        );
+            .expect("require quire.ci.runtime");
+        assert!(matches!(
+            stub.get::<mlua::Value>("sh").expect("sh"),
+            mlua::Value::Nil
+        ));
+        let global: mlua::Value = f.lua().globals().get("runtime").expect("globals lookup");
+        assert!(matches!(global, mlua::Value::Nil));
+        let quire_runtime: mlua::Value = f
+            .lua()
+            .load(r#"return package.loaded["quire.runtime"]"#)
+            .eval()
+            .expect("eval");
+        assert!(matches!(quire_runtime, mlua::Value::Nil));
     }
 
     #[test]
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index 3fac96a..f5f94f7 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -290,7 +290,7 @@ impl Run {
         // jobs that did run before the failure are useful context.
         let outputs = runtime.take_outputs();
         let timings = runtime.take_sh_timings();
-        lua.remove_app_data::<Rc<Runtime>>();
+        RuntimeHandle::uninstall(lua).expect("uninstall runtime");
 
         self.write_sh_records(&outputs, &timings)?;
 
@@ -1032,8 +1032,8 @@ mod tests {
         fs_err::write(workspace.join("marker"), "x").expect("write marker");
 
         let pipeline = load(
-            r#"(local ci (require :quire.ci))
-(ci.job :pwd [:quire/push] (fn [] (runtime.sh ["ls"])))"#,
+            r#"(local {: job : runtime} (require :quire.ci))
+(job :pwd [:quire/push] (fn [] (runtime.sh ["ls"])))"#,
         );
 
         let outputs = run
@@ -1060,9 +1060,9 @@ mod tests {
         let run = runs.create(&test_meta()).expect("create");
 
         let pipeline = load(
-            r#"(local ci (require :quire.ci))
-(ci.job :a [:quire/push] (fn [] (runtime.sh ["echo" "from-a"])))
-(ci.job :b [:a] (fn [] (runtime.sh ["echo" "from-b"])))"#,
+            r#"(local {: job : runtime} (require :quire.ci))
+(job :a [:quire/push] (fn [] (runtime.sh ["echo" "from-a"])))
+(job :b [:a] (fn [] (runtime.sh ["echo" "from-b"])))"#,
         );
 
         let run_id = run.id().to_string();
@@ -1096,9 +1096,9 @@ mod tests {
         let log = quire.base_dir().join("order.log");
         let log_str = log.to_string_lossy();
         let source = format!(
-            r#"(local ci (require :quire.ci))
-(ci.job :b [:a] (fn [] (runtime.sh (.. "echo b >> {log}"))))
-(ci.job :a [:quire/push] (fn [] (runtime.sh (.. "echo a >> {log}"))))"#,
+            r#"(local {{: job : runtime}} (require :quire.ci))
+(job :b [:a] (fn [] (runtime.sh (.. "echo b >> {log}"))))
+(job :a [:quire/push] (fn [] (runtime.sh (.. "echo a >> {log}"))))"#,
             log = log_str
         );
         let pipeline = load(&source);
@@ -1122,9 +1122,9 @@ mod tests {
         let run = runs.create(&test_meta()).expect("create");
 
         let pipeline = load(
-            r#"(local ci (require :quire.ci))
-(ci.job :a [:quire/push] (fn [] (error "boom")))
-(ci.job :b [:a] (fn [] (runtime.sh ["echo" "should-not-run"])))"#,
+            r#"(local {: job : runtime} (require :quire.ci))
+(job :a [:quire/push] (fn [] (error "boom")))
+(job :b [:a] (fn [] (runtime.sh ["echo" "should-not-run"])))"#,
         );
 
         let run_id = run.id().to_string();
@@ -1150,8 +1150,8 @@ mod tests {
         let run = runs.create(&test_meta()).expect("create");
 
         let pipeline = load(
-            r#"(local ci (require :quire.ci))
-(ci.job :grab [:quire/push]
+            r#"(local {: job : runtime} (require :quire.ci))
+(job :grab [:quire/push]
   (fn []
     (let [push (runtime.jobs :quire/push)]
       (runtime.sh ["echo" push.sha push.ref]))))"#,
@@ -1178,9 +1178,9 @@ mod tests {
         let run = runs.create(&test_meta()).expect("create");
 
         let pipeline = load(
-            r#"(local ci (require :quire.ci))
-(ci.job :a [:quire/push] (fn [] nil))
-(ci.job :b [:a]
+            r#"(local {: job : runtime} (require :quire.ci))
+(job :a [:quire/push] (fn [] nil))
+(job :b [:a]
   (fn []
     (let [push (runtime.jobs :quire/push)]
       (runtime.sh ["echo" push.sha]))))"#,
@@ -1207,8 +1207,8 @@ mod tests {
         let run = runs.create(&test_meta()).expect("create");
 
         let pipeline = load(
-            r#"(local ci (require :quire.ci))
-(ci.job :grab [:quire/push] (fn [] (runtime.jobs :nope)))"#,
+            r#"(local {: job : runtime} (require :quire.ci))
+(job :grab [:quire/push] (fn [] (runtime.jobs :nope)))"#,
         );
 
         let err = run
@@ -1237,9 +1237,9 @@ mod tests {
         let run = runs.create(&test_meta()).expect("create");
 
         let pipeline = load(
-            r#"(local ci (require :quire.ci))
-(ci.job :peer [:quire/push] (fn [] nil))
-(ci.job :grab [:quire/push] (fn [] (runtime.jobs :peer)))"#,
+            r#"(local {: job : runtime} (require :quire.ci))
+(job :peer [:quire/push] (fn [] nil))
+(job :grab [:quire/push] (fn [] (runtime.jobs :peer)))"#,
         );
 
         let err = run
@@ -1267,8 +1267,8 @@ mod tests {
         let run = runs.create(&test_meta()).expect("create");
 
         let pipeline = load(
-            r#"(local ci (require :quire.ci))
-(ci.job :grab [:quire/push] (fn [] (runtime.jobs :grab)))"#,
+            r#"(local {: job : runtime} (require :quire.ci))
+(job :grab [:quire/push] (fn [] (runtime.jobs :grab)))"#,
         );
 
         let err = run
@@ -1296,9 +1296,9 @@ mod tests {
         let run = runs.create(&test_meta()).expect("create");
 
         let pipeline = load(
-            r#"(local ci (require :quire.ci))
-(ci.job :a [:quire/push] (fn [] nil))
-(ci.job :b [:a]
+            r#"(local {: job : runtime} (require :quire.ci))
+(job :a [:quire/push] (fn [] nil))
+(job :b [:a]
   (fn []
     (let [a-outputs (runtime.jobs :a)]
       (runtime.sh ["echo" (tostring a-outputs)]))))"#,
@@ -1324,8 +1324,8 @@ mod tests {
         let run = runs.create(&test_meta()).expect("create");
 
         let pipeline = load(
-            r#"(local ci (require :quire.ci))
-(ci.job :greet [:quire/push] (fn [] (runtime.sh ["echo" "hello"])))"#,
+            r#"(local {: job : runtime} (require :quire.ci))
+(job :greet [:quire/push] (fn [] (runtime.sh ["echo" "hello"])))"#,
         );
 
         let run_id = run.id().to_string();
@@ -1369,9 +1369,9 @@ 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 [] (runtime.sh ["echo" "from-a"])))
-(ci.job :b [:a] (fn [] (error "boom")))"#,
+            r#"(local {: job : runtime} (require :quire.ci))
+(job :a [:quire/push] (fn [] (runtime.sh ["echo" "from-a"])))
+(job :b [:a] (fn [] (error "boom")))"#,
         );
 
         let run_id = run.id().to_string();
@@ -1402,11 +1402,11 @@ mod tests {
         let run = runs.create(&test_meta()).expect("create");
 
         let pipeline = load(
-            r#"(local ci (require :quire.ci))
-(ci.image "alpine")
-(ci.job :bad [:quire/push]
+            r#"(local {: job : image} (require :quire.ci))
+(image "alpine")
+(job :bad [:quire/push]
   (fn []
-    (ci.image "sneaky")))"#,
+    (image "sneaky")))"#,
         );
 
         let err = run
@@ -1437,8 +1437,8 @@ mod tests {
         let run = runs.create(&test_meta()).expect("create");
 
         let mut pipeline = load(
-            r#"(local ci (require :quire.ci))
-(ci.job :only [:quire/push] (fn [] nil))"#,
+            r#"(local {: job : runtime} (require :quire.ci))
+(job :only [:quire/push] (fn [] nil))"#,
         );
 
         let called = Rc::new(Cell::new(false));
@@ -1465,8 +1465,8 @@ mod tests {
         let run = runs.create(&test_meta()).expect("create");
 
         let mut pipeline = load(
-            r#"(local ci (require :quire.ci))
-(ci.job :boom [:quire/push] (fn [] nil))"#,
+            r#"(local {: job : runtime} (require :quire.ci))
+(job :boom [:quire/push] (fn [] nil))"#,
         );
 
         pipeline.replace_first_run_fn(RunFn::Rust(Rc::new(|_rt| {