Pass runtime primitives as the run-fn argument
The planned jobs accessor needs per-execution scope (calling job,
transitive inputs); separating runtime primitives from the parse-time
registration is the prerequisite.

Assisted-by: Claude Opus 4.7 via Claude Code
change numxvusooxssslkottnkzxotxzsrpsul
commit f6afc03465cba91532df5a96dda4e8b77b4f2248
author Alpha Chen <alpha@kejadlen.dev>
date
parent rnltlvqy
diff --git a/docs/CI-FENNEL.md b/docs/CI-FENNEL.md
index d22271b..4876d53 100644
--- a/docs/CI-FENNEL.md
+++ b/docs/CI-FENNEL.md
@@ -47,26 +47,19 @@ The dependency graph is *derived* from the inputs list. No separate `:needs` fie
 
 ### Accessing inputs
 
-The function receives an outer table with an `:inputs` key. Standard pattern is to destructure:
+The function receives the **runtime handle** as its sole argument — a table with `sh`, `secret`, and (planned) `jobs` bound on it. Destructure the primitives the body needs:
 
 ```
-(fn [{: inputs}]
-  (.. "checkout " inputs.build.sha))
+(fn [{: sh : jobs}]
+  (let [push (jobs :quire/push)]
+    (sh ["git" "checkout" push.sha])))
 ```
 
-For source inputs whose names contain `/`, the dot-access syntax is awkward. **Destructure at the function arg** — both cleaner and less error-prone:
+`(jobs name)` returns the outputs for `name` if `name` is a transitive ancestor of the calling job in the input graph; an unknown or non-ancestor name raises a Lua error. Self-lookup is rejected. Sources and jobs share one namespace — `(jobs :quire/push)` reads the source's outputs uniformly.
 
-```
-(fn [{:inputs {:quire/push push}}]
-  (.. "checkout " push.sha))
-```
+The function-call form sidesteps the awkward dot-access on `/`-containing keys. The `:as` rebinding sugar planned for cron and cherry-picked outputs (see "Future: input args" below) layers on top of the same accessor.
 
-The `push` local rebinding is the recommended idiom for any source input. Use the same pattern when destructuring multiple inputs:
-
-```
-(fn [{:inputs {:quire/push push : build : compute-version}}]
-  (.. "deploying " compute-version.version " from " push.sha))
-```
+> **v0 status:** the runtime handle currently carries `sh` and `secret`. The `jobs` accessor is the next addition (tracked separately); until it lands, jobs cannot read upstream outputs and `:quire/push` data is not exposed to the body.
 
 ### Sources
 
@@ -87,16 +80,16 @@ Every push to any ref fires a run that includes every job whose transitive input
 
 ```
 (job :test-main [:quire/push]
-  (fn [{:inputs {:quire/push push}}]
-    (when (= "main" push.branch)
-      (container {:image "rust:1.75"
-                  :cmd (.. "git checkout " push.sha " && cargo test")}))))
+  (fn [{: sh : jobs}]
+    (let [push (jobs :quire/push)]
+      (when (= "main" push.branch)
+        (sh (.. "git checkout " push.sha " && cargo test"))))))
 
 (job :release [:quire/push]
-  (fn [{:inputs {:quire/push push}}]
-    (when (and push.tag (string.match push.tag "^v"))
-      (container {:image "alpine"
-                  :cmd (.. "publish " push.tag)}))))
+  (fn [{: sh : jobs}]
+    (let [push (jobs :quire/push)]
+      (when (and push.tag (string.match push.tag "^v"))
+        (sh (.. "publish " push.tag))))))
 ```
 
 Fennel's `when` returns `nil` if the predicate is false, otherwise the body. That nil propagates out as the `run` return value, the runner records the job as skipped. The gate and the work are in the same expression.
@@ -109,10 +102,12 @@ Source types that need configuration — cron schedules, webhook paths — can't
 
 ```
 (job :nightly-audit [(cron :daily)]
-  (fn [{:inputs {: cron}}] ...))
+  (fn [{: jobs}]
+    (let [tick (jobs :cron)] ...)))
 
 (job :hourly-check [(cron :every "1h" :as :hourly)]
-  (fn [{:inputs {: hourly}}] ...))
+  (fn [{: jobs}]
+    (let [tick (jobs :hourly)] ...)))
 ```
 
 `cron`, `webhook`, etc. would be quire-provided functions in the eval scope. They return marker values; the runner inspects the inputs list for them, registers their event sources, instantiates runs when they fire. The `:as` keyword names the binding when the default name (the source type) would collide.
@@ -135,20 +130,21 @@ A bad `ci.fnl` push gets a CI run that fails immediately with the parse error, s
 
 ## `run` — the only primitive
 
