Skip to main content

lex_runtime/handler/
dispatch.rs

1//! `EffectHandler` for `DefaultHandler`: the `(kind, op)` router every runtime effect passes through, plus request-scope, budget and worker hooks. Family-specific helpers live in the sibling modules.
2
3use super::*;
4
5impl EffectHandler for DefaultHandler {
6    /// Push a fresh per-request arena onto the stack (#463
7    /// scaffolding). Returns the scope id; pair with
8    /// `exit_request_scope(id)` to drop it.
9    fn enter_request_scope(&mut self) -> u64 {
10        let id = self.next_scope_id;
11        self.next_scope_id = self.next_scope_id.wrapping_add(1);
12        self.arena_stack.push((id, crate::arena::Arena::new()));
13        id
14    }
15
16    /// Drop the arena associated with `scope_id`. Mismatched pairs
17    /// (exit called with a scope id we don't recognize, or out-of-
18    /// order exit) are tolerated as no-ops rather than panicking —
19    /// runtime layer should pair them strictly but a stray exit
20    /// shouldn't crash a live server.
21    fn exit_request_scope(&mut self, scope_id: u64) {
22        if let Some(pos) = self.arena_stack.iter().position(|(id, _)| *id == scope_id) {
23            // Drop this entry and any later entries that escaped
24            // pairing (out-of-order exit). Order matters: pop in
25            // reverse so the most recent arena drops first, then
26            // its predecessor, etc.
27            self.arena_stack.truncate(pos);
28        }
29    }
30
31    /// Per-call budget enforcement (#225). VM calls this before
32    /// invoking any function whose signature declares `[budget(N)]`.
33    /// The cost N is deducted atomically from the shared pool;
34    /// returning `Err` aborts the call before any frame is pushed.
35    fn note_call_budget(&mut self, cost: u64) -> Result<(), String> {
36        // Skip the work entirely when no ceiling is configured —
37        // the pool is `u64::MAX` and would never trip.
38        let Some(ceiling) = self.budget_ceiling else { return Ok(()); };
39        // Compare-and-swap: speculatively subtract; if we'd
40        // underflow, return BudgetExceeded without mutating.
41        // Use SeqCst because parallel branches may race here and
42        // the relative ordering of "used so far" vs. "this call's
43        // cost" needs to be deterministic across threads.
44        loop {
45            let cur = self.budget_remaining.load(Ordering::SeqCst);
46            if cost > cur {
47                let used = ceiling.saturating_sub(cur);
48                return Err(format!(
49                    "budget exceeded: requested {cost}, used so far {used}, ceiling {ceiling}"));
50            }
51            let next = cur - cost;
52            // Conservative accounting: if the CAS races and loses,
53            // re-read and try again. No refund-on-failure path.
54            if self.budget_remaining.compare_exchange(cur, next,
55                Ordering::SeqCst, Ordering::SeqCst).is_ok() {
56                return Ok(());
57            }
58        }
59    }
60
61    fn dispatch(&mut self, kind: &str, op: &str, args: Vec<Value>) -> Result<Value, String> {
62        // Pure stdlib builtins (str, list, json, ...) bypass the policy
63        // gate — they have no observable side effects and aren't tracked
64        // by the type system as effects.
65        if is_pure_call(kind, op) {
66            return call_pure_builtin(kind, op, args);
67        }
68        // `std.fs` ops use the fine-grained `[fs_walk]` and `[fs_write]`
69        // effect kinds (distinct from the module name `fs`); the
70        // policy check uses the per-op kind, not the module's.
71        if kind == "process" {
72            // `exit` carries its own effect kind (#754). Spawning a
73            // subprocess and ending your caller's process are different
74            // authorities: a program granted `proc` to shell out should
75            // not thereby decide what its invoker sees. Same split, and
76            // the same reasoning, as `fs` → `fs_walk` / `fs_write`.
77            if op == "exit" {
78                self.ensure_kind_allowed("proc_exit")?;
79                let code = match args.first() {
80                    Some(Value::Int(n)) => *n,
81                    other => {
82                        return Err(format!(
83                            "process.exit expects an Int status, got {other:?}"
84                        ))
85                    }
86                };
87                // Clamped, not wrapped. A shell sees status & 0xff, so
88                // `exit(256)` would arrive as 0 — a program signalling
89                // failure that reads as success is the one outcome this
90                // must never produce.
91                let code = code.clamp(0, 255) as i64;
92                // First writer wins: the program stopped at the first
93                // exit, so a later one cannot restate the verdict.
94                let _ = self.requested_exit.compare_exchange(
95                    crate::handler::NO_EXIT,
96                    code,
97                    Ordering::SeqCst,
98                    Ordering::SeqCst,
99                );
100                return Ok(Value::Unit);
101            }
102            self.ensure_kind_allowed("proc")?;
103            return self.dispatch_process(op, args);
104        }
105        if kind == "approval" {
106            self.ensure_kind_allowed("approval")?;
107            return self.dispatch_approval(op, args);
108        }
109        if kind == "log" {
110            // Emit ops are [log]; config ops are [io] (set_sink also
111            // [fs_write]). The dispatch picks the right kind per op.
112            let effect_kind = match op {
113                "debug" | "info" | "warn" | "error" => "log",
114                "set_level" | "set_format" => "io",
115                "set_sink" => {
116                    self.ensure_kind_allowed("io")?;
117                    self.ensure_kind_allowed("fs_write")?;
118                    return self.dispatch_log(op, args);
119                }
120                other => return Err(format!("unsupported log.{other}")),
121            };
122            self.ensure_kind_allowed(effect_kind)?;
123            return self.dispatch_log(op, args);
124        }
125        if kind == "fs" {
126            let effect_kind = match op {
127                "exists" | "is_file" | "is_dir" | "stat"
128                | "list_dir" | "walk" | "glob" => "fs_walk",
129                "read_to_string" => "fs_read",
130                "mkdir_p" | "remove" | "write" => "fs_write",
131                "copy" => {
132                    self.ensure_kind_allowed("fs_walk")?;
133                    self.ensure_kind_allowed("fs_write")?;
134                    return self.dispatch_fs(op, args);
135                }
136                other => return Err(format!("unsupported fs.{other}")),
137            };
138            self.ensure_kind_allowed(effect_kind)?;
139            return self.dispatch_fs(op, args);
140        }
141        // `crypto.random` is the lone effectful op in `std.crypto`. Its
142        // declared effect kind is `random` (fine-grained on purpose so
143        // `lex audit --effect random` flags every token-generating
144        // call), distinct from the `crypto` module name.
145        // datetime.now is the only effectful op in std.datetime;
146        // declared kind is `time`, matching the existing `time.now`.
147        if kind == "datetime" && op == "now" {
148            self.ensure_kind_allowed("time")?;
149            // LEX_TEST_NOW (Unix seconds) pins the clock for deterministic tests (#350).
150            if let Ok(s) = std::env::var("LEX_TEST_NOW") {
151                if let Ok(secs) = s.trim().parse::<i64>() {
152                    return Ok(Value::Int(secs.saturating_mul(1_000_000_000)));
153                }
154            }
155            let now = chrono::Utc::now();
156            let nanos = now.timestamp_nanos_opt().unwrap_or(i64::MAX);
157            return Ok(Value::Int(nanos));
158        }
159        if kind == "crypto" && op == "random" {
160            self.ensure_kind_allowed("random")?;
161            let n = expect_int(args.first())?;
162            if !(0..=1_048_576).contains(&n) {
163                return Err("crypto.random: n must be in 0..=1048576".into());
164            }
165            use rand::{rngs::SysRng, TryRng};
166            let mut buf = vec![0u8; n as usize];
167            SysRng.try_fill_bytes(&mut buf)
168                .map_err(|e| format!("crypto.random: OS RNG: {e}"))?;
169            return Ok(Value::Bytes(buf));
170        }
171        // crypto.random_str_hex(n) — N random bytes rendered as 2N
172        // lowercase hex chars (#382). The most common token-mint
173        // pattern (session ids, OAuth `state`, CSRF, request ids).
174        // Same `[random]` gate as `crypto.random`.
175        if kind == "crypto" && op == "random_str_hex" {
176            self.ensure_kind_allowed("random")?;
177            let n = expect_int(args.first())?;
178            if !(0..=1_048_576).contains(&n) {
179                return Err("crypto.random_str_hex: n must be in 0..=1048576".into());
180            }
181            use rand::{rngs::SysRng, TryRng};
182            let mut buf = vec![0u8; n as usize];
183            SysRng.try_fill_bytes(&mut buf)
184                .map_err(|e| format!("crypto.random_str_hex: OS RNG: {e}"))?;
185            return Ok(Value::Str(hex::encode(&buf).into()));
186        }
187        // crypto.p256_generate() — mint a fresh P-256 (ES256) secret
188        // key from the OS RNG (#651). Returns the 32-byte scalar as
189        // `Ok(Bytes)`. Same `[random]` gate as `crypto.random`: key
190        // minting stays visible to `lex audit --effect random`.
191        //
192        // We sample 32 bytes and let `SigningKey::from_slice` reject
193        // the (vanishingly rare, ~2^-32) out-of-range scalar rather
194        // than pulling in p256's own `rand_core` — that crate is on a
195        // different `rand_core` major than the workspace `rand`, so
196        // bridging RNG traits here would mean an extra dependency for
197        // no behavioural gain. Retry a handful of times so a one-in-
198        // four-billion miss never surfaces as a spurious `Err`.
199        if kind == "crypto" && op == "p256_generate" {
200            self.ensure_kind_allowed("random")?;
201            use p256::ecdsa::SigningKey;
202            use rand::{rngs::SysRng, TryRng};
203            for _ in 0..16 {
204                let mut buf = [0u8; 32];
205                SysRng.try_fill_bytes(&mut buf)
206                    .map_err(|e| format!("crypto.p256_generate: OS RNG: {e}"))?;
207                if let Ok(sk) = SigningKey::from_slice(&buf) {
208                    return Ok(ok(Value::Bytes(sk.to_bytes().to_vec())));
209                }
210            }
211            return Ok(err(Value::Str(
212                "crypto.p256_generate: failed to sample a valid scalar".into())));
213        }
214        // crypto.secp256k1_generate() — mint a fresh secp256k1 secret key
215        // from the OS RNG (#655) for EVM / EIP-712 / x402 signing. Returns
216        // the 32-byte scalar as `Ok(Bytes)`. Same `[random]` gate and
217        // sample-and-reject loop as `p256_generate` (the curve order is
218        // close enough to 2^256 that a miss is ~2^-128, but the loop
219        // keeps the contract identical).
220        if kind == "crypto" && op == "secp256k1_generate" {
221            self.ensure_kind_allowed("random")?;
222            use k256::ecdsa::SigningKey;
223            use rand::{rngs::SysRng, TryRng};
224            for _ in 0..16 {
225                let mut buf = [0u8; 32];
226                SysRng.try_fill_bytes(&mut buf)
227                    .map_err(|e| format!("crypto.secp256k1_generate: OS RNG: {e}"))?;
228                if let Ok(sk) = SigningKey::from_slice(&buf) {
229                    return Ok(ok(Value::Bytes(sk.to_bytes().to_vec())));
230                }
231            }
232            return Ok(err(Value::Str(
233                "crypto.secp256k1_generate: failed to sample a valid scalar".into())));
234        }
235        // `std.http` wire ops (send/get/post) gate on the `net`
236        // effect kind, not the module name. This matches the
237        // declared signature (`http.get :: Str -> [net] ...`) and
238        // keeps `--allow-effects net` doing the obvious thing for
239        // both `net.*` and `http.*` callers.
240        // `std.agent` (#184): the four runtime effects added for
241        // agent-style programs (`llm_local`, `llm_cloud`, `a2a`,
242        // `mcp`). The handlers are stubs — they enforce the
243        // declared-effect gate, return a sentinel `Ok` so traces
244        // record the call, and defer the real wire formats to
245        // downstream crates (`soft-agent` for `llm_*` and `a2a`)
246        // and #185 (MCP client wrapper).
247        if kind == "agent" {
248            let effect_kind = match op {
249                "local_complete" => "llm_local",
250                "cloud_complete" => "llm_cloud",
251                "cloud_stream"   => "llm_cloud",
252                "send_a2a"       => "a2a",
253                "call_mcp"       => "mcp",
254                other => return Err(format!("unsupported agent.{other}")),
255            };
256            self.ensure_kind_allowed(effect_kind)?;
257            // `call_mcp` runs through the LRU client cache
258            // (#197). `local_complete` / `cloud_complete` hit
259            // Ollama / OpenAI via env-var-driven configuration
260            // (#196); custom backends override at the
261            // EffectHandler layer rather than via a config file.
262            // `send_a2a` keeps its stub — that wire format
263            // lives in downstream `soft-a2a`.
264            return match op {
265                "call_mcp"       => Ok(self.dispatch_call_mcp(args)),
266                "local_complete" => Ok(dispatch_llm_local(args)),
267                "cloud_complete" => Ok(dispatch_llm_cloud(args)),
268                "cloud_stream"   => Ok(self.dispatch_cloud_stream(args)),
269                _ => Ok(ok(Value::Str(format!("<{effect_kind} stub>").into()))),
270            };
271        }
272        if kind == "stream" {
273            // #305 slice 3: consumer-side stream operations. Each
274            // op resolves the opaque handle in the parent handler's
275            // stream registry and pulls one or all items. The
276            // `stream` effect must be granted by policy; default
277            // policies for agent runs grant it alongside the
278            // producer effect (e.g. `llm_cloud`).
279            self.ensure_kind_allowed("stream")?;
280            return match op {
281                "next"    => Ok(self.dispatch_stream_next(args)),
282                "collect" => Ok(self.dispatch_stream_collect(args)),
283                other => Err(format!("unsupported stream.{other}")),
284            };
285        }
286        if kind == "http" && matches!(op, "send" | "get" | "post" | "stream_lines") {
287            self.ensure_kind_allowed("net")?;
288            return match op {
289                "send" => {
290                    let req = expect_record(args.first())?;
291                    Ok(http_send_record(self, req))
292                }
293                "get" => {
294                    let url = expect_str(args.first())?.to_string();
295                    self.ensure_host_allowed(&url)?;
296                    Ok(http_send_simple("GET", &url, None, "", None))
297                }
298                "post" => {
299                    let url = expect_str(args.first())?.to_string();
300                    let body = expect_bytes(args.get(1))?.clone();
301                    let content_type = expect_str(args.get(2))?.to_string();
302                    self.ensure_host_allowed(&url)?;
303                    Ok(http_send_simple("POST", &url, Some(body), &content_type, None))
304                }
305                "stream_lines" => {
306                    let url = expect_str(args.first())?.to_string();
307                    let headers_val = args.get(1).cloned().unwrap_or(Value::Map(Default::default()));
308                    let body = expect_str(args.get(2))?.to_string();
309                    self.ensure_host_allowed(&url)?;
310                    Ok(http_stream_lines_impl(self, &url, &headers_val, &body))
311                }
312                _ => unreachable!(),
313            };
314        }
315        // `arrow.read_csv` declares `[fs_read]`, not `[arrow]` — its effect
316        // string in the type system is `fs_read`. Intercept before the
317        // generic `ensure_kind_allowed(kind)` below so the policy check
318        // looks at `fs_read` rather than `arrow`. Same pattern as
319        // `http.{send,get,post}` mapping to `[net]` above.
320        if kind == "arrow" && op == "read_csv" {
321            self.ensure_kind_allowed("fs_read")?;
322            let path = expect_str(args.first())?.to_string();
323            let resolved = self.resolve_read_path(&path);
324            if !self.policy.allow_fs_read.is_empty()
325                && !self.policy.allow_fs_read.iter().any(|a| resolved.starts_with(a))
326            {
327                return Err(format!("arrow.read_csv: `{path}` outside --allow-fs-read"));
328            }
329            return match crate::arrow::read_csv_at(&resolved) {
330                Ok(v)  => Ok(ok(v)),
331                Err(e) => Ok(err(Value::Str(e.into()))),
332            };
333        }
334        // `arrow.read_parquet` and `arrow.read_parquet_cols` are the
335        // Parquet siblings of `read_csv`. Same `[fs_read]` effect, same
336        // path-scope check. `_cols` takes an extra `List[Str]` argument.
337        if kind == "arrow" && (op == "read_parquet" || op == "read_parquet_cols") {
338            self.ensure_kind_allowed("fs_read")?;
339            let path = expect_str(args.first())?.to_string();
340            let resolved = self.resolve_read_path(&path);
341            if !self.policy.allow_fs_read.is_empty()
342                && !self.policy.allow_fs_read.iter().any(|a| resolved.starts_with(a))
343            {
344                return Err(format!("arrow.{op}: `{path}` outside --allow-fs-read"));
345            }
346            let r = if op == "read_parquet" {
347                crate::arrow::read_parquet_at(&resolved)
348            } else {
349                let cols = match args.get(1) {
350                    Some(Value::List(items)) => {
351                        let mut out = Vec::with_capacity(items.len());
352                        for v in items.iter() {
353                            match v {
354                                Value::Str(s) => out.push(s.to_string()),
355                                other => return Err(format!(
356                                    "arrow.read_parquet_cols: column name not Str: {other:?}")),
357                            }
358                        }
359                        out
360                    }
361                    other => return Err(format!(
362                        "arrow.read_parquet_cols: expected List[Str], got {other:?}")),
363                };
364                crate::arrow::read_parquet_cols_at(&resolved, &cols)
365            };
366            return match r {
367                Ok(v) => Ok(ok(v)),
368                Err(e) => Ok(err(Value::Str(e.into()))),
369            };
370        }
371        // `arrow.write_parquet` and `arrow.write_csv` declare `[fs_write]`.
372        // Path scope uses `--allow-fs-write` (symmetric with `io.write`).
373        if kind == "arrow" && (op == "write_parquet" || op == "write_csv") {
374            self.ensure_kind_allowed("fs_write")?;
375            let table_v = args.first().cloned().unwrap_or(Value::Unit);
376            let rb = match &table_v {
377                Value::ArrowTable(t) => Arc::clone(t),
378                other => return Err(format!("arrow.{op}: first arg must be arrow.Table, got {other:?}")),
379            };
380            let path = expect_str(args.get(1))?.to_string();
381            if let Err(e) = self.ensure_fs_write_path(&path) {
382                return Ok(err(Value::Str(format!("arrow.{op}: {e}").into())));
383            }
384            let r = if op == "write_parquet" {
385                crate::arrow::write_parquet_at(&rb, std::path::Path::new(&path))
386            } else {
387                crate::arrow::write_csv_at(&rb, std::path::Path::new(&path))
388            };
389            return match r {
390                Ok(_)  => Ok(ok(Value::Unit)),
391                Err(e) => Ok(err(Value::Str(e.into()))),
392            };
393        }
394        // `net.default_opts()` is a pure record constructor — typed
395        // with `EffectSet::empty()` in builtins.rs. Bypass the generic
396        // `ensure_kind_allowed("net")` gate so callers don't need to
397        // declare `[net]` just to build a ServeOpts literal default.
398        if kind == "net" && op == "default_opts" {
399            return Ok(ServeOpts::lex_defaults().to_value());
400        }
401        // `tls.*` (#496) — TlsConfig constructors map to different
402        // effect kinds than the namespace name suggests:
403        //   `tls.from_pem_files` :: [fs_read]   (reads cert + key PEM)
404        //   `tls.self_signed`    :: pure        (rcgen, in-memory)
405        // Intercept before the generic `ensure_kind_allowed("tls")`
406        // gate so policy can check the *real* effect. Same pattern
407        // as the `http.{send,get,post}` arms above.
408        if kind == "tls" {
409            return match op {
410                "from_pem_files" => {
411                    self.ensure_kind_allowed("fs_read")?;
412                    dispatch_tls_from_pem_files(self, args)
413                }
414                "self_signed" => dispatch_tls_self_signed(args),
415                other => Err(format!("unsupported tls.{other}")),
416            };
417        }
418        // `std.redis` ops all carry `[net]` in their declared effect sets,
419        // not `[redis]`. Gate on `net` here and skip the generic kind-check
420        // below, matching the `std.http` precedent.
421        if kind == "redis" {
422            self.ensure_kind_allowed("net")?;
423        } else if kind == "rand" {
424            // `std.rand.int_in` draws from the OS RNG → `[random]` effect,
425            // the same gate as `crypto.random` (#677). No separate `rand`
426            // effect grant exists.
427            self.ensure_kind_allowed("random")?;
428        } else {
429            self.ensure_kind_allowed(kind)?;
430        }
431        match (kind, op) {
432            ("io", "print") => {
433                let line = expect_str(args.first())?;
434                self.sink.print_line(line);
435                Ok(Value::Unit)
436            }
437            ("io", "read") => {
438                // Legacy content read. Declares [io] rather than [fs_read],
439                // which is why `fs.read_to_string` exists (#882); the path
440                // scope is identical, and shared so the two cannot drift.
441                let path = expect_str(args.first())?.to_string();
442                let resolved = self.ensure_fs_read_content_path(&path)?;
443                match std::fs::read_to_string(&resolved) {
444                    Ok(s) => Ok(ok(Value::Str(s.into()))),
445                    Err(e) => Ok(err(Value::Str(format!("{e}").into()))),
446                }
447            }
448            ("io", "readline") => {
449                use std::io::BufRead;
450                let stdin = std::io::stdin();
451                let mut line = String::new();
452                match stdin.lock().read_line(&mut line) {
453                    Ok(0) => Ok(Value::Variant { name: "None".into(), args: vec![] }),
454                    Ok(_) => {
455                        if line.ends_with('\n') { line.pop(); }
456                        if line.ends_with('\r') { line.pop(); }
457                        Ok(Value::Variant { name: "Some".into(), args: vec![Value::Str(line.into())] })
458                    }
459                    Err(_) => Ok(Value::Variant { name: "None".into(), args: vec![] }),
460                }
461            }
462            ("io", "argv") => {
463                let list: Vec<Value> = self.program_args.iter()
464                    .map(|s| Value::Str(s.as_str().into()))
465                    .collect();
466                Ok(Value::List(list.into()))
467            }
468            ("io", "write") => {
469                // Legacy content write; see the note on ("io", "read").
470                let path = expect_str(args.first())?.to_string();
471                let contents = expect_str(args.get(1))?.to_string();
472                self.ensure_fs_write_content_path(&path)?;
473                match std::fs::write(&path, contents) {
474                    Ok(_) => Ok(ok(Value::Unit)),
475                    Err(e) => Ok(err(Value::Str(format!("{e}").into()))),
476                }
477            }
478            ("time", "now") => {
479                // LEX_TEST_NOW (Unix seconds) pins for deterministic tests.
480                if let Ok(s) = std::env::var("LEX_TEST_NOW") {
481                    if let Ok(secs) = s.trim().parse::<i64>() {
482                        return Ok(Value::Int(secs));
483                    }
484                }
485                let secs = SystemTime::now().duration_since(UNIX_EPOCH)
486                    .map_err(|e| format!("time: {e}"))?.as_secs();
487                Ok(Value::Int(secs as i64))
488            }
489            ("time", "now_ms") => {
490                // Unix epoch in milliseconds (#378). `LEX_TEST_NOW` is
491                // documented in seconds, so we lift it to ms by *1000
492                // to keep the pinning story uniform across `time.now`
493                // and `time.now_ms`.
494                if let Ok(s) = std::env::var("LEX_TEST_NOW") {
495                    if let Ok(secs) = s.trim().parse::<i64>() {
496                        return Ok(Value::Int(secs.saturating_mul(1000)));
497                    }
498                }
499                let ms = SystemTime::now().duration_since(UNIX_EPOCH)
500                    .map_err(|e| format!("time: {e}"))?.as_millis();
501                Ok(Value::Int(ms as i64))
502            }
503            ("time", "now_str") => {
504                // ISO-8601 / RFC 3339 in UTC (#378). Format mirrors
505                // `chrono::Utc::now().to_rfc3339()` already used
506                // elsewhere in the handler.
507                if let Ok(s) = std::env::var("LEX_TEST_NOW") {
508                    if let Ok(secs) = s.trim().parse::<i64>() {
509                        let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0)
510                            .unwrap_or_else(chrono::Utc::now);
511                        return Ok(Value::Str(dt.to_rfc3339().into()));
512                    }
513                }
514                Ok(Value::Str(chrono::Utc::now().to_rfc3339().into()))
515            }
516            ("time", "mono_ns") => {
517                // Monotonic clock relative to process start. Cached
518                // `Instant::now()` anchor so successive `mono_ns`
519                // calls return strictly non-decreasing values without
520                // depending on the wall clock. Not affected by
521                // `LEX_TEST_NOW` — pinning a monotonic clock would
522                // defeat its purpose; tests needing a fake monotonic
523                // clock should swap in their own `EffectHandler`.
524                static MONO_START: OnceLock<std::time::Instant> = OnceLock::new();
525                let start = MONO_START.get_or_init(std::time::Instant::now);
526                let dur = std::time::Instant::now().duration_since(*start);
527                Ok(Value::Int(dur.as_nanos() as i64))
528            }
529            ("time", "sleep_ms") => {
530                // Block the current thread for `n` ms (#226). Used
531                // by `flow.retry_with_backoff`'s exponential delay.
532                // Negative or zero is a no-op. Bounded at 60s in the
533                // runtime to avoid pathological agent-emitted loops
534                // wedging the host — anything legitimate beyond
535                // that should use process-level scheduling, not a
536                // blocking sleep.
537                let n = expect_int(args.first())?;
538                if n > 0 {
539                    let ms = (n as u64).min(60_000);
540                    std::thread::sleep(std::time::Duration::from_millis(ms));
541                }
542                Ok(Value::Unit)
543            }
544            ("time", "sleep") => {
545                // Duration-typed sleep (#445). Duration values are
546                // backed by `Int` nanoseconds at runtime (see the
547                // `datetime.duration_*` constructors). Same 60s cap
548                // as `sleep_ms` — kept consistent so all blocking
549                // sleeps share one ceiling.
550                let nanos = expect_int(args.first())?;
551                if nanos > 0 {
552                    let bounded_nanos = (nanos as u64).min(60_000 * 1_000_000);
553                    std::thread::sleep(std::time::Duration::from_nanos(bounded_nanos));
554                }
555                Ok(Value::Unit)
556            }
557            ("rand", "int_in") => {
558                // Honest uniform draw in [lo, hi] inclusive from the OS RNG
559                // (#677), replacing the old deterministic midpoint stub.
560                // Same entropy source as `crypto.random`; gated `[random]`.
561                let lo = expect_int(args.first())?;
562                let hi = expect_int(args.get(1))?;
563                if hi < lo {
564                    return Err(format!("rand.int_in: empty range [{lo}, {hi}]"));
565                }
566                use rand::{rngs::SysRng, TryRng};
567                // span fits in u128 even for the full i64 range; bias from
568                // the modulo over a 128-bit draw is < 2^-64 (negligible).
569                let span = (hi as i128 - lo as i128 + 1) as u128;
570                let mut buf = [0u8; 16];
571                SysRng.try_fill_bytes(&mut buf)
572                    .map_err(|e| format!("rand.int_in: OS RNG: {e}"))?;
573                let draw = (u128::from_le_bytes(buf) % span) as i128;
574                Ok(Value::Int((lo as i128 + draw) as i64))
575            }
576            // `env.get` returns `Option[Str]` — `None` for unset vars.
577            // Per-var scoping (`[env(NAME)]`) arrives with #207's
578            // per-capability effect parameterization; today the flat
579            // `[env]` grants access to the entire process environment.
580            ("env", "get") => {
581                let name = expect_str(args.first())?;
582                Ok(match std::env::var(name) {
583                    Ok(v) => Value::Variant {
584                        name: "Some".into(),
585                        args: vec![Value::Str(v.into())],
586                    },
587                    Err(_) => Value::Variant { name: "None".into(), args: Vec::new() },
588                })
589            }
590            ("budget", _) => {
591                // Budget calls are nominally tracked here; budget itself is
592                // enforced statically in `policy::check_program`.
593                Ok(Value::Unit)
594            }
595            ("net", "get") => {
596                let url = expect_str(args.first())?.to_string();
597                self.ensure_host_allowed(&url)?;
598                Ok(http_request("GET", &url, None))
599            }
600            ("net", "post") => {
601                let url = expect_str(args.first())?.to_string();
602                let body = expect_str(args.get(1))?.to_string();
603                self.ensure_host_allowed(&url)?;
604                Ok(http_request("POST", &url, Some(&body)))
605            }
606            // ── UDP datagrams (#760) ──────────────────────────────
607            ("net", "udp_open") => {
608                let port = match args.first() {
609                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
610                    _ => return Err("net.udp_open(port): port must be Int 0..=65535".into()),
611                };
612                let mut reg = udp_registry().lock().unwrap();
613                if reg.len() >= MAX_UDP_HANDLES {
614                    return Ok(err(Value::Str(format!(
615                        "net.udp_open: too many open sockets ({MAX_UDP_HANDLES}); \
616                         close them with net.udp_close"
617                    ).into())));
618                }
619                match std::net::UdpSocket::bind(("0.0.0.0", port)) {
620                    Ok(sock) => {
621                        let handle = next_udp_handle();
622                        reg.insert(handle, sock);
623                        Ok(ok(Value::Int(handle as i64)))
624                    }
625                    Err(e) => Ok(err(Value::Str(format!("net.udp_open: {e}").into()))),
626                }
627            }
628            ("net", "udp_close") => {
629                let handle = expect_int(args.first()).map_err(|e| format!("net.udp_close(sock): {e}"))?;
630                // Dropping the socket closes it. Idempotent on purpose: a
631                // double close is a caller being careful, not an error.
632                udp_registry().lock().unwrap().remove(&(handle as u64));
633                Ok(ok(Value::Unit))
634            }
635            ("net", "udp_send") => {
636                let handle = expect_int(args.first()).map_err(|e| format!("net.udp_send(sock, ...): {e}"))?;
637                let host = expect_str(args.get(1))?.to_string();
638                let port = match args.get(2) {
639                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
640                    _ => return Err("net.udp_send(sock, host, port, data): port must be Int 0..=65535".into()),
641                };
642                let data = match args.get(3) {
643                    Some(Value::Bytes(b)) => b.clone(),
644                    _ => return Err("net.udp_send(sock, host, port, data): data must be Bytes".into()),
645                };
646                // The same gate `net.get` applies to a URL's host, applied
647                // to the datagram's destination. Without this, `udp_send`
648                // would be a way around the only network policy this
649                // module has. Broadcast and multicast addresses are not
650                // special-cased: they must be allowlisted like anything
651                // else, which is the point.
652                if let Err(e) = self.ensure_udp_dest_allowed(&host) {
653                    return Ok(err(Value::Str(e.into())));
654                }
655                let res = with_udp(handle, "net.udp_send", |sock| {
656                    sock.send_to(&data, (host.as_str(), port))
657                        .map_err(|e| format!("net.udp_send: {e}"))
658                });
659                match res {
660                    Ok(n) => Ok(ok(Value::Int(n as i64))),
661                    Err(e) => Ok(err(Value::Str(e.into()))),
662                }
663            }
664            ("net", "udp_recv") => {
665                let handle = expect_int(args.first()).map_err(|e| format!("net.udp_recv(sock, ...): {e}"))?;
666                let timeout_ms = expect_int(args.get(1)).map_err(|e| format!("net.udp_recv(..., timeout_ms): {e}"))?;
667                if timeout_ms < 0 {
668                    return Err("net.udp_recv(sock, timeout_ms): timeout_ms must be >= 0".into());
669                }
670                let res = with_udp(handle, "net.udp_recv", |sock| {
671                    // 0 means "no timeout" to the OS, which would block
672                    // this thread forever. Callers asking for 0 want a
673                    // poll, so give them the shortest real timeout instead.
674                    let d = std::time::Duration::from_millis(
675                        if timeout_ms == 0 { 1 } else { timeout_ms as u64 });
676                    sock.set_read_timeout(Some(d))
677                        .map_err(|e| format!("net.udp_recv: setting timeout: {e}"))?;
678                    // 65507 is the largest payload IPv4/UDP can carry, so
679                    // this cannot truncate a legal datagram. recv_from
680                    // discards any excess rather than reporting it, which
681                    // would be a silent wrong answer.
682                    let mut buf = vec![0u8; 65_507];
683                    match sock.recv_from(&mut buf) {
684                        Ok((n, addr)) => {
685                            buf.truncate(n);
686                            Ok(udp_datagram_value(buf, addr))
687                        }
688                        Err(e) if matches!(e.kind(),
689                            std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) =>
690                            // Deliberately Err, not an empty datagram: a
691                            // zero-length UDP payload is legal, and the
692                            // caller must be able to tell "nothing came"
693                            // from "something empty came".
694                            Err(format!("net.udp_recv: timed out after {timeout_ms}ms")),
695                        Err(e) => Err(format!("net.udp_recv: {e}")),
696                    }
697                });
698                match res {
699                    Ok(v) => Ok(ok(v)),
700                    Err(e) => Ok(err(Value::Str(e.into()))),
701                }
702            }
703            ("net", "udp_broadcast") => {
704                let handle = expect_int(args.first()).map_err(|e| format!("net.udp_broadcast(sock, on): {e}"))?;
705                let on = matches!(args.get(1), Some(Value::Bool(true)));
706                let res = with_udp(handle, "net.udp_broadcast", |sock| {
707                    sock.set_broadcast(on)
708                        .map_err(|e| format!("net.udp_broadcast: {e}"))
709                });
710                match res {
711                    Ok(()) => Ok(ok(Value::Unit)),
712                    Err(e) => Ok(err(Value::Str(e.into()))),
713                }
714            }
715            ("net", "udp_join_multicast") => {
716                let handle = expect_int(args.first()).map_err(|e| format!("net.udp_join_multicast(sock, group): {e}"))?;
717                let group = expect_str(args.get(1))?.to_string();
718                let res = with_udp(handle, "net.udp_join_multicast", |sock| {
719                    let g: std::net::Ipv4Addr = group.parse().map_err(|_| format!(
720                        "net.udp_join_multicast: `{group}` is not an IPv4 address"))?;
721                    if !g.is_multicast() {
722                        return Err(format!(
723                            "net.udp_join_multicast: {g} is not a multicast address \
724                             (224.0.0.0/4)"));
725                    }
726                    sock.join_multicast_v4(&g, &std::net::Ipv4Addr::UNSPECIFIED)
727                        .map_err(|e| format!("net.udp_join_multicast: {e}"))
728                });
729                match res {
730                    Ok(()) => Ok(ok(Value::Unit)),
731                    Err(e) => Ok(err(Value::Str(e.into()))),
732                }
733            }
734            ("net", "serve") => {
735                let port = match args.first() {
736                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
737                    _ => return Err("net.serve(port, handler): port must be Int 0..=65535".into()),
738                };
739                let handler_name = expect_str(args.get(1))?.to_string();
740                let program = self.program.clone()
741                    .ok_or_else(|| "net.serve requires a Program reference; use DefaultHandler::with_program".to_string())?;
742                let policy = self.policy.clone();
743                serve_http(port, handler_name, program, policy, None, ServeOpts::from_env())
744            }
745            ("net", "serve_fn") => {
746                let port = match args.first() {
747                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
748                    _ => return Err("net.serve_fn(port, handler): port must be Int 0..=65535".into()),
749                };
750                let closure = match args.into_iter().nth(1) {
751                    Some(c @ Value::Closure { .. }) => c,
752                    _ => return Err("net.serve_fn(port, handler): handler must be a closure".into()),
753                };
754                let program = self.program.clone()
755                    .ok_or_else(|| "net.serve_fn requires a Program reference; use DefaultHandler::with_program".to_string())?;
756                let policy = self.policy.clone();
757                serve_http_fn(port, closure, program, policy, ServeOpts::from_env())
758            }
759            ("net", "serve_routed") => {
760                let port = match args.first() {
761                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
762                    _ => return Err("net.serve_routed(port, routes, fallback): port must be Int 0..=65535".into()),
763                };
764                let routes_val = args.get(1).cloned()
765                    .ok_or_else(|| "net.serve_routed(port, routes, fallback): missing routes".to_string())?;
766                let fallback = match args.into_iter().nth(2) {
767                    Some(c @ Value::Closure { .. }) => c,
768                    _ => return Err("net.serve_routed(port, routes, fallback): fallback must be a closure".into()),
769                };
770                let routes = decode_routes_arg(routes_val)?;
771                let program = self.program.clone()
772                    .ok_or_else(|| "net.serve_routed requires a Program reference; use DefaultHandler::with_program".to_string())?;
773                let policy = self.policy.clone();
774                serve_http_routed(port, routes, fallback, program, policy, ServeOpts::from_env())
775            }
776            ("net", "serve_with") => {
777                // serve_with(port, handler_name, opts)
778                let port = match args.first() {
779                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
780                    _ => return Err("net.serve_with(port, handler, opts): port must be Int 0..=65535".into()),
781                };
782                let handler_name = expect_str(args.get(1))?.to_string();
783                let opts = decode_serve_opts(args.get(2)
784                    .ok_or_else(|| "net.serve_with(port, handler, opts): missing opts".to_string())?)?;
785                let program = self.program.clone()
786                    .ok_or_else(|| "net.serve_with requires a Program reference; use DefaultHandler::with_program".to_string())?;
787                let policy = self.policy.clone();
788                serve_http(port, handler_name, program, policy, None, opts)
789            }
790            ("net", "serve_fn_with") => {
791                // serve_fn_with(port, handler_closure, opts)
792                let port = match args.first() {
793                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
794                    _ => return Err("net.serve_fn_with(port, handler, opts): port must be Int 0..=65535".into()),
795                };
796                let opts = decode_serve_opts(args.get(2)
797                    .ok_or_else(|| "net.serve_fn_with(port, handler, opts): missing opts".to_string())?)?;
798                let closure = match args.into_iter().nth(1) {
799                    Some(c @ Value::Closure { .. }) => c,
800                    _ => return Err("net.serve_fn_with(port, handler, opts): handler must be a closure".into()),
801                };
802                let program = self.program.clone()
803                    .ok_or_else(|| "net.serve_fn_with requires a Program reference; use DefaultHandler::with_program".to_string())?;
804                let policy = self.policy.clone();
805                serve_http_fn(port, closure, program, policy, opts)
806            }
807            ("net", "serve_routed_with") => {
808                // serve_routed_with(port, routes, fallback, opts)
809                let port = match args.first() {
810                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
811                    _ => return Err("net.serve_routed_with(port, routes, fallback, opts): port must be Int 0..=65535".into()),
812                };
813                let routes_val = args.get(1).cloned()
814                    .ok_or_else(|| "net.serve_routed_with(port, routes, fallback, opts): missing routes".to_string())?;
815                let opts = decode_serve_opts(args.get(3)
816                    .ok_or_else(|| "net.serve_routed_with(port, routes, fallback, opts): missing opts".to_string())?)?;
817                let fallback = match args.into_iter().nth(2) {
818                    Some(c @ Value::Closure { .. }) => c,
819                    _ => return Err("net.serve_routed_with(port, routes, fallback, opts): fallback must be a closure".into()),
820                };
821                let routes = decode_routes_arg(routes_val)?;
822                let program = self.program.clone()
823                    .ok_or_else(|| "net.serve_routed_with requires a Program reference; use DefaultHandler::with_program".to_string())?;
824                let policy = self.policy.clone();
825                serve_http_routed(port, routes, fallback, program, policy, opts)
826            }
827            ("net", "serve_quic") => self.dispatch_serve_quic_named(args),
828            ("net", "serve_quic_fn") => self.dispatch_serve_quic_fn(args),
829            ("net", "serve_quic_routed") => self.dispatch_serve_quic_routed(args),
830            ("net", "serve_tls") => {
831                let port = match args.first() {
832                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
833                    _ => return Err("net.serve_tls(port, cert, key, handler): port must be Int 0..=65535".into()),
834                };
835                let cert_path = expect_str(args.get(1))?.to_string();
836                let key_path = expect_str(args.get(2))?.to_string();
837                let handler_name = expect_str(args.get(3))?.to_string();
838                let program = self.program.clone()
839                    .ok_or_else(|| "net.serve_tls requires a Program reference".to_string())?;
840                let policy = self.policy.clone();
841                let cert = std::fs::read(&cert_path)
842                    .map_err(|e| format!("net.serve_tls: read cert {cert_path}: {e}"))?;
843                let key = std::fs::read(&key_path)
844                    .map_err(|e| format!("net.serve_tls: read key {key_path}: {e}"))?;
845                serve_http(port, handler_name, program, policy, Some(TlsConfig { cert, key }), ServeOpts::from_env())
846            }
847            ("net", "serve_ws") => {
848                let port = match args.first() {
849                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
850                    _ => return Err("net.serve_ws(port, on_message): port must be Int 0..=65535".into()),
851                };
852                let handler_name = expect_str(args.get(1))?.to_string();
853                let program = self.program.clone()
854                    .ok_or_else(|| "net.serve_ws requires a Program reference".to_string())?;
855                let policy = self.policy.clone();
856                let registry = Arc::new(crate::ws::ChatRegistry::default());
857                crate::ws::serve_ws(port, handler_name, program, policy, registry)
858            }
859            ("net", "serve_ws_fn") => {
860                let port = match args.first() {
861                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
862                    _ => return Err("net.serve_ws_fn(port, subprotocol, handler): port must be Int 0..=65535".into()),
863                };
864                let subprotocol = expect_str(args.get(1))?.to_string();
865                let closure = match args.into_iter().nth(2) {
866                    Some(c @ Value::Closure { .. }) => c,
867                    _ => return Err("net.serve_ws_fn(port, subprotocol, handler): handler must be a closure".into()),
868                };
869                let program = self.program.clone()
870                    .ok_or_else(|| "net.serve_ws_fn requires a Program reference; use DefaultHandler::with_program".to_string())?;
871                let policy = self.policy.clone();
872                let registry = Arc::new(crate::ws::ChatRegistry::default());
873                crate::ws::serve_ws_fn(port, subprotocol, closure, program, policy, registry)
874            }
875            ("net", "serve_ws_fn_auth") => {
876                // serve_ws_fn_auth(port, subprotocol, auth, on_message)
877                let port = match args.first() {
878                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
879                    _ => return Err("net.serve_ws_fn_auth(port, subprotocol, auth, on_message): port must be Int 0..=65535".into()),
880                };
881                let subprotocol = expect_str(args.get(1))?.to_string();
882                let mut it = args.into_iter().skip(2);
883                let auth_closure = match it.next() {
884                    Some(c @ Value::Closure { .. }) => c,
885                    _ => return Err("net.serve_ws_fn_auth(port, subprotocol, auth, on_message): auth must be a closure".into()),
886                };
887                let handler_closure = match it.next() {
888                    Some(c @ Value::Closure { .. }) => c,
889                    _ => return Err("net.serve_ws_fn_auth(port, subprotocol, auth, on_message): on_message must be a closure".into()),
890                };
891                let program = self.program.clone()
892                    .ok_or_else(|| "net.serve_ws_fn_auth requires a Program reference; use DefaultHandler::with_program".to_string())?;
893                let policy = self.policy.clone();
894                let registry = Arc::new(crate::ws::ChatRegistry::default());
895                crate::ws::serve_ws_fn_auth(
896                    port, subprotocol, auth_closure, handler_closure,
897                    program, policy, registry,
898                )
899            }
900            ("net", "serve_ws_fn_actor_with") => {
901                // serve_ws_fn_actor_with(port, subprotocol, name_of,
902                //                        on_message, opts)  — #719
903                //
904                // Same server, with the bind interface named in the
905                // source. `opts` is the record `net.default_opts()`
906                // returns; only `host` is read here, because `http2`
907                // and `inline_vm` describe an HTTP server and mean
908                // nothing to a websocket listener. Sharing the record
909                // rather than minting a WS-specific one is what the
910                // issue asked for, and it keeps one `ServeOpts` in the
911                // language instead of two that differ by two fields.
912                let port = match args.first() {
913                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
914                    _ => return Err("net.serve_ws_fn_actor_with(port, subprotocol, name_of, on_message, opts): port must be Int 0..=65535".into()),
915                };
916                let subprotocol = expect_str(args.get(1))?.to_string();
917                // Decoded with the same reader `net.serve_*_with` uses,
918                // so the two families cannot drift on what an opts
919                // record means.
920                let opts = match args.get(4) {
921                    Some(v) => crate::handler::http_serve::decode_serve_opts(v)
922                        .map_err(|e| format!("net.serve_ws_fn_actor_with: {e} — use net.default_opts()"))?,
923                    None => return Err("net.serve_ws_fn_actor_with(port, subprotocol, name_of, on_message, opts): opts is required — use net.default_opts()".into()),
924                };
925                // An opts record whose host is blank is a caller who set
926                // the field and meant nothing by it; fall back rather
927                // than binding "".
928                let host = if opts.host.trim().is_empty() {
929                    crate::ws::ws_bind_host()
930                } else {
931                    opts.host.clone()
932                };
933                let mut it = args.into_iter().skip(2);
934                let name_of_closure = match it.next() {
935                    Some(c @ Value::Closure { .. }) => c,
936                    _ => return Err("net.serve_ws_fn_actor_with(port, subprotocol, name_of, on_message, opts): name_of must be a closure".into()),
937                };
938                let on_message_closure = match it.next() {
939                    Some(c @ Value::Closure { .. }) => c,
940                    _ => return Err("net.serve_ws_fn_actor_with(port, subprotocol, name_of, on_message, opts): on_message must be a closure".into()),
941                };
942                let program = self.program.clone()
943                    .ok_or_else(|| "net.serve_ws_fn_actor_with requires a Program reference; use DefaultHandler::with_program".to_string())?;
944                let policy = self.policy.clone();
945                let registry = Arc::new(crate::ws::ChatRegistry::default());
946                crate::ws::serve_ws_fn_actor_on(
947                    host, port, subprotocol, name_of_closure, on_message_closure,
948                    program, policy, registry,
949                )
950            }
951            ("net", "serve_ws_fn_actor") => {
952                // serve_ws_fn_actor(port, subprotocol, name_of, on_message)
953                let port = match args.first() {
954                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
955                    _ => return Err("net.serve_ws_fn_actor(port, subprotocol, name_of, on_message): port must be Int 0..=65535".into()),
956                };
957                let subprotocol = expect_str(args.get(1))?.to_string();
958                let mut it = args.into_iter().skip(2);
959                let name_of_closure = match it.next() {
960                    Some(c @ Value::Closure { .. }) => c,
961                    _ => return Err("net.serve_ws_fn_actor(port, subprotocol, name_of, on_message): name_of must be a closure".into()),
962                };
963                let on_message_closure = match it.next() {
964                    Some(c @ Value::Closure { .. }) => c,
965                    _ => return Err("net.serve_ws_fn_actor(port, subprotocol, name_of, on_message): on_message must be a closure".into()),
966                };
967                let program = self.program.clone()
968                    .ok_or_else(|| "net.serve_ws_fn_actor requires a Program reference; use DefaultHandler::with_program".to_string())?;
969                let policy = self.policy.clone();
970                let registry = Arc::new(crate::ws::ChatRegistry::default());
971                crate::ws::serve_ws_fn_actor(
972                    port, subprotocol, name_of_closure, on_message_closure,
973                    program, policy, registry,
974                )
975            }
976            ("net", "dial_ws") => {
977                // dial_ws(url, subprotocol, on_open, on_message)
978                let url = expect_str(args.first())?.to_string();
979                let subprotocol = expect_str(args.get(1))?.to_string();
980                let on_open = match args.get(2).cloned() {
981                    Some(c @ Value::Closure { .. }) => c,
982                    _ => return Err(
983                        "net.dial_ws(url, subprotocol, on_open, on_message): on_open must be a closure".into(),
984                    ),
985                };
986                let on_message = match args.into_iter().nth(3) {
987                    Some(c @ Value::Closure { .. }) => c,
988                    _ => return Err(
989                        "net.dial_ws(url, subprotocol, on_open, on_message): on_message must be a closure".into(),
990                    ),
991                };
992                let program = self.program.clone().ok_or_else(|| {
993                    "net.dial_ws requires a Program reference; use DefaultHandler::with_program".to_string()
994                })?;
995                let policy = self.policy.clone();
996                crate::ws::dial_ws(url, subprotocol, on_open, on_message, program, policy)
997            }
998            ("net", "dial_ws_actor") => {
999                // dial_ws_actor(url, subprotocol, name, on_open, on_message)
1000                let url = expect_str(args.first())?.to_string();
1001                let subprotocol = expect_str(args.get(1))?.to_string();
1002                let name = expect_str(args.get(2))?.to_string();
1003                let on_open = match args.get(3).cloned() {
1004                    Some(c @ Value::Closure { .. }) => c,
1005                    _ => return Err(
1006                        "net.dial_ws_actor(url, subprotocol, name, on_open, on_message): on_open must be a closure".into(),
1007                    ),
1008                };
1009                let on_message = match args.into_iter().nth(4) {
1010                    Some(c @ Value::Closure { .. }) => c,
1011                    _ => return Err(
1012                        "net.dial_ws_actor(url, subprotocol, name, on_open, on_message): on_message must be a closure".into(),
1013                    ),
1014                };
1015                let program = self.program.clone().ok_or_else(|| {
1016                    "net.dial_ws_actor requires a Program reference; use DefaultHandler::with_program".to_string()
1017                })?;
1018                let policy = self.policy.clone();
1019                crate::ws::dial_ws_actor(url, subprotocol, name, on_open, on_message, program, policy)
1020            }
1021            ("chat", "broadcast") => {
1022                let registry = self.chat_registry.as_ref()
1023                    .ok_or_else(|| "chat.broadcast called outside a net.serve_ws handler".to_string())?;
1024                let room = expect_str(args.first())?;
1025                let body = expect_str(args.get(1))?;
1026                crate::ws::chat_broadcast(registry, room, body);
1027                Ok(Value::Unit)
1028            }
1029            ("chat", "send") => {
1030                let registry = self.chat_registry.as_ref()
1031                    .ok_or_else(|| "chat.send called outside a net.serve_ws handler".to_string())?;
1032                let conn_id = match args.first() {
1033                    Some(Value::Int(n)) if *n >= 0 => *n as u64,
1034                    _ => return Err("chat.send: conn_id must be non-negative Int".into()),
1035                };
1036                let body = expect_str(args.get(1))?;
1037                Ok(Value::Bool(crate::ws::chat_send(registry, conn_id, body)))
1038            }
1039            ("kv", "open") => {
1040                let path = expect_str(args.first())?.to_string();
1041                // Honor write-allowlist: opening a Kv writes its
1042                // backing files at `path`, so the same scoping that
1043                // applies to `io.write` applies here.
1044                if !self.policy.allow_fs_write.is_empty() {
1045                    let p = std::path::Path::new(&path);
1046                    if !self.policy.allow_fs_write.iter().any(|a| p.starts_with(a)) {
1047                        return Ok(err(Value::Str(format!(
1048                            "kv.open: `{path}` outside --allow-fs-write").into())));
1049                    }
1050                }
1051                match sled::open(&path) {
1052                    Ok(db) => {
1053                        let handle = next_kv_handle();
1054                        kv_registry().lock().unwrap().insert(handle, db);
1055                        Ok(ok(Value::Int(handle as i64)))
1056                    }
1057                    Err(e) => Ok(err(Value::Str(format!("kv.open: {e}").into()))),
1058                }
1059            }
1060            ("kv", "close") => {
1061                let h = expect_kv_handle(args.first())?;
1062                kv_registry().lock().unwrap().remove(h);
1063                Ok(Value::Unit)
1064            }
1065            ("kv", "get") => {
1066                let h = expect_kv_handle(args.first())?;
1067                let key = expect_str(args.get(1))?;
1068                let mut reg = kv_registry().lock().unwrap();
1069                let db = reg.touch_get(h).ok_or_else(|| "kv.get: closed or unknown Kv handle".to_string())?;
1070                match db.get(key.as_bytes()) {
1071                    Ok(Some(ivec)) => Ok(some(Value::Bytes(ivec.to_vec()))),
1072                    Ok(None) => Ok(none()),
1073                    Err(e) => Err(format!("kv.get: {e}")),
1074                }
1075            }
1076            ("kv", "put") => {
1077                let h = expect_kv_handle(args.first())?;
1078                let key = expect_str(args.get(1))?.to_string();
1079                let val = expect_bytes(args.get(2))?.clone();
1080                let mut reg = kv_registry().lock().unwrap();
1081                let db = reg.touch_get(h).ok_or_else(|| "kv.put: closed or unknown Kv handle".to_string())?;
1082                match db.insert(key.as_bytes(), val) {
1083                    Ok(_) => Ok(ok(Value::Unit)),
1084                    Err(e) => Ok(err(Value::Str(format!("kv.put: {e}").into()))),
1085                }
1086            }
1087            ("kv", "delete") => {
1088                let h = expect_kv_handle(args.first())?;
1089                let key = expect_str(args.get(1))?;
1090                let mut reg = kv_registry().lock().unwrap();
1091                let db = reg.touch_get(h).ok_or_else(|| "kv.delete: closed or unknown Kv handle".to_string())?;
1092                match db.remove(key.as_bytes()) {
1093                    Ok(_) => Ok(ok(Value::Unit)),
1094                    Err(e) => Ok(err(Value::Str(format!("kv.delete: {e}").into()))),
1095                }
1096            }
1097            ("kv", "contains") => {
1098                let h = expect_kv_handle(args.first())?;
1099                let key = expect_str(args.get(1))?;
1100                let mut reg = kv_registry().lock().unwrap();
1101                let db = reg.touch_get(h).ok_or_else(|| "kv.contains: closed or unknown Kv handle".to_string())?;
1102                match db.contains_key(key.as_bytes()) {
1103                    Ok(present) => Ok(Value::Bool(present)),
1104                    Err(e) => Err(format!("kv.contains: {e}")),
1105                }
1106            }
1107            ("kv", "list_prefix") => {
1108                let h = expect_kv_handle(args.first())?;
1109                let prefix = expect_str(args.get(1))?;
1110                let mut reg = kv_registry().lock().unwrap();
1111                let db = reg.touch_get(h).ok_or_else(|| "kv.list_prefix: closed or unknown Kv handle".to_string())?;
1112                let mut keys: Vec<Value> = Vec::new();
1113                for kv in db.scan_prefix(prefix.as_bytes()) {
1114                    let (k, _) = kv.map_err(|e| format!("kv.list_prefix: {e}"))?;
1115                    let s = String::from_utf8_lossy(&k).to_string();
1116                    keys.push(Value::Str(s.into()));
1117                }
1118                Ok(Value::List(keys.into()))
1119            }
1120            // ── std.vcs: content-addressed blob store (#5) ──
1121            // Backed by lex-store's blob CAS (Store::put_blob/get_blob/
1122            // set_blob_ref/get_blob_ref). Effect `vcs` is gated by the generic
1123            // ensure_kind_allowed(kind) above. put_blob's sha ==
1124            // crypto.sha256_str(content), so vcs blobs and loom's SQLite
1125            // artifacts share ids. We depend on lex-store with the `trace`
1126            // feature off to avoid a lex-store → lex-trace → lex-runtime cycle.
1127            ("vcs", "put_blob") => {
1128                let content = expect_str(args.first())?.to_string();
1129                match lex_store::Store::open(vcs_store_root())
1130                    .and_then(|s| s.put_blob(&content)) {
1131                    Ok(sha) => Ok(ok(Value::Str(sha.into()))),
1132                    Err(e)  => Ok(err(Value::Str(format!("vcs.put_blob: {e}").into()))),
1133                }
1134            }
1135            ("vcs", "get_blob") => {
1136                let sha = expect_str(args.first())?.to_string();
1137                match lex_store::Store::open(vcs_store_root())
1138                    .and_then(|s| s.get_blob(&sha)) {
1139                    Ok(content) => Ok(ok(Value::Str(content.into()))),
1140                    Err(e)      => Ok(err(Value::Str(format!("vcs.get_blob: {e}").into()))),
1141                }
1142            }
1143            ("vcs", "has_blob") => {
1144                let sha = expect_str(args.first())?.to_string();
1145                let has = lex_store::Store::open(vcs_store_root())
1146                    .map(|s| s.has_blob(&sha)).unwrap_or(false);
1147                Ok(Value::Bool(has))
1148            }
1149            ("vcs", "ref_set") => {
1150                let ns  = expect_str(args.first())?.to_string();
1151                let key = expect_str(args.get(1))?.to_string();
1152                let sha = expect_str(args.get(2))?.to_string();
1153                match lex_store::Store::open(vcs_store_root())
1154                    .and_then(|s| s.set_blob_ref(&ns, &key, &sha)) {
1155                    Ok(())  => Ok(ok(Value::Unit)),
1156                    Err(e)  => Ok(err(Value::Str(format!("vcs.ref_set: {e}").into()))),
1157                }
1158            }
1159            ("vcs", "ref_get") => {
1160                let ns  = expect_str(args.first())?.to_string();
1161                let key = expect_str(args.get(1))?.to_string();
1162                match lex_store::Store::open(vcs_store_root())
1163                    .and_then(|s| s.get_blob_ref(&ns, &key)) {
1164                    Ok(sha) => Ok(ok(Value::Str(sha.into()))),
1165                    Err(e)  => Ok(err(Value::Str(format!("vcs.ref_get: {e}").into()))),
1166                }
1167            }
1168            ("sql", "open") => {
1169                let path = expect_str(args.first())?.to_string();
1170                if path.starts_with("postgres://") || path.starts_with("postgresql://") {
1171                    // Postgres: connect via sync driver; no fs-write policy applies.
1172                    match postgres::Client::connect(&path, postgres::NoTls) {
1173                        Ok(client) => {
1174                            let handle = next_sql_handle();
1175                            sql_registry().lock().unwrap().insert(handle, SqlConn::Postgres(client));
1176                            Ok(ok(Value::Int(handle as i64)))
1177                        }
1178                        Err(e) => Ok(err(pg_err_to_sql_error(e, "sql.open"))),
1179                    }
1180                } else {
1181                    // SQLite: same shape as `kv.open`; fs-write allowlist applies
1182                    // (in-memory paths are exempt).
1183                    if path != ":memory:" && !self.policy.allow_fs_write.is_empty() {
1184                        let p = std::path::Path::new(&path);
1185                        if !self.policy.allow_fs_write.iter().any(|a| p.starts_with(a)) {
1186                            return Ok(err(sql_error(
1187                                format!("sql.open: `{path}` outside --allow-fs-write"),
1188                                None, None,
1189                            )));
1190                        }
1191                    }
1192                    match rusqlite::Connection::open(&path) {
1193                        Ok(conn) => {
1194                            let handle = next_sql_handle();
1195                            sql_registry().lock().unwrap().insert(handle, SqlConn::Sqlite(conn));
1196                            Ok(ok(Value::Int(handle as i64)))
1197                        }
1198                        Err(e) => Ok(err(sqlite_err_to_sql_error(e, "sql.open"))),
1199                    }
1200                }
1201            }
1202            ("sql", "close") => {
1203                let h = expect_sql_handle(args.first())?;
1204                sql_registry().lock().unwrap().remove(h);
1205                Ok(Value::Unit)
1206            }
1207            ("sql", "exec") => {
1208                let h = expect_sql_handle(args.first())?;
1209                let stmt = expect_str(args.get(1))?.to_string();
1210                let params = expect_sql_params(args.get(2))?;
1211                let arc = sql_registry().lock().unwrap()
1212                    .touch_get(h)
1213                    .ok_or_else(|| "sql.exec: closed or unknown Db handle".to_string())?;
1214                let mut conn = arc.lock().unwrap();
1215                match &mut *conn {
1216                    SqlConn::Sqlite(c) => {
1217                        let bound = sqlite_params(&params);
1218                        let bind: Vec<&dyn rusqlite::ToSql> =
1219                            bound.iter().map(|p| p as &dyn rusqlite::ToSql).collect();
1220                        match c.execute(&stmt, rusqlite::params_from_iter(bind.iter())) {
1221                            Ok(n)  => Ok(ok(Value::Int(n as i64))),
1222                            Err(e) => Ok(err(sqlite_err_to_sql_error(e, "sql.exec"))),
1223                        }
1224                    }
1225                    SqlConn::Postgres(c) => {
1226                        let pg = pg_param_refs(&params);
1227                        let refs: Vec<&(dyn postgres::types::ToSql + Sync)> =
1228                            pg.iter().map(|b| b.as_ref()).collect();
1229                        match c.execute(pg_rewrite_placeholders(stmt.as_str()).as_str(), &refs) {
1230                            Ok(n)  => Ok(ok(Value::Int(n as i64))),
1231                            Err(e) => Ok(err(pg_err_to_sql_error(e, "sql.exec"))),
1232                        }
1233                    }
1234                }
1235            }
1236            ("sql", "query") => {
1237                let h = expect_sql_handle(args.first())?;
1238                let stmt_str = expect_str(args.get(1))?.to_string();
1239                let params = expect_sql_params(args.get(2))?;
1240                let arc = sql_registry().lock().unwrap()
1241                    .touch_get(h)
1242                    .ok_or_else(|| "sql.query: closed or unknown Db handle".to_string())?;
1243                let mut conn = arc.lock().unwrap();
1244                Ok(match &mut *conn {
1245                    SqlConn::Sqlite(c)   => sql_run_query_sqlite(c, &stmt_str, &params),
1246                    SqlConn::Postgres(c) => sql_run_query_pg(c, &stmt_str, &params),
1247                })
1248            }
1249            // Streaming cursor (#379). Allocates an mpsc-backed cursor
1250            // handle, spawns a producer thread to ship rows one at a
1251            // time, and returns `__IterCursor(handle)` wrapped in `Ok`.
1252            // `iter.next` bytecode dispatches the variant tag and
1253            // effect-calls `sql.cursor_next` (below) to advance.
1254            ("sql", "query_iter") => {
1255                let h = expect_sql_handle(args.first())?;
1256                let stmt_str = expect_str(args.get(1))?.to_string();
1257                let params = expect_sql_params(args.get(2))?;
1258                let arc = sql_registry().lock().unwrap()
1259                    .touch_get(h)
1260                    .ok_or_else(|| "sql.query_iter: closed or unknown Db handle".to_string())?;
1261
1262                // Dispatch producer on the connection kind without
1263                // holding the SqlRegistry lock — the producer thread
1264                // owns its own clone of the connection Arc.
1265                let (sender, receiver) = std::sync::mpsc::sync_channel::<Result<Value, String>>(
1266                    CURSOR_CHANNEL_CAPACITY,
1267                );
1268                let cursor_h = next_cursor_handle();
1269                cursor_registry().lock().unwrap().insert(cursor_h, receiver);
1270
1271                let arc_for_thread = Arc::clone(&arc);
1272                // Decide which producer to spawn based on the
1273                // connection's variant. We can briefly peek at the
1274                // variant here without holding the lock for the
1275                // producer's lifetime — the producer locks again
1276                // inside its thread function.
1277                let is_sqlite = matches!(*arc.lock().unwrap(), SqlConn::Sqlite(_));
1278                std::thread::spawn(move || {
1279                    if is_sqlite {
1280                        sqlite_cursor_producer(arc_for_thread, stmt_str, params, sender);
1281                    } else {
1282                        pg_cursor_producer(arc_for_thread, stmt_str, params, sender);
1283                    }
1284                });
1285
1286                Ok(ok(Value::Variant {
1287                    name: "__IterCursor".into(),
1288                    args: vec![Value::Int(cursor_h as i64)],
1289                }))
1290            }
1291            // Pull one row from the producer; called from
1292            // `iter.next`'s `__IterCursor` dispatch branch. Returns
1293            // a Lex `Option[Row]`: `Some(row)` while the producer
1294            // has more, `None` once the channel closes (producer
1295            // done, errored, or cursor evicted from the registry).
1296            ("sql", "cursor_next") => {
1297                let h = match args.first() {
1298                    Some(Value::Int(n)) if *n >= 0 => *n as u64,
1299                    _ => return Err("sql.cursor_next: expected cursor handle (Int)".into()),
1300                };
1301                let rx_arc = match cursor_registry().lock().unwrap().touch_get(h) {
1302                    Some(a) => a,
1303                    None => return Ok(Value::Variant { name: "None".into(), args: vec![] }),
1304                };
1305                // Lock the receiver itself (separate from the global
1306                // registry lock) and block on `recv()`. The producer
1307                // is on a different thread, so this can sleep without
1308                // contention beyond the per-cursor mutex.
1309                let recv_result = {
1310                    let rx = match rx_arc.lock() {
1311                        Ok(g) => g,
1312                        Err(p) => p.into_inner(),
1313                    };
1314                    rx.recv()
1315                };
1316                match recv_result {
1317                    Ok(Ok(row)) => Ok(Value::Variant {
1318                        name: "Some".into(),
1319                        args: vec![row],
1320                    }),
1321                    Ok(Err(_)) | Err(_) => {
1322                        // Channel closed (producer done) or row error
1323                        // — drop the registry entry and signal None
1324                        // so callers stop polling.
1325                        cursor_registry().lock().unwrap().remove(h);
1326                        Ok(Value::Variant { name: "None".into(), args: vec![] })
1327                    }
1328                }
1329            }
1330            // Transactions: begin issues BEGIN SQL on the connection;
1331            // commit/rollback issue COMMIT/ROLLBACK. SqlTx reuses the
1332            // same Int handle as Db — the type system enforces correct
1333            // usage; the runtime treats both as the same registry key.
1334            ("sql", "begin") => {
1335                let h = expect_sql_handle(args.first())?;
1336                let arc = sql_registry().lock().unwrap()
1337                    .touch_get(h)
1338                    .ok_or_else(|| "sql.begin: closed or unknown Db handle".to_string())?;
1339                let mut conn = arc.lock().unwrap();
1340                match &mut *conn {
1341                    SqlConn::Sqlite(c) => match c.execute_batch("BEGIN") {
1342                        Ok(()) => Ok(ok(Value::Int(h as i64))),
1343                        Err(e) => Ok(err(sqlite_err_to_sql_error(e, "sql.begin"))),
1344                    },
1345                    SqlConn::Postgres(c) => match c.batch_execute("BEGIN") {
1346                        Ok(()) => Ok(ok(Value::Int(h as i64))),
1347                        Err(e) => Ok(err(pg_err_to_sql_error(e, "sql.begin"))),
1348                    },
1349                }
1350            }
1351            ("sql", "commit") => {
1352                let h = expect_sql_handle(args.first())?;
1353                let arc = sql_registry().lock().unwrap()
1354                    .touch_get(h)
1355                    .ok_or_else(|| "sql.commit: closed or unknown SqlTx handle".to_string())?;
1356                let mut conn = arc.lock().unwrap();
1357                match &mut *conn {
1358                    SqlConn::Sqlite(c) => match c.execute_batch("COMMIT") {
1359                        Ok(()) => Ok(ok(Value::Unit)),
1360                        Err(e) => Ok(err(sqlite_err_to_sql_error(e, "sql.commit"))),
1361                    },
1362                    SqlConn::Postgres(c) => match c.batch_execute("COMMIT") {
1363                        Ok(()) => Ok(ok(Value::Unit)),
1364                        Err(e) => Ok(err(pg_err_to_sql_error(e, "sql.commit"))),
1365                    },
1366                }
1367            }
1368            ("sql", "rollback") => {
1369                let h = expect_sql_handle(args.first())?;
1370                let arc = sql_registry().lock().unwrap()
1371                    .touch_get(h)
1372                    .ok_or_else(|| "sql.rollback: closed or unknown SqlTx handle".to_string())?;
1373                let mut conn = arc.lock().unwrap();
1374                match &mut *conn {
1375                    SqlConn::Sqlite(c) => match c.execute_batch("ROLLBACK") {
1376                        Ok(()) => Ok(ok(Value::Unit)),
1377                        Err(e) => Ok(err(sqlite_err_to_sql_error(e, "sql.rollback"))),
1378                    },
1379                    SqlConn::Postgres(c) => match c.batch_execute("ROLLBACK") {
1380                        Ok(()) => Ok(ok(Value::Unit)),
1381                        Err(e) => Ok(err(pg_err_to_sql_error(e, "sql.rollback"))),
1382                    },
1383                }
1384            }
1385            ("sql", "exec_tx") => {
1386                let h = expect_sql_handle(args.first())?;
1387                let stmt = expect_str(args.get(1))?.to_string();
1388                let params = expect_sql_params(args.get(2))?;
1389                let arc = sql_registry().lock().unwrap()
1390                    .touch_get(h)
1391                    .ok_or_else(|| "sql.exec_tx: closed or unknown SqlTx handle".to_string())?;
1392                let mut conn = arc.lock().unwrap();
1393                match &mut *conn {
1394                    SqlConn::Sqlite(c) => {
1395                        let bound = sqlite_params(&params);
1396                        let bind: Vec<&dyn rusqlite::ToSql> =
1397                            bound.iter().map(|p| p as &dyn rusqlite::ToSql).collect();
1398                        match c.execute(&stmt, rusqlite::params_from_iter(bind.iter())) {
1399                            Ok(n)  => Ok(ok(Value::Int(n as i64))),
1400                            Err(e) => Ok(err(sqlite_err_to_sql_error(e, "sql.exec_tx"))),
1401                        }
1402                    }
1403                    SqlConn::Postgres(c) => {
1404                        let pg = pg_param_refs(&params);
1405                        let refs: Vec<&(dyn postgres::types::ToSql + Sync)> =
1406                            pg.iter().map(|b| b.as_ref()).collect();
1407                        match c.execute(pg_rewrite_placeholders(stmt.as_str()).as_str(), &refs) {
1408                            Ok(n)  => Ok(ok(Value::Int(n as i64))),
1409                            Err(e) => Ok(err(pg_err_to_sql_error(e, "sql.exec_tx"))),
1410                        }
1411                    }
1412                }
1413            }
1414            ("sql", "query_tx") => {
1415                let h = expect_sql_handle(args.first())?;
1416                let stmt_str = expect_str(args.get(1))?.to_string();
1417                let params = expect_sql_params(args.get(2))?;
1418                let arc = sql_registry().lock().unwrap()
1419                    .touch_get(h)
1420                    .ok_or_else(|| "sql.query_tx: closed or unknown SqlTx handle".to_string())?;
1421                let mut conn = arc.lock().unwrap();
1422                Ok(match &mut *conn {
1423                    SqlConn::Sqlite(c)   => sql_run_query_sqlite(c, &stmt_str, &params),
1424                    SqlConn::Postgres(c) => sql_run_query_pg(c, &stmt_str, &params),
1425                })
1426            }
1427            ("sql", "get_str") => Ok(sql_get_col(&args, |v| match v {
1428                Value::Str(s) => Some(Value::Str(s.clone())),
1429                Value::Int(n) => Some(Value::Str(n.to_string().into())),
1430                _ => None,
1431            })?),
1432            ("sql", "get_int") => Ok(sql_get_col(&args, |v| match v {
1433                Value::Int(n) => Some(Value::Int(*n)),
1434                Value::Float(f) => Some(Value::Int(*f as i64)),
1435                _ => None,
1436            })?),
1437            ("sql", "get_float") => Ok(sql_get_col(&args, |v| match v {
1438                Value::Float(f) => Some(Value::Float(*f)),
1439                Value::Int(n)   => Some(Value::Float(*n as f64)),
1440                _ => None,
1441            })?),
1442            ("sql", "get_bool") => Ok(sql_get_col(&args, |v| match v {
1443                Value::Bool(b)  => Some(Value::Bool(*b)),
1444                Value::Int(n)   => Some(Value::Bool(*n != 0)),
1445                _ => None,
1446            })?),
1447
1448            // ── std.redis (#533) ─────────────────────────────────────────
1449            //
1450            // ConnRedis is an opaque Int handle into the global RedisRegistry.
1451            // All ops carry [net] — Redis is a TCP service.
1452            //
1453            // subscribe/psubscribe open a *dedicated* connection so they don't
1454            // interfere with the handle's regular connection. Redis disallows
1455            // non-Pub/Sub commands on a subscribed connection.
1456            ("redis", "connect") => {
1457                let url = expect_str(args.first())?.to_string();
1458                self.ensure_host_allowed(&url)?;
1459                match redis::Client::open(url.as_str()) {
1460                    Ok(client) => match client.get_connection() {
1461                        Ok(conn) => {
1462                            let handle = next_redis_handle();
1463                            redis_registry().lock().unwrap().insert(handle, RedisEntry { url, conn });
1464                            Ok(ok(Value::Int(handle as i64)))
1465                        }
1466                        Err(e) => Ok(err(Value::Str(format!("redis.connect: {e}").into()))),
1467                    },
1468                    Err(e) => Ok(err(Value::Str(format!("redis.connect: {e}").into()))),
1469                }
1470            }
1471            ("redis", "close") => {
1472                let h = expect_redis_handle(args.first())?;
1473                redis_registry().lock().unwrap().remove(h);
1474                Ok(Value::Unit)
1475            }
1476            ("redis", "get") => {
1477                let h = expect_redis_handle(args.first())?;
1478                let key = expect_str(args.get(1))?.to_string();
1479                let mut reg = redis_registry().lock().unwrap();
1480                let entry = reg.touch_get_mut(h)
1481                    .ok_or_else(|| "redis.get: closed or unknown ConnRedis handle".to_string())?;
1482                use redis::Commands;
1483                match entry.conn.get::<_, Option<String>>(&key) {
1484                    Ok(Some(v)) => Ok(some(Value::Str(v.into()))),
1485                    Ok(None)    => Ok(none()),
1486                    Err(e)      => Err(format!("redis.get: {e}")),
1487                }
1488            }
1489            ("redis", "set") => {
1490                let h = expect_redis_handle(args.first())?;
1491                let key = expect_str(args.get(1))?.to_string();
1492                let val = expect_str(args.get(2))?.to_string();
1493                let mut reg = redis_registry().lock().unwrap();
1494                let entry = reg.touch_get_mut(h)
1495                    .ok_or_else(|| "redis.set: closed or unknown ConnRedis handle".to_string())?;
1496                use redis::Commands;
1497                entry.conn.set::<_, _, ()>(&key, &val)
1498                    .map_err(|e| format!("redis.set: {e}"))?;
1499                Ok(Value::Unit)
1500            }
1501            ("redis", "set_ex") => {
1502                let h = expect_redis_handle(args.first())?;
1503                let key = expect_str(args.get(1))?.to_string();
1504                let val = expect_str(args.get(2))?.to_string();
1505                let ttl = expect_int(args.get(3))?;
1506                let mut reg = redis_registry().lock().unwrap();
1507                let entry = reg.touch_get_mut(h)
1508                    .ok_or_else(|| "redis.set_ex: closed or unknown ConnRedis handle".to_string())?;
1509                use redis::Commands;
1510                entry.conn.set_ex::<_, _, ()>(&key, &val, ttl as u64)
1511                    .map_err(|e| format!("redis.set_ex: {e}"))?;
1512                Ok(Value::Unit)
1513            }
1514            ("redis", "del") => {
1515                let h = expect_redis_handle(args.first())?;
1516                let key = expect_str(args.get(1))?.to_string();
1517                let mut reg = redis_registry().lock().unwrap();
1518                let entry = reg.touch_get_mut(h)
1519                    .ok_or_else(|| "redis.del: closed or unknown ConnRedis handle".to_string())?;
1520                use redis::Commands;
1521                entry.conn.del::<_, ()>(&key)
1522                    .map_err(|e| format!("redis.del: {e}"))?;
1523                Ok(Value::Unit)
1524            }
1525            ("redis", "exists") => {
1526                let h = expect_redis_handle(args.first())?;
1527                let key = expect_str(args.get(1))?.to_string();
1528                let mut reg = redis_registry().lock().unwrap();
1529                let entry = reg.touch_get_mut(h)
1530                    .ok_or_else(|| "redis.exists: closed or unknown ConnRedis handle".to_string())?;
1531                use redis::Commands;
1532                let present: bool = entry.conn.exists(&key)
1533                    .map_err(|e| format!("redis.exists: {e}"))?;
1534                Ok(Value::Bool(present))
1535            }
1536            ("redis", "expire") => {
1537                let h = expect_redis_handle(args.first())?;
1538                let key = expect_str(args.get(1))?.to_string();
1539                let ttl = expect_int(args.get(2))?;
1540                let mut reg = redis_registry().lock().unwrap();
1541                let entry = reg.touch_get_mut(h)
1542                    .ok_or_else(|| "redis.expire: closed or unknown ConnRedis handle".to_string())?;
1543                use redis::Commands;
1544                entry.conn.expire::<_, ()>(&key, ttl)
1545                    .map_err(|e| format!("redis.expire: {e}"))?;
1546                Ok(Value::Unit)
1547            }
1548            ("redis", "publish") => {
1549                let h = expect_redis_handle(args.first())?;
1550                let channel = expect_str(args.get(1))?.to_string();
1551                let msg = expect_str(args.get(2))?.to_string();
1552                let mut reg = redis_registry().lock().unwrap();
1553                let entry = reg.touch_get_mut(h)
1554                    .ok_or_else(|| "redis.publish: closed or unknown ConnRedis handle".to_string())?;
1555                use redis::Commands;
1556                let n: i64 = entry.conn.publish(&channel, &msg)
1557                    .map_err(|e| format!("redis.publish: {e}"))?;
1558                Ok(Value::Int(n))
1559            }
1560            // subscribe / psubscribe: blocking loops on dedicated connections.
1561            // Each inbound message calls the Lex closure in a fresh VM built
1562            // from `self.program` — same pattern as net.serve_fn's per-request
1563            // dispatch. Returns Unit (Nil) only if the connection drops.
1564            ("redis", "subscribe") => {
1565                let h = expect_redis_handle(args.first())?;
1566                let channel = expect_str(args.get(1))?.to_string();
1567                let closure = match args.into_iter().nth(2) {
1568                    Some(c @ Value::Closure { .. }) => c,
1569                    _ => return Err("redis.subscribe: handler must be a Closure".into()),
1570                };
1571                let program = self.program.clone()
1572                    .ok_or("redis.subscribe: no program; call DefaultHandler::with_program")?;
1573                let policy = self.policy.clone();
1574                let url = redis_registry().lock().unwrap()
1575                    .get_url(h)
1576                    .ok_or("redis.subscribe: closed or unknown ConnRedis handle")?;
1577                let client = redis::Client::open(url.as_str())
1578                    .map_err(|e| format!("redis.subscribe: {e}"))?;
1579                let mut conn = client.get_connection()
1580                    .map_err(|e| format!("redis.subscribe: {e}"))?;
1581                let mut pubsub = conn.as_pubsub();
1582                pubsub.subscribe(&channel)
1583                    .map_err(|e| format!("redis.subscribe: {e}"))?;
1584                loop {
1585                    let msg = pubsub.get_message()
1586                        .map_err(|e| format!("redis.subscribe: {e}"))?;
1587                    let ch: String = msg.get_channel_name().to_string();
1588                    let payload: String = msg.get_payload()
1589                        .map_err(|e| format!("redis.subscribe: payload: {e}"))?;
1590                    let handler = DefaultHandler::new(policy.clone())
1591                        .with_program(Arc::clone(&program));
1592                    let mut vm = Vm::with_handler(&program, Box::new(handler));
1593                    vm.invoke_closure_value(closure.clone(), vec![
1594                        Value::Str(ch.into()),
1595                        Value::Str(payload.into()),
1596                    ]).map_err(|e| format!("redis.subscribe: handler: {e:?}"))?;
1597                }
1598            }
1599            ("redis", "psubscribe") => {
1600                let h = expect_redis_handle(args.first())?;
1601                let pattern = expect_str(args.get(1))?.to_string();
1602                let closure = match args.into_iter().nth(2) {
1603                    Some(c @ Value::Closure { .. }) => c,
1604                    _ => return Err("redis.psubscribe: handler must be a Closure".into()),
1605                };
1606                let program = self.program.clone()
1607                    .ok_or("redis.psubscribe: no program; call DefaultHandler::with_program")?;
1608                let policy = self.policy.clone();
1609                let url = redis_registry().lock().unwrap()
1610                    .get_url(h)
1611                    .ok_or("redis.psubscribe: closed or unknown ConnRedis handle")?;
1612                let client = redis::Client::open(url.as_str())
1613                    .map_err(|e| format!("redis.psubscribe: {e}"))?;
1614                let mut conn = client.get_connection()
1615                    .map_err(|e| format!("redis.psubscribe: {e}"))?;
1616                let mut pubsub = conn.as_pubsub();
1617                pubsub.psubscribe(&pattern)
1618                    .map_err(|e| format!("redis.psubscribe: {e}"))?;
1619                loop {
1620                    let msg = pubsub.get_message()
1621                        .map_err(|e| format!("redis.psubscribe: {e}"))?;
1622                    let pat: String = msg.get_pattern()
1623                        .ok()
1624                        .and_then(|v: Option<String>| v)
1625                        .unwrap_or_else(|| pattern.clone());
1626                    let ch: String = msg.get_channel_name().to_string();
1627                    let payload: String = msg.get_payload()
1628                        .map_err(|e| format!("redis.psubscribe: payload: {e}"))?;
1629                    let handler = DefaultHandler::new(policy.clone())
1630                        .with_program(Arc::clone(&program));
1631                    let mut vm = Vm::with_handler(&program, Box::new(handler));
1632                    vm.invoke_closure_value(closure.clone(), vec![
1633                        Value::Str(pat.into()),
1634                        Value::Str(ch.into()),
1635                        Value::Str(payload.into()),
1636                    ]).map_err(|e| format!("redis.psubscribe: handler: {e:?}"))?;
1637                }
1638            }
1639            ("redis", "lpush") => {
1640                let h = expect_redis_handle(args.first())?;
1641                let key = expect_str(args.get(1))?.to_string();
1642                let val = expect_str(args.get(2))?.to_string();
1643                let mut reg = redis_registry().lock().unwrap();
1644                let entry = reg.touch_get_mut(h)
1645                    .ok_or_else(|| "redis.lpush: closed or unknown ConnRedis handle".to_string())?;
1646                use redis::Commands;
1647                let n: i64 = entry.conn.lpush(&key, &val)
1648                    .map_err(|e| format!("redis.lpush: {e}"))?;
1649                Ok(Value::Int(n))
1650            }
1651            ("redis", "rpush") => {
1652                let h = expect_redis_handle(args.first())?;
1653                let key = expect_str(args.get(1))?.to_string();
1654                let val = expect_str(args.get(2))?.to_string();
1655                let mut reg = redis_registry().lock().unwrap();
1656                let entry = reg.touch_get_mut(h)
1657                    .ok_or_else(|| "redis.rpush: closed or unknown ConnRedis handle".to_string())?;
1658                use redis::Commands;
1659                let n: i64 = entry.conn.rpush(&key, &val)
1660                    .map_err(|e| format!("redis.rpush: {e}"))?;
1661                Ok(Value::Int(n))
1662            }
1663            ("redis", "brpop") => {
1664                // timeout=0 means block indefinitely; the Lex runtime does not
1665                // treat this as a hung effect — it is the caller's intent.
1666                let h = expect_redis_handle(args.first())?;
1667                let key = expect_str(args.get(1))?.to_string();
1668                let timeout = expect_int(args.get(2))?;
1669                let mut reg = redis_registry().lock().unwrap();
1670                let entry = reg.touch_get_mut(h)
1671                    .ok_or_else(|| "redis.brpop: closed or unknown ConnRedis handle".to_string())?;
1672                use redis::Commands;
1673                // brpop returns Option<(String, String)>: (key, value).
1674                // We surface only the value to the Lex caller.
1675                let result: Option<(String, String)> = entry.conn
1676                    .brpop(&key, timeout as f64)
1677                    .map_err(|e| format!("redis.brpop: {e}"))?;
1678                match result {
1679                    Some((_, v)) => Ok(some(Value::Str(v.into()))),
1680                    None         => Ok(none()),
1681                }
1682            }
1683            ("redis", "llen") => {
1684                let h = expect_redis_handle(args.first())?;
1685                let key = expect_str(args.get(1))?.to_string();
1686                let mut reg = redis_registry().lock().unwrap();
1687                let entry = reg.touch_get_mut(h)
1688                    .ok_or_else(|| "redis.llen: closed or unknown ConnRedis handle".to_string())?;
1689                use redis::Commands;
1690                let n: i64 = entry.conn.llen(&key)
1691                    .map_err(|e| format!("redis.llen: {e}"))?;
1692                Ok(Value::Int(n))
1693            }
1694            ("redis", "hset") => {
1695                let h = expect_redis_handle(args.first())?;
1696                let key   = expect_str(args.get(1))?.to_string();
1697                let field = expect_str(args.get(2))?.to_string();
1698                let val   = expect_str(args.get(3))?.to_string();
1699                let mut reg = redis_registry().lock().unwrap();
1700                let entry = reg.touch_get_mut(h)
1701                    .ok_or_else(|| "redis.hset: closed or unknown ConnRedis handle".to_string())?;
1702                use redis::Commands;
1703                entry.conn.hset::<_, _, _, ()>(&key, &field, &val)
1704                    .map_err(|e| format!("redis.hset: {e}"))?;
1705                Ok(Value::Unit)
1706            }
1707            ("redis", "hget") => {
1708                let h = expect_redis_handle(args.first())?;
1709                let key   = expect_str(args.get(1))?.to_string();
1710                let field = expect_str(args.get(2))?.to_string();
1711                let mut reg = redis_registry().lock().unwrap();
1712                let entry = reg.touch_get_mut(h)
1713                    .ok_or_else(|| "redis.hget: closed or unknown ConnRedis handle".to_string())?;
1714                use redis::Commands;
1715                match entry.conn.hget::<_, _, Option<String>>(&key, &field) {
1716                    Ok(Some(v)) => Ok(some(Value::Str(v.into()))),
1717                    Ok(None)    => Ok(none()),
1718                    Err(e)      => Err(format!("redis.hget: {e}")),
1719                }
1720            }
1721            ("redis", "hdel") => {
1722                let h = expect_redis_handle(args.first())?;
1723                let key   = expect_str(args.get(1))?.to_string();
1724                let field = expect_str(args.get(2))?.to_string();
1725                let mut reg = redis_registry().lock().unwrap();
1726                let entry = reg.touch_get_mut(h)
1727                    .ok_or_else(|| "redis.hdel: closed or unknown ConnRedis handle".to_string())?;
1728                use redis::Commands;
1729                entry.conn.hdel::<_, _, ()>(&key, &field)
1730                    .map_err(|e| format!("redis.hdel: {e}"))?;
1731                Ok(Value::Unit)
1732            }
1733            ("redis", "hgetall") => {
1734                let h = expect_redis_handle(args.first())?;
1735                let key = expect_str(args.get(1))?.to_string();
1736                let mut reg = redis_registry().lock().unwrap();
1737                let entry = reg.touch_get_mut(h)
1738                    .ok_or_else(|| "redis.hgetall: closed or unknown ConnRedis handle".to_string())?;
1739                use redis::Commands;
1740                let map: std::collections::HashMap<String, String> = entry.conn
1741                    .hgetall(&key)
1742                    .map_err(|e| format!("redis.hgetall: {e}"))?;
1743                let pairs: Vec<Value> = map.into_iter()
1744                    .map(|(k, v)| Value::Tuple(vec![Value::Str(k.into()), Value::Str(v.into())]))
1745                    .collect();
1746                Ok(Value::List(pairs.into()))
1747            }
1748
1749            // `proc.spawn` was removed with the `std.proc` module (#678);
1750            // the blocking-capture path now lives at `process.run`, handled
1751            // in the `kind == "process"` block above.
1752            other => Err(format!("unsupported effect {}.{}", other.0, other.1)),
1753        }
1754    }
1755
1756    /// `list.par_map` worker-handler factory (#305 slice 2).
1757    ///
1758    /// Builds a fresh `DefaultHandler` per worker that shares the
1759    /// budget pool with the parent (`Arc<AtomicU64>`) so a parallel
1760    /// batch can't escape the run-wide budget ceiling. Other state
1761    /// is intentionally split per-worker:
1762    ///
1763    /// - `sink`: a `StdoutSink` per worker. Tests that capture
1764    ///   output via a `SharedSink` wrapped in `Arc<Mutex<…>>` see
1765    ///   each worker as a fresh handler. Print interleaving on
1766    ///   stdout is acceptable; tests that need ordered capture run
1767    ///   workloads serially anyway.
1768    /// - `mcp_clients`: a fresh per-worker LRU cache. The parent's
1769    ///   subprocess handles can't be shared across threads without
1770    ///   mutex-serialising every MCP call, which would defeat the
1771    ///   parallelism. Cache hit rate is sub-optimal across the
1772    ///   first call per worker; warmed caches still amortise within
1773    ///   a worker.
1774    /// - `chat_registry`: cloned `Arc<ChatRegistry>` so all workers
1775    ///   route into the same chat dispatch layer.
1776    /// - `program`: cloned `Arc<Program>` so `net.serve` (if a
1777    ///   worker invokes it) sees the same compiled program.
1778    fn take_exit(&mut self) -> Option<i32> {
1779        match self.requested_exit.load(Ordering::SeqCst) {
1780            crate::handler::NO_EXIT => None,
1781            code => Some(code as i32),
1782        }
1783    }
1784
1785    fn spawn_for_worker(&self) -> Option<Box<dyn lex_bytecode::vm::EffectHandler + Send>> {
1786        let mut fresh = DefaultHandler::new(self.policy.clone());
1787        // Share the budget pool atomically — slice 2's correctness
1788        // contract: parallel work counts against the same ceiling.
1789        fresh.budget_remaining = std::sync::Arc::clone(&self.budget_remaining);
1790        fresh.budget_ceiling = self.budget_ceiling;
1791        fresh.read_root = self.read_root.clone();
1792        fresh.program = self.program.clone();
1793        fresh.chat_registry = self.chat_registry.clone();
1794        // #305 slice 3: share the stream registry across workers so
1795        // a stream produced on one thread (or the parent) is
1796        // consumable on any other. The registry is already
1797        // `Arc<Mutex<…>>` so concurrent access is safe.
1798        fresh.streams = std::sync::Arc::clone(&self.streams);
1799        fresh.next_stream_id = std::sync::Arc::clone(&self.next_stream_id);
1800        fresh.program_args = self.program_args.clone();
1801        // #754: an exit called inside parallel work has to reach the
1802        // VM that returns, not die with the worker's handler.
1803        fresh.requested_exit = std::sync::Arc::clone(&self.requested_exit);
1804        Some(Box::new(fresh))
1805    }
1806}