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