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