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