Skip to main content

lex_runtime/
builtins.rs

1//! Pure stdlib builtins — string, numeric, list, option, result, json
2//! ops dispatched via the same `EffectHandler` interface as effects, but
3//! without policy gates (they have no observable side effects).
4
5use lex_bytecode::{MapKey, Value};
6use std::collections::{BTreeMap, BTreeSet, HashMap};
7use std::sync::{Mutex, OnceLock};
8
9/// Returns `true` if `(kind, op)` will be handled by the pure-builtin
10/// path (no side effects, no policy gate needed). Used by the effect
11/// handler to decide whether to consume `args` by value.
12pub fn is_pure_call(kind: &str, op: &str) -> bool {
13    if !is_pure_module(kind) { return false; }
14    !matches!(
15        (kind, op),
16        ("crypto", "random")
17        | ("crypto", "random_str_hex")
18        // p256_generate mints key material from the OS RNG → [random]
19        // effect, handled on the effect path (#651).
20        | ("crypto", "p256_generate")
21        // secp256k1_generate likewise mints from the OS RNG → [random] (#655).
22        | ("crypto", "secp256k1_generate")
23        | ("datetime", "now")
24        | ("http", "send")
25        | ("http", "get")
26        | ("http", "post")
27        | ("http", "stream_lines")
28        // arrow.read_csv reads from disk → effect-handler path (#426 I/O slice).
29        | ("arrow", "read_csv")
30        // arrow.{read,write}_parquet + arrow.write_csv — effect-gated I/O (#432).
31        | ("arrow", "read_parquet")
32        | ("arrow", "read_parquet_cols")
33        | ("arrow", "write_parquet")
34        | ("arrow", "write_csv")
35    )
36}
37
38/// Dispatch a pure-builtin call with owned args (no clone of arg values).
39/// Callers must first verify `is_pure_call(kind, op)` to ensure args
40/// ownership is only transferred for known-pure ops.
41///
42/// `list.cons` is handled here with move semantics so the tail `Vec<Value>`
43/// is extended without cloning each element (#405).
44pub fn call_pure_builtin(kind: &str, op: &str, args: Vec<Value>) -> Result<Value, String> {
45    if (kind, op) == ("list", "cons") {
46        let mut it = args.into_iter();
47        let head = it.next().unwrap_or(Value::Unit);
48        let mut tail = match it.next() {
49            Some(Value::List(v)) => v,
50            Some(other) => return Err(format!("list.cons: expected List, got {other:?}")),
51            None => std::collections::VecDeque::new(),
52        };
53        tail.push_front(head);
54        return Ok(Value::List(tail));
55    }
56    dispatch(kind, op, &args)
57}
58
59/// Returns Some(...) if `(kind, op)` names a known pure builtin.
60/// `None` means "not handled here; fall through to effect dispatch".
61///
62/// Prefer `is_pure_call` + `call_pure_builtin` in hot paths — this
63/// variant takes `&[Value]` and must clone args for operations like
64/// `list.cons`; kept for external callers that already hold a slice.
65pub fn try_pure_builtin(kind: &str, op: &str, args: &[Value]) -> Option<Result<Value, String>> {
66    if !is_pure_call(kind, op) { return None; }
67    Some(dispatch(kind, op, args))
68}
69
70/// `kind` is one of the known pure module aliases — used by the policy
71/// walk to skip pure builtins that programs reference via imports.
72pub fn is_pure_module(kind: &str) -> bool {
73    matches!(kind, "str" | "int" | "float" | "bool" | "list" | "iter"
74        | "option" | "result" | "tuple" | "json" | "bytes" | "flow" | "math"
75        | "map" | "set" | "crypto" | "regex" | "deque" | "datetime" | "duration" | "http"
76        | "toml" | "yaml" | "dotenv" | "csv" | "test" | "random" | "parser"
77        | "cli" | "arrow" | "df" | "decimal")
78}
79
80fn dispatch(kind: &str, op: &str, args: &[Value]) -> Result<Value, String> {
81    match (kind, op) {
82        // -- str --
83        ("str", "is_empty") => Ok(Value::Bool(expect_str(args.first())?.is_empty())),
84        ("str", "len") => Ok(Value::Int(expect_str(args.first())?.len() as i64)),
85        // O(1) single-char access. `str.slice(s, i, i+1)` resolves a codepoint
86        // index via `char_indices().nth(i)` — O(i) — so scanning a string
87        // char-by-char is O(n²). `char_at` indexes the UTF-8 bytes directly and
88        // returns the byte as a 1-char Str, letting ASCII-oriented scanners
89        // (e.g. the JSON parser, whose input is pre-sanitised to single bytes)
90        // run in O(n). Returns the char for ASCII bytes (< 128); out-of-range or
91        // a non-ASCII byte yields "" — total, never panics.
92        ("str", "char_at") => {
93            let s = expect_str(args.first())?;
94            let i = expect_int(args.get(1))?;
95            if i < 0 {
96                Ok(Value::Str("".into()))
97            } else {
98                match s.as_bytes().get(i as usize) {
99                    Some(&b) if b < 128 => {
100                        Ok(Value::Str((b as char).to_string().into()))
101                    }
102                    _ => Ok(Value::Str("".into())),
103                }
104            }
105        }
106        ("str", "concat") => {
107            let a = expect_str(args.first())?;
108            let b = expect_str(args.get(1))?;
109            Ok(Value::Str(format!("{a}{b}").into()))
110        }
111        ("str", "to_int") => {
112            let s = expect_str(args.first())?;
113            match s.parse::<i64>() {
114                Ok(n) => Ok(some(Value::Int(n))),
115                Err(_) => Ok(none()),
116            }
117        }
118        ("str", "split") => {
119            let s = expect_str(args.first())?;
120            let sep = expect_str(args.get(1))?;
121            let items: std::collections::VecDeque<Value> = if sep.is_empty() {
122                s.chars().map(|c| Value::Str(c.to_string().into())).collect()
123            } else {
124                s.split(sep.as_str()).map(|p| Value::Str(p.into())).collect()
125            };
126            Ok(Value::List(items))
127        }
128        ("str", "join") => {
129            let parts = expect_list(args.first())?;
130            let sep = expect_str(args.get(1))?;
131            let mut out = String::new();
132            for (i, p) in parts.iter().enumerate() {
133                if i > 0 { out.push_str(&sep); }
134                match p {
135                    Value::Str(s) => out.push_str(s),
136                    other => return Err(format!("str.join element must be Str, got {other:?}")),
137                }
138            }
139            Ok(Value::Str(out.into()))
140        }
141        ("str", "starts_with") => {
142            let s = expect_str(args.first())?;
143            let prefix = expect_str(args.get(1))?;
144            Ok(Value::Bool(s.starts_with(prefix.as_str())))
145        }
146        ("str", "ends_with") => {
147            let s = expect_str(args.first())?;
148            let suffix = expect_str(args.get(1))?;
149            Ok(Value::Bool(s.ends_with(suffix.as_str())))
150        }
151        ("str", "contains") => {
152            let s = expect_str(args.first())?;
153            let needle = expect_str(args.get(1))?;
154            Ok(Value::Bool(s.contains(needle.as_str())))
155        }
156        ("str", "cmp") => {
157            let a = expect_str(args.first())?;
158            let b = expect_str(args.get(1))?;
159            Ok(Value::Int(match a.as_str().cmp(b.as_str()) {
160                std::cmp::Ordering::Less => -1,
161                std::cmp::Ordering::Equal => 0,
162                std::cmp::Ordering::Greater => 1,
163            }))
164        }
165        ("str", "replace") => {
166            let s = expect_str(args.first())?;
167            let from = expect_str(args.get(1))?;
168            let to = expect_str(args.get(2))?;
169            Ok(Value::Str(s.replace(from.as_str(), to.as_str()).into()))
170        }
171        ("str", "trim") => Ok(Value::Str(expect_str(args.first())?.trim().into())),
172        ("str", "to_upper") => Ok(Value::Str(expect_str(args.first())?.to_uppercase().into())),
173        ("str", "to_lower") => Ok(Value::Str(expect_str(args.first())?.to_lowercase().into())),
174        ("str", "strip_prefix") => {
175            let s = expect_str(args.first())?;
176            let prefix = expect_str(args.get(1))?;
177            Ok(match s.strip_prefix(prefix.as_str()) {
178                Some(rest) => some(Value::Str(rest.into())),
179                None => none(),
180            })
181        }
182        ("str", "strip_suffix") => {
183            let s = expect_str(args.first())?;
184            let suffix = expect_str(args.get(1))?;
185            Ok(match s.strip_suffix(suffix.as_str()) {
186                Some(rest) => some(Value::Str(rest.into())),
187                None => none(),
188            })
189        }
190        ("str", "slice") => {
191            // Half-open codepoint-index slice. `lo` and `hi` are Unicode
192            // scalar value (codepoint) indices, not byte offsets. Out-of-range
193            // indices clamp to the codepoint count, mirroring Python's `s[lo:hi]`
194            // semantics. Reversed ranges error as a caller logic bug. (#620)
195            let s = expect_str(args.first())?;
196            let lo_i = expect_int(args.get(1))?;
197            let hi_i = expect_int(args.get(2))?;
198            let lo_cp = lo_i.max(0) as usize;
199            let hi_cp = hi_i.max(0) as usize;
200            if lo_cp > hi_cp {
201                return Err(format!(
202                    "str.slice: reversed range [{lo_cp}..{hi_cp}]"));
203            }
204            // Resolve codepoint indices to byte offsets in a single pass.
205            // Indices past the end clamp to s.len(), yielding an empty slice.
206            let lo_byte = s.char_indices().nth(lo_cp).map(|(b, _)| b).unwrap_or(s.len());
207            let hi_byte = s.char_indices().nth(hi_cp).map(|(b, _)| b).unwrap_or(s.len());
208            Ok(Value::Str(s[lo_byte..hi_byte].into()))
209        }
210
211        // -- int / float --
212        ("int", "to_str") => Ok(Value::Str(expect_int(args.first())?.to_string().into())),
213        ("int", "to_float") => Ok(Value::Float(expect_int(args.first())? as f64)),
214        // Int scalar helpers (#681) — integer counterparts to math.{abs,min,max}.
215        ("int", "abs") => Ok(Value::Int(expect_int(args.first())?.abs())),
216        ("int", "min") => Ok(Value::Int(expect_int(args.first())?.min(expect_int(args.get(1))?))),
217        ("int", "max") => Ok(Value::Int(expect_int(args.first())?.max(expect_int(args.get(1))?))),
218        ("float", "to_int") => Ok(Value::Int(expect_float(args.first())? as i64)),
219        ("float", "to_str") => Ok(Value::Str(expect_float(args.first())?.to_string().into())),
220        ("str", "to_float") => {
221            let s = expect_str(args.first())?;
222            match s.parse::<f64>() {
223                Ok(f) => Ok(some(Value::Float(f))),
224                Err(_) => Ok(none()),
225            }
226        }
227
228        // -- list --
229        ("list", "len") => Ok(Value::Int(expect_list(args.first())?.len() as i64)),
230        ("list", "is_empty") => Ok(Value::Bool(expect_list(args.first())?.is_empty())),
231        ("list", "head") => {
232            let xs = expect_list(args.first())?;
233            match xs.front() {
234                Some(v) => Ok(some(v.clone())),
235                None => Ok(none()),
236            }
237        }
238        ("list", "tail") => {
239            let xs = expect_list(args.first())?;
240            if xs.is_empty() { Ok(Value::List(std::collections::VecDeque::new())) }
241            else { Ok(Value::List(xs.iter().skip(1).cloned().collect::<std::collections::VecDeque<_>>())) }
242        }
243        ("list", "range") => {
244            let lo = expect_int(args.first())?;
245            let hi = expect_int(args.get(1))?;
246            Ok(Value::List((lo..hi).map(Value::Int).collect::<std::collections::VecDeque<_>>()))
247        }
248        ("list", "concat") => {
249            let mut out = expect_list(args.first())?.clone();
250            out.extend(expect_list(args.get(1))?.iter().cloned());
251            Ok(Value::List(out))
252        }
253        ("list", "reverse") => {
254            let out = expect_list(args.first())?.clone();
255            let rev: std::collections::VecDeque<Value> = out.into_iter().rev().collect();
256            Ok(Value::List(rev))
257        }
258        // #334: cons — prepend a single element to a list.
259        // (fast path via call_pure_builtin; this branch handles the
260        // borrow-based dispatch path which must clone)
261        ("list", "cons") => {
262            let head = args.first().cloned().unwrap_or(Value::Unit);
263            let mut out: std::collections::VecDeque<Value> =
264                expect_list(args.get(1))?.iter().cloned().collect();
265            out.push_front(head);
266            Ok(Value::List(out))
267        }
268        ("list", "enumerate") => {
269            let xs = expect_list(args.first())?;
270            let pairs = xs.iter().cloned().enumerate()
271                .map(|(i, v)| Value::Tuple(vec![Value::Int(i as i64), v]))
272                .collect::<std::collections::VecDeque<_>>();
273            Ok(Value::List(pairs))
274        }
275
276        // -- tuple --
277        // Per §11.1: fst, snd, third for 2- and 3-tuples. Index out of
278        // range is an error rather than a panic so calling `tuple.third`
279        // on a 2-tuple is a clean failure instead of a host crash.
280        ("tuple", "fst")   => tuple_index(first_arg(args)?, 0),
281        ("tuple", "snd")   => tuple_index(first_arg(args)?, 1),
282        ("tuple", "third") => tuple_index(first_arg(args)?, 2),
283        ("tuple", "len") => match first_arg(args)? {
284            Value::Tuple(items) => Ok(Value::Int(items.len() as i64)),
285            other => Err(format!("tuple.len: expected Tuple, got {other:?}")),
286        },
287
288        // -- option --
289        ("option", "unwrap_or") => {
290            let opt = first_arg(args)?;
291            let default = args.get(1).cloned().unwrap_or(Value::Unit);
292            match opt {
293                Value::Variant { name, args } if name == "Some" && !args.is_empty() => Ok(args[0].clone()),
294                Value::Variant { name, .. } if name == "None" => Ok(default),
295                other => Err(format!("option.unwrap_or expected Option, got {other:?}")),
296            }
297        }
298        // option.unwrap_or_else: lazy default via thunk — only called when None.
299        // Handled inline by the bytecode compiler; this arm is the interpreter
300        // fallback path (thunk is pre-applied as a Value::Unit default since the
301        // runtime cannot call closures itself — the compiler path is canonical).
302        ("option", "unwrap_or_else") => {
303            let opt = first_arg(args)?;
304            match opt {
305                Value::Variant { name, args } if name == "Some" && !args.is_empty() => Ok(args[0].clone()),
306                Value::Variant { name, .. } if name == "None" => {
307                    // The closure argument cannot be invoked from pure-builtin
308                    // context; callers that reach this path have already
309                    // evaluated the thunk and passed its result as args[1].
310                    Ok(args.get(1).cloned().unwrap_or(Value::Unit))
311                }
312                other => Err(format!("option.unwrap_or_else expected Option, got {other:?}")),
313            }
314        }
315        ("option", "is_some") => match first_arg(args)? {
316            Value::Variant { name, .. } => Ok(Value::Bool(name == "Some")),
317            other => Err(format!("option.is_some expected Option, got {other:?}")),
318        },
319        ("option", "is_none") => match first_arg(args)? {
320            Value::Variant { name, .. } => Ok(Value::Bool(name == "None")),
321            other => Err(format!("option.is_none expected Option, got {other:?}")),
322        },
323        // option.ok_or(opt, err): Some(x) -> Ok(x); None -> Err(err). (#679)
324        ("option", "ok_or") => {
325            let opt = first_arg(args)?;
326            let err_val = args.get(1).cloned().unwrap_or(Value::Unit);
327            match opt {
328                Value::Variant { name, args } if name == "Some" && !args.is_empty() =>
329                    Ok(ok_v(args[0].clone())),
330                Value::Variant { name, .. } if name == "None" => Ok(err_v(err_val)),
331                other => Err(format!("option.ok_or expected Option, got {other:?}")),
332            }
333        }
334
335        // -- result --
336        ("result", "is_ok") => match first_arg(args)? {
337            Value::Variant { name, .. } => Ok(Value::Bool(name == "Ok")),
338            other => Err(format!("result.is_ok expected Result, got {other:?}")),
339        },
340        ("result", "is_err") => match first_arg(args)? {
341            Value::Variant { name, .. } => Ok(Value::Bool(name == "Err")),
342            other => Err(format!("result.is_err expected Result, got {other:?}")),
343        },
344        ("result", "unwrap_or") => {
345            let res = first_arg(args)?;
346            let default = args.get(1).cloned().unwrap_or(Value::Unit);
347            match res {
348                Value::Variant { name, args } if name == "Ok" && !args.is_empty() => Ok(args[0].clone()),
349                Value::Variant { name, .. } if name == "Err" => Ok(default),
350                other => Err(format!("result.unwrap_or expected Result, got {other:?}")),
351            }
352        }
353        // result.unwrap_or_else: lazy fallback over the Err payload. The
354        // closure call is emitted inline by the bytecode compiler
355        // (`emit_result_unwrap_or_else`); this arm is the interpreter
356        // fallback, with the thunk result pre-evaluated into args[1]. (#679)
357        ("result", "unwrap_or_else") => {
358            let res = first_arg(args)?;
359            match res {
360                Value::Variant { name, args } if name == "Ok" && !args.is_empty() => Ok(args[0].clone()),
361                Value::Variant { name, .. } if name == "Err" =>
362                    Ok(args.get(1).cloned().unwrap_or(Value::Unit)),
363                other => Err(format!("result.unwrap_or_else expected Result, got {other:?}")),
364            }
365        }
366
367        // -- json --
368        ("json", "stringify") => {
369            let v = first_arg(args)?;
370            Ok(Value::Str(serde_json::to_string(&value_to_json(v)).unwrap_or_default().into()))
371        }
372        ("json", "parse") => {
373            let s = expect_str(args.first())?;
374            match serde_json::from_str::<serde_json::Value>(&s) {
375                Ok(v) => Ok(ok_v(json_to_value(&v))),
376                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
377            }
378        }
379        // Tactical fix for #168: validate required fields before
380        // returning Ok. #322: also validate field types via schema.
381        ("json", "parse_strict") => {
382            let s = expect_str(args.first())?;
383            let required = required_field_names(args.get(1))?;
384            let schema = extract_type_schema(args.get(2));
385            match serde_json::from_str::<serde_json::Value>(&s) {
386                Ok(v) => {
387                    if let Err(e) = check_required_fields(&v, &required) {
388                        return Ok(err_v(Value::Str(e.into())));
389                    }
390                    if let Err(e) = validate_field_types(&v, &schema) {
391                        return Ok(err_v(Value::Str(e.into())));
392                    }
393                    Ok(ok_v(apply_option_wrapping(json_to_value(&v), &v, &schema)))
394                }
395                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
396            }
397        }
398
399        // -- toml (config parser; routes through serde_json::Value
400        // so the parsed shape composes with the existing json
401        // tooling. Datetimes become RFC 3339 strings — the only
402        // info-losing step) --
403        ("toml", "parse") => {
404            let s = expect_str(args.first())?;
405            match toml::from_str::<serde_json::Value>(&s) {
406                Ok(mut v) => {
407                    unwrap_toml_datetime_markers(&mut v);
408                    Ok(ok_v(json_to_value(&v)))
409                }
410                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
411            }
412        }
413        // Compiler-emitted variant of parse_strict that carries the type
414        // schema injected by the type-checker rewrite pass (#322).
415        // Identical to parse_strict but the 3rd arg (schema) is always present.
416        ("json", "parse_strict_typed") => {
417            let s = expect_str(args.first())?;
418            let required = required_field_names(args.get(1))?;
419            let schema = extract_type_schema(args.get(2));
420            match serde_json::from_str::<serde_json::Value>(&s) {
421                Ok(v) => {
422                    if let Err(e) = check_required_fields(&v, &required) {
423                        return Ok(err_v(Value::Str(e.into())));
424                    }
425                    if let Err(e) = validate_field_types(&v, &schema) {
426                        return Ok(err_v(Value::Str(e.into())));
427                    }
428                    Ok(ok_v(apply_option_wrapping(json_to_value(&v), &v, &schema)))
429                }
430                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
431            }
432        }
433
434        // Tactical fix for #168: validate required fields before
435        // returning Ok. #322: also validate field types via schema.
436        ("toml", "parse_strict") => {
437            let s = expect_str(args.first())?;
438            let required = required_field_names(args.get(1))?;
439            let schema = extract_type_schema(args.get(2));
440            match toml::from_str::<serde_json::Value>(&s) {
441                Ok(mut v) => {
442                    unwrap_toml_datetime_markers(&mut v);
443                    if let Err(e) = check_required_fields(&v, &required) {
444                        return Ok(err_v(Value::Str(e.into())));
445                    }
446                    if let Err(e) = validate_field_types(&v, &schema) {
447                        return Ok(err_v(Value::Str(e.into())));
448                    }
449                    Ok(ok_v(apply_option_wrapping(json_to_value(&v), &v, &schema)))
450                }
451                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
452            }
453        }
454        ("toml", "parse_strict_typed") => {
455            let s = expect_str(args.first())?;
456            let required = required_field_names(args.get(1))?;
457            let schema = extract_type_schema(args.get(2));
458            match toml::from_str::<serde_json::Value>(&s) {
459                Ok(mut v) => {
460                    unwrap_toml_datetime_markers(&mut v);
461                    if let Err(e) = check_required_fields(&v, &required) {
462                        return Ok(err_v(Value::Str(e.into())));
463                    }
464                    if let Err(e) = validate_field_types(&v, &schema) {
465                        return Ok(err_v(Value::Str(e.into())));
466                    }
467                    Ok(ok_v(apply_option_wrapping(json_to_value(&v), &v, &schema)))
468                }
469                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
470            }
471        }
472        ("toml", "stringify") => {
473            let v = first_arg(args)?;
474            // serde_json::Value → toml::Value via its serde impls.
475            // TOML's grammar is stricter than JSON's (top-level
476            // must be a table; no `null`; no mixed-type arrays),
477            // so the conversion can fail — surface as Result::Err
478            // rather than panic.
479            let json = value_to_json(v);
480            match toml::to_string(&json) {
481                Ok(s)  => Ok(ok_v(Value::Str(s.into()))),
482                Err(e) => Ok(err_v(Value::Str(format!("toml.stringify: {e}").into()))),
483            }
484        }
485
486        // -- yaml -- mirrors std.toml. Wraps serde_yaml so values
487        // map to the same Lex shape as JSON. YAML's Tag/Anchor
488        // features are folded out by serde_yaml's deserialize-to-
489        // Value path; non-representable shapes (e.g. non-string
490        // map keys when stringifying) surface as Result::Err.
491        ("yaml", "parse") => {
492            let s = expect_str(args.first())?;
493            match serde_yaml::from_str::<serde_json::Value>(&s) {
494                Ok(v)  => Ok(ok_v(json_to_value(&v))),
495                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
496            }
497        }
498        // Tactical fix for #168 — same shape as toml.parse_strict.
499        // #322: also validate field types via schema.
500        ("yaml", "parse_strict") => {
501            let s = expect_str(args.first())?;
502            let required = required_field_names(args.get(1))?;
503            let schema = extract_type_schema(args.get(2));
504            match serde_yaml::from_str::<serde_json::Value>(&s) {
505                Ok(v) => {
506                    if let Err(e) = check_required_fields(&v, &required) {
507                        return Ok(err_v(Value::Str(e.into())));
508                    }
509                    if let Err(e) = validate_field_types(&v, &schema) {
510                        return Ok(err_v(Value::Str(e.into())));
511                    }
512                    Ok(ok_v(apply_option_wrapping(json_to_value(&v), &v, &schema)))
513                }
514                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
515            }
516        }
517        ("yaml", "parse_strict_typed") => {
518            let s = expect_str(args.first())?;
519            let required = required_field_names(args.get(1))?;
520            let schema = extract_type_schema(args.get(2));
521            match serde_yaml::from_str::<serde_json::Value>(&s) {
522                Ok(v) => {
523                    if let Err(e) = check_required_fields(&v, &required) {
524                        return Ok(err_v(Value::Str(e.into())));
525                    }
526                    if let Err(e) = validate_field_types(&v, &schema) {
527                        return Ok(err_v(Value::Str(e.into())));
528                    }
529                    Ok(ok_v(apply_option_wrapping(json_to_value(&v), &v, &schema)))
530                }
531                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
532            }
533        }
534        ("yaml", "stringify") => {
535            let v = first_arg(args)?;
536            let json = value_to_json(v);
537            match serde_yaml::to_string(&json) {
538                Ok(s)  => Ok(ok_v(Value::Str(s.into()))),
539                Err(e) => Ok(err_v(Value::Str(format!("yaml.stringify: {e}").into()))),
540            }
541        }
542
543        // -- dotenv -- KEY=VALUE pair files. Hand-rolled parser
544        // because the dotenvy crate's API is geared at loading
545        // into the process env, not parsing-to-data. The grammar
546        // we accept: blank lines, `# comment` lines, and
547        // `KEY=VALUE` (optional surrounding `"..."` or `'...'`,
548        // unescaped). Simple but covers the .env files in the
549        // wild that aren't trying to be shell.
550        ("dotenv", "parse") => {
551            use std::collections::BTreeMap;
552            use lex_bytecode::MapKey;
553            let s = expect_str(args.first())?;
554            match parse_dotenv(&s) {
555                Ok(map) => {
556                    let mut bt: BTreeMap<MapKey, Value> = BTreeMap::new();
557                    for (k, v) in map {
558                        bt.insert(MapKey::Str(k), Value::Str(v.into()));
559                    }
560                    Ok(ok_v(Value::Map(bt)))
561                }
562                Err(e) => Ok(err_v(Value::Str(e.into()))),
563            }
564        }
565
566        // -- csv -- rows-as-lists; first row is whatever the file
567        // has. The caller decides whether row 0 is a header. We
568        // could ship a `parse_with_headers` later that returns a
569        // List[Map[Str, Str]]; v1 keeps the surface tight.
570        ("csv", "parse") => {
571            let s = expect_str(args.first())?;
572            let mut rdr = csv::ReaderBuilder::new()
573                .has_headers(false)
574                .flexible(true)
575                .from_reader(s.as_bytes());
576            let mut rows: std::collections::VecDeque<Value> = std::collections::VecDeque::new();
577            for r in rdr.records() {
578                match r {
579                    Ok(rec) => {
580                        let row: std::collections::VecDeque<Value> = rec.iter()
581                            .map(|f| Value::Str(f.into()))
582                            .collect();
583                        rows.push_back(Value::List(row));
584                    }
585                    Err(e) => return Ok(err_v(Value::Str(format!("csv.parse: {e}").into()))),
586                }
587            }
588            Ok(ok_v(Value::List(rows)))
589        }
590        ("csv", "stringify") => {
591            // List[List[Str]] → CSV string. Mixed-type rows are
592            // not allowed (CSV is text-only); non-Str cells get
593            // stringified via to_json since that's already the
594            // convention for `json.stringify` etc.
595            let v = first_arg(args)?;
596            let rows = match v {
597                Value::List(rs) => rs,
598                _ => return Ok(err_v(Value::Str("csv.stringify expects List[List[Str]]".into()))),
599            };
600            let mut out = Vec::new();
601            {
602                let mut wtr = csv::WriterBuilder::new()
603                    .has_headers(false)
604                    .from_writer(&mut out);
605                for row in rows {
606                    let cells = match row {
607                        Value::List(cs) => cs,
608                        _ => return Ok(err_v(Value::Str("csv.stringify row must be List[Str]".into()))),
609                    };
610                    let strs: Vec<String> = cells.iter().map(|c| match c {
611                        Value::Str(s) => s.to_string(),
612                        other => serde_json::to_string(&other.to_json())
613                            .unwrap_or_else(|_| String::new()),
614                    }).collect();
615                    if let Err(e) = wtr.write_record(&strs) {
616                        return Ok(err_v(Value::Str(format!("csv.stringify: {e}").into())));
617                    }
618                }
619                if let Err(e) = wtr.flush() {
620                    return Ok(err_v(Value::Str(format!("csv.stringify flush: {e}").into())));
621                }
622            }
623            match String::from_utf8(out) {
624                Ok(s) => Ok(ok_v(Value::Str(s.into()))),
625                Err(e) => Ok(err_v(Value::Str(format!("csv.stringify utf8: {e}").into()))),
626            }
627        }
628
629        // -- test -- tiny assertion library. Each helper is pure
630        // and returns `Result[Unit, Str]` so tests are themselves
631        // functions returning a Result. A suite is a List the user
632        // iterates with `list.fold`; no Rust-side Suite/Runner
633        // types in v1, so the whole thing is 4 builtins + a few
634        // Lex-source helpers callers can copy into their tests/.
635        ("test", "assert_eq") => {
636            let a = first_arg(args)?;
637            let b = args.get(1).ok_or("test.assert_eq: missing second arg")?;
638            if a == b {
639                Ok(ok_v(Value::Unit))
640            } else {
641                Ok(err_v(Value::Str(format!("assert_eq: lhs {} != rhs {}",
642                    value_to_json(a), value_to_json(b)).into())))
643            }
644        }
645        ("test", "assert_ne") => {
646            let a = first_arg(args)?;
647            let b = args.get(1).ok_or("test.assert_ne: missing second arg")?;
648            if a != b {
649                Ok(ok_v(Value::Unit))
650            } else {
651                Ok(err_v(Value::Str(format!("assert_ne: both sides are {}",
652                    value_to_json(a)).into())))
653            }
654        }
655        ("test", "assert_true") => {
656            match first_arg(args)? {
657                Value::Bool(true) => Ok(ok_v(Value::Unit)),
658                Value::Bool(false) => Ok(err_v(Value::Str("assert_true: was false".into()))),
659                other => Err(format!("test.assert_true expects Bool, got {other:?}")),
660            }
661        }
662        ("test", "assert_false") => {
663            match first_arg(args)? {
664                Value::Bool(false) => Ok(ok_v(Value::Unit)),
665                Value::Bool(true)  => Ok(err_v(Value::Str("assert_false: was true".into()))),
666                other => Err(format!("test.assert_false expects Bool, got {other:?}")),
667            }
668        }
669
670        // -- bytes --
671        ("bytes", "len") => {
672            let b = expect_bytes(args.first())?;
673            Ok(Value::Int(b.len() as i64))
674        }
675        ("bytes", "eq") => {
676            let a = expect_bytes(args.first())?;
677            let b = expect_bytes(args.get(1))?;
678            Ok(Value::Bool(a == b))
679        }
680        ("bytes", "from_str") => {
681            let s = expect_str(args.first())?;
682            Ok(Value::Bytes(s.into_bytes()))
683        }
684        ("bytes", "to_str") => {
685            let b = expect_bytes(args.first())?;
686            match String::from_utf8(b.to_vec()) {
687                Ok(s) => Ok(ok_v(Value::Str(s.into()))),
688                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
689            }
690        }
691        ("bytes", "slice") => {
692            let b = expect_bytes(args.first())?;
693            let lo = expect_int(args.get(1))? as usize;
694            let hi = expect_int(args.get(2))? as usize;
695            if lo > hi || hi > b.len() {
696                return Err(format!("bytes.slice: out of range [{lo}..{hi}] of {}", b.len()));
697            }
698            Ok(Value::Bytes(b[lo..hi].to_vec()))
699        }
700        ("bytes", "is_empty") => {
701            let b = expect_bytes(args.first())?;
702            Ok(Value::Bool(b.is_empty()))
703        }
704
705        // -- math --
706        // Matrices are stored as the F64Array fast-lane variant (a flat
707        // row-major Vec<f64> with shape). Lex code treats them as the
708        // type alias `Matrix = { rows :: Int, cols :: Int, data ::
709        // List[Float] }`; field access is unsupported, so all
710        // introspection happens through these helpers.
711        ("math", "exp")   => Ok(Value::Float(expect_float(args.first())?.exp())),
712        ("math", "log")   => Ok(Value::Float(expect_float(args.first())?.ln())),
713        ("math", "log2")  => Ok(Value::Float(expect_float(args.first())?.log2())),
714        ("math", "log10") => Ok(Value::Float(expect_float(args.first())?.log10())),
715        ("math", "sqrt")  => Ok(Value::Float(expect_float(args.first())?.sqrt())),
716        ("math", "abs")   => Ok(Value::Float(expect_float(args.first())?.abs())),
717        ("math", "sin")   => Ok(Value::Float(expect_float(args.first())?.sin())),
718        ("math", "cos")   => Ok(Value::Float(expect_float(args.first())?.cos())),
719        ("math", "tan")   => Ok(Value::Float(expect_float(args.first())?.tan())),
720        ("math", "asin")  => Ok(Value::Float(expect_float(args.first())?.asin())),
721        ("math", "acos")  => Ok(Value::Float(expect_float(args.first())?.acos())),
722        ("math", "atan")  => Ok(Value::Float(expect_float(args.first())?.atan())),
723        ("math", "floor") => Ok(Value::Float(expect_float(args.first())?.floor())),
724        ("math", "ceil")  => Ok(Value::Float(expect_float(args.first())?.ceil())),
725        ("math", "round") => Ok(Value::Float(expect_float(args.first())?.round())),
726        ("math", "trunc") => Ok(Value::Float(expect_float(args.first())?.trunc())),
727        ("math", "pow") => {
728            let a = expect_float(args.first())?;
729            let b = expect_float(args.get(1))?;
730            Ok(Value::Float(a.powf(b)))
731        }
732        ("math", "atan2") => {
733            let y = expect_float(args.first())?;
734            let x = expect_float(args.get(1))?;
735            Ok(Value::Float(y.atan2(x)))
736        }
737        ("math", "min") => {
738            let a = expect_float(args.first())?;
739            let b = expect_float(args.get(1))?;
740            Ok(Value::Float(a.min(b)))
741        }
742        ("math", "max") => {
743            let a = expect_float(args.first())?;
744            let b = expect_float(args.get(1))?;
745            Ok(Value::Float(a.max(b)))
746        }
747        ("math", "zeros") => {
748            let r = expect_int(args.first())?;
749            let c = expect_int(args.get(1))?;
750            if r < 0 || c < 0 {
751                return Err(format!("math.zeros: negative dim {r}x{c}"));
752            }
753            let r = r as usize; let c = c as usize;
754            Ok(Value::F64Array { rows: r as u32, cols: c as u32, data: vec![0.0; r * c] })
755        }
756        ("math", "ones") => {
757            let r = expect_int(args.first())?;
758            let c = expect_int(args.get(1))?;
759            if r < 0 || c < 0 {
760                return Err(format!("math.ones: negative dim {r}x{c}"));
761            }
762            let r = r as usize; let c = c as usize;
763            Ok(Value::F64Array { rows: r as u32, cols: c as u32, data: vec![1.0; r * c] })
764        }
765        ("math", "from_lists") => {
766            let rows = expect_list(args.first())?;
767            let r = rows.len();
768            if r == 0 {
769                return Ok(Value::F64Array { rows: 0, cols: 0, data: Vec::new() });
770            }
771            let first_row = match &rows[0] {
772                Value::List(xs) => xs,
773                other => return Err(format!("math.from_lists: row 0 not List, got {other:?}")),
774            };
775            let c = first_row.len();
776            let mut data = Vec::with_capacity(r * c);
777            for (i, row) in rows.iter().enumerate() {
778                let row = match row {
779                    Value::List(xs) => xs,
780                    other => return Err(format!("math.from_lists: row {i} not List, got {other:?}")),
781                };
782                if row.len() != c {
783                    return Err(format!("math.from_lists: row {i} has {} cols, expected {c}", row.len()));
784                }
785                for (j, v) in row.iter().enumerate() {
786                    let f = match v {
787                        Value::Float(f) => *f,
788                        Value::Int(n) => *n as f64,
789                        other => return Err(format!("math.from_lists: ({i},{j}) not numeric, got {other:?}")),
790                    };
791                    data.push(f);
792                }
793            }
794            Ok(Value::F64Array { rows: r as u32, cols: c as u32, data })
795        }
796        ("math", "from_flat") => {
797            let r = expect_int(args.first())?;
798            let c = expect_int(args.get(1))?;
799            let xs = expect_list(args.get(2))?;
800            if r < 0 || c < 0 {
801                return Err(format!("math.from_flat: negative dim {r}x{c}"));
802            }
803            let r = r as usize; let c = c as usize;
804            if xs.len() != r * c {
805                return Err(format!("math.from_flat: list len {} != {}*{}", xs.len(), r, c));
806            }
807            let mut data = Vec::with_capacity(r * c);
808            for v in xs {
809                data.push(match v {
810                    Value::Float(f) => *f,
811                    Value::Int(n)   => *n as f64,
812                    other => return Err(format!("math.from_flat: non-numeric element {other:?}")),
813                });
814            }
815            Ok(Value::F64Array { rows: r as u32, cols: c as u32, data })
816        }
817        ("math", "rows") => {
818            let (r, _, _) = unpack_matrix(first_arg(args)?)?;
819            Ok(Value::Int(r as i64))
820        }
821        ("math", "cols") => {
822            let (_, c, _) = unpack_matrix(first_arg(args)?)?;
823            Ok(Value::Int(c as i64))
824        }
825        ("math", "get") => {
826            let (r, c, data) = unpack_matrix(first_arg(args)?)?;
827            let i = expect_int(args.get(1))? as usize;
828            let j = expect_int(args.get(2))? as usize;
829            if i >= r || j >= c {
830                return Err(format!("math.get: ({i},{j}) out of {r}x{c}"));
831            }
832            Ok(Value::Float(data[i * c + j]))
833        }
834        ("math", "to_flat") => {
835            let (_, _, data) = unpack_matrix(first_arg(args)?)?;
836            Ok(Value::List(data.into_iter().map(Value::Float).collect()))
837        }
838        ("math", "transpose") => {
839            let (r, c, data) = unpack_matrix(first_arg(args)?)?;
840            let mut out = vec![0.0; r * c];
841            for i in 0..r {
842                for j in 0..c {
843                    out[j * r + i] = data[i * c + j];
844                }
845            }
846            Ok(Value::F64Array { rows: c as u32, cols: r as u32, data: out })
847        }
848        ("math", "matmul") => {
849            let (m, k1, a) = unpack_matrix(first_arg(args)?)?;
850            let (k2, n, b) = unpack_matrix(args.get(1).ok_or("math.matmul: missing arg 1")?)?;
851            if k1 != k2 {
852                return Err(format!("math.matmul: dim mismatch {m}x{k1} · {k2}x{n}"));
853            }
854            // Plain triple loop. For the small matrices used in the ML
855            // demo (n<200, k<10) this is well under a millisecond and
856            // avoids pulling in matrixmultiply for the runtime crate.
857            let mut c = vec![0.0; m * n];
858            for i in 0..m {
859                for kk in 0..k1 {
860                    let aik = a[i * k1 + kk];
861                    for j in 0..n {
862                        c[i * n + j] += aik * b[kk * n + j];
863                    }
864                }
865            }
866            Ok(Value::F64Array { rows: m as u32, cols: n as u32, data: c })
867        }
868        ("math", "scale") => {
869            let s = expect_float(args.first())?;
870            let (r, c, mut data) = unpack_matrix(args.get(1).ok_or("math.scale: missing arg 1")?)?;
871            for x in &mut data { *x *= s; }
872            Ok(Value::F64Array { rows: r as u32, cols: c as u32, data })
873        }
874        ("math", "add") | ("math", "sub") => {
875            let (ar, ac, a) = unpack_matrix(first_arg(args)?)?;
876            let (br, bc, b) = unpack_matrix(args.get(1).ok_or("math.add/sub: missing arg 1")?)?;
877            if ar != br || ac != bc {
878                return Err(format!("math.{op}: shape mismatch {ar}x{ac} vs {br}x{bc}"));
879            }
880            let neg = op == "sub";
881            let mut out = a;
882            for (i, x) in out.iter_mut().enumerate() {
883                if neg { *x -= b[i] } else { *x += b[i] }
884            }
885            Ok(Value::F64Array { rows: ar as u32, cols: ac as u32, data: out })
886        }
887        ("math", "sigmoid") => {
888            let (r, c, mut data) = unpack_matrix(first_arg(args)?)?;
889            for x in &mut data { *x = 1.0 / (1.0 + (-*x).exp()); }
890            Ok(Value::F64Array { rows: r as u32, cols: c as u32, data })
891        }
892
893        // -- map --
894        ("map", "new") => Ok(Value::Map(BTreeMap::new())),
895        ("map", "size") => Ok(Value::Int(expect_map(args.first())?.len() as i64)),
896        ("map", "has") => {
897            let m = expect_map(args.first())?;
898            let k = MapKey::from_value(args.get(1).ok_or("map.has: missing key")?)?;
899            Ok(Value::Bool(m.contains_key(&k)))
900        }
901        ("map", "get") => {
902            let m = expect_map(args.first())?;
903            let k = MapKey::from_value(args.get(1).ok_or("map.get: missing key")?)?;
904            Ok(match m.get(&k) {
905                Some(v) => some(v.clone()),
906                None    => none(),
907            })
908        }
909        ("map", "set") => {
910            let mut m = expect_map(args.first())?.clone();
911            let k = MapKey::from_value(args.get(1).ok_or("map.set: missing key")?)?;
912            let v = args.get(2).ok_or("map.set: missing value")?.clone();
913            m.insert(k, v);
914            Ok(Value::Map(m))
915        }
916        ("map", "delete") => {
917            let mut m = expect_map(args.first())?.clone();
918            let k = MapKey::from_value(args.get(1).ok_or("map.delete: missing key")?)?;
919            m.remove(&k);
920            Ok(Value::Map(m))
921        }
922        ("map", "keys") => {
923            let m = expect_map(args.first())?;
924            Ok(Value::List(m.keys().cloned().map(MapKey::into_value).collect()))
925        }
926        ("map", "values") => {
927            let m = expect_map(args.first())?;
928            Ok(Value::List(m.values().cloned().collect()))
929        }
930        ("map", "entries") => {
931            let m = expect_map(args.first())?;
932            Ok(Value::List(m.iter()
933                .map(|(k, v)| Value::Tuple(vec![k.as_value(), v.clone()]))
934                .collect()))
935        }
936        ("map", "from_list") => {
937            let pairs = expect_list(args.first())?;
938            let mut m = BTreeMap::new();
939            for p in pairs {
940                let items = match p {
941                    Value::Tuple(items) if items.len() == 2 => items,
942                    other => return Err(format!(
943                        "map.from_list element must be a 2-tuple, got {other:?}")),
944                };
945                let k = MapKey::from_value(&items[0])?;
946                m.insert(k, items[1].clone());
947            }
948            Ok(Value::Map(m))
949        }
950
951        // -- set --
952        ("set", "new") => Ok(Value::Set(BTreeSet::new())),
953        ("set", "size") => Ok(Value::Int(expect_set(args.first())?.len() as i64)),
954        ("set", "has") => {
955            let s = expect_set(args.first())?;
956            let k = MapKey::from_value(args.get(1).ok_or("set.has: missing element")?)?;
957            Ok(Value::Bool(s.contains(&k)))
958        }
959        ("set", "add") => {
960            let mut s = expect_set(args.first())?.clone();
961            let k = MapKey::from_value(args.get(1).ok_or("set.add: missing element")?)?;
962            s.insert(k);
963            Ok(Value::Set(s))
964        }
965        ("set", "delete") => {
966            let mut s = expect_set(args.first())?.clone();
967            let k = MapKey::from_value(args.get(1).ok_or("set.delete: missing element")?)?;
968            s.remove(&k);
969            Ok(Value::Set(s))
970        }
971        ("set", "to_list") => {
972            let s = expect_set(args.first())?;
973            Ok(Value::List(s.iter().cloned().map(MapKey::into_value).collect()))
974        }
975        ("set", "from_list") => {
976            let xs = expect_list(args.first())?;
977            let mut s = BTreeSet::new();
978            for x in xs {
979                s.insert(MapKey::from_value(x)?);
980            }
981            Ok(Value::Set(s))
982        }
983        ("set", "union") => {
984            let a = expect_set(args.first())?;
985            let b = expect_set(args.get(1))?;
986            Ok(Value::Set(a.union(b).cloned().collect()))
987        }
988        ("set", "intersect") => {
989            let a = expect_set(args.first())?;
990            let b = expect_set(args.get(1))?;
991            Ok(Value::Set(a.intersection(b).cloned().collect()))
992        }
993        ("set", "diff") => {
994            let a = expect_set(args.first())?;
995            let b = expect_set(args.get(1))?;
996            Ok(Value::Set(a.difference(b).cloned().collect()))
997        }
998        ("set", "is_empty") => Ok(Value::Bool(expect_set(args.first())?.is_empty())),
999        ("set", "is_subset") => {
1000            let a = expect_set(args.first())?;
1001            let b = expect_set(args.get(1))?;
1002            Ok(Value::Bool(a.is_subset(b)))
1003        }
1004
1005        // -- map helpers --
1006        ("map", "merge") => {
1007            // b's entries override a's. We construct a new BTreeMap
1008            // by extending a with b's pairs.
1009            let a = expect_map(args.first())?.clone();
1010            let b = expect_map(args.get(1))?;
1011            let mut out = a;
1012            for (k, v) in b {
1013                out.insert(k.clone(), v.clone());
1014            }
1015            Ok(Value::Map(out))
1016        }
1017        ("map", "is_empty") => Ok(Value::Bool(expect_map(args.first())?.is_empty())),
1018
1019        // -- deque --
1020        ("deque", "new") => Ok(Value::Deque(std::collections::VecDeque::new())),
1021        ("deque", "size") => Ok(Value::Int(expect_deque(args.first())?.len() as i64)),
1022        ("deque", "is_empty") => Ok(Value::Bool(expect_deque(args.first())?.is_empty())),
1023        ("deque", "push_back") => {
1024            let mut d = expect_deque(args.first())?.clone();
1025            let x = args.get(1).ok_or("deque.push_back: missing value")?.clone();
1026            d.push_back(x);
1027            Ok(Value::Deque(d))
1028        }
1029        ("deque", "push_front") => {
1030            let mut d = expect_deque(args.first())?.clone();
1031            let x = args.get(1).ok_or("deque.push_front: missing value")?.clone();
1032            d.push_front(x);
1033            Ok(Value::Deque(d))
1034        }
1035        ("deque", "pop_back") => {
1036            let mut d = expect_deque(args.first())?.clone();
1037            match d.pop_back() {
1038                Some(x) => Ok(Value::Variant {
1039                    name: "Some".into(),
1040                    args: vec![Value::Tuple(vec![x, Value::Deque(d)])],
1041                }),
1042                None => Ok(Value::Variant { name: "None".into(), args: vec![] }),
1043            }
1044        }
1045        ("deque", "pop_front") => {
1046            let mut d = expect_deque(args.first())?.clone();
1047            match d.pop_front() {
1048                Some(x) => Ok(Value::Variant {
1049                    name: "Some".into(),
1050                    args: vec![Value::Tuple(vec![x, Value::Deque(d)])],
1051                }),
1052                None => Ok(Value::Variant { name: "None".into(), args: vec![] }),
1053            }
1054        }
1055        ("deque", "peek_back") => {
1056            let d = expect_deque(args.first())?;
1057            match d.back() {
1058                Some(x) => Ok(Value::Variant {
1059                    name: "Some".into(),
1060                    args: vec![x.clone()],
1061                }),
1062                None => Ok(Value::Variant { name: "None".into(), args: vec![] }),
1063            }
1064        }
1065        ("deque", "peek_front") => {
1066            let d = expect_deque(args.first())?;
1067            match d.front() {
1068                Some(x) => Ok(Value::Variant {
1069                    name: "Some".into(),
1070                    args: vec![x.clone()],
1071                }),
1072                None => Ok(Value::Variant { name: "None".into(), args: vec![] }),
1073            }
1074        }
1075        ("deque", "from_list") => {
1076            let xs = expect_list(args.first())?;
1077            Ok(Value::Deque(xs.iter().cloned().collect()))
1078        }
1079        ("deque", "to_list") => {
1080            let d = expect_deque(args.first())?;
1081            Ok(Value::List(d.iter().cloned().collect()))
1082        }
1083
1084        // -- crypto (pure ops; crypto.random is effectful and routes
1085        // through the handler under [random], see try_pure_builtin) --
1086        ("crypto", "sha256") => {
1087            use sha2::{Digest, Sha256};
1088            let data = expect_bytes(args.first())?;
1089            let mut h = Sha256::new();
1090            h.update(data);
1091            Ok(Value::Bytes(h.finalize().to_vec()))
1092        }
1093        ("crypto", "sha512") => {
1094            use sha2::{Digest, Sha512};
1095            let data = expect_bytes(args.first())?;
1096            let mut h = Sha512::new();
1097            h.update(data);
1098            Ok(Value::Bytes(h.finalize().to_vec()))
1099        }
1100        ("crypto", "md5") => {
1101            use md5::{Digest, Md5};
1102            let data = expect_bytes(args.first())?;
1103            let mut h = Md5::new();
1104            h.update(data);
1105            Ok(Value::Bytes(h.finalize().to_vec()))
1106        }
1107        // BLAKE2b (#382) — 64-byte digest, faster than SHA-512 on most
1108        // CPUs with the same security level. Backed by the `blake2`
1109        // crate; uses `Blake2b512` (the standard 512-bit variant).
1110        ("crypto", "blake2b") => {
1111            use blake2::{Blake2b512, Digest};
1112            let data = expect_bytes(args.first())?;
1113            let mut h = Blake2b512::new();
1114            h.update(data);
1115            Ok(Value::Bytes(h.finalize().to_vec()))
1116        }
1117        // Keccak-256 (#655) — Ethereum's hash. This is the original
1118        // Keccak padding (0x01), NOT NIST SHA3-256 (0x06); they produce
1119        // different digests for the same input. Used for EIP-712 struct
1120        // hashing, the final signing digest, and address derivation.
1121        ("crypto", "keccak256") => {
1122            use sha3::{Digest, Keccak256};
1123            let data = expect_bytes(args.first())?;
1124            let mut h = Keccak256::new();
1125            h.update(data);
1126            Ok(Value::Bytes(h.finalize().to_vec()))
1127        }
1128        // Hex-string convenience hashers (#382). Equivalent to
1129        // `hex_encode(shaN(bytes_of_str(s)))` for the common case
1130        // where the caller has a Str and wants a hex Str digest.
1131        ("crypto", "sha256_str") => {
1132            use sha2::{Digest, Sha256};
1133            let s = expect_str(args.first())?;
1134            let mut h = Sha256::new();
1135            h.update(s.as_bytes());
1136            Ok(Value::Str(hex::encode(h.finalize()).into()))
1137        }
1138        ("crypto", "sha512_str") => {
1139            use sha2::{Digest, Sha512};
1140            let s = expect_str(args.first())?;
1141            let mut h = Sha512::new();
1142            h.update(s.as_bytes());
1143            Ok(Value::Str(hex::encode(h.finalize()).into()))
1144        }
1145        ("crypto", "hmac_sha256") => {
1146            use hmac::{Hmac, KeyInit, Mac};
1147            type HmacSha256 = Hmac<sha2::Sha256>;
1148            let key = expect_bytes(args.first())?;
1149            let data = expect_bytes(args.get(1))?;
1150            let mut mac = HmacSha256::new_from_slice(key)
1151                .map_err(|e| format!("hmac_sha256 key: {e}"))?;
1152            mac.update(data);
1153            Ok(Value::Bytes(mac.finalize().into_bytes().to_vec()))
1154        }
1155        ("crypto", "hmac_sha512") => {
1156            use hmac::{Hmac, KeyInit, Mac};
1157            type HmacSha512 = Hmac<sha2::Sha512>;
1158            let key = expect_bytes(args.first())?;
1159            let data = expect_bytes(args.get(1))?;
1160            let mut mac = HmacSha512::new_from_slice(key)
1161                .map_err(|e| format!("hmac_sha512 key: {e}"))?;
1162            mac.update(data);
1163            Ok(Value::Bytes(mac.finalize().into_bytes().to_vec()))
1164        }
1165        // ed25519 asymmetric signatures (#643). A secret key is its 32-byte
1166        // seed — generate one with the effectful `crypto.random(32)`. These three
1167        // ops are pure (deterministic given their inputs).
1168        ("crypto", "ed25519_public_key") => {
1169            use ed25519_dalek::SigningKey;
1170            let secret = expect_bytes(args.first())?;
1171            let seed: [u8; 32] = match secret.as_slice().try_into() {
1172                Ok(s)  => s,
1173                Err(_) => return Ok(err_v(Value::Str("ed25519_public_key: secret must be 32 bytes".into()))),
1174            };
1175            let sk = SigningKey::from_bytes(&seed);
1176            Ok(ok_v(Value::Bytes(sk.verifying_key().to_bytes().to_vec())))
1177        }
1178        ("crypto", "ed25519_sign") => {
1179            use ed25519_dalek::{Signer, SigningKey};
1180            let secret = expect_bytes(args.first())?;
1181            let message = expect_bytes(args.get(1))?;
1182            let seed: [u8; 32] = match secret.as_slice().try_into() {
1183                Ok(s)  => s,
1184                Err(_) => return Ok(err_v(Value::Str("ed25519_sign: secret must be 32 bytes".into()))),
1185            };
1186            let sk = SigningKey::from_bytes(&seed);
1187            Ok(ok_v(Value::Bytes(sk.sign(message).to_bytes().to_vec())))
1188        }
1189        ("crypto", "ed25519_verify") => {
1190            use ed25519_dalek::{Signature, Verifier, VerifyingKey};
1191            let public = expect_bytes(args.first())?;
1192            let message = expect_bytes(args.get(1))?;
1193            let sig_bytes = expect_bytes(args.get(2))?;
1194            let pk_arr: [u8; 32] = match public.as_slice().try_into() {
1195                Ok(p)  => p,
1196                Err(_) => return Ok(Value::Bool(false)),
1197            };
1198            let sig_arr: [u8; 64] = match sig_bytes.as_slice().try_into() {
1199                Ok(s)  => s,
1200                Err(_) => return Ok(Value::Bool(false)),
1201            };
1202            let vk = match VerifyingKey::from_bytes(&pk_arr) {
1203                Ok(v)  => v,
1204                Err(_) => return Ok(Value::Bool(false)),
1205            };
1206            let sig = Signature::from_bytes(&sig_arr);
1207            Ok(Value::Bool(vk.verify(message, &sig).is_ok()))
1208        }
1209        // P-256 ECDSA / ES256 (#651). Backs the JWT/SD-JWT signing
1210        // primitives `lex-jose` needs for AP2 mandates. Key minting
1211        // (`p256_generate`) is effectful (`[random]`) and lives in the
1212        // handler; these three ops are deterministic given their inputs.
1213        //
1214        // - Secret key: 32-byte scalar (`SigningKey::to_bytes`).
1215        // - Public key: 33-byte SEC1 *compressed* point.
1216        // - Signature: ASN.1 DER-encoded (standard for ES256/JOSE
1217        //   producers that emit DER; JWK/raw-r||s conversion is a
1218        //   `lex-jose` concern).
1219        // Signing hashes `msg` with SHA-256 internally (ES256).
1220        ("crypto", "p256_public_key") => {
1221            use p256::ecdsa::SigningKey;
1222            let secret = expect_bytes(args.first())?;
1223            let sk = match SigningKey::from_slice(secret) {
1224                Ok(k)  => k,
1225                Err(_) => return Ok(err_v(Value::Str(
1226                    "p256_public_key: secret must be a 32-byte P-256 scalar".into()))),
1227            };
1228            let point = sk.verifying_key().to_encoded_point(true);
1229            Ok(ok_v(Value::Bytes(point.as_bytes().to_vec())))
1230        }
1231        ("crypto", "p256_sign") => {
1232            use p256::ecdsa::{signature::Signer, Signature, SigningKey};
1233            let secret = expect_bytes(args.first())?;
1234            let message = expect_bytes(args.get(1))?;
1235            let sk = match SigningKey::from_slice(secret) {
1236                Ok(k)  => k,
1237                Err(_) => return Ok(err_v(Value::Str(
1238                    "p256_sign: secret must be a 32-byte P-256 scalar".into()))),
1239            };
1240            let sig: Signature = sk.sign(message);
1241            Ok(ok_v(Value::Bytes(sig.to_der().as_bytes().to_vec())))
1242        }
1243        ("crypto", "p256_verify") => {
1244            use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey};
1245            let public = expect_bytes(args.first())?;
1246            let message = expect_bytes(args.get(1))?;
1247            let sig_bytes = expect_bytes(args.get(2))?;
1248            let vk = match VerifyingKey::from_sec1_bytes(public) {
1249                Ok(v)  => v,
1250                Err(_) => return Ok(Value::Bool(false)),
1251            };
1252            let sig = match Signature::from_der(sig_bytes) {
1253                Ok(s)  => s,
1254                Err(_) => return Ok(Value::Bool(false)),
1255            };
1256            Ok(Value::Bool(vk.verify(message, &sig).is_ok()))
1257        }
1258        // secp256k1 ECDSA + recovery (#655) — the EVM curve, for EIP-712
1259        // typed-data signing (EIP-3009 / x402 `exact`). Key minting
1260        // (`secp256k1_generate`) is effectful (`[random]`) and lives in
1261        // the handler; these ops are deterministic given their inputs.
1262        //
1263        // Unlike `p256_*`, sign/verify take a PRE-HASHED 32-byte digest
1264        // (EIP-712 already hashed) and do not hash again.
1265        // - Secret key: 32-byte scalar.
1266        // - Public key: 65-byte uncompressed SEC1 point (0x04‖X‖Y).
1267        // - Signature: 65 bytes `r‖s‖v`, v ∈ {27,28}, low-S (EIP-2).
1268        ("crypto", "secp256k1_public_key") => {
1269            use k256::ecdsa::SigningKey;
1270            let secret = expect_bytes(args.first())?;
1271            let sk = match SigningKey::from_slice(secret) {
1272                Ok(k)  => k,
1273                Err(_) => return Ok(err_v(Value::Str(
1274                    "secp256k1_public_key: secret must be a 32-byte secp256k1 scalar".into()))),
1275            };
1276            // Uncompressed SEC1 so callers can derive an Ethereum address
1277            // as keccak256(point[1..])[12..] without decompressing.
1278            let point = sk.verifying_key().to_encoded_point(false);
1279            Ok(ok_v(Value::Bytes(point.as_bytes().to_vec())))
1280        }
1281        ("crypto", "secp256k1_sign_digest") => {
1282            use k256::ecdsa::SigningKey;
1283            let secret = expect_bytes(args.first())?;
1284            let digest = expect_bytes(args.get(1))?;
1285            if digest.len() != 32 {
1286                return Ok(err_v(Value::Str(
1287                    "secp256k1_sign_digest: digest must be exactly 32 bytes".into())));
1288            }
1289            let sk = match SigningKey::from_slice(secret) {
1290                Ok(k)  => k,
1291                Err(_) => return Ok(err_v(Value::Str(
1292                    "secp256k1_sign_digest: secret must be a 32-byte secp256k1 scalar".into()))),
1293            };
1294            // RustCrypto normalizes to low-S (EIP-2) and returns the
1295            // recovery id. Ethereum's `v` is 27 + recid.
1296            match sk.sign_prehash_recoverable(digest) {
1297                Ok((sig, recid)) => {
1298                    let mut out = sig.to_bytes().to_vec(); // 64 bytes: r‖s
1299                    out.push(27u8 + recid.to_byte());
1300                    Ok(ok_v(Value::Bytes(out)))
1301                }
1302                Err(e) => Ok(err_v(Value::Str(
1303                    format!("secp256k1_sign_digest: {e}").into()))),
1304            }
1305        }
1306        ("crypto", "secp256k1_recover") => {
1307            use k256::ecdsa::{RecoveryId, Signature, VerifyingKey};
1308            let digest = expect_bytes(args.first())?;
1309            let sig_bytes = expect_bytes(args.get(1))?;
1310            if digest.len() != 32 {
1311                return Ok(err_v(Value::Str(
1312                    "secp256k1_recover: digest must be exactly 32 bytes".into())));
1313            }
1314            if sig_bytes.len() != 65 {
1315                return Ok(err_v(Value::Str(
1316                    "secp256k1_recover: signature must be 65 bytes (r‖s‖v)".into())));
1317            }
1318            let sig = match Signature::from_slice(&sig_bytes[..64]) {
1319                Ok(s)  => s,
1320                Err(_) => return Ok(err_v(Value::Str(
1321                    "secp256k1_recover: malformed r‖s".into()))),
1322            };
1323            // Accept both Ethereum {27,28} and raw {0,1} encodings of v.
1324            let v = sig_bytes[64];
1325            let recid_byte = if v >= 27 { v - 27 } else { v };
1326            let recid = match RecoveryId::from_byte(recid_byte) {
1327                Some(r) => r,
1328                None    => return Ok(err_v(Value::Str(
1329                    "secp256k1_recover: invalid recovery id".into()))),
1330            };
1331            match VerifyingKey::recover_from_prehash(digest, &sig, recid) {
1332                Ok(vk) => Ok(ok_v(Value::Bytes(
1333                    vk.to_encoded_point(false).as_bytes().to_vec()))),
1334                Err(e) => Ok(err_v(Value::Str(
1335                    format!("secp256k1_recover: {e}").into()))),
1336            }
1337        }
1338        ("crypto", "secp256k1_verify") => {
1339            use k256::ecdsa::{signature::hazmat::PrehashVerifier, Signature, VerifyingKey};
1340            let public = expect_bytes(args.first())?;
1341            let digest = expect_bytes(args.get(1))?;
1342            let sig_bytes = expect_bytes(args.get(2))?;
1343            if digest.len() != 32 {
1344                return Ok(Value::Bool(false));
1345            }
1346            let vk = match VerifyingKey::from_sec1_bytes(public) {
1347                Ok(v)  => v,
1348                Err(_) => return Ok(Value::Bool(false)),
1349            };
1350            // Accept a 65-byte recoverable sig (drop v) or a bare 64-byte r‖s.
1351            let rs = if sig_bytes.len() == 65 { &sig_bytes[..64] } else { sig_bytes.as_slice() };
1352            let sig = match Signature::from_slice(rs) {
1353                Ok(s)  => s,
1354                Err(_) => return Ok(Value::Bool(false)),
1355            };
1356            Ok(Value::Bool(vk.verify_prehash(digest, &sig).is_ok()))
1357        }
1358        ("crypto", "base64_encode") => {
1359            use base64::{Engine, engine::general_purpose::STANDARD};
1360            let data = expect_bytes(args.first())?;
1361            Ok(Value::Str(STANDARD.encode(data).into()))
1362        }
1363        ("crypto", "base64_decode") => {
1364            use base64::{Engine, engine::general_purpose::STANDARD};
1365            let s = expect_str(args.first())?;
1366            match STANDARD.decode(s) {
1367                Ok(b)  => Ok(ok_v(Value::Bytes(b))),
1368                Err(e) => Ok(err_v(Value::Str(format!("base64: {e}").into()))),
1369            }
1370        }
1371        // URL-safe base64 (#382). Alphabet `-_` instead of `+/`,
1372        // padding stripped. Use for JWT segments, signed cookies, any
1373        // token that travels in a URL or path component.
1374        ("crypto", "base64url_encode") => {
1375            use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
1376            let data = expect_bytes(args.first())?;
1377            Ok(Value::Str(URL_SAFE_NO_PAD.encode(data).into()))
1378        }
1379        ("crypto", "base64url_decode") => {
1380            use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
1381            let s = expect_str(args.first())?;
1382            match URL_SAFE_NO_PAD.decode(s) {
1383                Ok(b)  => Ok(ok_v(Value::Bytes(b))),
1384                Err(e) => Ok(err_v(Value::Str(format!("base64url: {e}").into()))),
1385            }
1386        }
1387        ("crypto", "hex_encode") => {
1388            let data = expect_bytes(args.first())?;
1389            Ok(Value::Str(hex::encode(data).into()))
1390        }
1391        ("crypto", "hex_decode") => {
1392            let s = expect_str(args.first())?;
1393            match hex::decode(s) {
1394                Ok(b)  => Ok(ok_v(Value::Bytes(b))),
1395                Err(e) => Ok(err_v(Value::Str(format!("hex: {e}").into()))),
1396            }
1397        }
1398        // base58 (#658). Bitcoin/Solana alphabet, no Base58Check checksum —
1399        // the encoding Solana uses for pubkeys, signatures, and the x402
1400        // `exact` payload. Pure, like base64/hex.
1401        ("crypto", "base58_encode") => {
1402            let data = expect_bytes(args.first())?;
1403            Ok(Value::Str(bs58::encode(data).into_string().into()))
1404        }
1405        ("crypto", "base58_decode") => {
1406            let s = expect_str(args.first())?;
1407            match bs58::decode(s).into_vec() {
1408                Ok(b)  => Ok(ok_v(Value::Bytes(b))),
1409                Err(e) => Ok(err_v(Value::Str(format!("base58: {e}").into()))),
1410            }
1411        }
1412        ("crypto", "constant_time_eq") | ("crypto", "eq") => {
1413            use subtle::ConstantTimeEq;
1414            let a = expect_bytes(args.first())?;
1415            let b = expect_bytes(args.get(1))?;
1416            // `subtle` returns Choice; comparison only meaningful when
1417            // lengths match. For mismatched lengths return false in
1418            // constant time (length itself isn't secret, but we want
1419            // a single comparison shape).
1420            //
1421            // `eq` (#382) is the recommended spelling — same semantics,
1422            // shorter name. `constant_time_eq` stays as an alias for
1423            // existing callers.
1424            let eq = if a.len() == b.len() {
1425                a.ct_eq(b).into()
1426            } else {
1427                false
1428            };
1429            Ok(Value::Bool(eq))
1430        }
1431        // Constant-time string equality (#382). Compares the bytes of
1432        // both strings; semantics identical to `eq` after `.as_bytes()`.
1433        ("crypto", "eq_str") => {
1434            use subtle::ConstantTimeEq;
1435            let a = expect_str(args.first())?;
1436            let b = expect_str(args.get(1))?;
1437            let eq = if a.len() == b.len() {
1438                a.as_bytes().ct_eq(b.as_bytes()).into()
1439            } else {
1440                false
1441            };
1442            Ok(Value::Bool(eq))
1443        }
1444
1445        // -- AEAD (#382 AEAD slice). Pure: same key + nonce + aad +
1446        // plaintext always produce the same ciphertext + tag. The
1447        // `[random]` effect lives one level up at the caller, where the
1448        // nonce is generated; AEAD ops themselves are deterministic and
1449        // therefore pure.
1450        //
1451        // AES-GCM key length is 128 / 192 / 256 bits; we pick the
1452        // variant from the key size at runtime so callers don't have
1453        // to choose between three near-identical wrappers.
1454        ("crypto", "aes_gcm_seal") => Ok(aes_gcm_seal_impl(args)),
1455        ("crypto", "aes_gcm_open") => Ok(aes_gcm_open_impl(args)),
1456        ("crypto", "chacha20_poly1305_seal") => Ok(chacha20_seal_impl(args)),
1457        ("crypto", "chacha20_poly1305_open") => Ok(chacha20_open_impl(args)),
1458        ("crypto", "pbkdf2_sha256") => Ok(pbkdf2_sha256_impl(args)),
1459        ("crypto", "hkdf_sha256")   => Ok(hkdf_sha256_impl(args)),
1460        ("crypto", "argon2id")      => Ok(argon2id_impl(args)),
1461
1462        // -- random (#219): pure, seeded RNG. Backed by SplitMix64;
1463        // state is the u64 mixer state stored as a single i64 in
1464        // `Rng = { state :: Int }`. Threading the Rng through the
1465        // call site is the user's responsibility — there is no
1466        // global RNG and therefore no `[random]` effect tag for
1467        // pure-seeded usage. --
1468        ("random", "seed") => {
1469            let s = args.first().ok_or("random.seed: missing arg")?.as_int();
1470            // Hash the user-supplied seed once before installing it.
1471            // SplitMix64 is fine when seeded with any u64, but
1472            // hashing first protects against pathological seeds
1473            // (e.g., 0) that would make the very first draw zero.
1474            let mixed = splitmix64(s as u64).0;
1475            Ok(rng_value(mixed))
1476        }
1477        ("random", "int") => {
1478            let state = rng_decode(args.first())?;
1479            let lo = args.get(1).ok_or("random.int: missing lo")?.as_int();
1480            let hi = args.get(2).ok_or("random.int: missing hi")?.as_int();
1481            if hi < lo {
1482                return Err(format!(
1483                    "random.int: hi ({hi}) must be >= lo ({lo})"));
1484            }
1485            let span = (hi as i128) - (lo as i128) + 1;
1486            let (raw, next_state) = splitmix64(state);
1487            // Reduce uniformly to [lo, hi]. The bias from a plain
1488            // modulo is at most `(u64::MAX % span) / u64::MAX`,
1489            // which for any practical span is invisible. Crypto
1490            // applications should use `crypto.random` instead.
1491            let drawn = lo as i128 + (raw as u128 % span as u128) as i128;
1492            Ok(Value::Tuple(vec![
1493                Value::Int(drawn as i64),
1494                rng_value(next_state),
1495            ]))
1496        }
1497        ("random", "float") => {
1498            let state = rng_decode(args.first())?;
1499            let (raw, next_state) = splitmix64(state);
1500            // Take the top 53 bits and divide by 2^53 to land in
1501            // [0.0, 1.0); this is the standard f64 uniform draw.
1502            let f = ((raw >> 11) as f64) / ((1u64 << 53) as f64);
1503            Ok(Value::Tuple(vec![Value::Float(f), rng_value(next_state)]))
1504        }
1505        ("random", "choose") => {
1506            let state = rng_decode(args.first())?;
1507            let xs = match args.get(1) {
1508                Some(Value::List(xs)) => xs,
1509                _ => return Err("random.choose: expected List".into()),
1510            };
1511            if xs.is_empty() {
1512                return Ok(Value::Variant {
1513                    name: "None".into(), args: vec![],
1514                });
1515            }
1516            let (raw, next_state) = splitmix64(state);
1517            let idx = (raw as usize) % xs.len();
1518            let pick = xs[idx].clone();
1519            Ok(Value::Variant {
1520                name: "Some".into(),
1521                args: vec![Value::Tuple(vec![pick, rng_value(next_state)])],
1522            })
1523        }
1524
1525        // -- parser (#217): parser combinators. Parser values are
1526        // tagged Records — `{ kind: "Char", ch: "x" }` etc. — so
1527        // canonical equality follows from the canonical Record
1528        // encoding. The interpreter is `parser_run_impl`. --
1529        ("parser", "char") => {
1530            let s = expect_str(args.first())?;
1531            if s.chars().count() != 1 {
1532                return Err(format!(
1533                    "parser.char: expected 1-character string, got {s:?}"));
1534            }
1535            Ok(parser_node("Char", &[("ch", Value::Str(s.into()))]))
1536        }
1537        ("parser", "string") => {
1538            let s = expect_str(args.first())?;
1539            Ok(parser_node("String", &[("s", Value::Str(s.into()))]))
1540        }
1541        ("parser", "digit") => Ok(parser_node("Digit", &[])),
1542        ("parser", "alpha") => Ok(parser_node("Alpha", &[])),
1543        ("parser", "whitespace") => Ok(parser_node("Whitespace", &[])),
1544        ("parser", "eof") => Ok(parser_node("Eof", &[])),
1545        ("parser", "seq") => {
1546            let a = args.first().cloned()
1547                .ok_or_else(|| "parser.seq: missing first parser".to_string())?;
1548            let b = args.get(1).cloned()
1549                .ok_or_else(|| "parser.seq: missing second parser".to_string())?;
1550            Ok(parser_node("Seq", &[("a", a), ("b", b)]))
1551        }
1552        ("parser", "alt") => {
1553            let a = args.first().cloned()
1554                .ok_or_else(|| "parser.alt: missing first parser".to_string())?;
1555            let b = args.get(1).cloned()
1556                .ok_or_else(|| "parser.alt: missing second parser".to_string())?;
1557            Ok(parser_node("Alt", &[("a", a), ("b", b)]))
1558        }
1559        ("parser", "many") => {
1560            let p = args.first().cloned()
1561                .ok_or_else(|| "parser.many: missing inner parser".to_string())?;
1562            Ok(parser_node("Many", &[("p", p)]))
1563        }
1564        ("parser", "optional") => {
1565            let p = args.first().cloned()
1566                .ok_or_else(|| "parser.optional: missing inner parser".to_string())?;
1567            Ok(parser_node("Optional", &[("p", p)]))
1568        }
1569        // `parser.map` and `parser.and_then` (#221): closure-bearing
1570        // combinators. Constructors only — actual closure invocation
1571        // happens at parser.run time via the Vm-level interpreter.
1572        ("parser", "map") => {
1573            let p = args.first().cloned()
1574                .ok_or_else(|| "parser.map: missing parser".to_string())?;
1575            let f = args.get(1).cloned()
1576                .ok_or_else(|| "parser.map: missing closure".to_string())?;
1577            Ok(parser_node("Map", &[("p", p), ("f", f)]))
1578        }
1579        ("parser", "and_then") => {
1580            let p = args.first().cloned()
1581                .ok_or_else(|| "parser.and_then: missing parser".to_string())?;
1582            let f = args.get(1).cloned()
1583                .ok_or_else(|| "parser.and_then: missing closure".to_string())?;
1584            Ok(parser_node("AndThen", &[("p", p), ("f", f)]))
1585        }
1586        // `parser.run` is handled at the Vm level (lex-bytecode's
1587        // `Op::EffectCall` intercept) — it needs reentrant Vm access
1588        // to invoke the closures inside `Map` / `AndThen` nodes. The
1589        // pure-builtin path doesn't have that, so we deliberately do
1590        // *not* have a `("parser", "run")` arm here.
1591
1592        // -- regex (the compiled `Regex` is stored as the pattern
1593        // string; the runtime caches the actual `regex::Regex` so
1594        // ops don't re-compile on every call) --
1595        ("regex", "compile") => {
1596            let pat = expect_str(args.first())?;
1597            match get_or_compile_regex(&pat) {
1598                Ok(_) => Ok(ok_v(Value::Str(pat.into()))),
1599                Err(e) => Ok(err_v(Value::Str(e.into()))),
1600            }
1601        }
1602        ("regex", "is_match") => {
1603            let pat = expect_str(args.first())?;
1604            let s = expect_str(args.get(1))?;
1605            let re = get_or_compile_regex(&pat).map_err(|e| format!("regex.is_match: {e}"))?;
1606            Ok(Value::Bool(re.is_match(&s)))
1607        }
1608        // is_match_str :: Str, Str -> Bool
1609        // Compiles the first argument as a pattern on the fly (uses the shared
1610        // cache) and matches against the second.  Returns false on invalid
1611        // pattern rather than propagating an error, keeping the pure signature.
1612        ("regex", "is_match_str") => {
1613            let pat = expect_str(args.first())?;
1614            let s = expect_str(args.get(1))?;
1615            match get_or_compile_regex(&pat) {
1616                Ok(re) => Ok(Value::Bool(re.is_match(&s))),
1617                Err(_) => Ok(Value::Bool(false)),
1618            }
1619        }
1620        ("regex", "find") => {
1621            let pat = expect_str(args.first())?;
1622            let s = expect_str(args.get(1))?;
1623            let re = get_or_compile_regex(&pat).map_err(|e| format!("regex.find: {e}"))?;
1624            match re.captures(&s) {
1625                Some(caps) => Ok(Value::Variant {
1626                    name: "Some".into(),
1627                    args: vec![match_value(&caps)],
1628                }),
1629                None => Ok(Value::Variant { name: "None".into(), args: vec![] }),
1630            }
1631        }
1632        ("regex", "find_all") => {
1633            let pat = expect_str(args.first())?;
1634            let s = expect_str(args.get(1))?;
1635            let re = get_or_compile_regex(&pat).map_err(|e| format!("regex.find_all: {e}"))?;
1636            let items: std::collections::VecDeque<Value> = re.captures_iter(&s).map(|caps| match_value(&caps)).collect();
1637            Ok(Value::List(items))
1638        }
1639        ("regex", "replace") => {
1640            let pat = expect_str(args.first())?;
1641            let s = expect_str(args.get(1))?;
1642            let rep = expect_str(args.get(2))?;
1643            let re = get_or_compile_regex(&pat).map_err(|e| format!("regex.replace: {e}"))?;
1644            Ok(Value::Str(re.replace(&s, rep.as_str()).into_owned().into()))
1645        }
1646        ("regex", "replace_all") => {
1647            let pat = expect_str(args.first())?;
1648            let s = expect_str(args.get(1))?;
1649            let rep = expect_str(args.get(2))?;
1650            let re = get_or_compile_regex(&pat).map_err(|e| format!("regex.replace_all: {e}"))?;
1651            Ok(Value::Str(re.replace_all(&s, rep.as_str()).into_owned().into()))
1652        }
1653        // -- datetime (pure ops; datetime.now is effectful and routes
1654        // through the handler under [time]) --
1655        ("datetime", "parse_iso") => {
1656            let s = expect_str(args.first())?;
1657            match chrono::DateTime::parse_from_rfc3339(&s) {
1658                Ok(dt) => Ok(ok_v(Value::Int(instant_from_chrono(dt)))),
1659                Err(e) => Ok(err_v(Value::Str(format!("parse_iso: {e}").into()))),
1660            }
1661        }
1662        ("datetime", "format_iso") => {
1663            let n = expect_int(args.first())?;
1664            Ok(Value::Str(format_iso(n).into()))
1665        }
1666        ("datetime", "parse") => {
1667            let s = expect_str(args.first())?;
1668            let fmt = expect_str(args.get(1))?;
1669            match chrono::NaiveDateTime::parse_from_str(&s, &fmt) {
1670                Ok(naive) => {
1671                    use chrono::TimeZone;
1672                    match chrono::Utc.from_local_datetime(&naive).single() {
1673                        Some(dt) => Ok(ok_v(Value::Int(instant_from_chrono(dt)))),
1674                        None => Ok(err_v(Value::Str("parse: ambiguous local time".into()))),
1675                    }
1676                }
1677                Err(e) => Ok(err_v(Value::Str(format!("parse: {e}").into()))),
1678            }
1679        }
1680        ("datetime", "format") => {
1681            let n = expect_int(args.first())?;
1682            let fmt = expect_str(args.get(1))?;
1683            let dt = chrono_from_instant(n);
1684            Ok(Value::Str(dt.format(&fmt).to_string().into()))
1685        }
1686        ("datetime", "to_components") => {
1687            let n = expect_int(args.first())?;
1688            let tz = match parse_tz_arg(args.get(1)) {
1689                Ok(t) => t,
1690                Err(e) => return Ok(err_v(Value::Str(e.into()))),
1691            };
1692            match resolve_tz_to_components(n, &tz) {
1693                Ok(rec) => Ok(ok_v(rec)),
1694                Err(e) => Ok(err_v(Value::Str(e.into()))),
1695            }
1696        }
1697        ("datetime", "from_components") => {
1698            let rec = match args.first() {
1699                Some(Value::Record { fields: r, .. }) => r.clone(),
1700                _ => return Err("from_components: expected DateTime record".into()),
1701            };
1702            match instant_from_components(&rec) {
1703                Ok(n) => Ok(ok_v(Value::Int(n))),
1704                Err(e) => Ok(err_v(Value::Str(e.into()))),
1705            }
1706        }
1707        ("datetime", "add") => {
1708            let a = expect_int(args.first())?;
1709            let d = expect_int(args.get(1))?;
1710            Ok(Value::Int(a.saturating_add(d)))
1711        }
1712        ("datetime", "diff") => {
1713            let a = expect_int(args.first())?;
1714            let b = expect_int(args.get(1))?;
1715            Ok(Value::Int(a.saturating_sub(b)))
1716        }
1717        ("datetime", "duration_seconds") => {
1718            let s = expect_float(args.first())?;
1719            let nanos = (s * 1_000_000_000.0) as i64;
1720            Ok(Value::Int(nanos))
1721        }
1722        ("datetime", "duration_minutes") => {
1723            let m = expect_int(args.first())?;
1724            Ok(Value::Int(m.saturating_mul(60_000_000_000)))
1725        }
1726        ("datetime", "duration_days") => {
1727            let d = expect_int(args.first())?;
1728            Ok(Value::Int(d.saturating_mul(86_400_000_000_000)))
1729        }
1730        // #331: Instant comparison ops.
1731        ("datetime", "before") => {
1732            let a = expect_int(args.first())?;
1733            let b = expect_int(args.get(1))?;
1734            Ok(Value::Bool(a < b))
1735        }
1736        ("datetime", "after") => {
1737            let a = expect_int(args.first())?;
1738            let b = expect_int(args.get(1))?;
1739            Ok(Value::Bool(a > b))
1740        }
1741        ("datetime", "compare") => {
1742            let a = expect_int(args.first())?;
1743            let b = expect_int(args.get(1))?;
1744            Ok(Value::Int(a.cmp(&b) as i64))
1745        }
1746        // #331: Duration scalar extraction (nanoseconds under the hood).
1747        // #681 rounds out the unit set; each truncates toward zero.
1748        ("duration", "millis")  => Ok(Value::Int(expect_int(args.first())? / 1_000_000)),
1749        ("duration", "seconds") => Ok(Value::Int(expect_int(args.first())? / 1_000_000_000)),
1750        ("duration", "minutes") => Ok(Value::Int(expect_int(args.first())? / 60_000_000_000)),
1751        ("duration", "hours")   => Ok(Value::Int(expect_int(args.first())? / 3_600_000_000_000)),
1752        ("duration", "days")    => Ok(Value::Int(expect_int(args.first())? / 86_400_000_000_000)),
1753
1754        ("regex", "split") => {
1755            let pat = expect_str(args.first())?;
1756            let s = expect_str(args.get(1))?;
1757            let re = get_or_compile_regex(&pat).map_err(|e| format!("regex.split: {e}"))?;
1758            let parts: std::collections::VecDeque<Value> = re.split(&s).map(|p| Value::Str(p.into())).collect();
1759            Ok(Value::List(parts))
1760        }
1761
1762        // -- http (builders + decoders; wire ops live in the
1763        // effect handler under `[net]`) --
1764        ("http", "with_header") => {
1765            let req = expect_record_pure(args.first())?.clone();
1766            let k = expect_str(args.get(1))?;
1767            let v = expect_str(args.get(2))?;
1768            Ok(Value::record_interned(http_set_header(req, &k, &v)))
1769        }
1770        ("http", "with_auth") => {
1771            let req = expect_record_pure(args.first())?.clone();
1772            let scheme = expect_str(args.get(1))?;
1773            let token = expect_str(args.get(2))?;
1774            let value = format!("{scheme} {token}");
1775            Ok(Value::record_interned(http_set_header(req, "Authorization", &value)))
1776        }
1777        ("http", "with_query") => {
1778            let req = expect_record_pure(args.first())?.clone();
1779            let params = match args.get(1) {
1780                Some(Value::Map(m)) => m.clone(),
1781                Some(other) => return Err(format!(
1782                    "http.with_query: params must be Map[Str, Str], got {other:?}")),
1783                None => return Err("http.with_query: missing params argument".into()),
1784            };
1785            Ok(Value::record_interned(http_append_query(req, &params)))
1786        }
1787        ("http", "with_timeout_ms") => {
1788            let req = expect_record_pure(args.first())?.clone();
1789            let ms = expect_int(args.get(1))?;
1790            let mut out = req;
1791            out.insert("timeout_ms".into(), Value::Variant {
1792                name: "Some".into(),
1793                args: vec![Value::Int(ms)],
1794            });
1795            Ok(Value::record_interned(out))
1796        }
1797        ("http", "json_body") => {
1798            let resp = expect_record_pure(args.first())?;
1799            let body = match resp.get("body") {
1800                Some(Value::Bytes(b)) => b.clone(),
1801                _ => return Err("http.json_body: HttpResponse.body must be Bytes".into()),
1802            };
1803            let s = match std::str::from_utf8(&body) {
1804                Ok(s) => s,
1805                Err(e) => return Ok(http_decode_err_pure(format!("body not UTF-8: {e}"))),
1806            };
1807            match serde_json::from_str::<serde_json::Value>(s) {
1808                Ok(j) => Ok(ok_v(Value::from_json(&j))),
1809                Err(e) => Ok(http_decode_err_pure(format!("json parse: {e}"))),
1810            }
1811        }
1812        // Compiler-emitted typed variant of http.json_body (#684): the
1813        // type-checker rewrite injects the required-field list and the type
1814        // schema derived from T (when T is a record), so a missing or
1815        // wrong-typed field surfaces as a DecodeError instead of a later
1816        // field-access panic — the same guarantee json.parse_strict gives,
1817        // now on the most common API-decode path. Errors are HttpError
1818        // (via http_decode_err_pure), matching json_body's error type.
1819        ("http", "json_body_typed") => {
1820            let resp = expect_record_pure(args.first())?;
1821            let required = required_field_names(args.get(1))?;
1822            let schema = extract_type_schema(args.get(2));
1823            let body = match resp.get("body") {
1824                Some(Value::Bytes(b)) => b.clone(),
1825                _ => return Err("http.json_body: HttpResponse.body must be Bytes".into()),
1826            };
1827            let s = match std::str::from_utf8(&body) {
1828                Ok(s) => s,
1829                Err(e) => return Ok(http_decode_err_pure(format!("body not UTF-8: {e}"))),
1830            };
1831            match serde_json::from_str::<serde_json::Value>(s) {
1832                Ok(j) => {
1833                    if let Err(e) = check_required_fields(&j, &required) {
1834                        return Ok(http_decode_err_pure(e));
1835                    }
1836                    if let Err(e) = validate_field_types(&j, &schema) {
1837                        return Ok(http_decode_err_pure(e));
1838                    }
1839                    Ok(ok_v(apply_option_wrapping(json_to_value(&j), &j, &schema)))
1840                }
1841                Err(e) => Ok(http_decode_err_pure(format!("json parse: {e}"))),
1842            }
1843        }
1844        ("http", "text_body") => {
1845            let resp = expect_record_pure(args.first())?;
1846            let body = match resp.get("body") {
1847                Some(Value::Bytes(b)) => b.clone(),
1848                _ => return Err("http.text_body: HttpResponse.body must be Bytes".into()),
1849            };
1850            match String::from_utf8(body) {
1851                Ok(s) => Ok(ok_v(Value::Str(s.into()))),
1852                Err(e) => Ok(http_decode_err_pure(format!("body not UTF-8: {e}"))),
1853            }
1854        }
1855
1856        // -- std.cli (Rubric port): argparse-equivalent for end-user
1857        // programs. Specs are tagged Json values; the parser walks
1858        // argv against the spec and returns a CliParsed Json record.
1859        ("cli", "flag") => {
1860            let name = expect_str(args.first())?;
1861            let short = opt_str(args.get(1));
1862            let help = expect_str(args.get(2))?;
1863            Ok(value_from_json(crate::cli::flag_spec(&name, short.as_deref(), &help)))
1864        }
1865        ("cli", "option") => {
1866            let name = expect_str(args.first())?;
1867            let short = opt_str(args.get(1));
1868            let help = expect_str(args.get(2))?;
1869            let default = opt_str(args.get(3));
1870            Ok(value_from_json(crate::cli::option_spec(&name, short.as_deref(), &help, default.as_deref())))
1871        }
1872        ("cli", "positional") => {
1873            let name = expect_str(args.first())?;
1874            let help = expect_str(args.get(1))?;
1875            let required = expect_bool(args.get(2))?;
1876            Ok(value_from_json(crate::cli::positional_spec(&name, &help, required)))
1877        }
1878        ("cli", "spec") => {
1879            let name = expect_str(args.first())?;
1880            let help = expect_str(args.get(1))?;
1881            let arg_specs: Vec<serde_json::Value> = expect_list(args.get(2))?
1882                .iter().map(value_to_json).collect();
1883            let subs: Vec<serde_json::Value> = expect_list(args.get(3))?
1884                .iter().map(value_to_json).collect();
1885            Ok(value_from_json(crate::cli::build_spec(&name, &help, arg_specs, subs)))
1886        }
1887        ("cli", "parse") => {
1888            let spec = value_to_json(args.first().unwrap_or(&Value::Unit));
1889            let argv: Vec<String> = expect_list(args.get(1))?
1890                .iter().map(|v| match v {
1891                    Value::Str(s) => Ok(s.to_string()),
1892                    other => Err(format!("cli.parse: argv must be List[Str], got {other:?}")),
1893                }).collect::<Result<_, _>>()?;
1894            match crate::cli::parse(&spec, &argv) {
1895                Ok(parsed) => Ok(ok_v(value_from_json(parsed))),
1896                Err(msg) => Ok(err_v(Value::Str(msg.into()))),
1897            }
1898        }
1899        ("cli", "envelope") => {
1900            let ok = expect_bool(args.first())?;
1901            let cmd = expect_str(args.get(1))?;
1902            let data = value_to_json(args.get(2).unwrap_or(&Value::Unit));
1903            Ok(value_from_json(crate::cli::envelope(ok, &cmd, data)))
1904        }
1905        ("cli", "describe") => {
1906            let spec = value_to_json(args.first().unwrap_or(&Value::Unit));
1907            Ok(value_from_json(crate::cli::describe(&spec)))
1908        }
1909        ("cli", "help") => {
1910            let spec = value_to_json(args.first().unwrap_or(&Value::Unit));
1911            Ok(Value::Str(crate::cli::help_text(&spec).into()))
1912        }
1913
1914        // -- arrow -- delegated to a dedicated module (#426)
1915        ("arrow", op) => match crate::arrow::dispatch(op, args) {
1916            Some(r) => r,
1917            None => Err(format!("unknown pure builtin: arrow.{op}")),
1918        },
1919        // -- df -- Polars-backed query ops (#427), gated behind the
1920        // `df` feature so embedders that don't need dataframes avoid
1921        // the polars dep tree.
1922        #[cfg(feature = "df")]
1923        ("df", op) => match crate::df::dispatch(op, args) {
1924            Some(r) => r,
1925            None => Err(format!("unknown pure builtin: df.{op}")),
1926        },
1927        #[cfg(not(feature = "df"))]
1928        ("df", op) => Err(format!(
1929            "df.{op}: this build was compiled without the `df` feature; \
1930             Polars-backed dataframe query ops are unavailable"
1931        )),
1932
1933        // -- std.decimal (#574): exact scaled-integer decimal arithmetic.
1934        // Decimal values are `{ coefficient :: Int, exponent :: Int }` records
1935        // representing `coefficient × 10^exponent`. All arithmetic is exact
1936        // (no IEEE 754 rounding); precision loss happens only at `round_to`,
1937        // which requires an explicit rounding mode string.
1938
1939        ("decimal", "decimal") => {
1940            let coef = expect_int(args.first())?;
1941            let exp  = expect_int(args.get(1))?;
1942            Ok(make_decimal(coef, exp))
1943        }
1944        ("decimal", "zero") => Ok(make_decimal(0, 0)),
1945        ("decimal", "one")  => Ok(make_decimal(1, 0)),
1946        ("decimal", "from_int") => {
1947            Ok(make_decimal(expect_int(args.first())?, 0))
1948        }
1949        ("decimal", "pow10") => {
1950            Ok(Value::Int(decimal_pow10(expect_int(args.first())?)?))
1951        }
1952        ("decimal", "add") => {
1953            let (ca, ea) = expect_decimal(args.first())?;
1954            let (cb, eb) = expect_decimal(args.get(1))?;
1955            let (a2, b2, e) = decimal_align(ca, ea, cb, eb)?;
1956            Ok(make_decimal(
1957                a2.checked_add(b2).ok_or("decimal.add: overflow")?, e))
1958        }
1959        ("decimal", "sub") => {
1960            let (ca, ea) = expect_decimal(args.first())?;
1961            let (cb, eb) = expect_decimal(args.get(1))?;
1962            let (a2, b2, e) = decimal_align(ca, ea, cb, eb)?;
1963            Ok(make_decimal(
1964                a2.checked_sub(b2).ok_or("decimal.sub: overflow")?, e))
1965        }
1966        ("decimal", "mul") => {
1967            let (ca, ea) = expect_decimal(args.first())?;
1968            let (cb, eb) = expect_decimal(args.get(1))?;
1969            Ok(make_decimal(
1970                ca.checked_mul(cb).ok_or("decimal.mul: overflow")?,
1971                ea.checked_add(eb).ok_or("decimal.mul: exponent overflow")?,
1972            ))
1973        }
1974        ("decimal", "compare") => {
1975            let (ca, ea) = expect_decimal(args.first())?;
1976            let (cb, eb) = expect_decimal(args.get(1))?;
1977            let (a2, b2, _) = decimal_align(ca, ea, cb, eb)?;
1978            Ok(Value::Int(if a2 < b2 { -1 } else if a2 > b2 { 1 } else { 0 }))
1979        }
1980        ("decimal", "is_zero")     => {
1981            let (c, _) = expect_decimal(args.first())?;
1982            Ok(Value::Bool(c == 0))
1983        }
1984        ("decimal", "is_positive") => {
1985            let (c, _) = expect_decimal(args.first())?;
1986            Ok(Value::Bool(c > 0))
1987        }
1988        ("decimal", "is_negative") => {
1989            let (c, _) = expect_decimal(args.first())?;
1990            Ok(Value::Bool(c < 0))
1991        }
1992        ("decimal", "negate") => {
1993            let (c, e) = expect_decimal(args.first())?;
1994            Ok(make_decimal(-c, e))
1995        }
1996        ("decimal", "abs") => {
1997            let (c, e) = expect_decimal(args.first())?;
1998            Ok(make_decimal(c.abs(), e))
1999        }
2000        ("decimal", "normalize") => {
2001            let (mut c, mut e) = expect_decimal(args.first())?;
2002            if c == 0 { return Ok(make_decimal(0, 0)); }
2003            while c % 10 == 0 { c /= 10; e += 1; }
2004            Ok(make_decimal(c, e))
2005        }
2006        ("decimal", "round_to") => {
2007            let (c, e)   = expect_decimal(args.first())?;
2008            let target_e = expect_int(args.get(1))?;
2009            let mode     = expect_str(args.get(2))?;
2010            Ok(make_decimal(decimal_round(c, e, target_e, &mode)?, target_e))
2011        }
2012        ("decimal", "to_str") => {
2013            let (c, e) = expect_decimal(args.first())?;
2014            Ok(Value::Str(decimal_to_str(c, e)?.into()))
2015        }
2016
2017        _ => Err(format!("unknown pure builtin: {kind}.{op}")),
2018    }
2019}
2020
2021// -- std.decimal helpers (#574) ------------------------------------------
2022
2023/// Extract `(coefficient, exponent)` from a `Decimal` record value.
2024fn expect_decimal(v: Option<&Value>) -> Result<(i64, i64), String> {
2025    match v {
2026        Some(Value::Record { fields, .. }) => {
2027            let coef = match fields.get("coefficient") {
2028                Some(Value::Int(n)) => *n,
2029                _ => return Err("decimal: missing or invalid 'coefficient' field".into()),
2030            };
2031            let exp = match fields.get("exponent") {
2032                Some(Value::Int(n)) => *n,
2033                _ => return Err("decimal: missing or invalid 'exponent' field".into()),
2034            };
2035            Ok((coef, exp))
2036        }
2037        Some(other) => Err(format!("decimal: expected {{ coefficient, exponent }} record, got {other:?}")),
2038        None => Err("decimal: missing argument".into()),
2039    }
2040}
2041
2042/// Build a `Decimal` `Value::Record`.
2043fn make_decimal(coefficient: i64, exponent: i64) -> Value {
2044    let mut fields = indexmap::IndexMap::new();
2045    fields.insert("coefficient".into(), Value::Int(coefficient));
2046    fields.insert("exponent".into(), Value::Int(exponent));
2047    Value::record_interned(fields)
2048}
2049
2050/// 10^n for n in [0, 18]. Returns an error outside that range.
2051fn decimal_pow10(n: i64) -> Result<i64, String> {
2052    if n < 0  { return Err(format!("decimal.pow10: negative exponent {n}")); }
2053    if n > 18 { return Err(format!("decimal.pow10: exponent {n} exceeds max (18)")); }
2054    Ok(10i64.pow(n as u32))
2055}
2056
2057/// Bring two Decimals to the same exponent.
2058/// Returns `(coef_a_aligned, coef_b_aligned, common_exponent)`.
2059fn decimal_align(ca: i64, ea: i64, cb: i64, eb: i64) -> Result<(i64, i64, i64), String> {
2060    if ea == eb { return Ok((ca, cb, ea)); }
2061    if ea > eb {
2062        let scale = decimal_pow10(ea - eb)?;
2063        let ca2 = ca.checked_mul(scale)
2064            .ok_or_else(|| format!("decimal: overflow aligning (shift {})", ea - eb))?;
2065        Ok((ca2, cb, eb))
2066    } else {
2067        let scale = decimal_pow10(eb - ea)?;
2068        let cb2 = cb.checked_mul(scale)
2069            .ok_or_else(|| format!("decimal: overflow aligning (shift {})", eb - ea))?;
2070        Ok((ca, cb2, ea))
2071    }
2072}
2073
2074/// Compute the rounded coefficient when scaling `c × 10^e` to `target_e`.
2075/// `target_e > e` (we're reducing precision): divides by `10^(target_e - e)`
2076/// and applies `mode`. When `target_e <= e` (gaining precision) multiplies
2077/// exactly — no rounding needed.
2078fn decimal_round(c: i64, e: i64, target_e: i64, mode: &str) -> Result<i64, String> {
2079    if e >= target_e {
2080        // Gaining precision (or staying equal) — exact, no rounding.
2081        let shift = e - target_e;
2082        let scale = decimal_pow10(shift)?;
2083        return c.checked_mul(scale)
2084            .ok_or_else(|| format!("decimal.round_to: overflow scaling (shift {shift})"));
2085    }
2086    // Losing precision — divide and round.
2087    let shift   = target_e - e;
2088    let divisor = decimal_pow10(shift)?;
2089    let q = c / divisor;
2090    let r = c % divisor;  // same sign as c (Rust truncation toward zero)
2091
2092    if r == 0 { return Ok(q); }
2093
2094    let abs_r   = r.abs();
2095    let positive = r > 0; // sign of the original value when q is near zero
2096
2097    let rounded = match mode {
2098        "Down"     => q,
2099        "Up"       => if positive { q + 1 } else { q - 1 },
2100        "Floor"    => if positive { q }     else { q - 1 },
2101        "Ceiling"  => if positive { q + 1 } else { q },
2102        "HalfUp"   => {
2103            if abs_r * 2 >= divisor {
2104                if positive { q + 1 } else { q - 1 }
2105            } else { q }
2106        }
2107        "HalfDown" => {
2108            if abs_r * 2 > divisor {
2109                if positive { q + 1 } else { q - 1 }
2110            } else { q }
2111        }
2112        "HalfEven" => {
2113            if abs_r * 2 > divisor {
2114                if positive { q + 1 } else { q - 1 }
2115            } else if abs_r * 2 == divisor {
2116                // Round to nearest even (banker's rounding)
2117                if q % 2 == 0 { q } else { if positive { q + 1 } else { q - 1 } }
2118            } else { q }
2119        }
2120        other => return Err(format!(
2121            "decimal.round_to: unknown rounding mode {other:?}; \
2122             valid modes: HalfUp HalfDown HalfEven Down Up Ceiling Floor")),
2123    };
2124    Ok(rounded)
2125}
2126
2127/// Format a Decimal as a decimal-notation string.
2128/// e.g. `(12345, -2)` → `"123.45"`, `(7, 2)` → `"700"`, `(-63, -2)` → `"-0.63"`.
2129fn decimal_to_str(c: i64, e: i64) -> Result<String, String> {
2130    if e == 0 { return Ok(c.to_string()); }
2131    if e > 0 {
2132        let scale = decimal_pow10(e)?;
2133        let val   = c.checked_mul(scale)
2134            .ok_or("decimal.to_str: overflow")?;
2135        return Ok(val.to_string());
2136    }
2137    // e < 0: render fractional digits
2138    let scale          = decimal_pow10(-e)?;
2139    let sign           = if c < 0 { "-" } else { "" };
2140    let abs_c          = c.abs();
2141    let int_part       = abs_c / scale;
2142    let frac_part      = abs_c % scale;
2143    let decimal_places = (-e) as usize;
2144    Ok(format!("{sign}{int_part}.{frac_part:0>decimal_places$}"))
2145}
2146
2147/// Extract `Option[Str]` arg as `Option<String>`. None and missing
2148/// arg both map to `None`. Used by the `cli` builders so callers can
2149/// pass `option.none()` or `Some("v")` interchangeably.
2150fn opt_str(arg: Option<&Value>) -> Option<String> {
2151    match arg {
2152        Some(Value::Variant { name, args }) if name == "Some" => {
2153            args.first().and_then(|v| match v {
2154                Value::Str(s) => Some(s.to_string()),
2155                _ => None,
2156            })
2157        }
2158        _ => None,
2159    }
2160}
2161
2162fn value_from_json(v: serde_json::Value) -> Value { Value::from_json(&v) }
2163
2164/// Process-wide cache of compiled regexes, keyed by the pattern
2165/// string. Compilation is the only cost we want to amortize; matching
2166/// the same `Regex` from multiple threads is safe (`regex::Regex` is
2167/// `Send + Sync`).
2168fn regex_cache() -> &'static Mutex<HashMap<String, regex::Regex>> {
2169    static CACHE: OnceLock<Mutex<HashMap<String, regex::Regex>>> = OnceLock::new();
2170    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
2171}
2172
2173fn get_or_compile_regex(pattern: &str) -> Result<regex::Regex, String> {
2174    let cache = regex_cache();
2175    {
2176        let guard = cache.lock().unwrap();
2177        if let Some(re) = guard.get(pattern) {
2178            return Ok(re.clone());
2179        }
2180    }
2181    let re = regex::Regex::new(pattern).map_err(|e| format!("invalid regex: {e}"))?;
2182    let mut guard = cache.lock().unwrap();
2183    guard.insert(pattern.to_string(), re.clone());
2184    Ok(re)
2185}
2186
2187/// Build a `Match` record value: `{ text, start, end, groups }` where
2188/// `groups` is the captured groups in order (group 0 is the full match).
2189/// Missing optional groups become empty strings.
2190fn match_value(caps: &regex::Captures) -> Value {
2191    let m0 = caps.get(0).expect("regex match always has group 0");
2192    let mut rec = indexmap::IndexMap::new();
2193    rec.insert("text".into(), Value::Str(m0.as_str().into()));
2194    rec.insert("start".into(), Value::Int(m0.start() as i64));
2195    rec.insert("end".into(), Value::Int(m0.end() as i64));
2196    let groups: std::collections::VecDeque<Value> = (1..caps.len())
2197        .map(|i| {
2198            Value::Str(
2199                caps.get(i)
2200                    .map(|m| m.as_str())
2201                    .unwrap_or_default()
2202                    .into(),
2203            )
2204        })
2205        .collect();
2206    rec.insert("groups".into(), Value::List(groups));
2207    Value::record_dynamic(rec)
2208}
2209
2210fn expect_map(v: Option<&Value>) -> Result<&BTreeMap<MapKey, Value>, String> {
2211    match v {
2212        Some(Value::Map(m)) => Ok(m),
2213        other => Err(format!("expected Map, got {other:?}")),
2214    }
2215}
2216
2217fn expect_set(v: Option<&Value>) -> Result<&BTreeSet<MapKey>, String> {
2218    match v {
2219        Some(Value::Set(s)) => Ok(s),
2220        other => Err(format!("expected Set, got {other:?}")),
2221    }
2222}
2223
2224/// Unpack any matrix-shaped Value into (rows, cols, flat row-major data).
2225/// Accepts the F64Array fast lane and the legacy `Record { rows, cols,
2226/// data: List[Float] }` shape for compatibility with hand-built matrices.
2227fn unpack_matrix(v: &Value) -> Result<(usize, usize, Vec<f64>), String> {
2228    if let Value::F64Array { rows, cols, data } = v {
2229        return Ok((*rows as usize, *cols as usize, data.clone()));
2230    }
2231    let rec = match v {
2232        Value::Record { fields: r, .. } => r,
2233        other => return Err(format!("expected matrix, got {other:?}")),
2234    };
2235    let rows = match rec.get("rows") {
2236        Some(Value::Int(n)) => *n as usize,
2237        _ => return Err("matrix: missing/invalid `rows`".into()),
2238    };
2239    let cols = match rec.get("cols") {
2240        Some(Value::Int(n)) => *n as usize,
2241        _ => return Err("matrix: missing/invalid `cols`".into()),
2242    };
2243    let data = match rec.get("data") {
2244        Some(Value::List(items)) => {
2245            let mut out = Vec::with_capacity(items.len());
2246            for it in items {
2247                out.push(match it {
2248                    Value::Float(f) => *f,
2249                    Value::Int(n) => *n as f64,
2250                    other => return Err(format!("matrix data: not numeric, got {other:?}")),
2251                });
2252            }
2253            out
2254        }
2255        _ => return Err("matrix: missing/invalid `data`".into()),
2256    };
2257    if data.len() != rows * cols {
2258        return Err(format!("matrix: data len {} != {rows}*{cols}", data.len()));
2259    }
2260    Ok((rows, cols, data))
2261}
2262
2263fn expect_bytes(v: Option<&Value>) -> Result<&Vec<u8>, String> {
2264    match v {
2265        Some(Value::Bytes(b)) => Ok(b),
2266        Some(other) => Err(format!("expected Bytes, got {other:?}")),
2267        None => Err("missing argument".into()),
2268    }
2269}
2270
2271fn first_arg(args: &[Value]) -> Result<&Value, String> {
2272    args.first().ok_or_else(|| "missing argument".into())
2273}
2274
2275fn tuple_index(v: &Value, i: usize) -> Result<Value, String> {
2276    match v {
2277        Value::Tuple(items) => items.get(i).cloned()
2278            .ok_or_else(|| format!("tuple index {i} out of range (len={})", items.len())),
2279        other => Err(format!("expected Tuple, got {other:?}")),
2280    }
2281}
2282
2283fn expect_str(v: Option<&Value>) -> Result<String, String> {
2284    match v {
2285        Some(Value::Str(s)) => Ok(s.to_string()),
2286        Some(other) => Err(format!("expected Str, got {other:?}")),
2287        None => Err("missing argument".into()),
2288    }
2289}
2290
2291fn expect_int(v: Option<&Value>) -> Result<i64, String> {
2292    match v {
2293        Some(Value::Int(n)) => Ok(*n),
2294        Some(other) => Err(format!("expected Int, got {other:?}")),
2295        None => Err("missing argument".into()),
2296    }
2297}
2298
2299fn expect_float(v: Option<&Value>) -> Result<f64, String> {
2300    match v {
2301        Some(Value::Float(f)) => Ok(*f),
2302        Some(other) => Err(format!("expected Float, got {other:?}")),
2303        None => Err("missing argument".into()),
2304    }
2305}
2306
2307fn expect_list(v: Option<&Value>) -> Result<&std::collections::VecDeque<Value>, String> {
2308    match v {
2309        Some(Value::List(xs)) => Ok(xs),
2310        Some(other) => Err(format!("expected List, got {other:?}")),
2311        None => Err("missing argument".into()),
2312    }
2313}
2314
2315fn expect_bool(v: Option<&Value>) -> Result<bool, String> {
2316    match v {
2317        Some(Value::Bool(b)) => Ok(*b),
2318        Some(other) => Err(format!("expected Bool, got {other:?}")),
2319        None => Err("missing argument".into()),
2320    }
2321}
2322
2323fn expect_deque(v: Option<&Value>) -> Result<&std::collections::VecDeque<Value>, String> {
2324    match v {
2325        Some(Value::Deque(d)) => Ok(d),
2326        Some(other) => Err(format!("expected Deque, got {other:?}")),
2327        None => Err("missing argument".into()),
2328    }
2329}
2330
2331fn some(v: Value) -> Value { Value::Variant { name: "Some".into(), args: vec![v] } }
2332fn none() -> Value { Value::Variant { name: "None".into(), args: Vec::new() } }
2333fn ok_v(v: Value) -> Value { Value::Variant { name: "Ok".into(), args: vec![v] } }
2334fn err_v(v: Value) -> Value { Value::Variant { name: "Err".into(), args: vec![v] } }
2335
2336// -- std.parser helpers (#217) ----------------------------------------
2337
2338/// Construct a tagged parser-AST node. The runtime representation is
2339/// `{ kind: "Char" | "Seq" | ..., ...children }`; the type system
2340/// treats these as opaque `Parser[T]` so user code can't poke at the
2341/// fields. Encoding is canonical because `IndexMap` insertion order
2342/// is stable and we always insert `kind` first.
2343fn parser_node(kind: &str, fields: &[(&str, Value)]) -> Value {
2344    let mut r = indexmap::IndexMap::new();
2345    r.insert("kind".into(), Value::Str(kind.into()));
2346    for (k, v) in fields {
2347        r.insert((*k).into(), v.clone());
2348    }
2349    Value::record_dynamic(r)
2350}
2351
2352// `parser.run` interpretation lives in `lex-bytecode::parser_runtime`
2353// (#221) — it needs reentrant Vm access to invoke closures inside
2354// `Map` / `AndThen` nodes, which the pure-builtin path doesn't have.
2355
2356// -- std.random helpers (#219) ----------------------------------------
2357
2358/// SplitMix64 — single-`u64` state PRNG that is byte-identical
2359/// across platforms (no float math, no platform-dependent reductions).
2360/// Returns `(drawn, next_state)`. Constants are the canonical
2361/// SplitMix64 mixer from the original 2014 paper.
2362fn splitmix64(state: u64) -> (u64, u64) {
2363    let next = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
2364    let mut z = next;
2365    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
2366    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
2367    let z = z ^ (z >> 31);
2368    (z, next)
2369}
2370
2371/// Encode a SplitMix64 state as the user-facing `Rng` value.
2372/// `Rng = { state :: Int }`; the type-checker treats `Rng` as
2373/// opaque so users can't poke at the field.
2374fn rng_value(state: u64) -> Value {
2375    let mut fields = indexmap::IndexMap::new();
2376    fields.insert("state".into(), Value::Int(state as i64));
2377    Value::record_dynamic(fields)
2378}
2379
2380/// Pull the SplitMix64 state out of a `Value::Record { state }`.
2381fn rng_decode(v: Option<&Value>) -> Result<u64, String> {
2382    let rec = match v {
2383        Some(Value::Record { fields: r, .. }) => r,
2384        Some(other) => return Err(format!("expected Rng, got {other:?}")),
2385        None => return Err("missing Rng arg".into()),
2386    };
2387    match rec.get("state") {
2388        Some(Value::Int(n)) => Ok(*n as u64),
2389        _ => Err("malformed Rng: missing `state :: Int`".into()),
2390    }
2391}
2392
2393// -- helpers for `std.http` builders / decoders --
2394
2395fn expect_record_pure(v: Option<&Value>) -> Result<&indexmap::IndexMap<smol_str::SmolStr, Value>, String> {
2396    match v {
2397        Some(Value::Record { fields: r, .. }) => Ok(r),
2398        Some(other) => Err(format!("expected Record, got {other:?}")),
2399        None => Err("missing Record argument".into()),
2400    }
2401}
2402
2403fn http_decode_err_pure(msg: String) -> Value {
2404    let inner = Value::Variant {
2405        name: "DecodeError".into(),
2406        args: vec![Value::Str(msg.into())],
2407    };
2408    err_v(inner)
2409}
2410
2411/// Apply or replace a header in an `HttpRequest` record's `headers`
2412/// field. Header names are normalized to lowercase to match HTTP/1.1
2413/// case-insensitivity; an existing entry under any casing is
2414/// overwritten by the new value.
2415fn http_set_header(
2416    mut req: indexmap::IndexMap<smol_str::SmolStr, Value>,
2417    name: &str,
2418    value: &str,
2419) -> indexmap::IndexMap<smol_str::SmolStr, Value> {
2420    use lex_bytecode::MapKey;
2421    let mut headers = match req.shift_remove("headers") {
2422        Some(Value::Map(m)) => m,
2423        _ => std::collections::BTreeMap::new(),
2424    };
2425    let key = MapKey::Str(name.to_lowercase());
2426    // Drop any case variant of the same header name first so casing
2427    // flips don't accumulate duplicates.
2428    let lowered = name.to_lowercase();
2429    headers.retain(|k, _| match k {
2430        MapKey::Str(s) => s.to_lowercase() != lowered,
2431        _ => true,
2432    });
2433    headers.insert(key, Value::Str(value.into()));
2434    req.insert("headers".into(), Value::Map(headers));
2435    req
2436}
2437
2438/// Append `?k=v&...` (URL-encoded) to the `url` field of an
2439/// `HttpRequest` record. Existing query string is preserved and
2440/// extended with `&`. Iteration order is the input map's natural
2441/// order (`BTreeMap` → sorted by key) so the produced URL is
2442/// deterministic.
2443fn http_append_query(
2444    mut req: indexmap::IndexMap<smol_str::SmolStr, Value>,
2445    params: &std::collections::BTreeMap<lex_bytecode::MapKey, Value>,
2446) -> indexmap::IndexMap<smol_str::SmolStr, Value> {
2447    use lex_bytecode::MapKey;
2448    let url = match req.get("url") {
2449        Some(Value::Str(s)) => s.clone(),
2450        _ => return req,
2451    };
2452    let mut pieces = Vec::new();
2453    for (k, v) in params {
2454        let kk = match k { MapKey::Str(s) => s.to_string(), _ => continue };
2455        let vv = match v { Value::Str(s) => s.to_string(), _ => continue };
2456        pieces.push(format!("{}={}", url_encode(&kk), url_encode(&vv)));
2457    }
2458    if pieces.is_empty() { return req; }
2459    let sep = if url.contains('?') { '&' } else { '?' };
2460    let new_url = format!("{url}{sep}{}", pieces.join("&"));
2461    req.insert("url".into(), Value::Str(new_url.into()));
2462    req
2463}
2464
2465/// Minimal RFC-3986 percent-encode for `application/x-www-form-
2466/// urlencoded` query values. Pulling in `urlencoding` for one
2467/// callsite would drag a dep into the runtime; the inline version is
2468/// short and easy to audit.
2469fn url_encode(s: &str) -> String {
2470    let mut out = String::with_capacity(s.len());
2471    for b in s.bytes() {
2472        match b {
2473            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
2474                out.push(b as char);
2475            }
2476            _ => out.push_str(&format!("%{:02X}", b)),
2477        }
2478    }
2479    out
2480}
2481
2482fn value_to_json(v: &Value) -> serde_json::Value { v.to_json() }
2483
2484/// The `toml` crate's serde adapter wraps datetimes in a sentinel
2485/// object `{"$__toml_private_datetime": "<rfc3339>"}` so that the
2486/// `Datetime` type round-trips through `serde::Value`. For Lex's
2487/// purposes a plain RFC-3339 string is what we want — callers can
2488/// then pipe through `datetime.parse_iso` if they need an
2489/// `Instant`. Walk the tree and replace each wrapper with its
2490/// inner string, in-place.
2491fn unwrap_toml_datetime_markers(v: &mut serde_json::Value) {
2492    use serde_json::Value as J;
2493    match v {
2494        J::Object(map) => {
2495            // Detect single-key marker objects and replace them
2496            // with their inner string. We have to take care to
2497            // avoid borrow conflicts.
2498            if map.len() == 1 {
2499                if let Some(J::String(s)) = map.get("$__toml_private_datetime") {
2500                    let s = s.clone();
2501                    *v = J::String(s);
2502                    return;
2503                }
2504            }
2505            for (_, child) in map.iter_mut() {
2506                unwrap_toml_datetime_markers(child);
2507            }
2508        }
2509        J::Array(items) => {
2510            for item in items.iter_mut() {
2511                unwrap_toml_datetime_markers(item);
2512            }
2513        }
2514        _ => {}
2515    }
2516}
2517
2518fn json_to_value(v: &serde_json::Value) -> Value { Value::from_json(v) }
2519
2520/// Extract the `List[Str]` of required field names from the second
2521/// argument of `*.parse_strict`. The list is allowed to be empty
2522/// (the parse degenerates to plain `parse`); other shapes are a
2523/// caller bug rather than a parse error.
2524fn required_field_names(arg: Option<&Value>) -> Result<Vec<String>, String> {
2525    let list = expect_list(arg)?;
2526    let mut out = Vec::with_capacity(list.len());
2527    for v in list {
2528        match v {
2529            Value::Str(s) => out.push(s.to_string()),
2530            other => return Err(format!(
2531                "parse_strict: required-fields list must contain Str, got {other:?}"
2532            )),
2533        }
2534    }
2535    Ok(out)
2536}
2537
2538/// Verify that `value` is an object containing every entry in
2539/// `required`. A required entry may be a plain field name (must
2540/// exist at the top level) or a dotted path (`"project.license"`)
2541/// which descends through nested objects. Returns a stable,
2542/// human-readable error listing every missing path so the agent's
2543/// verifier can surface it directly.
2544///
2545/// Tactical fix for #168 — gives users a way to make `parse[T]`
2546/// errors propagate as `Result::Err` instead of as runtime
2547/// `GetField` errors at access time. The full type-driven fix
2548/// (deriving `required` from `T` at type-check time so plain
2549/// `parse[T]` works, including auto-wrapping `Option[F]` fields
2550/// as not-required) is the cleaner endgame; see #168.
2551///
2552/// Path semantics:
2553/// * `"name"` → top-level `name` must be present (any value).
2554/// * `"a.b.c"` → walk `a`, then `b`, then check `c` exists. Each
2555///   intermediate value must itself be an object.
2556/// * `\\.` is the literal-dot escape (e.g. `"weird\\.key"` for a
2557///   field that genuinely contains a dot in its name).
2558fn check_required_fields(
2559    value: &serde_json::Value,
2560    required: &[String],
2561) -> Result<(), String> {
2562    if required.is_empty() {
2563        return Ok(());
2564    }
2565    if !matches!(value, serde_json::Value::Object(_)) {
2566        return Err(format!(
2567            "parse_strict: expected top-level object with fields {:?}, got {value}",
2568            required
2569        ));
2570    }
2571    let mut missing: Vec<String> = Vec::new();
2572    for path in required {
2573        if !path_exists(value, path) {
2574            missing.push(path.clone());
2575        }
2576    }
2577    if missing.is_empty() {
2578        Ok(())
2579    } else {
2580        Err(format!("missing required field(s): {}", missing.join(", ")))
2581    }
2582}
2583
2584/// Walk `value` along the dotted `path` and report whether the
2585/// terminal segment exists. Intermediate non-object stops surface
2586/// as "missing" — a path can't traverse through a string, list, or
2587/// scalar.
2588fn path_exists(value: &serde_json::Value, path: &str) -> bool {
2589    let mut cursor = value;
2590    let segments = split_dotted_path(path);
2591    for seg in &segments {
2592        match cursor {
2593            serde_json::Value::Object(o) => match o.get(seg.as_str()) {
2594                Some(next) => cursor = next,
2595                None => return false,
2596            },
2597            _ => return false,
2598        }
2599    }
2600    true
2601}
2602
2603/// Split `"a.b.c"` into `["a", "b", "c"]`, with `\.` recognised
2604/// as a literal-dot escape so legitimate dotted field names
2605/// (e.g. `"package\.json"`) don't accidentally start a descent.
2606fn split_dotted_path(path: &str) -> Vec<String> {
2607    let mut out: Vec<String> = Vec::new();
2608    let mut cur = String::new();
2609    let mut iter = path.chars().peekable();
2610    while let Some(c) = iter.next() {
2611        if c == '\\' {
2612            // Backslash at end is preserved; only `\.` is special.
2613            if let Some(&'.') = iter.peek() {
2614                cur.push('.');
2615                iter.next();
2616                continue;
2617            }
2618            cur.push(c);
2619        } else if c == '.' {
2620            out.push(std::mem::take(&mut cur));
2621        } else {
2622            cur.push(c);
2623        }
2624    }
2625    out.push(cur);
2626    out
2627}
2628
2629/// Extract the `List[(Str, Str)]` type schema from the third argument
2630/// of `*.parse_strict` (#322). If the argument is absent or malformed,
2631/// returns an empty vec — callers treat that as "skip type validation".
2632fn extract_type_schema(v: Option<&Value>) -> Vec<(String, String)> {
2633    match v {
2634        Some(Value::List(pairs)) => pairs.iter().filter_map(|p| {
2635            if let Value::Tuple(items) = p {
2636                if items.len() == 2 {
2637                    if let (Value::Str(name), Value::Str(tag)) = (&items[0], &items[1]) {
2638                        return Some((name.to_string(), tag.to_string()));
2639                    }
2640                }
2641            }
2642            None
2643        }).collect(),
2644        _ => vec![],
2645    }
2646}
2647
2648/// Validate each field in `json` against its declared type tag from
2649/// the schema. Returns `Err` for the first field whose JSON value
2650/// doesn't match its tag. Fields not present in the JSON object are
2651/// silently skipped (presence is enforced separately by
2652/// `check_required_fields`).
2653fn validate_field_types(
2654    json: &serde_json::Value,
2655    schema: &[(String, String)],
2656) -> Result<(), String> {
2657    if schema.is_empty() {
2658        return Ok(());
2659    }
2660    let obj = match json.as_object() {
2661        Some(o) => o,
2662        None => return Ok(()), // not an object — let other validation handle it
2663    };
2664    for (field, tag) in schema {
2665        if let Some(val) = obj.get(field) {
2666            if let Err(e) = check_json_type(val, tag) {
2667                return Err(format!("field `{field}`: {e}"));
2668            }
2669        }
2670    }
2671    Ok(())
2672}
2673
2674/// Post-process a Record produced by `json_to_value` to correctly wrap
2675/// `Option[X]` fields. `json_to_value` is schema-blind: it converts JSON null
2676/// to `Value::Unit` and never wraps non-null values in `some(...)`. This pass
2677/// fixes that for every field declared as `Option[X]` in the type schema.
2678fn apply_option_wrapping(v: Value, json: &serde_json::Value, schema: &[(String, String)]) -> Value {
2679    if schema.is_empty() {
2680        return v;
2681    }
2682    let fields = match v {
2683        Value::Record { fields, .. } => *fields,
2684        other => return other,
2685    };
2686    let json_obj = match json.as_object() {
2687        Some(o) => o,
2688        None => return Value::record_interned(fields),
2689    };
2690    let mut new_fields = fields;
2691    for (field_name, tag) in schema {
2692        if tag.starts_with("Option[") && tag.ends_with(']') {
2693            let json_val = json_obj.get(field_name.as_str());
2694            let wrapped = match json_val {
2695                None | Some(serde_json::Value::Null) => none(),
2696                Some(_) => {
2697                    let inner = new_fields
2698                        .get(field_name.as_str())
2699                        .cloned()
2700                        .unwrap_or(Value::Unit);
2701                    some(inner)
2702                }
2703            };
2704            new_fields.insert(smol_str::SmolStr::from(field_name.as_str()), wrapped);
2705        }
2706    }
2707    Value::record_interned(new_fields)
2708}
2709
2710/// Recursively check that `val` conforms to the compact type `tag`.
2711fn check_json_type(val: &serde_json::Value, tag: &str) -> Result<(), String> {
2712    use serde_json::Value as J;
2713    match (tag, val) {
2714        ("Int", J::Number(n)) if n.is_i64() || n.is_u64() => Ok(()),
2715        ("Int", other) => Err(format!("expected Int, got {}", json_type_name(other))),
2716        ("Float", J::Number(_)) => Ok(()),
2717        ("Float", other) => Err(format!("expected Float, got {}", json_type_name(other))),
2718        ("Bool", J::Bool(_)) => Ok(()),
2719        ("Bool", other) => Err(format!("expected Bool, got {}", json_type_name(other))),
2720        ("Str", J::String(_)) => Ok(()),
2721        ("Str", other) => Err(format!("expected Str, got {}", json_type_name(other))),
2722        // Option[X]: null maps to None — any null is acceptable
2723        (tag, J::Null) if tag.starts_with("Option[") => Ok(()),
2724        (tag, val) if tag.starts_with("Option[") && tag.ends_with(']') => {
2725            let inner = &tag[7..tag.len() - 1]; // strip "Option[" and "]"
2726            check_json_type(val, inner)
2727        }
2728        // List[X]: validate each element
2729        (tag, J::Array(items)) if tag.starts_with("List[") && tag.ends_with(']') => {
2730            let inner = &tag[5..tag.len() - 1]; // strip "List[" and "]"
2731            for (i, item) in items.iter().enumerate() {
2732                if let Err(e) = check_json_type(item, inner) {
2733                    return Err(format!("[{i}]: {e}"));
2734                }
2735            }
2736            Ok(())
2737        }
2738        ("Record", _) => Ok(()), // opaque nested record — skip deep check
2739        ("Any", _) => Ok(()),    // unknown type — skip
2740        _ => Ok(()),             // unrecognized tag — skip
2741    }
2742}
2743
2744fn json_type_name(v: &serde_json::Value) -> &'static str {
2745    match v {
2746        serde_json::Value::Null => "null",
2747        serde_json::Value::Bool(_) => "Bool",
2748        serde_json::Value::Number(_) => "Number",
2749        serde_json::Value::String(_) => "Str",
2750        serde_json::Value::Array(_) => "Array",
2751        serde_json::Value::Object(_) => "Object",
2752    }
2753}
2754
2755/// Parse a `.env`-style file into key→value pairs. Accepts:
2756///
2757/// * Blank lines and `# comment` lines (ignored).
2758/// * `KEY=VALUE` with no spaces around `=`. Optional surrounding
2759///   `"..."` or `'...'` quotes on the value. No escape sequences,
2760///   no shell expansion — by design; we want this to be a *data*
2761///   parser, not a shell snippet evaluator.
2762///
2763/// Errors carry the offending line number (1-indexed) so the
2764/// agent's verifier can point a human at the right place.
2765fn parse_dotenv(src: &str) -> Result<indexmap::IndexMap<String, String>, String> {
2766    let mut out = indexmap::IndexMap::new();
2767    for (idx, raw) in src.lines().enumerate() {
2768        let line = raw.trim();
2769        if line.is_empty() || line.starts_with('#') {
2770            continue;
2771        }
2772        // Optional `export KEY=VALUE` shell form — accepted for
2773        // compat with files that grew out of `set -a` workflows.
2774        let after_export = line.strip_prefix("export ").unwrap_or(line);
2775        let (k, v) = match after_export.split_once('=') {
2776            Some(kv) => kv,
2777            None => return Err(format!("dotenv.parse line {}: missing `=`", idx + 1)),
2778        };
2779        let key = k.trim();
2780        if key.is_empty() {
2781            return Err(format!("dotenv.parse line {}: empty key", idx + 1));
2782        }
2783        let v_trim = v.trim();
2784        let value = if let Some(q) = v_trim.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
2785            q.to_string()
2786        } else if let Some(q) = v_trim.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) {
2787            q.to_string()
2788        } else {
2789            v_trim.to_string()
2790        };
2791        out.insert(key.to_string(), value);
2792    }
2793    Ok(out)
2794}
2795
2796// -- datetime helpers (Instant ↔ chrono::DateTime<Utc>) --
2797
2798/// Convert a `chrono::DateTime` (any `TimeZone`) into a Lex `Instant`,
2799/// represented as nanoseconds since the UTC unix epoch. Saturates on
2800/// out-of-range timestamps so the runtime never panics.
2801fn instant_from_chrono<Tz: chrono::TimeZone>(dt: chrono::DateTime<Tz>) -> i64 {
2802    dt.timestamp_nanos_opt().unwrap_or(i64::MAX)
2803}
2804
2805fn chrono_from_instant(n: i64) -> chrono::DateTime<chrono::Utc> {
2806    let secs = n.div_euclid(1_000_000_000);
2807    let nanos = n.rem_euclid(1_000_000_000) as u32;
2808    use chrono::TimeZone;
2809    chrono::Utc
2810        .timestamp_opt(secs, nanos)
2811        .single()
2812        .unwrap_or_else(chrono::Utc::now)
2813}
2814
2815fn format_iso(n: i64) -> String {
2816    chrono_from_instant(n).to_rfc3339()
2817}
2818
2819/// Parsed form of the user-side `Tz` variant. Mirrors the type
2820/// registered in `TypeEnv::new_with_builtins`.
2821enum TzArg {
2822    Utc,
2823    Local,
2824    /// Fixed offset in minutes east of UTC.
2825    Offset(i32),
2826    /// IANA name like `"America/New_York"`.
2827    Iana(String),
2828}
2829
2830fn parse_tz_arg(v: Option<&Value>) -> Result<TzArg, String> {
2831    match v {
2832        Some(Value::Variant { name, args }) => match (name.as_str(), args.as_slice()) {
2833            ("Utc", []) => Ok(TzArg::Utc),
2834            ("Local", []) => Ok(TzArg::Local),
2835            ("Offset", [Value::Int(m)]) => {
2836                let m = i32::try_from(*m).map_err(|_| {
2837                    format!("Tz::Offset: minutes out of range: {m}")
2838                })?;
2839                Ok(TzArg::Offset(m))
2840            }
2841            ("Iana", [Value::Str(s)]) => Ok(TzArg::Iana(s.to_string())),
2842            (other, _) => Err(format!(
2843                "expected Tz variant (Utc | Local | Offset(Int) | Iana(Str)), got `{other}` with {} arg(s)",
2844                args.len()
2845            )),
2846        },
2847        Some(other) => Err(format!("expected Tz variant, got {other:?}")),
2848        None => Err("missing Tz argument".into()),
2849    }
2850}
2851
2852fn resolve_tz_to_components(n: i64, tz: &TzArg) -> Result<Value, String> {
2853    use chrono::{TimeZone, Datelike, Timelike, Offset};
2854    let utc_dt = chrono_from_instant(n);
2855    let (y, m, d, hh, mm, ss, ns, off_min) = match tz {
2856        TzArg::Utc => {
2857            let d = utc_dt;
2858            (d.year(), d.month() as i32, d.day() as i32,
2859             d.hour() as i32, d.minute() as i32, d.second() as i32,
2860             d.nanosecond() as i32, 0)
2861        }
2862        TzArg::Local => {
2863            let d = utc_dt.with_timezone(&chrono::Local);
2864            let off = d.offset().fix().local_minus_utc() / 60;
2865            (d.year(), d.month() as i32, d.day() as i32,
2866             d.hour() as i32, d.minute() as i32, d.second() as i32,
2867             d.nanosecond() as i32, off)
2868        }
2869        TzArg::Offset(off_min) => {
2870            let off_secs = off_min.saturating_mul(60);
2871            let fixed = chrono::FixedOffset::east_opt(off_secs)
2872                .ok_or("to_components: offset out of range")?;
2873            let d = utc_dt.with_timezone(&fixed);
2874            (d.year(), d.month() as i32, d.day() as i32,
2875             d.hour() as i32, d.minute() as i32, d.second() as i32,
2876             d.nanosecond() as i32, *off_min)
2877        }
2878        TzArg::Iana(name) => {
2879            let tz: chrono_tz::Tz = name.parse()
2880                .map_err(|e| format!("to_components: unknown timezone `{name}`: {e}"))?;
2881            let d = utc_dt.with_timezone(&tz);
2882            let off = d.offset().fix().local_minus_utc() / 60;
2883            (d.year(), d.month() as i32, d.day() as i32,
2884             d.hour() as i32, d.minute() as i32, d.second() as i32,
2885             d.nanosecond() as i32, off)
2886        }
2887    };
2888    let mut rec = indexmap::IndexMap::new();
2889    rec.insert("year".into(),    Value::Int(y as i64));
2890    rec.insert("month".into(),   Value::Int(m as i64));
2891    rec.insert("day".into(),     Value::Int(d as i64));
2892    rec.insert("hour".into(),    Value::Int(hh as i64));
2893    rec.insert("minute".into(),  Value::Int(mm as i64));
2894    rec.insert("second".into(),  Value::Int(ss as i64));
2895    rec.insert("nano".into(),    Value::Int(ns as i64));
2896    rec.insert("tz_offset_minutes".into(), Value::Int(off_min as i64));
2897    let _ = chrono::Utc.timestamp_opt(0, 0); // touch TimeZone to suppress unused-import lint paths
2898    Ok(Value::record_dynamic(rec))
2899}
2900
2901
2902fn instant_from_components(rec: &indexmap::IndexMap<smol_str::SmolStr, Value>) -> Result<i64, String> {
2903    use chrono::TimeZone;
2904    fn get_int(rec: &indexmap::IndexMap<smol_str::SmolStr, Value>, k: &str) -> Result<i64, String> {
2905        match rec.get(k) {
2906            Some(Value::Int(n)) => Ok(*n),
2907            other => Err(format!("from_components: missing or non-int field `{k}`: {other:?}")),
2908        }
2909    }
2910    let y = get_int(rec, "year")? as i32;
2911    let m = get_int(rec, "month")? as u32;
2912    let d = get_int(rec, "day")? as u32;
2913    let hh = get_int(rec, "hour")? as u32;
2914    let mm = get_int(rec, "minute")? as u32;
2915    let ss = get_int(rec, "second")? as u32;
2916    let ns = get_int(rec, "nano")? as u32;
2917    let off_min = get_int(rec, "tz_offset_minutes")? as i32;
2918    let off = chrono::FixedOffset::east_opt(off_min * 60)
2919        .ok_or("from_components: offset out of range")?;
2920    let dt = off
2921        .with_ymd_and_hms(y, m, d, hh, mm, ss)
2922        .single()
2923        .ok_or("from_components: invalid or ambiguous date/time")?;
2924    let dt = dt + chrono::Duration::nanoseconds(ns as i64);
2925    Ok(instant_from_chrono(dt))
2926}
2927
2928// ── AEAD helpers (#382 AEAD slice) ────────────────────────────────────
2929//
2930// Each `*_seal_impl` returns a `Result[AeadResult, Str]` Lex Variant:
2931// `Ok(AeadResult { ciphertext, tag })` on success, `Err(msg)` on input
2932// validation failure (wrong key/nonce length). Each `*_open_impl`
2933// returns `Result[Bytes, Str]` — authentication failure (bad tag /
2934// modified ciphertext) surfaces as `Err`, not a panic.
2935//
2936// Pure ops: every output is a deterministic function of the inputs;
2937// no syscalls, no clock reads, no entropy. Live in the pure-builtin
2938// dispatch table so callers don't need an effect grant beyond
2939// whatever they used to obtain key + nonce in the first place.
2940
2941/// `(key, nonce, aad, plaintext)` references unpacked from a 4-arg
2942/// AEAD seal call. Aliased so the `type_complexity` clippy lint stays
2943/// quiet on the tuple of four borrows.
2944type Aead4<'a> = (&'a Vec<u8>, &'a Vec<u8>, &'a Vec<u8>, &'a Vec<u8>);
2945
2946/// `(key, nonce, aad, ciphertext, tag)` references unpacked from a
2947/// 5-arg AEAD open call.
2948type Aead5<'a> = (&'a Vec<u8>, &'a Vec<u8>, &'a Vec<u8>, &'a Vec<u8>, &'a Vec<u8>);
2949
2950fn unpack4_bytes<'a>(
2951    args: &'a [Value],
2952    op: &str,
2953) -> Result<Aead4<'a>, String> {
2954    let pick = |i: usize, name: &str| -> Result<&'a Vec<u8>, String> {
2955        match args.get(i) {
2956            Some(Value::Bytes(b)) => Ok(b),
2957            Some(other) => Err(format!("{op}: {name} must be Bytes, got {other:?}")),
2958            None => Err(format!("{op}: missing {name} argument")),
2959        }
2960    };
2961    Ok((pick(0, "key")?, pick(1, "nonce")?, pick(2, "aad")?, pick(3, "plaintext")?))
2962}
2963
2964fn unpack5_bytes<'a>(
2965    args: &'a [Value],
2966    op: &str,
2967) -> Result<Aead5<'a>, String> {
2968    let pick = |i: usize, name: &str| -> Result<&'a Vec<u8>, String> {
2969        match args.get(i) {
2970            Some(Value::Bytes(b)) => Ok(b),
2971            Some(other) => Err(format!("{op}: {name} must be Bytes, got {other:?}")),
2972            None => Err(format!("{op}: missing {name} argument")),
2973        }
2974    };
2975    Ok((
2976        pick(0, "key")?,
2977        pick(1, "nonce")?,
2978        pick(2, "aad")?,
2979        pick(3, "ciphertext")?,
2980        pick(4, "tag")?,
2981    ))
2982}
2983
2984fn aead_result(ciphertext: Vec<u8>, tag: Vec<u8>) -> Value {
2985    let mut rec = indexmap::IndexMap::new();
2986    rec.insert("ciphertext".into(), Value::Bytes(ciphertext));
2987    rec.insert("tag".into(), Value::Bytes(tag));
2988    Value::record_dynamic(rec)
2989}
2990
2991fn aead_err(msg: impl Into<String>) -> Value {
2992    let s: String = msg.into();
2993    err_v(Value::Str(s.into()))
2994}
2995
2996fn aes_gcm_seal_impl(args: &[Value]) -> Value {
2997    use aes_gcm::aead::{Aead, KeyInit, Payload};
2998    use aes_gcm::{Aes128Gcm, Aes256Gcm, Nonce};
2999    let (key, nonce, aad, plaintext) = match unpack4_bytes(args, "aes_gcm_seal") {
3000        Ok(t) => t,
3001        Err(e) => return aead_err(e),
3002    };
3003    if nonce.len() != 12 {
3004        return aead_err(format!(
3005            "aes_gcm_seal: nonce must be exactly 12 bytes, got {}", nonce.len()
3006        ));
3007    }
3008    let n = Nonce::from_slice(nonce);
3009    let payload = Payload { msg: plaintext, aad };
3010    // Encrypts and appends the 16-byte tag. We split the tag back out so
3011    // the caller sees the structured AeadResult shape.
3012    let combined = match key.len() {
3013        16 => {
3014            let cipher = Aes128Gcm::new_from_slice(key)
3015                .map_err(|e| e.to_string());
3016            match cipher {
3017                Ok(c) => c.encrypt(n, payload).map_err(|e| format!("aes_gcm_seal: {e}")),
3018                Err(e) => Err(format!("aes_gcm_seal: {e}")),
3019            }
3020        }
3021        32 => {
3022            let cipher = Aes256Gcm::new_from_slice(key)
3023                .map_err(|e| e.to_string());
3024            match cipher {
3025                Ok(c) => c.encrypt(n, payload).map_err(|e| format!("aes_gcm_seal: {e}")),
3026                Err(e) => Err(format!("aes_gcm_seal: {e}")),
3027            }
3028        }
3029        // AES-192 is rarely used; the aes-gcm crate doesn't expose
3030        // Aes192Gcm in its default API. Reject other sizes explicitly.
3031        other => return aead_err(format!(
3032            "aes_gcm_seal: key must be 16 or 32 bytes, got {other}"
3033        )),
3034    };
3035    match combined {
3036        Ok(mut buf) => {
3037            // tag is the last 16 bytes.
3038            let tag_start = buf.len() - 16;
3039            let tag = buf.split_off(tag_start);
3040            ok_v(aead_result(buf, tag))
3041        }
3042        Err(e) => aead_err(e),
3043    }
3044}
3045
3046fn aes_gcm_open_impl(args: &[Value]) -> Value {
3047    use aes_gcm::aead::{Aead, KeyInit, Payload};
3048    use aes_gcm::{Aes128Gcm, Aes256Gcm, Nonce};
3049    let (key, nonce, aad, ciphertext, tag) = match unpack5_bytes(args, "aes_gcm_open") {
3050        Ok(t) => t,
3051        Err(e) => return err_v(Value::Str(e.into())),
3052    };
3053    if nonce.len() != 12 {
3054        return err_v(Value::Str(format!(
3055            "aes_gcm_open: nonce must be exactly 12 bytes, got {}", nonce.len()
3056        ).into()));
3057    }
3058    if tag.len() != 16 {
3059        return err_v(Value::Str(format!(
3060            "aes_gcm_open: tag must be exactly 16 bytes, got {}", tag.len()
3061        ).into()));
3062    }
3063    // Rebuild the "ciphertext || tag" buffer the aes-gcm crate expects.
3064    let mut combined = Vec::with_capacity(ciphertext.len() + tag.len());
3065    combined.extend_from_slice(ciphertext);
3066    combined.extend_from_slice(tag);
3067    let n = Nonce::from_slice(nonce);
3068    let payload = Payload { msg: &combined, aad };
3069    let plaintext = match key.len() {
3070        16 => Aes128Gcm::new_from_slice(key)
3071            .map_err(|e| format!("aes_gcm_open: {e}"))
3072            .and_then(|c| c.decrypt(n, payload).map_err(|e| format!("aes_gcm_open: {e}"))),
3073        32 => Aes256Gcm::new_from_slice(key)
3074            .map_err(|e| format!("aes_gcm_open: {e}"))
3075            .and_then(|c| c.decrypt(n, payload).map_err(|e| format!("aes_gcm_open: {e}"))),
3076        other => return err_v(Value::Str(format!(
3077            "aes_gcm_open: key must be 16 or 32 bytes, got {other}"
3078        ).into())),
3079    };
3080    match plaintext {
3081        Ok(p) => ok_v(Value::Bytes(p)),
3082        Err(e) => err_v(Value::Str(e.into())),
3083    }
3084}
3085
3086fn chacha20_seal_impl(args: &[Value]) -> Value {
3087    use chacha20poly1305::aead::{Aead, KeyInit, Payload};
3088    use chacha20poly1305::{ChaCha20Poly1305, Nonce};
3089    let (key, nonce, aad, plaintext) = match unpack4_bytes(args, "chacha20_poly1305_seal") {
3090        Ok(t) => t,
3091        Err(e) => return aead_err(e),
3092    };
3093    if key.len() != 32 {
3094        return aead_err(format!(
3095            "chacha20_poly1305_seal: key must be exactly 32 bytes, got {}", key.len()
3096        ));
3097    }
3098    if nonce.len() != 12 {
3099        return aead_err(format!(
3100            "chacha20_poly1305_seal: nonce must be exactly 12 bytes, got {}", nonce.len()
3101        ));
3102    }
3103    let cipher = ChaCha20Poly1305::new_from_slice(key)
3104        .map_err(|e| format!("chacha20_poly1305_seal: {e}"));
3105    let n = Nonce::from_slice(nonce);
3106    let payload = Payload { msg: plaintext, aad };
3107    let combined = match cipher {
3108        Ok(c) => c.encrypt(n, payload).map_err(|e| format!("chacha20_poly1305_seal: {e}")),
3109        Err(e) => Err(e),
3110    };
3111    match combined {
3112        Ok(mut buf) => {
3113            let tag_start = buf.len() - 16;
3114            let tag = buf.split_off(tag_start);
3115            ok_v(aead_result(buf, tag))
3116        }
3117        Err(e) => aead_err(e),
3118    }
3119}
3120
3121fn chacha20_open_impl(args: &[Value]) -> Value {
3122    use chacha20poly1305::aead::{Aead, KeyInit, Payload};
3123    use chacha20poly1305::{ChaCha20Poly1305, Nonce};
3124    let (key, nonce, aad, ciphertext, tag) = match unpack5_bytes(args, "chacha20_poly1305_open") {
3125        Ok(t) => t,
3126        Err(e) => return err_v(Value::Str(e.into())),
3127    };
3128    if key.len() != 32 {
3129        return err_v(Value::Str(format!(
3130            "chacha20_poly1305_open: key must be exactly 32 bytes, got {}", key.len()
3131        ).into()));
3132    }
3133    if nonce.len() != 12 {
3134        return err_v(Value::Str(format!(
3135            "chacha20_poly1305_open: nonce must be exactly 12 bytes, got {}", nonce.len()
3136        ).into()));
3137    }
3138    if tag.len() != 16 {
3139        return err_v(Value::Str(format!(
3140            "chacha20_poly1305_open: tag must be exactly 16 bytes, got {}", tag.len()
3141        ).into()));
3142    }
3143    let mut combined = Vec::with_capacity(ciphertext.len() + tag.len());
3144    combined.extend_from_slice(ciphertext);
3145    combined.extend_from_slice(tag);
3146    let cipher = ChaCha20Poly1305::new_from_slice(key)
3147        .map_err(|e| format!("chacha20_poly1305_open: {e}"));
3148    let n = Nonce::from_slice(nonce);
3149    let payload = Payload { msg: &combined, aad };
3150    match cipher.and_then(|c| c.decrypt(n, payload).map_err(|e| format!("chacha20_poly1305_open: {e}"))) {
3151        Ok(p) => ok_v(Value::Bytes(p)),
3152        Err(e) => err_v(Value::Str(e.into())),
3153    }
3154}
3155
3156// ── KDFs (#382 KDF slice) ──────────────────────────────────────────────────
3157//
3158// All three primitives return Result[Bytes, Str] so caller-controlled
3159// inputs (iteration count, output length, argon2id work factors) that
3160// violate the underlying crate's contract surface as Err, never as a
3161// VM panic.
3162
3163/// `(password :: Bytes, salt :: Bytes, iterations :: Int, len :: Int)`
3164/// references unpacked from a 4-arg KDF call.
3165type Kdf4<'a> = (&'a Vec<u8>, &'a Vec<u8>, i64, i64);
3166
3167/// `(ikm :: Bytes, salt :: Bytes, info :: Bytes, len :: Int)`
3168/// references unpacked from a 4-arg HKDF call.
3169type Hkdf4<'a> = (&'a Vec<u8>, &'a Vec<u8>, &'a Vec<u8>, i64);
3170
3171/// `(password :: Bytes, salt :: Bytes, t_cost :: Int, m_cost :: Int, len :: Int)`
3172/// for argon2id.
3173type Argon5<'a> = (&'a Vec<u8>, &'a Vec<u8>, i64, i64, i64);
3174
3175fn pick_bytes<'a>(args: &'a [Value], i: usize, op: &str, name: &str)
3176    -> Result<&'a Vec<u8>, String>
3177{
3178    match args.get(i) {
3179        Some(Value::Bytes(b)) => Ok(b),
3180        Some(other) => Err(format!("{op}: {name} must be Bytes, got {other:?}")),
3181        None => Err(format!("{op}: missing {name} argument")),
3182    }
3183}
3184
3185fn pick_int(args: &[Value], i: usize, op: &str, name: &str) -> Result<i64, String> {
3186    match args.get(i) {
3187        Some(Value::Int(n)) => Ok(*n),
3188        Some(other) => Err(format!("{op}: {name} must be Int, got {other:?}")),
3189        None => Err(format!("{op}: missing {name} argument")),
3190    }
3191}
3192
3193fn unpack_kdf4<'a>(args: &'a [Value], op: &str) -> Result<Kdf4<'a>, String> {
3194    Ok((
3195        pick_bytes(args, 0, op, "password")?,
3196        pick_bytes(args, 1, op, "salt")?,
3197        pick_int(args, 2, op, "iterations")?,
3198        pick_int(args, 3, op, "len")?,
3199    ))
3200}
3201
3202fn unpack_hkdf4<'a>(args: &'a [Value], op: &str) -> Result<Hkdf4<'a>, String> {
3203    Ok((
3204        pick_bytes(args, 0, op, "ikm")?,
3205        pick_bytes(args, 1, op, "salt")?,
3206        pick_bytes(args, 2, op, "info")?,
3207        pick_int(args, 3, op, "len")?,
3208    ))
3209}
3210
3211fn unpack_argon5<'a>(args: &'a [Value], op: &str) -> Result<Argon5<'a>, String> {
3212    Ok((
3213        pick_bytes(args, 0, op, "password")?,
3214        pick_bytes(args, 1, op, "salt")?,
3215        pick_int(args, 2, op, "t_cost")?,
3216        pick_int(args, 3, op, "m_cost")?,
3217        pick_int(args, 4, op, "len")?,
3218    ))
3219}
3220
3221/// Output-length sanity check shared by all three KDFs. A negative or
3222/// absurdly large `len` is a programmer error, not a runtime concern;
3223/// we cap at 1 MiB to keep accidental `i64::MAX` calls from OOMing the
3224/// process.
3225const KDF_MAX_LEN: usize = 1024 * 1024;
3226
3227fn check_len(op: &str, len: i64) -> Result<usize, String> {
3228    if len <= 0 {
3229        return Err(format!("{op}: len must be > 0, got {len}"));
3230    }
3231    if (len as u64) > KDF_MAX_LEN as u64 {
3232        return Err(format!(
3233            "{op}: len must be <= {KDF_MAX_LEN}, got {len}"
3234        ));
3235    }
3236    Ok(len as usize)
3237}
3238
3239fn pbkdf2_sha256_impl(args: &[Value]) -> Value {
3240    use hmac::Hmac;
3241    use sha2::Sha256;
3242    let op = "pbkdf2_sha256";
3243    let (password, salt, iterations, len) = match unpack_kdf4(args, op) {
3244        Ok(t) => t,
3245        Err(e) => return err_v(Value::Str(e.into())),
3246    };
3247    if iterations <= 0 {
3248        return err_v(Value::Str(format!(
3249            "{op}: iterations must be > 0, got {iterations}"
3250        ).into()));
3251    }
3252    let out_len = match check_len(op, len) {
3253        Ok(n) => n,
3254        Err(e) => return err_v(Value::Str(e.into())),
3255    };
3256    let rounds = match u32::try_from(iterations) {
3257        Ok(r) => r,
3258        Err(_) => {
3259            return err_v(Value::Str(format!(
3260                "{op}: iterations must fit in u32, got {iterations}"
3261            ).into()))
3262        }
3263    };
3264    let mut out = vec![0u8; out_len];
3265    if let Err(e) = pbkdf2::pbkdf2::<Hmac<Sha256>>(password, salt, rounds, &mut out) {
3266        return err_v(Value::Str(format!("{op}: {e}").into()));
3267    }
3268    ok_v(Value::Bytes(out))
3269}
3270
3271fn hkdf_sha256_impl(args: &[Value]) -> Value {
3272    use hkdf::Hkdf;
3273    use sha2::Sha256;
3274    let op = "hkdf_sha256";
3275    let (ikm, salt, info, len) = match unpack_hkdf4(args, op) {
3276        Ok(t) => t,
3277        Err(e) => return err_v(Value::Str(e.into())),
3278    };
3279    let out_len = match check_len(op, len) {
3280        Ok(n) => n,
3281        Err(e) => return err_v(Value::Str(e.into())),
3282    };
3283    // RFC 5869 caps output at 255 * HashLen; the `expand` call below
3284    // returns InvalidLength when exceeded — surface that as Err.
3285    let salt_opt: Option<&[u8]> = if salt.is_empty() { None } else { Some(salt) };
3286    let hk = Hkdf::<Sha256>::new(salt_opt, ikm);
3287    let mut out = vec![0u8; out_len];
3288    match hk.expand(info, &mut out) {
3289        Ok(()) => ok_v(Value::Bytes(out)),
3290        Err(e) => err_v(Value::Str(format!("{op}: {e}").into())),
3291    }
3292}
3293
3294fn argon2id_impl(args: &[Value]) -> Value {
3295    use argon2::{Algorithm, Argon2, Params, Version};
3296    let op = "argon2id";
3297    let (password, salt, t_cost, m_cost, len) = match unpack_argon5(args, op) {
3298        Ok(t) => t,
3299        Err(e) => return err_v(Value::Str(e.into())),
3300    };
3301    let out_len = match check_len(op, len) {
3302        Ok(n) => n,
3303        Err(e) => return err_v(Value::Str(e.into())),
3304    };
3305    let t = match u32::try_from(t_cost) {
3306        Ok(n) if n >= 1 => n,
3307        _ => return err_v(Value::Str(format!(
3308            "{op}: t_cost must be a u32 >= 1, got {t_cost}"
3309        ).into())),
3310    };
3311    let m = match u32::try_from(m_cost) {
3312        Ok(n) if n >= Params::MIN_M_COST => n,
3313        _ => return err_v(Value::Str(format!(
3314            "{op}: m_cost must be a u32 >= {}, got {m_cost}",
3315            Params::MIN_M_COST
3316        ).into())),
3317    };
3318    // p=1 is the default and what every interop spec assumes (PHC
3319    // string, libsodium's argon2id_str). We don't expose parallelism
3320    // as a knob for now to keep callers from picking a value that
3321    // makes hashes uncomparable across machines.
3322    let params = match Params::new(m, t, 1, Some(out_len)) {
3323        Ok(p) => p,
3324        Err(e) => return err_v(Value::Str(format!("{op}: {e}").into())),
3325    };
3326    let hasher = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
3327    let mut out = vec![0u8; out_len];
3328    if let Err(e) = hasher.hash_password_into(password, salt, &mut out) {
3329        return err_v(Value::Str(format!("{op}: {e}").into()));
3330    }
3331    ok_v(Value::Bytes(out))
3332}