-`run` is a host-side Fennel function (the container can't run Fennel) called when the job is about to execute, with all upstream inputs resolved. It returns either:
+`run` is a host-side Fennel function (the container can't run Fennel) called when the job is about to execute. It receives the runtime handle and returns either:
 
-* **A table** — the job's outputs. Whatever keys are in it become available to dependent jobs as `inputs.<this-job>.<key>`.
-* **`nil`** — the job is skipped. Dependents see `inputs.<this-job>` as `nil`.
+* **A table** — the job's outputs. Available to dependent jobs through `(jobs <this-job>)`.
+* **`nil`** — the job is skipped. Dependents see `(jobs <this-job>)` return `nil`.
 
 That's the whole contract. No sugar layer, no introspection, no defaulting. The runner records what was returned.
 
-Inside `run`, the function uses **runtime primitives** to do work. The most important is `(container {...})`, which runs a container and returns a result table:
+Inside `run`, the function uses **runtime primitives** bound on the handle. The most important is `(container {...})`, which runs a container and returns a result table:
 
 ```
 (job :test [:quire/push]
-  (fn [{:inputs {:quire/push push}}]
-    (container {:image "rust:1.75"
-                :cmd (.. "git checkout " push.sha " && cargo test")})))
+  (fn [{: container : jobs}]
+    (let [push (jobs :quire/push)]
+      (container {:image "rust:1.75"
+                  :cmd (.. "git checkout " push.sha " && cargo test")}))))
 ```
 
 `(container ...)` returns `{:exit :stdout :stderr :duration}`. That's what `run` returns. The runner records it as the outputs.
@@ -157,8 +153,9 @@ For more complex jobs, the function does its own orchestration: multiple contain
 
 ```
 (job :test-and-package [:quire/push]
-  (fn [{:inputs {:quire/push push}}]
-    (let [test (container {:image "rust:1.75"
+  (fn [{: container : jobs}]
+    (let [push (jobs :quire/push)
+          test (container {:image "rust:1.75"
                            :cmd ["git checkout" push.sha "&&" "cargo test"]})]
       (when (= 0 test.exit)
         (let [pkg (container {:image "alpine"
@@ -183,16 +180,20 @@ The residual things that *aren't* "just functions" — the inputs list and the i
 
 ## Runtime primitives
 
-Functions in scope inside `run`:
+Bound on the runtime handle passed into each `run` function. Destructure what you need: `(fn [{: sh : secret : jobs}] ...)`.
 
+* `(jobs name)` — return outputs for `name` (a transitive ancestor of the calling job, or a source ref). Errors if `name` is not in the calling job's transitive inputs.
 * `(container {opts})` — run a container, return `{:exit :stdout :stderr :duration}`. Opts: `:image`, `:cmd` (string or list), `:env`, `:cwd`, `:cache` (cache dir mount, defaults to job's image-keyed cache).
 * `(sh cmd)` — run a command on the host, no container. For cheap utility work. Returns the same shape as `container`.
+* `(secret name)` — resolve a named secret from the operator's config. Errors if the name isn't declared.
 * `(read-file path)`, `(read-json path)`, `(write-file path content)` — workspace I/O. Paths relative to the workspace.
 * `(log msg)` — append to the job's log file. Visible in the web UI.
-* `(env name)` — read an environment variable from the runner's environment (typically secrets).
+* `(env name)` — read an environment variable from the runner's environment.
 
 Each of these blocks the Fennel function until it returns. Multi-container parallelism inside one job is a v2 want; the v1 model is "the function runs sequentially, calling primitives that block."
 
+> **v0 status:** `sh` and `secret` are bound today. `jobs`, `container`, `read-file`/`read-json`/`write-file`, `log`, and `env` are planned and tracked separately.
+
 Eval is unsandboxed by default (see CI.md). A `run` function that loops forever or allocates without bound will hang or OOM `quire serve`. The mitigation is the same as for any Fennel hang: write `ci.fnl` thoughtfully. The bwrap opt-in (also see CI.md) covers eval and primitive calls together when it lands.
 
 ## A worked example
@@ -201,11 +202,12 @@ Eval is unsandboxed by default (see CI.md). A `run` function that loops forever
 ;; Helper: a parameterized test job
 (fn rust-test [version]
   (job (.. "test-" version) [:quire/push]
-    (fn [{:inputs {:quire/push push}}]
-      (when (= "main" push.branch)
-        (container {:image (.. "rust:" version)
-                    :cmd [(.. "git checkout " push.sha)
-                          "cargo test --all-features"]})))))
+    (fn [{: container : jobs}]
+      (let [push (jobs :quire/push)]
+        (when (= "main" push.branch)
+          (container {:image (.. "rust:" version)
+                      :cmd [(.. "git checkout " push.sha)
+                            "cargo test --all-features"]}))))))
 
 ;; Matrix testing on every push to main
 (each [_ v (ipairs [:1.75 :1.76 :stable])]
@@ -213,31 +215,36 @@ Eval is unsandboxed by default (see CI.md). A `run` function that loops forever
 
 ;; Build only if all tests passed
 (job :build [:test-1.75 :test-1.76 :test-stable :quire/push]
-  (fn [{:inputs {:quire/push push : test-1.75 : test-1.76 : test-stable}}]
-    (when (and test-1.75 test-1.76 test-stable
-               (= 0 test-1.75.exit)
-               (= 0 test-1.76.exit)
-               (= 0 test-stable.exit))
-      (let [r (container {:image "rust:1.75"
-                          :cmd [(.. "git checkout " push.sha)
-                                "cargo build --release"]})]
-        {:exit r.exit
-         :artifacts ["target/release/quire"]}))))
+  (fn [{: container : jobs}]
+    (let [push (jobs :quire/push)
+          t-175 (jobs :test-1.75)
+          t-176 (jobs :test-1.76)
+          t-stb (jobs :test-stable)]
+      (when (and t-175 t-176 t-stb
+                 (= 0 t-175.exit)
+                 (= 0 t-176.exit)
+                 (= 0 t-stb.exit))
+        (let [r (container {:image "rust:1.75"
+                            :cmd [(.. "git checkout " push.sha)
+                                  "cargo build --release"]})]
+          {:exit r.exit
+           :artifacts ["target/release/quire"]})))))
 
 ;; Deploy on push to main only
 (job :deploy [:build]
-  (fn [{:inputs {: build}}]
-    (when build
+  (fn [{: container : jobs}]
+    (when (jobs :build)
       (container {:image "alpine"
                   :cmd "scp target/release/quire host:/usr/local/bin/"}))))
 
 ;; Tagged release: publish to a registry
 (job :publish [:quire/push]
-  (fn [{:inputs {:quire/push push}}]
-    (when (and push.tag (string.match push.tag "^v"))
-      (container {:image "rust:1.75"
-                  :cmd [(.. "git checkout " push.tag)
-                        "cargo publish"]}))))
+  (fn [{: container : jobs}]
+    (let [push (jobs :quire/push)]
+      (when (and push.tag (string.match push.tag "^v"))
+        (container {:image "rust:1.75"
+                    :cmd [(.. "git checkout " push.tag)
+                          "cargo publish"]})))))
 ```
 
 What this expresses:
@@ -279,10 +286,11 @@ The three-context model means **`ci.fnl` is re-evaluated more than you might exp
 * **Builtins live under `quire/`**; user job ids cannot contain `/`.
 * **For v1, the only source is `:quire/push`.** Cron, webhook, manual deferred.
 * **Filtering happens inside `run`** by returning `nil`. Every push starts a run; jobs that return nil from `run` are skipped.
-* **Destructure source inputs at the function arg** — `(fn [{:inputs {:quire/push push}}] ...)` — to avoid awkward dot-access on `/`-containing keys.
+* **Runtime handle as the run-fn argument.** The function receives a single table `{: sh : secret : jobs : container ...}` and destructures the primitives it uses. Slash-containing source names are read via the `jobs` accessor — `(jobs :quire/push)` — never via dot access.
+* **`(jobs name)` is the only accessor for upstream outputs**, covering both source refs and job outputs. Transitive ancestors are visible; non-ancestors and unknown names raise a Lua error.
 * **Dependency graph derived from the inputs list**, not declared separately. No `:needs`.
 * **Four structural validations**: acyclic (registration eval), non-empty inputs (registration eval), reachability from a source (registration eval), no `/` in user job ids (parse time). All fail-closed with named-target error messages.
-* **`run` is a function** `(fn [{: inputs}] ...)`. Returns a table (the outputs) or `nil` (skipped). No sugar.
+* **`run` is a function** `(fn [{: jobs ...}] ...)`. Returns a table (the outputs) or `nil` (skipped). No sugar.
 * **`(container {opts})` is the primary primitive** for running containers. Opts include `:image`, so a single job can use multiple images by making multiple container calls.
 * **Three eval contexts** — registration, run start, per job — all in-process inside `quire serve`. Sandboxing model and threat model are described in CI.md.
 * **Source registration sourced from the default branch only** (relevant once registration becomes meaningful — for v1 it's a no-op since `:quire/push` needs no registration).
diff --git a/src/bin/quire/commands/ci.rs b/src/bin/quire/commands/ci.rs
index 773de3a..1818805 100644
--- a/src/bin/quire/commands/ci.rs
+++ b/src/bin/quire/commands/ci.rs
@@ -14,8 +14,7 @@ pub async fn validate(maybe_sha: Option<&str>) -> Result<()> {
     let commit = resolve_commit(maybe_sha)?;
     let ci = Ci::new(repo_path);
 
-    // Structural validation only — no need to resolve secrets.
-    let Some(pipeline) = ci.load(&commit, std::collections::HashMap::new())? else {
+    let Some(pipeline) = ci.load(&commit)? else {
         println!("No ci.fnl found at {}.", commit.display);
         return Ok(());
     };
@@ -48,14 +47,16 @@ pub async fn run(quire: &Quire, maybe_sha: Option<&str>) -> Result<()> {
     let ci = Ci::new(repo_path);
 
     // Pull secrets from the global config; absence is fine for local
-    // testing. A broken-but-present config is a real error.
+    // testing. A broken-but-present config is a real error. Secrets
+    // are passed to `Run::execute` rather than `Ci::load` since they
+    // only matter when the run-fns actually fire.
     let secrets = match quire.global_config() {
         Ok(c) => c.secrets,
         Err(quire::Error::ConfigNotFound(_)) => std::collections::HashMap::new(),
         Err(e) => return Err(e).into_diagnostic(),
     };
 
-    let Some(pipeline) = ci.load(&commit, secrets)? else {
+    let Some(pipeline) = ci.load(&commit)? else {
         println!("No ci.fnl found at {}.", commit.display);
         return Ok(());
     };
@@ -75,7 +76,7 @@ pub async fn run(quire: &Quire, maybe_sha: Option<&str>) -> Result<()> {
     let mut run = runs.create(&meta)?;
     println!("Run {}: executing at {}", run.id(), commit.display);
 
-    let exec_result = run.execute(&pipeline);
+    let exec_result = run.execute(&pipeline, secrets);
 
     for job in pipeline.jobs() {
         let outputs = run.outputs(&job.id);
diff --git a/src/ci/lua.rs b/src/ci/lua.rs
index f96106e..ba3d796 100644
--- a/src/ci/lua.rs
+++ b/src/ci/lua.rs
@@ -1,10 +1,11 @@
-//! Lua bridge for `ci.fnl`: the `quire.ci` module exposed to Fennel
-//! scripts and the runtime primitives (`job`, `secret`, `sh`).
+//! Lua bridge for `ci.fnl`: the registration-time module exposed via
+//! `(require :quire.ci)` and the per-execution runtime handle passed
+//! 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;
-//! everything else (the `quire.ci` table, the primitive bodies, the
-//! Lua-side data shapes) is internal.
+//! the run module installs a [`Runtime`] and threads its handle into
+//! each `run-fn` at execute time.
 
 use std::cell::RefCell;
 use std::collections::HashMap;
@@ -17,25 +18,22 @@ use crate::Result;
 use crate::fennel::Fennel;
 use crate::secret::SecretString;
 
-/// Evaluate `source` with the `quire.ci` module bound and collect the
-/// registration results — one `Result` per `(ci.job …)` call. Pre-graph
-/// rules run inside the callback, so a single bad job does not abort
-/// the rest of the script.
+/// Evaluate `source` with the registration module bound and collect
+/// the registration results — one `Result` per `(ci.job …)` call.
+/// 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,
-    secrets: HashMap<String, SecretString>,
 ) -> Result<Vec<std::result::Result<Job, ValidationError>>> {
     let jobs = Rc::new(RefCell::new(Vec::new()));
     let src = Rc::new(source.to_string());
-    let secrets = Rc::new(secrets);
 
     fennel.eval_raw(source, name, |lua| {
-        let module = CiModule {
+        let module = Registration {
             jobs: jobs.clone(),
             source: src.clone(),
-            secrets: secrets.clone(),
         }
         .install(lua)?;
         let loaded: mlua::Table = lua.globals().get::<mlua::Table>("package")?.get("loaded")?;
@@ -46,34 +44,33 @@ pub(super) fn parse(
     Ok(jobs.take())
 }
 
-/// The `quire.ci` module exposed to Fennel scripts via `require`.
+/// The registration-time module exposed to Fennel scripts via
+/// `(require :quire.ci)`.
 ///
-/// `install` stows the module on the Lua VM via `set_app_data`, then
-/// builds a plain table whose entries are bare functions that look the
-/// module back up at call time. Both `(ci.job …)` field access and
-/// `(local {: job : secret} (require :quire.ci))` destructuring work.
+/// `install` stows the registration sink on the Lua VM via
+/// `set_app_data` (so `register_job` can find it) and returns the
+/// `quire.ci` table — `{job}` for now. Runtime primitives (`sh`,
+/// `secret`) live on the per-execution [`Runtime`] handle, not here.
 ///
 /// ```fennel
 /// (local ci (require :quire.ci))
-/// (ci.job :build [:quire/push] (fn [_] nil))
-/// (ci.secret :github_token)
+/// (ci.job :build [:quire/push]
+///   (fn [{: sh : secret}]
+///     (sh ["echo" (secret :github_token)])))
 /// ```
-struct CiModule {
+struct Registration {
     jobs: Rc<RefCell<Vec<std::result::Result<Job, ValidationError>>>>,
     source: Rc<String>,
-    secrets: Rc<HashMap<String, SecretString>>,
 }
 
-impl CiModule {
-    /// Install the module on `lua` as app data and return the
-    /// `quire.ci` table. The registered functions error at call time
-    /// if the module isn't installed first.
+impl Registration {
+    /// Install on `lua` as app data and return the `quire.ci` table.
+    /// `register_job` errors at call time if the registration isn't
+    /// installed first.
     fn install(self, lua: &Lua) -> mlua::Result<mlua::Table> {
         lua.set_app_data(self);
         let table = lua.create_table()?;
         table.set("job", lua.create_function(register_job)?)?;
-        table.set("secret", lua.create_function(lookup_secret)?)?;
-        table.set("sh", lua.create_function(run_sh)?)?;
         Ok(table)
     }
 }
@@ -85,27 +82,88 @@ fn register_job(
     lua: &Lua,
     (id, inputs, run_fn): (String, Vec<String>, mlua::Function),
 ) -> mlua::Result<()> {
-    let m = lua
-        .app_data_ref::<CiModule>()
-        .ok_or_else(|| mlua::Error::external("quire.ci module not installed on Lua VM"))?;
+    let r = lua
+        .app_data_ref::<Registration>()
+        .ok_or_else(|| mlua::Error::external("quire.ci registration not installed on Lua VM"))?;
     let line = lua
         .inspect_stack(1, |d| d.current_line())
         .flatten()
         .map(|l| l as u32)
         .unwrap_or(0);
-    m.jobs
+    r.jobs
         .borrow_mut()
-        .push(Job::new(id, inputs, run_fn, line, &m.source));
+        .push(Job::new(id, inputs, run_fn, line, &r.source));
     Ok(())
 }
 
-/// Body of `(ci.secret name)`. Errors as a Lua error if the name is
-/// undeclared or the file form fails to read.
+/// Per-execution runtime: holds the secrets exposed to the job, the
+/// current-job cursor, and the per-job captured `sh` outputs.
+///
+/// `Run::execute` constructs an `Rc<Runtime>`, calls [`install`] to
+/// stow it on the Lua VM as app data and produce the handle table,
+/// and updates `current_job` around each `run_fn` call. `(sh …)` and
+/// `(secret …)` look the runtime back up via app data.
+///
+/// Outside a run, no runtime is installed; in that case `(sh …)`
+/// runs the command but doesn't record (the cursor lookup misses).
+/// `(secret …)` requires a runtime — without one, calls error.
+///
+/// [`install`]: Runtime::install
+#[derive(Debug)]
+pub(super) struct Runtime {
+    secrets: HashMap<String, SecretString>,
+    current_job: RefCell<Option<String>>,
+    outputs: RefCell<HashMap<String, Vec<ShOutput>>>,
+}
+
+impl Runtime {
+    /// Build a fresh runtime with the given secrets. Cursor and
+    /// outputs start empty.
+    pub(super) fn new(secrets: HashMap<String, SecretString>) -> Self {
+        Self {
+            secrets,
+            current_job: RefCell::new(None),
+            outputs: RefCell::new(HashMap::new()),
+        }
+    }
+
+    /// Install on `lua` as app data and return the runtime handle —
+    /// the table passed as the sole argument to each `run_fn`.
+    pub(super) fn install(self: Rc<Self>, lua: &Lua) -> mlua::Result<mlua::Table> {
+        lua.set_app_data(self);
+        let table = lua.create_table()?;
+        table.set("sh", lua.create_function(run_sh)?)?;
+        table.set("secret", lua.create_function(lookup_secret)?)?;
+        Ok(table)
+    }
+
+    /// Mark `id` as the currently executing job. `(sh …)` invocations
+    /// from this job's `run_fn` will record output under `id`.
+    pub(super) fn enter_job(&self, id: &str) {
+        *self.current_job.borrow_mut() = Some(id.to_string());
+    }
+
+    /// Clear the current-job cursor. Subsequent `(sh …)` calls (if
+    /// any) won't be attributed to a job until `enter_job` is called again.
+    pub(super) fn leave_job(&self) {
+        *self.current_job.borrow_mut() = None;
+    }
+
+    /// Snapshot the recorded outputs for `id`. Empty if the job
+    /// produced none (or hasn't run).
+    pub(super) fn outputs(&self, id: &str) -> Vec<ShOutput> {
+        self.outputs.borrow().get(id).cloned().unwrap_or_default()
+    }
+}
+
+/// Body of `(secret name)`. Errors as a Lua error if the runtime
+/// isn't installed, the name is undeclared, or the file form fails to
+/// read.
 fn lookup_secret(lua: &Lua, name: String) -> mlua::Result<String> {
-    let m = lua
-        .app_data_ref::<CiModule>()
-        .ok_or_else(|| mlua::Error::external("quire.ci module not installed on Lua VM"))?;
-    let secret = m
+    let rt = lua
+        .app_data_ref::<Rc<Runtime>>()
+        .ok_or_else(|| mlua::Error::external("runtime not installed on Lua VM"))?;
+    let secret = rt
         .secrets
         .get(&name)
         .ok_or_else(|| mlua::Error::external(crate::Error::UnknownSecret(name)))?;
@@ -115,7 +173,7 @@ fn lookup_secret(lua: &Lua, name: String) -> mlua::Result<String> {
         .map_err(mlua::Error::external)
 }
 
-/// The two valid shapes of `cmd` for `(ci.sh cmd …)`. A bare string
+/// The two valid shapes of `cmd` for `(sh cmd …)`. A bare string
 /// runs under `sh -c`; a sequence runs as argv with no shell.
 #[derive(serde::Deserialize)]
 #[serde(untagged)]
@@ -183,8 +241,7 @@ impl mlua::FromLua for Cmd {
                     from: "table",
                     to: "Cmd".into(),
                     message: Some(
-                        "ci.sh: cmd must be a non-empty sequence of strings or a shell string"
-                            .into(),
+                        "sh: cmd must be a non-empty sequence of strings or a shell string".into(),
                     ),
                 })
             }
@@ -192,14 +249,14 @@ impl mlua::FromLua for Cmd {
             other => Err(mlua::Error::FromLuaConversionError {
                 from: other.type_name(),
                 to: "Cmd".into(),
-                message: Some("ci.sh: cmd must be a string or sequence of strings".into()),
+                message: Some("sh: cmd must be a string or sequence of strings".into()),
             }),
         }
     }
 }
 
-/// The optional `opts` table for `(ci.sh cmd opts?)`. Unknown keys
-/// fail closed so typos surface rather than being silently ignored.
+/// The optional `opts` table for `(sh cmd opts?)`. Unknown keys fail
+/// closed so typos surface rather than being silently ignored.
 #[derive(Default, serde::Deserialize)]
 #[serde(default, deny_unknown_fields)]
 struct ShOpts {
@@ -213,9 +270,9 @@ impl mlua::FromLua for ShOpts {
     }
 }
 
-/// The captured outcome of running a process — what `(ci.sh …)`
-/// returns. Crosses the boundary as plain serde data: `lua.to_value`
-/// on the way out, `lua.from_value` on the way in.
+/// The captured outcome of running a process — what `(sh …)` returns.
+/// Crosses the boundary as plain serde data: `lua.to_value` on the
+/// way out, `lua.from_value` on the way in.
 ///
 /// A non-zero exit is reported in `:exit`, not raised as a Lua error —
 /// matches the shape `(container …)` will eventually use so callers can
@@ -227,52 +284,16 @@ pub struct ShOutput {
     pub stderr: String,
 }
 
-/// Per-execution state the Lua bridge consults at run time.
-///
-/// `Run::execute` constructs one of these, installs it on the Lua VM
-/// via app data, and updates `current_job` around each `run_fn` call.
-/// `(ci.sh …)` reads the state on each invocation and appends its
-/// captured output to `outputs[current_job]`.
-///
-/// Outside a run (e.g. unit tests that drive `run_fn` directly), no
-/// `Rc<RuntimeState>` is installed and `(ci.sh …)` simply executes
-/// the command and returns its result without recording.
-#[derive(Debug, Default)]
-pub(super) struct RuntimeState {
-    current_job: RefCell<Option<String>>,
-    outputs: RefCell<HashMap<String, Vec<ShOutput>>>,
-}
-
-impl RuntimeState {
-    /// Mark `id` as the currently executing job. `(ci.sh …)` invocations
-    /// from this job's `run_fn` will record output under `id`.
-    pub(super) fn enter_job(&self, id: &str) {
-        *self.current_job.borrow_mut() = Some(id.to_string());
-    }
-
-    /// Clear the current-job cursor. Subsequent `(ci.sh …)` calls (if
-    /// any) won't be attributed to a job until `enter_job` is called again.
-    pub(super) fn leave_job(&self) {
-        *self.current_job.borrow_mut() = None;
-    }
-
-    /// Snapshot the recorded outputs for `id`. Empty if the job
-    /// produced none (or hasn't run).
-    pub(super) fn outputs(&self, id: &str) -> Vec<ShOutput> {
-        self.outputs.borrow().get(id).cloned().unwrap_or_default()
-    }
-}
-
-/// Body of `(ci.sh cmd opts?)`. Glue between the Lua call and
-/// `Cmd::run` — defaults the opts, runs the command, records output
-/// into the active `RuntimeState` (if any), and converts the result
-/// back to a Lua table.
+/// Body of `(sh cmd opts?)`. Glue between the Lua call and `Cmd::run`
+/// — defaults the opts, runs the command, records output into the
+/// active runtime (if any) under the current job, and converts the
+/// result back to a Lua table.
 fn run_sh(lua: &Lua, (cmd, opts): (Cmd, Option<ShOpts>)) -> mlua::Result<mlua::Value> {
     let output = cmd
         .run(opts.unwrap_or_default())
         .map_err(mlua::Error::external)?;
 
-    if let Some(rt) = lua.app_data_ref::<Rc<RuntimeState>>()
+    if let Some(rt) = lua.app_data_ref::<Rc<Runtime>>()
         && let Some(job) = rt.current_job.borrow().as_ref()
     {
         rt.outputs
@@ -290,32 +311,40 @@ mod tests {
     use super::super::pipeline::Pipeline;
     use super::*;
 
+    /// Install a runtime with the given secrets on `pipeline`'s VM and
+    /// return the runtime handle. Mirrors what `Run::execute` does so
+    /// tests can drive a `run_fn` directly.
+    fn rt(pipeline: &Pipeline, secrets: HashMap<String, SecretString>) -> mlua::Table {
+        Rc::new(Runtime::new(secrets))
+            .install(pipeline.fennel().lua())
+            .expect("install runtime")
+    }
+
     #[test]
-    fn ci_secret_returns_resolved_value() {
+    fn secret_returns_resolved_value() {
         let mut secrets = HashMap::new();
         secrets.insert(
             "github_token".to_string(),
             SecretString::from_plain("ghp_test_value"),
         );
         let source = r#"(local ci (require :quire.ci))
-(ci.job :grab [:quire/push] (fn [_] (ci.secret :github_token)))"#;
-        let pipeline = Pipeline::load(source, "ci.fnl", secrets).expect("load should succeed");
+(ci.job :grab [:quire/push] (fn [{: secret}] (secret :github_token)))"#;
+        let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
         let token: String = pipeline.jobs()[0]
             .run_fn
-            .call(())
+            .call(rt(&pipeline, secrets))
             .expect("run_fn should return the secret value");
         assert_eq!(token, "ghp_test_value");
     }
 
     #[test]
-    fn ci_secret_errors_for_unknown_name() {
+    fn secret_errors_for_unknown_name() {
         let source = r#"(local ci (require :quire.ci))
-(ci.job :grab [:quire/push] (fn [_] (ci.secret :missing)))"#;
-        let pipeline =
-            Pipeline::load(source, "ci.fnl", HashMap::new()).expect("load should succeed");
+(ci.job :grab [:quire/push] (fn [{: secret}] (secret :missing)))"#;
+        let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
         let err = pipeline.jobs()[0]
             .run_fn
-            .call::<mlua::Value>(())
+            .call::<mlua::Value>(rt(&pipeline, HashMap::new()))
             .unwrap_err();
         let msg = err.to_string();
         assert!(
@@ -324,16 +353,15 @@ mod tests {
         );
     }
 
-    /// Build a pipeline whose single job's run-fn invokes `(ci.sh …)`,
-    /// invoke it, and decode the resulting Lua table as ShOutput through
-    /// the pipeline's VM via `lua.from_value`.
+    /// Build a pipeline whose single job's run-fn invokes `(sh …)`,
+    /// invoke it with the runtime handle, and decode the resulting Lua
+    /// table as ShOutput through the pipeline's VM via `lua.from_value`.
     fn run_sh_via_job(source: &str) -> ShOutput {
-        let pipeline =
-            Pipeline::load(source, "ci.fnl", HashMap::new()).expect("load should succeed");
+        let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
         let value: mlua::Value = pipeline.jobs()[0]
             .run_fn
-            .call(())
-            .expect("ci.sh call should return a value");
+            .call(rt(&pipeline, HashMap::new()))
+            .expect("sh call should return a value");
         pipeline
             .fennel()
             .lua()
@@ -342,10 +370,10 @@ mod tests {
     }
 
     #[test]
-    fn ci_sh_runs_argv_and_captures_stdout() {
+    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 [_] (ci.sh ["echo" "hello"])))"#,
+(ci.job :go [:quire/push] (fn [{: sh}] (sh ["echo" "hello"])))"#,
         );
         assert_eq!(r.exit, 0);
         assert_eq!(r.stdout, "hello\n");
@@ -353,26 +381,26 @@ mod tests {
     }
 
     #[test]
-    fn ci_sh_runs_string_under_shell() {
+    fn sh_runs_string_under_shell() {
         let r = run_sh_via_job(
             r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [_] (ci.sh "echo hello | tr a-z A-Z")))"#,
+(ci.job :go [:quire/push] (fn [{: sh}] (sh "echo hello | tr a-z A-Z")))"#,
         );
         assert_eq!(r.exit, 0);
         assert_eq!(r.stdout, "HELLO\n");
     }
 
     #[test]
-    fn ci_sh_reports_nonzero_exit_without_erroring() {
+    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 [_] (ci.sh "exit 7")))"#,
+(ci.job :go [:quire/push] (fn [{: sh}] (sh "exit 7")))"#,
         );
         assert_eq!(r.exit, 7);
     }
 
     #[test]
-    fn ci_sh_merges_env_into_inherited() {
+    fn sh_merges_env_into_inherited() {
         // SAFETY: setting an env var in a single-threaded test process.
         unsafe {
             std::env::set_var("CI_SH_INHERITED_TEST", "from-parent");
@@ -380,22 +408,22 @@ mod tests {
         let r = run_sh_via_job(
             r#"(local ci (require :quire.ci))
 (ci.job :go [:quire/push]
-  (fn [_]
-    (ci.sh "echo $CI_SH_INHERITED_TEST $CI_SH_OVERRIDE_TEST"
-           {:env {:CI_SH_OVERRIDE_TEST "from-opts"}})))"#,
+  (fn [{: sh}]
+    (sh "echo $CI_SH_INHERITED_TEST $CI_SH_OVERRIDE_TEST"
+        {:env {:CI_SH_OVERRIDE_TEST "from-opts"}})))"#,
         );
         assert_eq!(r.exit, 0);
         assert_eq!(r.stdout, "from-parent from-opts\n");
     }
 
     #[test]
-    fn ci_sh_honors_cwd() {
+    fn sh_honors_cwd() {
         let dir = tempfile::tempdir().expect("tempdir");
         // Resolve symlinks (macOS /tmp → /private/tmp) so the assertion holds.
         let canonical = fs_err::canonicalize(dir.path()).expect("canonicalize");
         let source = format!(
             r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [_] (ci.sh "pwd" {{:cwd "{}"}})))"#,
+(ci.job :go [:quire/push] (fn [{{: sh}}] (sh "pwd" {{:cwd "{}"}})))"#,
             canonical.display()
         );
         let r = run_sh_via_job(&source);
@@ -404,17 +432,16 @@ mod tests {
     }
 
     #[test]
-    fn ci_sh_rejects_unknown_opt_key() {
+    fn sh_rejects_unknown_opt_key() {
         let pipeline = Pipeline::load(
             r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [_] (ci.sh "echo hi" {:cwdir "/tmp"})))"#,
+(ci.job :go [:quire/push] (fn [{: sh}] (sh "echo hi" {:cwdir "/tmp"})))"#,
             "ci.fnl",
-            HashMap::new(),
         )
         .expect("load should succeed");
         let err = pipeline.jobs()[0]
             .run_fn
-            .call::<mlua::Value>(())
+            .call::<mlua::Value>(rt(&pipeline, HashMap::new()))
             .unwrap_err();
         let msg = err.to_string();
         assert!(
@@ -424,17 +451,16 @@ mod tests {
     }
 
     #[test]
-    fn ci_sh_rejects_non_sequence_table_as_cmd() {
+    fn sh_rejects_non_sequence_table_as_cmd() {
         let pipeline = Pipeline::load(
             r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [_] (ci.sh {:env {:FOO "bar"}})))"#,
+(ci.job :go [:quire/push] (fn [{: sh}] (sh {:env {:FOO "bar"}})))"#,
             "ci.fnl",
-            HashMap::new(),
         )
         .expect("load should succeed");
         let err = pipeline.jobs()[0]
             .run_fn
-            .call::<mlua::Value>(())
+            .call::<mlua::Value>(rt(&pipeline, HashMap::new()))
             .unwrap_err();
         let msg = err.to_string();
         assert!(
@@ -444,17 +470,16 @@ mod tests {
     }
 
     #[test]
-    fn ci_sh_rejects_empty_argv() {
+    fn sh_rejects_empty_argv() {
         let pipeline = Pipeline::load(
             r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [_] (ci.sh [])))"#,
+(ci.job :go [:quire/push] (fn [{: sh}] (sh [])))"#,
             "ci.fnl",
-            HashMap::new(),
         )
         .expect("load should succeed");
         let err = pipeline.jobs()[0]
             .run_fn
-            .call::<mlua::Value>(())
+            .call::<mlua::Value>(rt(&pipeline, HashMap::new()))
             .unwrap_err();
         let msg = err.to_string();
         assert!(
diff --git a/src/ci/mod.rs b/src/ci/mod.rs
index 90391db..6cb97e7 100644
--- a/src/ci/mod.rs
+++ b/src/ci/mod.rs
@@ -18,13 +18,11 @@ pub struct CommitRef {
     pub display: String,
 }
 
-use std::collections::HashMap;
 use std::path::PathBuf;
 
 use crate::Result;
 use crate::event::{PushEvent, PushRef};
 use crate::quire::Repo;
-use crate::secret::SecretString;
 
 /// Path to the CI config within a bare repo, relative to the repo root.
 pub const CI_FNL: &str = ".quire/ci.fnl";
@@ -50,23 +48,19 @@ impl Ci {
 
     /// Load ci.fnl at a given SHA and return the validated pipeline.
     ///
-    /// `secrets` is the map of named secrets exposed to the script via
-    /// `(ci:secret …)`; pass an empty map for structural validation that
-    /// does not need to resolve secrets at registration time.
+    /// Pure parse 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,
-        secrets: HashMap<String, SecretString>,
-    ) -> Result<Option<Pipeline>> {
+    pub fn load(&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, secrets)?;
+        let pipeline = Pipeline::load(&source, &name)?;
         Ok(Some(pipeline))
     }
 
@@ -117,20 +111,8 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
         }
     };
 
-    // Pull the secrets map up front; missing global config means no
-    // secrets are available, but a present-but-broken config is a real
-    // error and aborts the trigger.
-    let secrets = match quire.global_config() {
-        Ok(c) => c.secrets,
-        Err(crate::Error::ConfigNotFound(_)) => HashMap::new(),
-        Err(e) => {
-            tracing::error!(repo = %event.repo, %e, "failed to load global config");
-            return;
-        }
-    };
-
     for push_ref in event.updated_refs() {
-        if let Err(e) = trigger_ref(&repo, event.pushed_at, push_ref, secrets.clone()) {
+        if let Err(e) = trigger_ref(&repo, event.pushed_at, push_ref) {
             tracing::error!(
                 repo = %event.repo,
                 sha = %push_ref.new_sha,
@@ -142,12 +124,7 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
 }
 
 /// Create and run CI for a single updated ref.
-fn trigger_ref(
-    repo: &Repo,
-    pushed_at: jiff::Timestamp,
-    push_ref: &PushRef,
-    secrets: HashMap<String, SecretString>,
-) -> Result<()> {
+fn trigger_ref(repo: &Repo, pushed_at: jiff::Timestamp, push_ref: &PushRef) -> Result<()> {
     let ci = repo.ci();
 
     let Some(source) = ci.source(&push_ref.new_sha)? else {
@@ -173,7 +150,7 @@ fn trigger_ref(
 
     let name = CI_FNL.to_string();
 
-    match Pipeline::load(&source, &name, secrets) {
+    match Pipeline::load(&source, &name) {
         Ok(_pipeline) => run.transition(RunState::Complete)?,
         Err(e) => {
             run.transition(RunState::Failed)?;
diff --git a/src/ci/pipeline.rs b/src/ci/pipeline.rs
index 816c8b9..df01835 100644
--- a/src/ci/pipeline.rs
+++ b/src/ci/pipeline.rs
@@ -13,7 +13,6 @@ use petgraph::graph::NodeIndex;
 use super::lua;
 use crate::Result;
 use crate::fennel::Fennel;
-use crate::secret::SecretString;
 
 /// Edges point from dependency to dependent. Node weights are indices
 /// into `Pipeline::jobs`; source refs (e.g. `quire/push`) are not nodes.
@@ -124,13 +123,9 @@ impl Pipeline {
     /// 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,
-        secrets: HashMap<String, SecretString>,
-    ) -> Result<Pipeline> {
+    pub(crate) fn load(source: &str, name: &str) -> Result<Pipeline> {
         let fennel = Fennel::new()?;
-        let results = lua::parse(&fennel, source, name, secrets)?;
+        let results = lua::parse(&fennel, source, name)?;
 
         let mut errors = Vec::new();
         let mut jobs = Vec::new();
@@ -343,8 +338,7 @@ mod tests {
     fn load_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", HashMap::new()).expect("load should succeed");
+        let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
         let jobs = pipeline.jobs();
         assert_eq!(jobs.len(), 1);
         assert_eq!(jobs[0].id, "test");
@@ -358,8 +352,7 @@ mod tests {
 (ci.job :build [:quire/push] (fn [_] nil))
 (ci.job :test [:build] (fn [_] nil))
 "#;
-        let pipeline =
-            Pipeline::load(source, "ci.fnl", HashMap::new()).expect("load should succeed");
+        let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
         let jobs = pipeline.jobs();
         assert_eq!(jobs.len(), 2);
         assert_eq!(jobs[0].id, "build");
@@ -376,8 +369,7 @@ mod tests {
 
 
 (ci.job :sixth [:quire/push] (fn [_] nil))";
-        let pipeline =
-            Pipeline::load(source, "ci.fnl", HashMap::new()).expect("load should succeed");
+        let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
         let lines: Vec<usize> = pipeline
             .jobs()
             .iter()
@@ -388,7 +380,7 @@ mod tests {
 
     #[test]
     fn load_errors_on_bad_fennel() {
-        let result = Pipeline::load("{:bad {:}", "ci.fnl", HashMap::new());
+        let result = Pipeline::load("{:bad {:}", "ci.fnl");
         assert!(result.is_err(), "malformed Fennel should fail");
     }
 
@@ -398,7 +390,7 @@ mod tests {
     /// but the returned `Job`s only need their non-VM fields here.
     fn parse_results(source: &str) -> Vec<std::result::Result<Job, ValidationError>> {
         let f = Fennel::new().expect("Fennel::new() should succeed");
-        lua::parse(&f, source, "ci.fnl", HashMap::new()).expect("parse should succeed")
+        lua::parse(&f, source, "ci.fnl").expect("parse should succeed")
     }
 
     /// Discard parse errors and return only the successfully registered
diff --git a/src/ci/run.rs b/src/ci/run.rs
index ffbb79f..0cb4918 100644
--- a/src/ci/run.rs
+++ b/src/ci/run.rs
@@ -5,13 +5,15 @@
 //! parent name is the authoritative state; transitions are atomic
 //! `rename` operations.
 
+use std::collections::HashMap;
 use std::path::{Path, PathBuf};
 use std::rc::Rc;
 
 use jiff::Timestamp;
 
-use super::lua::{RuntimeState, ShOutput};
+use super::lua::{Runtime, ShOutput};
 use super::pipeline::Pipeline;
+use crate::secret::SecretString;
 use crate::{Error, Result};
 
 /// The state of a CI run.
@@ -214,10 +216,11 @@ pub struct Run {
     base: PathBuf,
     state: RunState,
     id: String,
-    /// Per-execution state shared with the Lua bridge: tracks the
-    /// currently-running job and accumulates per-job `(ci.sh …)` output.
-    /// Cloned (Rc) into Lua app data when `execute` runs.
-    runtime: Rc<RuntimeState>,
+    /// Per-execution runtime shared with the Lua bridge: holds the
+    /// secrets exposed to the script, tracks the currently-running
+    /// job, and accumulates per-job captured `sh` output. Replaced
+    /// fresh each `execute` call so secrets are scoped to that call.
+    runtime: Rc<Runtime>,
 }
 
 impl Run {
@@ -246,7 +249,7 @@ impl Run {
             base,
             state,
             id,
-            runtime: Rc::new(RuntimeState::default()),
+            runtime: Rc::new(Runtime::new(HashMap::new())),
         };
         run.read_meta()?;
         run.read_times()?;
@@ -255,16 +258,28 @@ impl Run {
 
     /// Drive `pipeline` to completion through this run.
     ///
-    /// Topo-sorts the jobs, transitions Pending → Active, then invokes
-    /// each `run_fn` in dependency order. `(ci.sh …)` calls record their
-    /// captured output under the current job — readable via [`outputs`]
-    /// after `execute` returns. The run finishes in `Complete` if every
-    /// job's `run_fn` returned without error, otherwise `Failed`.
+    /// Constructs a fresh [`Runtime`] with `secrets`, installs it on
+    /// the pipeline's Lua VM, topo-sorts the jobs, transitions
+    /// Pending → Active, then invokes each `run_fn` in dependency
+    /// order with the runtime handle as its sole argument. `(sh …)`
+    /// calls record their captured output under the current job —
+    /// readable via [`Run::outputs`] after `execute` returns. The
+    /// run finishes in `Complete` if every job's `run_fn` returned
+    /// without error, otherwise `Failed`.
     ///
     /// Source-ref filtering (e.g. running only `quire/push`-reachable
     /// jobs) is not yet implemented; for now every validated job runs.
-    pub fn execute(&mut self, pipeline: &Pipeline) -> Result<()> {
-        pipeline.fennel().lua().set_app_data(self.runtime.clone());
+    pub fn execute(
+        &mut self,
+        pipeline: &Pipeline,
+        secrets: HashMap<String, SecretString>,
+    ) -> Result<()> {
+        self.runtime = Rc::new(Runtime::new(secrets));
+        let rt_table = self
+            .runtime
+            .clone()
+            .install(pipeline.fennel().lua())
+            .expect("install runtime on Lua VM");
 
         let order: Vec<String> = pipeline
             .topo_order()
@@ -280,7 +295,7 @@ impl Run {
                 .expect("topo_order returned a job id not in pipeline");
 
             self.runtime.enter_job(&job.id);
-            let result = job.run_fn.call::<mlua::Value>(());
+            let result = job.run_fn.call::<mlua::Value>(rt_table.clone());
             self.runtime.leave_job();
 
             if let Err(e) = result {
@@ -296,8 +311,8 @@ impl Run {
         Ok(())
     }
 
-    /// Snapshot the `(ci.sh …)` outputs recorded for `job_id` during
-    /// the most recent `execute` call. Empty if the job hasn't run or
+    /// Snapshot the `(sh …)` outputs recorded for `job_id` during the
+    /// most recent `execute` call. Empty if the job hasn't run or
     /// produced no output.
     pub fn outputs(&self, job_id: &str) -> Vec<ShOutput> {
         self.runtime.outputs(job_id)
@@ -549,7 +564,7 @@ mod tests {
             base: PathBuf::from("/tmp/quire-test-runs/test.git"),
             state: RunState::Pending,
             id: uuid::Uuid::now_v7().to_string(),
-            runtime: Rc::new(RuntimeState::default()),
+            runtime: Rc::new(Runtime::new(HashMap::new())),
         };
 
         let result = run.transition(RunState::Active);
@@ -641,8 +656,7 @@ mod tests {
     }
 
     fn load(source: &str) -> Pipeline {
-        super::super::pipeline::Pipeline::load(source, "ci.fnl", std::collections::HashMap::new())
-            .expect("load should succeed")
+        super::super::pipeline::Pipeline::load(source, "ci.fnl").expect("load should succeed")
     }
 
     #[test]
@@ -653,11 +667,11 @@ mod tests {
 
         let pipeline = load(
             r#"(local ci (require :quire.ci))
-(ci.job :a [:quire/push] (fn [_] (ci.sh ["echo" "from-a"])))
-(ci.job :b [:a] (fn [_] (ci.sh ["echo" "from-b"])))"#,
+(ci.job :a [:quire/push] (fn [{: sh}] (sh ["echo" "from-a"])))
+(ci.job :b [:a] (fn [{: sh}] (sh ["echo" "from-b"])))"#,
         );
 
-        run.execute(&pipeline).expect("execute");
+        run.execute(&pipeline, HashMap::new()).expect("execute");
 
         assert_eq!(run.state(), RunState::Complete);
 
@@ -681,13 +695,13 @@ mod tests {
         let log_str = log.to_string_lossy();
         let source = format!(
             r#"(local ci (require :quire.ci))
-(ci.job :b [:a] (fn [_] (ci.sh (.. "echo b >> {log}"))))
-(ci.job :a [:quire/push] (fn [_] (ci.sh (.. "echo a >> {log}"))))"#,
+(ci.job :b [:a] (fn [{{: sh}}] (sh (.. "echo b >> {log}"))))
+(ci.job :a [:quire/push] (fn [{{: sh}}] (sh (.. "echo a >> {log}"))))"#,
             log = log_str
         );
         let pipeline = load(&source);
 
-        run.execute(&pipeline).expect("execute");
+        run.execute(&pipeline, HashMap::new()).expect("execute");
 
         let contents = fs_err::read_to_string(&log).expect("read log");
         assert_eq!(contents, "a\nb\n");
@@ -702,10 +716,12 @@ mod tests {
         let pipeline = load(
             r#"(local ci (require :quire.ci))
 (ci.job :a [:quire/push] (fn [_] (error "boom")))
-(ci.job :b [:a] (fn [_] (ci.sh ["echo" "should-not-run"])))"#,
+(ci.job :b [:a] (fn [{: sh}] (sh ["echo" "should-not-run"])))"#,
         );
 
-        let err = run.execute(&pipeline).expect_err("expected failure");
+        let err = run
+            .execute(&pipeline, HashMap::new())
+            .expect_err("expected failure");
         assert!(matches!(err, Error::JobFailed { ref job, .. } if job == "a"));
         assert_eq!(run.state(), RunState::Failed);
         assert!(