Skip to main content

lex_types/
builtins.rs

1//! Built-in module signatures used by §3.13 examples and beyond.
2//!
3//! These are stub signatures that let the type-checker verify code that
4//! imports `std.io`, `std.str`, `std.list`, etc. They will be backed by
5//! real stages once the stdlib lands (M11).
6
7use crate::env::TypeEnv;
8use crate::types::*;
9use indexmap::IndexMap;
10
11/// Build the value-level scope of a module: a record of named functions.
12pub fn module_scope(name: &str, _env: &TypeEnv) -> Option<Ty> {
13    match name {
14        "io" => {
15            let mut fields = IndexMap::new();
16            // io.print(line :: Str) -> [io] Unit
17            fields.insert("print".into(), Ty::function(
18                vec![Ty::str()],
19                EffectSet::singleton("io"),
20                Ty::Unit,
21            ));
22            // io.read(path :: Str) -> [io] Result[Str, Str]
23            //
24            // DEPRECATED in favour of `fs.read_to_string`, which declares
25            // [fs_read]. This op takes a PATH and is gated by
26            // --allow-fs-read, but declares [io] — documented as
27            // "console / stdio" — so an effect row carrying [io] tells a
28            // reviewer nothing about filesystem reach (#882). Kept working
29            // because ~160 call sites across the fleet use it; re-typing it
30            // in place would change every declared row at once, which is
31            // the failure mode #808 already produced.
32            fields.insert("read".into(), Ty::function(
33                vec![Ty::str()],
34                EffectSet::singleton("io"),
35                Ty::Con("Result".into(), vec![Ty::str(), Ty::str()]),
36            ));
37            // io.write(path :: Str, contents :: Str) -> [io] Result[Unit, Str]
38            // DEPRECATED in favour of `fs.write` ([fs_write]); see io.read.
39            fields.insert("write".into(), Ty::function(
40                vec![Ty::str(), Ty::str()],
41                EffectSet::singleton("io"),
42                Ty::Con("Result".into(), vec![Ty::Unit, Ty::str()]),
43            ));
44            // io.readline() -> [io] Option[Str]
45            fields.insert("readline".into(), Ty::function(
46                vec![],
47                EffectSet::singleton("io"),
48                Ty::Con("Option".into(), vec![Ty::str()]),
49            ));
50            // io.argv() -> [io] List[Str]
51            fields.insert("argv".into(), Ty::function(
52                vec![],
53                EffectSet::singleton("io"),
54                Ty::List(Box::new(Ty::str())),
55            ));
56            Some(Ty::Record(fields))
57        }
58        // Declared in `crate::stdlib_spec` (#778): signatures, docs and
59        // the runtime table share one definition per builtin.
60        "str" | "list" => crate::stdlib_spec::module_record(name),
61        "int" => {
62            let mut fields = IndexMap::new();
63            fields.insert("to_str".into(), Ty::function(vec![Ty::int()], EffectSet::empty(), Ty::str()));
64            fields.insert("to_float".into(), Ty::function(vec![Ty::int()], EffectSet::empty(), Ty::float()));
65            // Int scalar helpers (#681). math.{min,max,abs} are Float-only;
66            // these are the integer equivalents so Int callers don't have to
67            // round-trip through Float (which is lossy for large ints).
68            fields.insert("abs".into(), Ty::function(vec![Ty::int()], EffectSet::empty(), Ty::int()));
69            for name in &["min", "max"] {
70                fields.insert((*name).into(), Ty::function(
71                    vec![Ty::int(), Ty::int()], EffectSet::empty(), Ty::int()));
72            }
73            Some(Ty::Record(fields))
74        }
75        "math" => {
76            let mut fields = IndexMap::new();
77            // Matrix is registered as a built-in type alias in
78            // TypeEnv::new_with_builtins; refer to it nominally so call
79            // sites unify against the user's `:: Matrix` annotations.
80            let mat = || Ty::Con("Matrix".into(), Vec::new());
81            // Scalar floats — single-arg `Float -> Float`.
82            for name in &[
83                "exp", "log", "log2", "log10", "sqrt", "abs",
84                "sin", "cos", "tan", "asin", "acos", "atan",
85                "floor", "ceil", "round", "trunc",
86            ] {
87                fields.insert((*name).into(), Ty::function(
88                    vec![Ty::float()], EffectSet::empty(), Ty::float(),
89                ));
90            }
91            // Two-arg `Float, Float -> Float`.
92            for name in &["pow", "atan2", "min", "max"] {
93                fields.insert((*name).into(), Ty::function(
94                    vec![Ty::float(), Ty::float()], EffectSet::empty(), Ty::float(),
95                ));
96            }
97            // Constructors.
98            fields.insert("zeros".into(), Ty::function(
99                vec![Ty::int(), Ty::int()], EffectSet::empty(), mat(),
100            ));
101            fields.insert("ones".into(), Ty::function(
102                vec![Ty::int(), Ty::int()], EffectSet::empty(), mat(),
103            ));
104            fields.insert("from_lists".into(), Ty::function(
105                vec![Ty::List(Box::new(Ty::List(Box::new(Ty::float()))))],
106                EffectSet::empty(),
107                mat(),
108            ));
109            fields.insert("from_flat".into(), Ty::function(
110                vec![Ty::int(), Ty::int(), Ty::List(Box::new(Ty::float()))],
111                EffectSet::empty(),
112                mat(),
113            ));
114            // Accessors.
115            fields.insert("rows".into(), Ty::function(vec![mat()], EffectSet::empty(), Ty::int()));
116            fields.insert("cols".into(), Ty::function(vec![mat()], EffectSet::empty(), Ty::int()));
117            fields.insert("get".into(), Ty::function(
118                vec![mat(), Ty::int(), Ty::int()], EffectSet::empty(), Ty::float(),
119            ));
120            fields.insert("to_flat".into(), Ty::function(
121                vec![mat()], EffectSet::empty(),
122                Ty::List(Box::new(Ty::float())),
123            ));
124            // Linalg ops.
125            fields.insert("transpose".into(), Ty::function(
126                vec![mat()], EffectSet::empty(), mat(),
127            ));
128            fields.insert("matmul".into(), Ty::function(
129                vec![mat(), mat()], EffectSet::empty(), mat(),
130            ));
131            fields.insert("scale".into(), Ty::function(
132                vec![Ty::float(), mat()], EffectSet::empty(), mat(),
133            ));
134            for name in &["add", "sub"] {
135                fields.insert((*name).into(), Ty::function(
136                    vec![mat(), mat()], EffectSet::empty(), mat(),
137                ));
138            }
139            fields.insert("sigmoid".into(), Ty::function(
140                vec![mat()], EffectSet::empty(), mat(),
141            ));
142            Some(Ty::Record(fields))
143        }
144        "float" => {
145            let mut fields = IndexMap::new();
146            fields.insert("to_int".into(), Ty::function(vec![Ty::float()], EffectSet::empty(), Ty::int()));
147            fields.insert("to_str".into(), Ty::function(vec![Ty::float()], EffectSet::empty(), Ty::str()));
148            Some(Ty::Record(fields))
149        }
150        "bytes" => {
151            let mut fields = IndexMap::new();
152            fields.insert("len".into(), Ty::function(
153                vec![Ty::bytes()], EffectSet::empty(), Ty::int(),
154            ));
155            fields.insert("is_empty".into(), Ty::function(
156                vec![Ty::bytes()], EffectSet::empty(), Ty::bool(),
157            ));
158            fields.insert("eq".into(), Ty::function(
159                vec![Ty::bytes(), Ty::bytes()], EffectSet::empty(), Ty::bool(),
160            ));
161            fields.insert("from_str".into(), Ty::function(
162                vec![Ty::str()], EffectSet::empty(), Ty::bytes(),
163            ));
164            fields.insert("to_str".into(), Ty::function(
165                vec![Ty::bytes()], EffectSet::empty(),
166                Ty::Con("Result".into(), vec![Ty::str(), Ty::str()]),
167            ));
168            fields.insert("slice".into(), Ty::function(
169                vec![Ty::bytes(), Ty::int(), Ty::int()],
170                EffectSet::empty(), Ty::bytes(),
171            ));
172            // concat/concat_all + fixed-width little-endian int encoders and
173            // decoders: the minimal primitive set needed to build/parse a
174            // binary wire format (e.g. a Solana transaction) from Lex without
175            // shelling out to another language's runtime. Round-tripping
176            // through Str (the only prior way to build a Bytes buffer) can't
177            // represent arbitrary byte sequences, so these were a hard
178            // blocker, not a nice-to-have.
179            fields.insert("concat".into(), Ty::function(
180                vec![Ty::bytes(), Ty::bytes()], EffectSet::empty(), Ty::bytes(),
181            ));
182            fields.insert("concat_all".into(), Ty::function(
183                vec![Ty::List(Box::new(Ty::bytes()))], EffectSet::empty(), Ty::bytes(),
184            ));
185            fields.insert("u8".into(), Ty::function(
186                vec![Ty::int()], EffectSet::empty(), Ty::bytes(),
187            ));
188            fields.insert("u16_le".into(), Ty::function(
189                vec![Ty::int()], EffectSet::empty(), Ty::bytes(),
190            ));
191            fields.insert("u32_le".into(), Ty::function(
192                vec![Ty::int()], EffectSet::empty(), Ty::bytes(),
193            ));
194            fields.insert("u64_le".into(), Ty::function(
195                vec![Ty::int()], EffectSet::empty(), Ty::bytes(),
196            ));
197            fields.insert("u8_at".into(), Ty::function(
198                vec![Ty::bytes(), Ty::int()], EffectSet::empty(),
199                Ty::Con("Result".into(), vec![Ty::int(), Ty::str()]),
200            ));
201            fields.insert("u16_le_at".into(), Ty::function(
202                vec![Ty::bytes(), Ty::int()], EffectSet::empty(),
203                Ty::Con("Result".into(), vec![Ty::int(), Ty::str()]),
204            ));
205            fields.insert("u32_le_at".into(), Ty::function(
206                vec![Ty::bytes(), Ty::int()], EffectSet::empty(),
207                Ty::Con("Result".into(), vec![Ty::int(), Ty::str()]),
208            ));
209            fields.insert("u64_le_at".into(), Ty::function(
210                vec![Ty::bytes(), Ty::int()], EffectSet::empty(),
211                Ty::Con("Result".into(), vec![Ty::int(), Ty::str()]),
212            ));
213            Some(Ty::Record(fields))
214        }
215        "time" => {
216            // time.now() -> [time] Int — unix timestamp seconds.
217            // Reading the clock is an effect for two reasons: it's
218            // non-deterministic (replay needs the captured value) and
219            // it's a side-channel surface (see "Capability ≠
220            // correctness" on the landing page).
221            let mut fields = IndexMap::new();
222            fields.insert("now".into(), Ty::function(
223                vec![],
224                EffectSet::singleton("time"),
225                Ty::int(),
226            ));
227            // now_ms :: () -> [time] Int — unix milliseconds (#378).
228            // Resolution beyond what `time.now` (seconds) offers, for
229            // request-latency measurement / rate-limiter windows.
230            // Honors `LEX_TEST_NOW` for deterministic tests.
231            fields.insert("now_ms".into(), Ty::function(
232                vec![],
233                EffectSet::singleton("time"),
234                Ty::int(),
235            ));
236            // now_str :: () -> [time] Str — wall-clock instant rendered
237            // as an ISO-8601 / RFC 3339 string in UTC (#378). Suitable
238            // for auto-managed `created_at` / `updated_at` timestamps
239            // and structured log lines. Honors `LEX_TEST_NOW`.
240            fields.insert("now_str".into(), Ty::function(
241                vec![],
242                EffectSet::singleton("time"),
243                Ty::str(),
244            ));
245            // mono_ns :: () -> [time] Int — monotonic-clock nanoseconds
246            // since process start (#378). Use for *duration*
247            // measurement (`end - start`); the value carries no wall-
248            // clock meaning and the clock can never go backwards
249            // (unlike `time.now_ms` under NTP jitter). Not affected by
250            // `LEX_TEST_NOW` — pinning a monotonic clock would defeat
251            // its purpose; tests that need a fake monotonic clock
252            // should inject one through `EffectHandler`.
253            fields.insert("mono_ns".into(), Ty::function(
254                vec![],
255                EffectSet::singleton("time"),
256                Ty::int(),
257            ));
258            // sleep_ms :: Int -> [time] Unit (#226).
259            // Used internally by flow.retry_with_backoff for
260            // exponential-backoff delays; also available to user
261            // code under `--allow-effects time`.
262            fields.insert("sleep_ms".into(), Ty::function(
263                vec![Ty::int()],
264                EffectSet::singleton("time"),
265                Ty::Unit,
266            ));
267            // sleep :: Duration -> [time] Unit (#445).
268            // Duration-typed sleep — pairs with the
269            // `datetime.duration_seconds` / `duration_minutes` /
270            // `duration_days` constructors so periodic-task code
271            // expresses the period in units of meaning rather than
272            // raw milliseconds. Backed by `std::thread::sleep` at
273            // runtime — blocks the calling thread, which is the right
274            // semantics for the agent-driven workloads this exists
275            // for. Inside a `net.serve` worker the same caveat as
276            // `LEX_NET_INLINE_VM=1` applies (worker is stalled for `d`).
277            fields.insert("sleep".into(), Ty::function(
278                vec![Ty::Con("Duration".into(), vec![])],
279                EffectSet::singleton("time"),
280                Ty::Unit,
281            ));
282            Some(Ty::Record(fields))
283        }
284        "rand" => {
285            // rand.int_in(lo, hi) -> [random] Int — honest uniform draw in
286            // [lo, hi] (inclusive) from the OS RNG (#677). Carries the same
287            // `[random]` effect as `crypto.random` rather than a separate
288            // `rand` effect, so a reviewer auditing `--effect random` sees
289            // every non-deterministic draw in one place. For deterministic,
290            // replayable randomness thread a seed through `std.random`
291            // instead; for cryptographic strength use `crypto.random`.
292            let mut fields = IndexMap::new();
293            fields.insert("int_in".into(), Ty::function(
294                vec![Ty::int(), Ty::int()],
295                EffectSet::singleton("random"),
296                Ty::int(),
297            ));
298            Some(Ty::Record(fields))
299        }
300        "random" => {
301            // #219: pure, seeded RNG. The caller threads the `Rng`
302            // value through computations explicitly — there is no
303            // global state and no effect tag, because the seed is
304            // visible in the program's value flow and replay is
305            // therefore deterministic by construction.
306            //
307            // Backed at runtime by SplitMix64 (deterministic across
308            // platforms, single-u64 state). The proposal mentioned
309            // `rand_chacha` for cryptographic-strength bias, but the
310            // acceptance criterion is just "byte-identical sequence
311            // across platforms," and SplitMix64 satisfies that with
312            // a state shape that fits in `Value::Int` cleanly.
313            let rng_t = || Ty::Con("Rng".into(), vec![]);
314            let mut fields = IndexMap::new();
315            // seed :: Int -> Rng
316            fields.insert("seed".into(), Ty::function(
317                vec![Ty::int()], EffectSet::empty(), rng_t()));
318            // int :: Rng, Int, Int -> (Int, Rng)
319            // Uniform in [lo, hi] inclusive at both ends. Returns
320            // the drawn value and the advanced Rng.
321            fields.insert("int".into(), Ty::function(
322                vec![rng_t(), Ty::int(), Ty::int()],
323                EffectSet::empty(),
324                Ty::Tuple(vec![Ty::int(), rng_t()])));
325            // float :: Rng -> (Float, Rng)
326            // Uniform in [0.0, 1.0).
327            fields.insert("float".into(), Ty::function(
328                vec![rng_t()], EffectSet::empty(),
329                Ty::Tuple(vec![Ty::float(), rng_t()])));
330            // choose :: Rng, List[T] -> Option[(T, Rng)]
331            // Returns None if the list is empty.
332            fields.insert("choose".into(), Ty::function(
333                vec![rng_t(), Ty::List(Box::new(Ty::Var(0)))],
334                EffectSet::empty(),
335                Ty::Con("Option".into(), vec![
336                    Ty::Tuple(vec![Ty::Var(0), rng_t()]),
337                ]),
338            ));
339            Some(Ty::Record(fields))
340        }
341        "env" => {
342            // #216: env.get(name) -> [env] Option[Str].
343            // Per-var scoping (`[env(NAME)]`) lands with the
344            // per-capability effect parameterization work (#207); the
345            // flat `[env]` is the v1 surface.
346            let mut fields = IndexMap::new();
347            fields.insert("get".into(), Ty::function(
348                vec![Ty::str()],
349                EffectSet::singleton("env"),
350                Ty::Con("Option".into(), vec![Ty::str()]),
351            ));
352            Some(Ty::Record(fields))
353        }
354        "net" => {
355            let mut fields = IndexMap::new();
356            // get :: Str -> [net] Result[Str, Str]
357            fields.insert("get".into(), Ty::function(
358                vec![Ty::str()],
359                EffectSet::singleton("net"),
360                Ty::Con("Result".into(), vec![Ty::str(), Ty::str()]),
361            ));
362            fields.insert("post".into(), Ty::function(
363                vec![Ty::str(), Ty::str()],
364                EffectSet::singleton("net"),
365                Ty::Con("Result".into(), vec![Ty::str(), Ty::str()]),
366            ));
367            // ── UDP datagrams (#760) ──────────────────────────────────
368            // Everything else in this module is a stream, and almost
369            // everything is HTTP. That leaves every protocol built on
370            // datagrams unreachable from Lex: Wake-on-LAN (a broadcast),
371            // NTP, mDNS/DNS-SD and SSDP discovery (multicast), MAVLink,
372            // and the device protocols behind most consumer hardware.
373            //
374            // Socket-handle shaped rather than one-shot, following
375            // `sql.open`: an Int handle into a runtime registry. A
376            // request/response helper would have covered the motivating
377            // case in one call, but not listening — mDNS and game traffic
378            // need a socket that outlives a single exchange.
379            //
380            //   udp_open(port)                    -> Result[Int, Str]
381            //   udp_close(sock)                   -> Result[Unit, Str]
382            //   udp_send(sock, host, port, data)  -> Result[Int, Str]
383            //   udp_recv(sock, timeout_ms)        -> Result[UdpDatagram, Str]
384            //   udp_broadcast(sock, on)           -> Result[Unit, Str]
385            //   udp_join_multicast(sock, group)   -> Result[Unit, Str]
386            //
387            // `udp_open(0)` binds an ephemeral port. `udp_send` returns the
388            // byte count written. `udp_recv` blocks up to `timeout_ms` and
389            // reports a timeout as `Err`, never as an empty datagram — an
390            // empty UDP payload is legal and must stay distinguishable from
391            // nothing having arrived.
392            //
393            // POLICY: `udp_send` honours `--allow-net-host` against the
394            // DESTINATION, exactly as `net.get` does against a URL's host.
395            // Without that a datagram socket would be a hole straight
396            // through the one gate the rest of this module respects.
397            // Broadcast and multicast addresses have to be named in the
398            // allowlist like any other destination, which is the intended
399            // friction: sending to 255.255.255.255 is a larger capability
400            // than sending to one known host, and should have to be asked
401            // for.
402            fields.insert("udp_open".into(), Ty::function(
403                vec![Ty::int()],
404                EffectSet::singleton("net"),
405                Ty::Con("Result".into(), vec![Ty::int(), Ty::str()]),
406            ));
407            fields.insert("udp_close".into(), Ty::function(
408                vec![Ty::int()],
409                EffectSet::singleton("net"),
410                Ty::Con("Result".into(), vec![Ty::Unit, Ty::str()]),
411            ));
412            fields.insert("udp_send".into(), Ty::function(
413                vec![Ty::int(), Ty::str(), Ty::int(), Ty::bytes()],
414                EffectSet::singleton("net"),
415                Ty::Con("Result".into(), vec![Ty::int(), Ty::str()]),
416            ));
417            fields.insert("udp_recv".into(), Ty::function(
418                vec![Ty::int(), Ty::int()],
419                EffectSet::singleton("net"),
420                Ty::Con("Result".into(), vec![
421                    Ty::Con("UdpDatagram".into(), vec![]), Ty::str(),
422                ]),
423            ));
424            fields.insert("udp_broadcast".into(), Ty::function(
425                vec![Ty::int(), Ty::bool()],
426                EffectSet::singleton("net"),
427                Ty::Con("Result".into(), vec![Ty::Unit, Ty::str()]),
428            ));
429            fields.insert("udp_join_multicast".into(), Ty::function(
430                vec![Ty::int(), Ty::str()],
431                EffectSet::singleton("net"),
432                Ty::Con("Result".into(), vec![Ty::Unit, Ty::str()]),
433            ));
434
435            // serve :: (Int, Str) -> [net] Unit  (blocks; never returns
436            // under normal use). Handler's signature isn't carried in
437            // the type system here — looked up by name at runtime.
438            fields.insert("serve".into(), Ty::function(
439                vec![Ty::int(), Ty::str()],
440                EffectSet::singleton("net"),
441                Ty::Unit,
442            ));
443            // serve_tls :: (Int, Str, Str, Str) -> [net] Unit
444            //              port  cert  key   handler
445            // cert and key are filesystem paths to PEM-encoded files.
446            fields.insert("serve_tls".into(), Ty::function(
447                vec![Ty::int(), Ty::str(), Ty::str(), Ty::str()],
448                EffectSet::singleton("net"),
449                Ty::Unit,
450            ));
451            // serve_ws :: (Int, Str) -> [net] Unit
452            //             port  on_message_handler_name
453            // The handler is looked up by name at runtime.
454            fields.insert("serve_ws".into(), Ty::function(
455                vec![Ty::int(), Ty::str()],
456                EffectSet::singleton("net"),
457                Ty::Unit,
458            ));
459            // serve_ws_fn[Eff] :: (Int, Str, (WsConn, WsMessage) -> [Eff] WsAction)
460            //                      -> [net, Eff] Unit
461            // Effect-polymorphic WebSocket server that accepts a handler closure.
462            // The second argument is the subprotocol string for the
463            // Sec-WebSocket-Protocol handshake header ("" for none).
464            // open_var(0) propagates the handler's effect row to the call site.
465            fields.insert("serve_ws_fn".into(), Ty::function(
466                vec![
467                    Ty::int(),
468                    Ty::str(), // subprotocol
469                    Ty::function(
470                        vec![
471                            Ty::Con("WsConn".into(), vec![]),
472                            Ty::Con("WsMessage".into(), vec![]),
473                        ],
474                        EffectSet::open_var(0),
475                        Ty::Con("WsAction".into(), vec![]),
476                    ),
477                ],
478                EffectSet::open_var(0).union(&EffectSet::singleton("net")),
479                Ty::Unit,
480            ));
481            // serve_ws_fn_auth[Eff] :: (Int, Str,
482            //   (Str, List[{name :: Str, value :: Str}]) -> [Eff] Result[Unit, Str],
483            //   (WsConn, WsMessage) -> [Eff] WsAction)
484            //   -> [net, Eff] Unit
485            // Variant of serve_ws_fn that runs a pre-handshake auth
486            // callback against the upgrade request's path + headers.
487            // `Err(msg)` from the callback responds 401 Unauthorized
488            // and skips the WS upgrade entirely (#423). The auth and
489            // message-handler closures share the same effect row, so
490            // a caller using e.g. `[sql]` to look up a password hash
491            // in auth can use `[sql]` in subsequent handlers without
492            // duplicating the declaration.
493            let header_entry = || {
494                let mut fs = IndexMap::new();
495                fs.insert("name".into(),  Ty::str());
496                fs.insert("value".into(), Ty::str());
497                Ty::Record(fs)
498            };
499            fields.insert("serve_ws_fn_auth".into(), Ty::function(
500                vec![
501                    Ty::int(),
502                    Ty::str(), // subprotocol
503                    // auth callback: (path, headers) -> [Eff] Result[Unit, Str]
504                    Ty::function(
505                        vec![
506                            Ty::str(),
507                            Ty::List(Box::new(header_entry())),
508                        ],
509                        EffectSet::open_var(0),
510                        Ty::Con("Result".into(), vec![Ty::Unit, Ty::str()]),
511                    ),
512                    // on_message: same shape as serve_ws_fn
513                    Ty::function(
514                        vec![
515                            Ty::Con("WsConn".into(), vec![]),
516                            Ty::Con("WsMessage".into(), vec![]),
517                        ],
518                        EffectSet::open_var(0),
519                        Ty::Con("WsAction".into(), vec![]),
520                    ),
521                ],
522                EffectSet::open_var(0).union(&EffectSet::singleton("net")),
523                Ty::Unit,
524            ));
525            // serve_ws_fn_actor[Eff] ::
526            //   (Int, Str,
527            //    (WsConn) -> Str,                        # name_of, registry name
528            //    (WsConn, WsMessage) -> [Eff] WsAction)  # on_message
529            //   -> [net, concurrent, Eff] Unit
530            //
531            // Variant of serve_ws_fn that registers each accepted
532            // connection as a named actor in conc_registry. Non-WS
533            // callers can then `conc.lookup(name) |> conc.tell(frame)`
534            // to push outbound frames into the socket from arbitrary
535            // [concurrent]-tagged code (HTTP webhooks, scheduled tasks,
536            // broadcast loops). Documented in #459.
537            //
538            // name_of is intentionally pure: it inspects the WsConn
539            // record (id / path / subprotocol) and decides what
540            // name to register the connection under. Empty string
541            // means "don't register this connection" — `on_message`
542            // still runs but no outbound handle is exposed.
543            //
544            // The result row carries `concurrent` because the runtime
545            // registers an `ActorHandler::Native` bridge in the conc
546            // registry; lookups from non-WS callers are themselves
547            // `[concurrent]` effects.
548            // serve_ws_fn_actor_with[Eff] ::
549            //   (Int, Str,
550            //    (WsConn) -> Str,
551            //    (WsConn, WsMessage) -> [Eff] WsAction,
552            //    ServeOpts)
553            //   -> [net, concurrent, Eff] Unit
554            //
555            // `serve_ws_fn_actor` with the bind interface named in the
556            // source (#719). Every WS server bound `127.0.0.1`
557            // unconditionally, so a containerised deployment could not
558            // accept cross-container connections without a `socat`
559            // sidecar republishing the loopback listener.
560            //
561            // The opts record is the one `net.default_opts()` already
562            // returns, per the issue's own suggestion — only `host` is
563            // read, since `http2` and `inline_vm` describe an HTTP
564            // server and mean nothing to a websocket listener. One
565            // `ServeOpts` in the language beats two that differ by two
566            // fields.
567            //
568            // The other three WS servers honour `LEX_WS_HOST` but have
569            // no `_with` variant yet; adding one is a hand-written
570            // builtin today, and `std.net` has not migrated to the
571            // declarative catalogue (#778) where it would be a row.
572            fields.insert("serve_ws_fn_actor_with".into(), Ty::function(
573                vec![
574                    Ty::int(),
575                    Ty::str(), // subprotocol
576                    Ty::function(
577                        vec![Ty::Con("WsConn".into(), vec![])],
578                        EffectSet::empty(),
579                        Ty::str(),
580                    ),
581                    Ty::function(
582                        vec![
583                            Ty::Con("WsConn".into(), vec![]),
584                            Ty::Con("WsMessage".into(), vec![]),
585                        ],
586                        EffectSet::open_var(0),
587                        Ty::Con("WsAction".into(), vec![]),
588                    ),
589                    {
590                        // The same shape `serve_opts_t()` builds below;
591                        // spelled out here because that helper is
592                        // declared further down this arm.
593                        let mut fs = IndexMap::new();
594                        fs.insert("http2".into(),     Ty::bool());
595                        fs.insert("inline_vm".into(), Ty::bool());
596                        fs.insert("host".into(),      Ty::str());
597                        Ty::Record(fs)
598                    },
599                ],
600                EffectSet::open_var(0)
601                    .union(&EffectSet::singleton("net"))
602                    .union(&EffectSet::singleton("concurrent")),
603                Ty::Unit,
604            ));
605            fields.insert("serve_ws_fn_actor".into(), Ty::function(
606                vec![
607                    Ty::int(),
608                    Ty::str(), // subprotocol
609                    Ty::function(
610                        vec![Ty::Con("WsConn".into(), vec![])],
611                        EffectSet::empty(),
612                        Ty::str(),
613                    ),
614                    Ty::function(
615                        vec![
616                            Ty::Con("WsConn".into(), vec![]),
617                            Ty::Con("WsMessage".into(), vec![]),
618                        ],
619                        EffectSet::open_var(0),
620                        Ty::Con("WsAction".into(), vec![]),
621                    ),
622                ],
623                EffectSet::open_var(0)
624                    .union(&EffectSet::singleton("net"))
625                    .union(&EffectSet::singleton("concurrent")),
626                Ty::Unit,
627            ));
628            // dial_ws[Eff] :: (Str, Str, () -> [Eff] WsAction,
629            //                  (WsMessage) -> [Eff] WsAction)
630            //                  -> [net, Eff] Result[Unit, Str]
631            //
632            // WebSocket *client* — the inverse of serve_ws_fn (#390).
633            // Connects to `url` (ws:// or wss://) with the given
634            // subprotocol header, calls `on_open` once after the
635            // handshake completes, then loops invoking `on_message`
636            // for every inbound frame. Each callback returns a
637            // `WsAction` that gets applied to the socket — same enum
638            // as the server side, same semantics for `WsSend` /
639            // `WsSendBinary` / `WsNoOp`. open_var(0) propagates the
640            // handler effects so callers that touch [io], [time],
641            // [random] etc. inside their handlers see those propagate
642            // out of the dial_ws call.
643            //
644            // Returns `Result[Unit, Str]` rather than the bare `Unit`
645            // that serve_ws_fn returns: a dial can fail on connect
646            // (DNS, refused, bad TLS) or mid-stream (read error,
647            // unexpected close) and the caller usually wants to know.
648            fields.insert("dial_ws".into(), Ty::function(
649                vec![
650                    Ty::str(), // url (ws:// or wss://)
651                    Ty::str(), // subprotocol (Sec-WebSocket-Protocol)
652                    Ty::function(
653                        vec![],
654                        EffectSet::open_var(0),
655                        Ty::Con("WsAction".into(), vec![]),
656                    ),
657                    Ty::function(
658                        vec![Ty::Con("WsMessage".into(), vec![])],
659                        EffectSet::open_var(0),
660                        Ty::Con("WsAction".into(), vec![]),
661                    ),
662                ],
663                EffectSet::open_var(0).union(&EffectSet::singleton("net")),
664                Ty::Con("Result".into(), vec![Ty::Unit, Ty::str()]),
665            ));
666            // dial_ws_actor[Eff] :: (Str, Str, Str,
667            //                        () -> [Eff] WsAction,
668            //                        (WsMessage) -> [Eff] WsAction)
669            //                        -> [net, Eff] Result[Unit, Str]
670            //
671            // Variant of dial_ws that registers the outgoing connection in the
672            // conc registry under `name`. conc.tell(actor, frame_str) enqueues
673            // a frame for delivery, enabling proactive sends (heartbeats,
674            // meter values) from any other actor without changing the
675            // reactive on_message signature.
676            fields.insert("dial_ws_actor".into(), Ty::function(
677                vec![
678                    Ty::str(), // url
679                    Ty::str(), // subprotocol ("" for none)
680                    Ty::str(), // conc registry name ("" to skip registration)
681                    Ty::function(
682                        vec![],
683                        EffectSet::open_var(0),
684                        Ty::Con("WsAction".into(), vec![]),
685                    ),
686                    Ty::function(
687                        vec![Ty::Con("WsMessage".into(), vec![])],
688                        EffectSet::open_var(0),
689                        Ty::Con("WsAction".into(), vec![]),
690                    ),
691                ],
692                EffectSet::open_var(0).union(&EffectSet::singleton("net")),
693                Ty::Con("Result".into(), vec![Ty::Unit, Ty::str()]),
694            ));
695            // serve_fn[Eff] :: (Int, (Request) -> [Eff] Response) -> [net, Eff] Unit
696            // Effect-polymorphic variant of serve that accepts a first-class closure
697            // instead of a handler name. open_var(0) captures the handler's effect row
698            // so callers that invoke e.g. [io] effects inside the closure propagate them
699            // to the serve_fn call site.
700            fields.insert("serve_fn".into(), Ty::function(
701                vec![
702                    Ty::int(),
703                    Ty::function(
704                        vec![Ty::Con("Request".into(), vec![])],
705                        EffectSet::open_var(0),
706                        Ty::Con("Response".into(), vec![]),
707                    ),
708                ],
709                EffectSet::open_var(0).union(&EffectSet::singleton("net")),
710                Ty::Unit,
711            ));
712            // serve_routed[Eff] :: (
713            //     Int,
714            //     List[(Str, Str, (Request) -> [Eff] Response)],
715            //     (Request) -> [Eff] Response
716            //   ) -> [net, Eff] Unit
717            //
718            // Pattern-matched dispatch over `serve_fn`. Each route is a
719            // (method, path-pattern, handler) triple — method is an
720            // HTTP verb (case-insensitive) or "*" for any; path-patterns
721            // use `:name` segments (e.g. "/users/:id") and matched values
722            // are stamped onto `req.path_params` before the handler runs.
723            // Routes are tried in registration order; the first match
724            // wins. `fallback` runs when no route matches — typically a
725            // 404 responder. Same `open_var(0)` effect-row trick as
726            // `serve_fn` so handler effects propagate to the call site.
727            fields.insert("serve_routed".into(), Ty::function(
728                vec![
729                    Ty::int(),
730                    Ty::List(Box::new(Ty::Tuple(vec![
731                        Ty::str(),
732                        Ty::str(),
733                        Ty::function(
734                            vec![Ty::Con("Request".into(), vec![])],
735                            EffectSet::open_var(0),
736                            Ty::Con("Response".into(), vec![]),
737                        ),
738                    ]))),
739                    Ty::function(
740                        vec![Ty::Con("Request".into(), vec![])],
741                        EffectSet::open_var(0),
742                        Ty::Con("Response".into(), vec![]),
743                    ),
744                ],
745                EffectSet::open_var(0).union(&EffectSet::singleton("net")),
746                Ty::Unit,
747            ));
748
749            // ServeOpts is a structural record literal — callers build
750            // it with `{ http2: ..., inline_vm: ..., host: ... }`. Used
751            // by `serve_with` / `serve_fn_with` / `serve_routed_with`
752            // to replace the legacy LEX_NET_HTTP2 / LEX_NET_INLINE_VM
753            // env-var gates with a first-class, type-checked config.
754            // See lex-lang#497.
755            let serve_opts_t = || {
756                let mut fs = IndexMap::new();
757                fs.insert("http2".into(),     Ty::bool());
758                fs.insert("inline_vm".into(), Ty::bool());
759                fs.insert("host".into(),      Ty::str());
760                Ty::Record(fs)
761            };
762
763            // default_opts :: () -> ServeOpts
764            // Returns the same defaults the legacy serve* paths use —
765            // http2=false, inline_vm=false, host="0.0.0.0". Pure; the
766            // env-var fallback only applies on the legacy serve* path,
767            // not here.
768            fields.insert("default_opts".into(), Ty::function(
769                vec![],
770                EffectSet::empty(),
771                serve_opts_t(),
772            ));
773
774            // serve_with :: (Int, Str, ServeOpts) -> [net] Unit
775            fields.insert("serve_with".into(), Ty::function(
776                vec![Ty::int(), Ty::str(), serve_opts_t()],
777                EffectSet::singleton("net"),
778                Ty::Unit,
779            ));
780
781            // serve_fn_with[Eff] :: (Int, (Request) -> [Eff] Response, ServeOpts)
782            //                       -> [net, Eff] Unit
783            fields.insert("serve_fn_with".into(), Ty::function(
784                vec![
785                    Ty::int(),
786                    Ty::function(
787                        vec![Ty::Con("Request".into(), vec![])],
788                        EffectSet::open_var(0),
789                        Ty::Con("Response".into(), vec![]),
790                    ),
791                    serve_opts_t(),
792                ],
793                EffectSet::open_var(0).union(&EffectSet::singleton("net")),
794                Ty::Unit,
795            ));
796
797            // serve_routed_with[Eff] :: (
798            //   Int, List[(Str, Str, (Request) -> [Eff] Response)],
799            //   (Request) -> [Eff] Response, ServeOpts
800            // ) -> [net, Eff] Unit
801            fields.insert("serve_routed_with".into(), Ty::function(
802                vec![
803                    Ty::int(),
804                    Ty::List(Box::new(Ty::Tuple(vec![
805                        Ty::str(),
806                        Ty::str(),
807                        Ty::function(
808                            vec![Ty::Con("Request".into(), vec![])],
809                            EffectSet::open_var(0),
810                            Ty::Con("Response".into(), vec![]),
811                        ),
812                    ]))),
813                    Ty::function(
814                        vec![Ty::Con("Request".into(), vec![])],
815                        EffectSet::open_var(0),
816                        Ty::Con("Response".into(), vec![]),
817                    ),
818                    serve_opts_t(),
819                ],
820                EffectSet::open_var(0).union(&EffectSet::singleton("net")),
821                Ty::Unit,
822            ));
823
824            // serve_quic / serve_quic_fn / serve_quic_routed (#496).
825            // HTTP/3 over QUIC. TlsConfig is an opaque value built by
826            // `tls.from_pem_files` or `tls.self_signed` — it carries the
827            // server certificate chain + private key needed for the
828            // QUIC handshake (TLS is mandatory for HTTP/3). Effect row
829            // stays `[net]` for symmetry with `serve` / `serve_fn`;
830            // policy gates don't distinguish HTTP/1.1+2 (TCP) from
831            // HTTP/3 (UDP) at the effect level.
832            //
833            // serve_quic :: (Int, TlsConfig, Str) -> [net] Unit
834            fields.insert("serve_quic".into(), Ty::function(
835                vec![Ty::int(), Ty::Con("TlsConfig".into(), vec![]), Ty::str()],
836                EffectSet::singleton("net"),
837                Ty::Unit,
838            ));
839
840            // serve_quic_fn[Eff] :: (Int, TlsConfig,
841            //                        (Request) -> [Eff] Response)
842            //                       -> [net, Eff] Unit
843            fields.insert("serve_quic_fn".into(), Ty::function(
844                vec![
845                    Ty::int(),
846                    Ty::Con("TlsConfig".into(), vec![]),
847                    Ty::function(
848                        vec![Ty::Con("Request".into(), vec![])],
849                        EffectSet::open_var(0),
850                        Ty::Con("Response".into(), vec![]),
851                    ),
852                ],
853                EffectSet::open_var(0).union(&EffectSet::singleton("net")),
854                Ty::Unit,
855            ));
856
857            // serve_quic_routed[Eff] :: (
858            //   Int, TlsConfig,
859            //   List[(Str, Str, (Request) -> [Eff] Response)],
860            //   (Request) -> [Eff] Response
861            // ) -> [net, Eff] Unit
862            fields.insert("serve_quic_routed".into(), Ty::function(
863                vec![
864                    Ty::int(),
865                    Ty::Con("TlsConfig".into(), vec![]),
866                    Ty::List(Box::new(Ty::Tuple(vec![
867                        Ty::str(),
868                        Ty::str(),
869                        Ty::function(
870                            vec![Ty::Con("Request".into(), vec![])],
871                            EffectSet::open_var(0),
872                            Ty::Con("Response".into(), vec![]),
873                        ),
874                    ]))),
875                    Ty::function(
876                        vec![Ty::Con("Request".into(), vec![])],
877                        EffectSet::open_var(0),
878                        Ty::Con("Response".into(), vec![]),
879                    ),
880                ],
881                EffectSet::open_var(0).union(&EffectSet::singleton("net")),
882                Ty::Unit,
883            ));
884
885            Some(Ty::Record(fields))
886        }
887        // `tls` — TLS certificate handling for `net.serve_quic` (#496).
888        // `TlsConfig` is opaque to user code; the only ways to obtain
889        // one are these constructors. The runtime keeps the certificate
890        // chain + private key behind that opaque type so we can change
891        // the internal representation (record-of-bytes today, possibly
892        // a Resource handle tomorrow) without breaking source code.
893        "tls" => {
894            let mut fields = IndexMap::new();
895            // from_pem_files :: (Str, Str) -> [fs_read] Result[TlsConfig, Str]
896            //                    cert  key
897            // Load a PEM-encoded certificate chain + private key from
898            // disk. Both paths are read with the `[fs_read]` effect so
899            // policy gates can restrict where certs may come from.
900            fields.insert("from_pem_files".into(), Ty::function(
901                vec![Ty::str(), Ty::str()],
902                EffectSet::singleton("fs_read"),
903                Ty::Con("Result".into(), vec![
904                    Ty::Con("TlsConfig".into(), vec![]),
905                    Ty::str(),
906                ]),
907            ));
908            // self_signed :: Str -> Result[TlsConfig, Str]
909            // Generate a self-signed certificate for the given hostname
910            // (or "localhost"). Pure — no effects needed. Intended for
911            // local development and integration tests only; real
912            // deployments should use a CA-signed cert via from_pem_files.
913            fields.insert("self_signed".into(), Ty::function(
914                vec![Ty::str()],
915                EffectSet::empty(),
916                Ty::Con("Result".into(), vec![
917                    Ty::Con("TlsConfig".into(), vec![]),
918                    Ty::str(),
919                ]),
920            ));
921            Some(Ty::Record(fields))
922        }
923        "chat" => {
924            let mut fields = IndexMap::new();
925            fields.insert("broadcast".into(), Ty::function(
926                vec![Ty::str(), Ty::str()],
927                EffectSet::singleton("chat"),
928                Ty::Unit,
929            ));
930            fields.insert("send".into(), Ty::function(
931                vec![Ty::int(), Ty::str()],
932                EffectSet::singleton("chat"),
933                Ty::bool(),
934            ));
935            Some(Ty::Record(fields))
936        }
937        "conc" => {
938            // Actor model (#381). Effect: [concurrent].
939            // spawn :: S, (S, M) -> [E] (S, R) -> [concurrent] Actor[S]
940            // ask   :: Actor[S], M -> [concurrent] R
941            // tell  :: Actor[S], M -> [concurrent] Unit
942            //
943            // The type variables used here are fresh placeholders;
944            // the checker instantiates them at each call site.
945            //   0 = S (state), 1 = M (message), 2 = R (reply), 3 = E (effect row)
946            let actor_t = |s: Ty| Ty::Con("Actor".into(), vec![s]);
947            let mut fields = IndexMap::new();
948            // spawn :: S, (S, M -> [E] (S, R)) -> [concurrent] Actor[S]
949            fields.insert("spawn".into(), Ty::function(
950                vec![
951                    Ty::Var(0),
952                    Ty::Function {
953                        params: vec![Ty::Var(0), Ty::Var(1)],
954                        effects: EffectSet::open_var(3),
955                        ret: Box::new(Ty::Tuple(vec![Ty::Var(0), Ty::Var(2)])),
956                    },
957                ],
958                EffectSet::singleton("concurrent"),
959                actor_t(Ty::Var(0)),
960            ));
961            // ask :: Actor[S], M -> [concurrent] R
962            fields.insert("ask".into(), Ty::function(
963                vec![actor_t(Ty::Var(0)), Ty::Var(1)],
964                EffectSet::singleton("concurrent"),
965                Ty::Var(2),
966            ));
967            // tell :: Actor[S], M -> [concurrent] Unit
968            fields.insert("tell".into(), Ty::function(
969                vec![actor_t(Ty::Var(0)), Ty::Var(1)],
970                EffectSet::singleton("concurrent"),
971                Ty::Unit,
972            ));
973            // #444 — named-actor discovery within a process.
974            //
975            // register :: Actor[S], Str -> [concurrent] Result[Unit, ConcError]
976            //   Returns Err(AlreadyRegistered(name)) if the name is
977            //   taken — registration is exclusive so name collisions
978            //   surface at the source level, not as silent overwrites.
979            //
980            // lookup :: Str -> [concurrent] Option[Actor[S]]
981            //   Returns Some(actor) if registered, None otherwise. The
982            //   static `[S]` parametrisation isn't checked at runtime in
983            //   v1; the caller is responsible for matching the
984            //   registration site's type. SigId-tagged variant deferred —
985            //   see `conc_registry.rs` in lex-bytecode.
986            //
987            // unregister :: Str -> [concurrent] Result[Unit, ConcError]
988            //   Returns Err(NotRegistered(name)) if absent. Existing
989            //   `Actor[S]` handles held by callers continue to work
990            //   after unregistration; the cell is reclaimed when the
991            //   last handle drops.
992            //
993            // registered :: () -> [concurrent] List[Str]
994            //   Sorted snapshot of currently registered names. Debug /
995            //   introspection — not part of the steady-state agent flow.
996            let conc_err = || Ty::Con("ConcError".into(), vec![]);
997            let result_ce = |ok: Ty| Ty::Con("Result".into(), vec![ok, conc_err()]);
998            fields.insert("register".into(), Ty::function(
999                vec![actor_t(Ty::Var(0)), Ty::str()],
1000                EffectSet::singleton("concurrent"),
1001                result_ce(Ty::Unit),
1002            ));
1003            fields.insert("lookup".into(), Ty::function(
1004                vec![Ty::str()],
1005                EffectSet::singleton("concurrent"),
1006                Ty::Con("Option".into(), vec![actor_t(Ty::Var(0))]),
1007            ));
1008            fields.insert("unregister".into(), Ty::function(
1009                vec![Ty::str()],
1010                EffectSet::singleton("concurrent"),
1011                result_ce(Ty::Unit),
1012            ));
1013            fields.insert("registered".into(), Ty::function(
1014                vec![],
1015                EffectSet::singleton("concurrent"),
1016                Ty::List(Box::new(Ty::str())),
1017            ));
1018            Some(Ty::Record(fields))
1019        }
1020        "arrow" => {
1021            // Apache Arrow tables (#426). All ops are pure (no effects);
1022            // tables are immutable and conversions / reductions all run as
1023            // one Rust call over the flat buffer.
1024            //
1025            // `arrow.Table` is opaque from the type system's point of view —
1026            // the runtime variant `Value::ArrowTable` is the only producer
1027            // and consumer, so we model it as a 0-arity type constructor.
1028            let table = Ty::Con("Table".into(), vec![]);
1029            let str_t   = Ty::str();
1030            let int_t   = Ty::int();
1031            let float_t = Ty::float();
1032            let opt = |inner: Ty| Ty::Con("Option".into(), vec![inner]);
1033            let res = |ok: Ty| Ty::Con("Result".into(), vec![ok, Ty::str()]);
1034            let no_eff = EffectSet::empty();
1035
1036            let mut fields = IndexMap::new();
1037
1038            // -- constructors --
1039            // arrow.from_int_columns   :: List[(Str, List[Int])]   -> Result[Table, Str]
1040            // arrow.from_float_columns :: List[(Str, List[Float])] -> Result[Table, Str]
1041            // arrow.from_str_columns   :: List[(Str, List[Str])]   -> Result[Table, Str]
1042            for (name, elem) in [
1043                ("from_int_columns",   int_t.clone()),
1044                ("from_float_columns", float_t.clone()),
1045                ("from_str_columns",   str_t.clone()),
1046            ] {
1047                fields.insert(name.into(), Ty::function(
1048                    vec![Ty::List(Box::new(Ty::Tuple(vec![
1049                        str_t.clone(),
1050                        Ty::List(Box::new(elem)),
1051                    ])))],
1052                    no_eff.clone(),
1053                    res(table.clone()),
1054                ));
1055            }
1056
1057            // -- introspection --
1058            // arrow.nrows / arrow.ncols :: Table -> Int
1059            fields.insert("nrows".into(), Ty::function(
1060                vec![table.clone()], no_eff.clone(), int_t.clone()));
1061            fields.insert("ncols".into(), Ty::function(
1062                vec![table.clone()], no_eff.clone(), int_t.clone()));
1063            // arrow.col_names :: Table -> List[Str]
1064            fields.insert("col_names".into(), Ty::function(
1065                vec![table.clone()], no_eff.clone(),
1066                Ty::List(Box::new(str_t.clone()))));
1067            // arrow.col_type :: Table, Str -> Option[Str]
1068            fields.insert("col_type".into(), Ty::function(
1069                vec![table.clone(), str_t.clone()],
1070                no_eff.clone(), opt(str_t.clone())));
1071
1072            // -- column reductions --
1073            // arrow.col_sum_int   :: Table, Str -> Result[Int, Str]
1074            // arrow.col_sum_float :: Table, Str -> Result[Float, Str]
1075            // arrow.col_mean      :: Table, Str -> Result[Option[Float], Str]
1076            // arrow.col_min_int   :: Table, Str -> Result[Option[Int], Str]
1077            // arrow.col_max_int   :: Table, Str -> Result[Option[Int], Str]
1078            // arrow.col_count     :: Table, Str -> Result[Int, Str]
1079            for (name, ret_ok) in [
1080                ("col_sum_int",   int_t.clone()),
1081                ("col_sum_float", float_t.clone()),
1082                ("col_mean",      opt(float_t.clone())),
1083                ("col_min_int",   opt(int_t.clone())),
1084                ("col_max_int",   opt(int_t.clone())),
1085                ("col_count",     int_t.clone()),
1086            ] {
1087                fields.insert(name.into(), Ty::function(
1088                    vec![table.clone(), str_t.clone()],
1089                    no_eff.clone(), res(ret_ok)));
1090            }
1091
1092            // -- slicing --
1093            // arrow.head / tail :: Table, Int -> Table
1094            for name in &["head", "tail"] {
1095                fields.insert((*name).into(), Ty::function(
1096                    vec![table.clone(), int_t.clone()],
1097                    no_eff.clone(), table.clone()));
1098            }
1099            // arrow.slice :: Table, Int, Int -> Table
1100            fields.insert("slice".into(), Ty::function(
1101                vec![table.clone(), int_t.clone(), int_t.clone()],
1102                no_eff.clone(), table.clone()));
1103            // arrow.select_cols :: Table, List[Str] -> Result[Table, Str]
1104            fields.insert("select_cols".into(), Ty::function(
1105                vec![table.clone(), Ty::List(Box::new(str_t.clone()))],
1106                no_eff.clone(), res(table.clone())));
1107            // arrow.drop_col :: Table, Str -> Result[Table, Str]
1108            fields.insert("drop_col".into(), Ty::function(
1109                vec![table.clone(), str_t.clone()],
1110                no_eff.clone(), res(table.clone())));
1111            // arrow.rename_col :: Table, Str, Str -> Result[Table, Str]
1112            // Schema-only rename (old name, new name); zero-copy — the
1113            // underlying column arrays are untouched.
1114            fields.insert("rename_col".into(), Ty::function(
1115                vec![table.clone(), str_t.clone(), str_t.clone()],
1116                no_eff.clone(), res(table.clone())));
1117
1118            // -- I/O (effect-gated) --
1119            // arrow.read_csv :: Str -> [fs_read] Result[Table, Str]
1120            // Header row required; schema inferred from the first 100 rows.
1121            // The `[fs_read]` effect surfaces in agent-tool policy gates
1122            // and `--allow-fs-read` per-path scoping, same as `io.read`.
1123            fields.insert("read_csv".into(), Ty::function(
1124                vec![str_t.clone()],
1125                EffectSet::singleton("fs_read"),
1126                res(table.clone())));
1127
1128            // arrow.read_parquet :: Str -> [fs_read] Result[Table, Str]
1129            // arrow.read_parquet_cols :: (Str, List[Str]) -> [fs_read] Result[Table, Str]
1130            // Same effect + path-scope rules as read_csv. _cols pushes
1131            // the projection into the Parquet reader (no decode of skipped
1132            // columns); missing column names surface as Err.
1133            fields.insert("read_parquet".into(), Ty::function(
1134                vec![str_t.clone()],
1135                EffectSet::singleton("fs_read"),
1136                res(table.clone())));
1137            fields.insert("read_parquet_cols".into(), Ty::function(
1138                vec![str_t.clone(), Ty::List(Box::new(str_t.clone()))],
1139                EffectSet::singleton("fs_read"),
1140                res(table.clone())));
1141
1142            // arrow.write_parquet :: (Table, Str) -> [fs_write] Result[Unit, Str]
1143            // arrow.write_csv     :: (Table, Str) -> [fs_write] Result[Unit, Str]
1144            // Path scope uses --allow-fs-write (symmetric with io.write).
1145            // Parquet default: Snappy compression, default page/row-group
1146            // sizes — sufficient for v1; a write_parquet_opts variant
1147            // can ride a later issue if knobs are needed.
1148            for name in &["write_parquet", "write_csv"] {
1149                fields.insert((*name).into(), Ty::function(
1150                    vec![table.clone(), str_t.clone()],
1151                    EffectSet::singleton("fs_write"),
1152                    res(Ty::Unit)));
1153            }
1154
1155            Some(Ty::Record(fields))
1156        }
1157        "df" => {
1158            // Polars-backed query ops over arrow.Table (#427). All pure
1159            // (no effects); the Polars DataFrame is internal plumbing,
1160            // never leaves the kernel.
1161            let table = Ty::Con("Table".into(), vec![]);
1162            let str_t = Ty::str();
1163            let int_t = Ty::int();
1164            let float_t = Ty::float();
1165            let bool_t = Ty::bool();
1166            let res = |ok: Ty| Ty::Con("Result".into(), vec![ok, Ty::str()]);
1167            let no_eff = EffectSet::empty();
1168
1169            let mut fields = IndexMap::new();
1170
1171            // df.filter_{eq,gt,lt}_int :: Table, Str, Int -> Result[Table, Str]
1172            for name in &["filter_eq_int", "filter_gt_int", "filter_lt_int"] {
1173                fields.insert((*name).into(), Ty::function(
1174                    vec![table.clone(), str_t.clone(), int_t.clone()],
1175                    no_eff.clone(), res(table.clone())));
1176            }
1177
1178            // #433 — string filters.
1179            // df.filter_eq_str  :: Table, Str, Str       -> Result[Table, Str]
1180            // df.filter_in_str  :: Table, Str, List[Str] -> Result[Table, Str]
1181            fields.insert("filter_eq_str".into(), Ty::function(
1182                vec![table.clone(), str_t.clone(), str_t.clone()],
1183                no_eff.clone(), res(table.clone())));
1184            fields.insert("filter_in_str".into(), Ty::function(
1185                vec![table.clone(), str_t.clone(), Ty::List(Box::new(str_t.clone()))],
1186                no_eff.clone(), res(table.clone())));
1187
1188            // #433 — float filters.
1189            // df.filter_{eq,lt,gt}_float :: Table, Str, Float -> Result[Table, Str]
1190            for name in &["filter_eq_float", "filter_lt_float", "filter_gt_float"] {
1191                fields.insert((*name).into(), Ty::function(
1192                    vec![table.clone(), str_t.clone(), float_t.clone()],
1193                    no_eff.clone(), res(table.clone())));
1194            }
1195
1196            // #433 — null handling.
1197            // df.filter_isnull  :: Table, Str       -> Result[Table, Str]
1198            // df.filter_notnull :: Table, Str       -> Result[Table, Str]
1199            // df.drop_nulls     :: Table, List[Str] -> Result[Table, Str]
1200            for name in &["filter_isnull", "filter_notnull"] {
1201                fields.insert((*name).into(), Ty::function(
1202                    vec![table.clone(), str_t.clone()],
1203                    no_eff.clone(), res(table.clone())));
1204            }
1205            fields.insert("drop_nulls".into(), Ty::function(
1206                vec![table.clone(), Ty::List(Box::new(str_t.clone()))],
1207                no_eff.clone(), res(table.clone())));
1208
1209            // df.sort_by :: Table, Str, Bool -> Result[Table, Str]
1210            fields.insert("sort_by".into(), Ty::function(
1211                vec![table.clone(), str_t.clone(), bool_t.clone()],
1212                no_eff.clone(), res(table.clone())));
1213
1214            // df.group_by_agg :: Table, List[Str], List[(Str, Str, Str)]
1215            //                    -> Result[Table, Str]
1216            // Spec tuple is (out_col, in_col, op). op ∈
1217            // "sum"|"mean"|"min"|"max"|"count"|"n_distinct".
1218            fields.insert("group_by_agg".into(), Ty::function(
1219                vec![
1220                    table.clone(),
1221                    Ty::List(Box::new(str_t.clone())),
1222                    Ty::List(Box::new(Ty::Tuple(vec![
1223                        str_t.clone(), str_t.clone(), str_t.clone(),
1224                    ]))),
1225                ],
1226                no_eff.clone(), res(table.clone())));
1227
1228            // df.inner_join / left_join :: Table, Table, Str -> Result[Table, Str]
1229            for name in &["inner_join", "left_join"] {
1230                fields.insert((*name).into(), Ty::function(
1231                    vec![table.clone(), table.clone(), str_t.clone()],
1232                    no_eff.clone(), res(table.clone())));
1233            }
1234            // df.cross_join :: Table, Table -> Result[Table, Str]
1235            // Cartesian product; no join key. Clashing column names get
1236            // Polars' default `_right` suffix on the right side, same
1237            // as inner_join/left_join.
1238            fields.insert("cross_join".into(), Ty::function(
1239                vec![table.clone(), table.clone()],
1240                no_eff.clone(), res(table.clone())));
1241
1242            Some(Ty::Record(fields))
1243        }
1244        // `std.proc` was removed in favour of `std.process` (#678): its
1245        // single op `proc.spawn(cmd, args)` was byte-for-byte equivalent to
1246        // `process.run(cmd, args)` — same `[proc]` effect, same
1247        // `{ stdout, stderr, exit_code }` result — and `std.process` is a
1248        // strict superset (streaming spawn / read / wait / kill). Callers
1249        // migrate `proc.spawn` → `process.run`.
1250        "json" => {
1251            let mut fields = IndexMap::new();
1252            // stringify :: T -> Str  (polymorphic on input)
1253            fields.insert("stringify".into(), Ty::function(
1254                vec![Ty::Var(0)], EffectSet::empty(), Ty::str(),
1255            ));
1256            // parse :: Str -> Result[T, Str]
1257            fields.insert("parse".into(), Ty::function(
1258                vec![Ty::str()], EffectSet::empty(),
1259                Ty::Con("Result".into(), vec![Ty::Var(0), Ty::str()]),
1260            ));
1261            // parse_strict :: (Str, List[Str]) -> Result[T, Str]
1262            // Tactical fix for #168 — caller passes the field names
1263            // T requires; runtime returns Err if any are missing
1264            // from the parsed object instead of letting field
1265            // access panic later.
1266            fields.insert("parse_strict".into(), Ty::function(
1267                vec![Ty::str(), Ty::List(Box::new(Ty::str()))],
1268                EffectSet::empty(),
1269                Ty::Con("Result".into(), vec![Ty::Var(0), Ty::str()]),
1270            ));
1271            // decode :: Str -> Result[Json, Str]
1272            // Parse into the generic `Json` value ADT (JNull/JBool/…),
1273            // total: the result is always a well-typed `Json`, so callers
1274            // walk it with pattern matches instead of trusting a shape.
1275            // Native (serde_json), O(n) — the drop-in for lex-schema's
1276            // interpreted `json_value.parse`. Distinct from `parse`
1277            // (which decodes into an inferred concrete type `T` and is
1278            // the subject of the parse_strict rewrite); `decode`'s
1279            // concrete `Json` return is never rewritten.
1280            let json_v = Ty::Con("Json".into(), vec![]);
1281            fields.insert("decode".into(), Ty::function(
1282                vec![Ty::str()], EffectSet::empty(),
1283                Ty::Con("Result".into(), vec![json_v.clone(), Ty::str()]),
1284            ));
1285            // encode :: Json -> Str  (compact)
1286            fields.insert("encode".into(), Ty::function(
1287                vec![json_v.clone()], EffectSet::empty(), Ty::str(),
1288            ));
1289            // encode_pretty :: (Json, Int) -> Str  (indent spaces per level)
1290            fields.insert("encode_pretty".into(), Ty::function(
1291                vec![json_v, Ty::int()], EffectSet::empty(), Ty::str(),
1292            ));
1293            Some(Ty::Record(fields))
1294        }
1295        "result" => {
1296            let mut fields = IndexMap::new();
1297            // result.map :: Result[T, E], (T) -> [E2] U -> [E2] Result[U, E]
1298            // Effect-polymorphic on the closure: result.map et al.
1299            // propagate the closure's effects to the surrounding call.
1300            fields.insert("map".into(), Ty::function(
1301                vec![
1302                    Ty::Con("Result".into(), vec![Ty::Var(0), Ty::Var(1)]),
1303                    Ty::function(vec![Ty::Var(0)], EffectSet::open_var(3), Ty::Var(2)),
1304                ],
1305                EffectSet::open_var(3),
1306                Ty::Con("Result".into(), vec![Ty::Var(2), Ty::Var(1)]),
1307            ));
1308            fields.insert("and_then".into(), Ty::function(
1309                vec![
1310                    Ty::Con("Result".into(), vec![Ty::Var(0), Ty::Var(1)]),
1311                    Ty::function(vec![Ty::Var(0)], EffectSet::open_var(4),
1312                        Ty::Con("Result".into(), vec![Ty::Var(2), Ty::Var(1)])),
1313                ],
1314                EffectSet::open_var(4),
1315                Ty::Con("Result".into(), vec![Ty::Var(2), Ty::Var(1)]),
1316            ));
1317            fields.insert("map_err".into(), Ty::function(
1318                vec![
1319                    Ty::Con("Result".into(), vec![Ty::Var(0), Ty::Var(1)]),
1320                    Ty::function(vec![Ty::Var(1)], EffectSet::open_var(5), Ty::Var(2)),
1321                ],
1322                EffectSet::open_var(5),
1323                Ty::Con("Result".into(), vec![Ty::Var(0), Ty::Var(2)]),
1324            ));
1325            // result.or_else :: Result[T, E1], (E1) -> [E] Result[T, E2]
1326            //                                    -> [E] Result[T, E2]
1327            // Recovery combinator: closure runs only on Err and returns
1328            // the next Result (which itself may swap the error type).
1329            fields.insert("or_else".into(), Ty::function(
1330                vec![
1331                    Ty::Con("Result".into(), vec![Ty::Var(0), Ty::Var(1)]),
1332                    Ty::function(vec![Ty::Var(1)], EffectSet::open_var(6),
1333                        Ty::Con("Result".into(), vec![Ty::Var(0), Ty::Var(2)])),
1334                ],
1335                EffectSet::open_var(6),
1336                Ty::Con("Result".into(), vec![Ty::Var(0), Ty::Var(2)]),
1337            ));
1338            // result.unwrap_or :: Result[T, E], T -> T
1339            // Eager fallback — the Ok payload, or the supplied default on
1340            // Err. Mirrors option.unwrap_or (#679).
1341            fields.insert("unwrap_or".into(), Ty::function(
1342                vec![Ty::Con("Result".into(), vec![Ty::Var(0), Ty::Var(1)]), Ty::Var(0)],
1343                EffectSet::empty(),
1344                Ty::Var(0),
1345            ));
1346            // result.unwrap_or_else :: Result[T, E], (E) -> [Eff] T -> [Eff] T
1347            // Lazy fallback — the closure runs only on Err and receives the
1348            // error payload (effect-polymorphic on the closure). Mirrors
1349            // option.unwrap_or_else (#679).
1350            fields.insert("unwrap_or_else".into(), Ty::function(
1351                vec![
1352                    Ty::Con("Result".into(), vec![Ty::Var(0), Ty::Var(1)]),
1353                    Ty::function(vec![Ty::Var(1)], EffectSet::open_var(7), Ty::Var(0)),
1354                ],
1355                EffectSet::open_var(7),
1356                Ty::Var(0),
1357            ));
1358            // result.is_ok / is_err :: Result[T, E] -> Bool (#679)
1359            fields.insert("is_ok".into(), Ty::function(
1360                vec![Ty::Con("Result".into(), vec![Ty::Var(0), Ty::Var(1)])],
1361                EffectSet::empty(), Ty::bool()));
1362            fields.insert("is_err".into(), Ty::function(
1363                vec![Ty::Con("Result".into(), vec![Ty::Var(0), Ty::Var(1)])],
1364                EffectSet::empty(), Ty::bool()));
1365            Some(Ty::Record(fields))
1366        }
1367        "option" => {
1368            let mut fields = IndexMap::new();
1369            // option.map :: Option[T], (T) -> [E] U -> [E] Option[U]
1370            fields.insert("map".into(), Ty::function(
1371                vec![
1372                    Ty::Con("Option".into(), vec![Ty::Var(0)]),
1373                    Ty::function(vec![Ty::Var(0)], EffectSet::open_var(2), Ty::Var(1)),
1374                ],
1375                EffectSet::open_var(2),
1376                Ty::Con("Option".into(), vec![Ty::Var(1)]),
1377            ));
1378            // option.and_then :: Option[T], (T) -> [E] Option[U] -> [E] Option[U]
1379            // The compiler entry has been wired since the result/option
1380            // variant_map work landed; this signature was missed,
1381            // making the call fail to type-check until now.
1382            fields.insert("and_then".into(), Ty::function(
1383                vec![
1384                    Ty::Con("Option".into(), vec![Ty::Var(0)]),
1385                    Ty::function(vec![Ty::Var(0)], EffectSet::open_var(3),
1386                        Ty::Con("Option".into(), vec![Ty::Var(1)])),
1387                ],
1388                EffectSet::open_var(3),
1389                Ty::Con("Option".into(), vec![Ty::Var(1)]),
1390            ));
1391            fields.insert("unwrap_or".into(), Ty::function(
1392                vec![Ty::Con("Option".into(), vec![Ty::Var(0)]), Ty::Var(0)],
1393                EffectSet::empty(),
1394                Ty::Var(0),
1395            ));
1396            // option.unwrap_or_else :: Option[T], () -> [E] T -> [E] T
1397            // Lazy variant of unwrap_or: the default is computed by a closure
1398            // only when the value is None (effect-polymorphic on the closure).
1399            fields.insert("unwrap_or_else".into(), Ty::function(
1400                vec![
1401                    Ty::Con("Option".into(), vec![Ty::Var(0)]),
1402                    Ty::function(vec![], EffectSet::open_var(5), Ty::Var(0)),
1403                ],
1404                EffectSet::open_var(5),
1405                Ty::Var(0),
1406            ));
1407            // option.or_else :: Option[T], () -> [E] Option[T] -> [E] Option[T]
1408            // The closure takes no arguments because None has no payload to pass.
1409            fields.insert("or_else".into(), Ty::function(
1410                vec![
1411                    Ty::Con("Option".into(), vec![Ty::Var(0)]),
1412                    Ty::function(vec![], EffectSet::open_var(4),
1413                        Ty::Con("Option".into(), vec![Ty::Var(0)])),
1414                ],
1415                EffectSet::open_var(4),
1416                Ty::Con("Option".into(), vec![Ty::Var(0)]),
1417            ));
1418            // option.is_some / is_none :: Option[T] -> Bool (#679)
1419            fields.insert("is_some".into(), Ty::function(
1420                vec![Ty::Con("Option".into(), vec![Ty::Var(0)])],
1421                EffectSet::empty(), Ty::bool()));
1422            fields.insert("is_none".into(), Ty::function(
1423                vec![Ty::Con("Option".into(), vec![Ty::Var(0)])],
1424                EffectSet::empty(), Ty::bool()));
1425            // option.ok_or :: Option[T], E -> Result[T, E]
1426            // Cross from Option into Result, supplying the error for None (#679).
1427            fields.insert("ok_or".into(), Ty::function(
1428                vec![Ty::Con("Option".into(), vec![Ty::Var(0)]), Ty::Var(1)],
1429                EffectSet::empty(),
1430                Ty::Con("Result".into(), vec![Ty::Var(0), Ty::Var(1)]),
1431            ));
1432            Some(Ty::Record(fields))
1433        }
1434        "tuple" => {
1435            // Tuple accessors per §11.1. Polymorphic in the tuple's
1436            // element types; we use the same row-variable shape used
1437            // by list helpers. Tuples are heterogeneous, so each
1438            // accessor is statically typed via independent type
1439            // variables for each position.
1440            let mut fields = IndexMap::new();
1441            // fst :: (T0, T1) -> T0
1442            fields.insert("fst".into(), Ty::function(
1443                vec![Ty::Tuple(vec![Ty::Var(0), Ty::Var(1)])],
1444                EffectSet::empty(),
1445                Ty::Var(0),
1446            ));
1447            // snd :: (T0, T1) -> T1
1448            fields.insert("snd".into(), Ty::function(
1449                vec![Ty::Tuple(vec![Ty::Var(0), Ty::Var(1)])],
1450                EffectSet::empty(),
1451                Ty::Var(1),
1452            ));
1453            // third :: (T0, T1, T2) -> T2
1454            fields.insert("third".into(), Ty::function(
1455                vec![Ty::Tuple(vec![Ty::Var(0), Ty::Var(1), Ty::Var(2)])],
1456                EffectSet::empty(),
1457                Ty::Var(2),
1458            ));
1459            // len :: (T0, T1) -> Int  (covers any pair shape; Int back)
1460            fields.insert("len".into(), Ty::function(
1461                vec![Ty::Tuple(vec![Ty::Var(0), Ty::Var(1)])],
1462                EffectSet::empty(),
1463                Ty::int(),
1464            ));
1465            Some(Ty::Record(fields))
1466        }
1467        "map" => {
1468            // Persistent map. Keys are `Str` or `Int` only — Lex's
1469            // type system tracks them polymorphically as Var(0)
1470            // ("K") and lets the runtime check the key shape; both
1471            // cases fit into `MapKey`.
1472            //
1473            // Type variables: 0 = K, 1 = V.
1474            let mt   = || Ty::Con("Map".into(), vec![Ty::Var(0), Ty::Var(1)]);
1475            let pair = || Ty::Tuple(vec![Ty::Var(0), Ty::Var(1)]);
1476            let mut fields = IndexMap::new();
1477            // new :: () -> Map[K, V]
1478            fields.insert("new".into(), Ty::function(
1479                vec![], EffectSet::empty(), mt()));
1480            // size :: Map[K, V] -> Int
1481            fields.insert("size".into(), Ty::function(
1482                vec![mt()], EffectSet::empty(), Ty::int()));
1483            // has :: Map[K, V], K -> Bool
1484            fields.insert("has".into(), Ty::function(
1485                vec![mt(), Ty::Var(0)], EffectSet::empty(), Ty::bool()));
1486            // get :: Map[K, V], K -> Option[V]
1487            fields.insert("get".into(), Ty::function(
1488                vec![mt(), Ty::Var(0)], EffectSet::empty(),
1489                Ty::Con("Option".into(), vec![Ty::Var(1)])));
1490            // set :: Map[K, V], K, V -> Map[K, V]
1491            fields.insert("set".into(), Ty::function(
1492                vec![mt(), Ty::Var(0), Ty::Var(1)],
1493                EffectSet::empty(), mt()));
1494            // delete :: Map[K, V], K -> Map[K, V]
1495            fields.insert("delete".into(), Ty::function(
1496                vec![mt(), Ty::Var(0)], EffectSet::empty(), mt()));
1497            // keys :: Map[K, V] -> List[K]
1498            fields.insert("keys".into(), Ty::function(
1499                vec![mt()], EffectSet::empty(),
1500                Ty::List(Box::new(Ty::Var(0)))));
1501            // values :: Map[K, V] -> List[V]
1502            fields.insert("values".into(), Ty::function(
1503                vec![mt()], EffectSet::empty(),
1504                Ty::List(Box::new(Ty::Var(1)))));
1505            // entries :: Map[K, V] -> List[(K, V)]
1506            fields.insert("entries".into(), Ty::function(
1507                vec![mt()], EffectSet::empty(),
1508                Ty::List(Box::new(pair()))));
1509            // from_list :: List[(K, V)] -> Map[K, V]
1510            fields.insert("from_list".into(), Ty::function(
1511                vec![Ty::List(Box::new(pair()))],
1512                EffectSet::empty(), mt()));
1513            // merge :: Map[K, V], Map[K, V] -> Map[K, V]   (b overrides a)
1514            fields.insert("merge".into(), Ty::function(
1515                vec![mt(), mt()], EffectSet::empty(), mt()));
1516            // is_empty :: Map[K, V] -> Bool
1517            fields.insert("is_empty".into(), Ty::function(
1518                vec![mt()], EffectSet::empty(), Ty::bool()));
1519            // fold :: Map[K, V], A, (A, K, V) -> [E] A -> [E] A
1520            // Iteration order matches `map.entries` (BTreeMap-sorted by
1521            // key). Effect-polymorphic on the combiner like `list.fold`.
1522            // Type variable 2 = A (accumulator), effect row 3.
1523            fields.insert("fold".into(), Ty::function(
1524                vec![
1525                    mt(),
1526                    Ty::Var(2),
1527                    Ty::function(
1528                        vec![Ty::Var(2), Ty::Var(0), Ty::Var(1)],
1529                        EffectSet::open_var(3),
1530                        Ty::Var(2),
1531                    ),
1532                ],
1533                EffectSet::open_var(3),
1534                Ty::Var(2),
1535            ));
1536            Some(Ty::Record(fields))
1537        }
1538        "set" => {
1539            // Persistent set with the same key-type discipline as map.
1540            // Type variable: 0 = T (the element type, also the key type).
1541            let st   = || Ty::Con("Set".into(), vec![Ty::Var(0)]);
1542            let mut fields = IndexMap::new();
1543            // new :: () -> Set[T]
1544            fields.insert("new".into(), Ty::function(
1545                vec![], EffectSet::empty(), st()));
1546            // size :: Set[T] -> Int
1547            fields.insert("size".into(), Ty::function(
1548                vec![st()], EffectSet::empty(), Ty::int()));
1549            // has :: Set[T], T -> Bool
1550            fields.insert("has".into(), Ty::function(
1551                vec![st(), Ty::Var(0)], EffectSet::empty(), Ty::bool()));
1552            // add :: Set[T], T -> Set[T]
1553            fields.insert("add".into(), Ty::function(
1554                vec![st(), Ty::Var(0)], EffectSet::empty(), st()));
1555            // delete :: Set[T], T -> Set[T]
1556            fields.insert("delete".into(), Ty::function(
1557                vec![st(), Ty::Var(0)], EffectSet::empty(), st()));
1558            // to_list :: Set[T] -> List[T]
1559            fields.insert("to_list".into(), Ty::function(
1560                vec![st()], EffectSet::empty(),
1561                Ty::List(Box::new(Ty::Var(0)))));
1562            // from_list :: List[T] -> Set[T]
1563            fields.insert("from_list".into(), Ty::function(
1564                vec![Ty::List(Box::new(Ty::Var(0)))],
1565                EffectSet::empty(), st()));
1566            // union :: Set[T], Set[T] -> Set[T]
1567            fields.insert("union".into(), Ty::function(
1568                vec![st(), st()], EffectSet::empty(), st()));
1569            // intersect :: Set[T], Set[T] -> Set[T]
1570            fields.insert("intersect".into(), Ty::function(
1571                vec![st(), st()], EffectSet::empty(), st()));
1572            // diff :: Set[T], Set[T] -> Set[T]
1573            fields.insert("diff".into(), Ty::function(
1574                vec![st(), st()], EffectSet::empty(), st()));
1575            // is_empty :: Set[T] -> Bool
1576            fields.insert("is_empty".into(), Ty::function(
1577                vec![st()], EffectSet::empty(), Ty::bool()));
1578            // is_subset :: Set[T], Set[T] -> Bool   (a is subset of b)
1579            fields.insert("is_subset".into(), Ty::function(
1580                vec![st(), st()], EffectSet::empty(), Ty::bool()));
1581            Some(Ty::Record(fields))
1582        }
1583        "iter" => {
1584            // Positional iterator (#364) + lazy variant via `iter.unfold`
1585            // (#376). Internal value shapes are `__IterEager(list, idx)` or
1586            // `__IterLazy(seed, step)`; all operations compile-inline and
1587            // dispatch on the variant tag at runtime.
1588            // Type var slots: 0 = T (element), 1 = U (mapped element) /
1589            // A (fold acc), 2 = S (unfold seed).
1590            let it = |n: u32| Ty::Con("Iter".into(), vec![Ty::Var(n)]);
1591            let mut fields = IndexMap::new();
1592            // from_list :: List[T] -> Iter[T]
1593            fields.insert("from_list".into(), Ty::function(
1594                vec![Ty::List(Box::new(Ty::Var(0)))],
1595                EffectSet::empty(), it(0)));
1596            // unfold[S, T] :: S, (S) -> Option[(T, S)] -> Iter[T] (#376)
1597            // The step closure may carry any effect row; the iterator
1598            // itself stays effect-free since the effects only fire when
1599            // the step is invoked via `iter.next` / `iter.to_list`.
1600            fields.insert("unfold".into(), Ty::function(
1601                vec![
1602                    Ty::Var(2), // seed S
1603                    Ty::function(
1604                        vec![Ty::Var(2)],
1605                        EffectSet::open_var(3),
1606                        Ty::Con("Option".into(), vec![
1607                            Ty::Tuple(vec![Ty::Var(0), Ty::Var(2)])
1608                        ]),
1609                    ),
1610                ],
1611                EffectSet::empty(), it(0)));
1612            // next :: Iter[T] -> Option[(T, Iter[T])]
1613            fields.insert("next".into(), Ty::function(
1614                vec![it(0)],
1615                EffectSet::empty(),
1616                Ty::Con("Option".into(), vec![
1617                    Ty::Tuple(vec![Ty::Var(0), it(0)])
1618                ])));
1619            // is_empty :: Iter[T] -> Bool
1620            fields.insert("is_empty".into(), Ty::function(
1621                vec![it(0)], EffectSet::empty(), Ty::bool()));
1622            // count :: Iter[T] -> Int   (remaining elements)
1623            fields.insert("count".into(), Ty::function(
1624                vec![it(0)], EffectSet::empty(), Ty::int()));
1625            // take :: Iter[T], Int -> Iter[T]
1626            fields.insert("take".into(), Ty::function(
1627                vec![it(0), Ty::int()], EffectSet::empty(), it(0)));
1628            // skip :: Iter[T], Int -> Iter[T]
1629            fields.insert("skip".into(), Ty::function(
1630                vec![it(0), Ty::int()], EffectSet::empty(), it(0)));
1631            // to_list :: Iter[T] -> List[T]
1632            fields.insert("to_list".into(), Ty::function(
1633                vec![it(0)], EffectSet::empty(),
1634                Ty::List(Box::new(Ty::Var(0)))));
1635            // collect :: Iter[T] -> List[T] — alias for `to_list`
1636            // (matches Rust / Python / Kotlin naming so call sites
1637            // coming from those languages don't have to re-learn).
1638            fields.insert("collect".into(), Ty::function(
1639                vec![it(0)], EffectSet::empty(),
1640                Ty::List(Box::new(Ty::Var(0)))));
1641            // map :: [E] Iter[T], (T) -> [E] U -> [E] Iter[U]
1642            fields.insert("map".into(), Ty::function(
1643                vec![
1644                    it(0),
1645                    Ty::function(vec![Ty::Var(0)], EffectSet::open_var(2), Ty::Var(1)),
1646                ],
1647                EffectSet::open_var(2), it(1)));
1648            // filter :: [E] Iter[T], (T) -> [E] Bool -> [E] Iter[T]
1649            fields.insert("filter".into(), Ty::function(
1650                vec![
1651                    it(0),
1652                    Ty::function(vec![Ty::Var(0)], EffectSet::open_var(1), Ty::bool()),
1653                ],
1654                EffectSet::open_var(1), it(0)));
1655            // fold :: [E] Iter[T], A, (A, T) -> [E] A -> [E] A
1656            fields.insert("fold".into(), Ty::function(
1657                vec![
1658                    it(0),
1659                    Ty::Var(1),
1660                    Ty::function(vec![Ty::Var(1), Ty::Var(0)], EffectSet::open_var(2), Ty::Var(1)),
1661                ],
1662                EffectSet::open_var(2), Ty::Var(1)));
1663            Some(Ty::Record(fields))
1664        }
1665        "flow" => {
1666            // Orchestration primitives (spec §11.2). Each takes one or
1667            // more closures and returns a closure with a derived shape.
1668            let mut fields = IndexMap::new();
1669            // sequential[T, U, V](f: (T) -> U, g: (U) -> V) -> (T) -> V
1670            fields.insert("sequential".into(), Ty::function(
1671                vec![
1672                    Ty::function(vec![Ty::Var(0)], EffectSet::empty(), Ty::Var(1)),
1673                    Ty::function(vec![Ty::Var(1)], EffectSet::empty(), Ty::Var(2)),
1674                ],
1675                EffectSet::empty(),
1676                Ty::function(vec![Ty::Var(0)], EffectSet::empty(), Ty::Var(2)),
1677            ));
1678            // branch[T, U](cond: (T) -> Bool, t: (T) -> U, f: (T) -> U) -> (T) -> U
1679            fields.insert("branch".into(), Ty::function(
1680                vec![
1681                    Ty::function(vec![Ty::Var(0)], EffectSet::empty(), Ty::bool()),
1682                    Ty::function(vec![Ty::Var(0)], EffectSet::empty(), Ty::Var(1)),
1683                    Ty::function(vec![Ty::Var(0)], EffectSet::empty(), Ty::Var(1)),
1684                ],
1685                EffectSet::empty(),
1686                Ty::function(vec![Ty::Var(0)], EffectSet::empty(), Ty::Var(1)),
1687            ));
1688            // retry[T, U, E, Eff](
1689            //   f: (T) -> [Eff] Result[U, E], n: Int
1690            // ) -> (T) -> [Eff] Result[U, E]
1691            // open_var(3) is the effect row carried by `f`; the
1692            // combinator itself is pure, so the outer EffectSet is
1693            // empty. The returned closure propagates Eff unchanged.
1694            let result_ty = Ty::Con("Result".into(), vec![Ty::Var(1), Ty::Var(2)]);
1695            fields.insert("retry".into(), Ty::function(
1696                vec![
1697                    Ty::function(vec![Ty::Var(0)], EffectSet::open_var(3), result_ty.clone()),
1698                    Ty::int(),
1699                ],
1700                EffectSet::empty(),
1701                Ty::function(vec![Ty::Var(0)], EffectSet::open_var(3), result_ty.clone()),
1702            ));
1703            // retry_with_backoff[T, U, E, Eff](
1704            //   f: (T) -> [Eff] Result[U, E], attempts: Int, base_ms: Int,
1705            // ) -> (T) -> [Eff, time] Result[U, E]
1706            // Same retry shape as `flow.retry` plus an exponential
1707            // backoff between attempts. The result function carries
1708            // `[time]` (from `time.sleep_ms`) unioned with the inner
1709            // closure's effect row Eff, so e.g. a `[net]` closure
1710            // produces a `[net, time]` result function. (#226)
1711            fields.insert("retry_with_backoff".into(), Ty::function(
1712                vec![
1713                    Ty::function(vec![Ty::Var(0)], EffectSet::open_var(3), result_ty.clone()),
1714                    Ty::int(),
1715                    Ty::int(),
1716                ],
1717                EffectSet::empty(),
1718                Ty::function(vec![Ty::Var(0)],
1719                    EffectSet::open_var(3).union(&EffectSet::singleton("time")), result_ty),
1720            ));
1721            // parallel[A, B](fa: () -> A, fb: () -> B) -> () -> (A, B)
1722            // Sequential implementation today; spec §11.2 reserves the
1723            // option of a true-threaded scheduler. parallel_record is
1724            // listed in the spec but not yet implemented — it needs row
1725            // polymorphism over the input record's fields plus a
1726            // record-iteration trampoline; tracked as follow-up.
1727            fields.insert("parallel".into(), Ty::function(
1728                vec![
1729                    Ty::function(vec![], EffectSet::empty(), Ty::Var(0)),
1730                    Ty::function(vec![], EffectSet::empty(), Ty::Var(1)),
1731                ],
1732                EffectSet::empty(),
1733                Ty::function(vec![], EffectSet::empty(),
1734                    Ty::Tuple(vec![Ty::Var(0), Ty::Var(1)])),
1735            ));
1736            // parallel_list[T](actions: List[() -> T]) -> List[T]
1737            // Variadic counterpart to `parallel`. Runs each action and
1738            // collects results in input order. Sequential under the
1739            // hood (same caveat as `parallel`); spec §11.2 reserves
1740            // true threading for a future scheduler. Unlike `parallel`,
1741            // this returns the result list directly rather than a
1742            // closure, since the input arity is dynamic.
1743            fields.insert("parallel_list".into(), Ty::function(
1744                vec![
1745                    Ty::List(Box::new(
1746                        Ty::function(vec![], EffectSet::empty(), Ty::Var(0)),
1747                    )),
1748                ],
1749                EffectSet::empty(),
1750                Ty::List(Box::new(Ty::Var(0))),
1751            ));
1752            Some(Ty::Record(fields))
1753        }
1754        "crypto" => {
1755            let mut fields = IndexMap::new();
1756            // Hashes: Bytes -> Bytes (digest as raw bytes).
1757            // SHA-256 / SHA-512 are vetted. MD5 is retained only for
1758            // interop with legacy systems — new code should not use it.
1759            // BLAKE2b (#382) is included as a faster alternative to
1760            // SHA-512 with the same security level.
1761            for name in &["sha256", "sha512", "md5", "blake2b"] {
1762                fields.insert((*name).into(), Ty::function(
1763                    vec![Ty::bytes()],
1764                    EffectSet::empty(),
1765                    Ty::bytes(),
1766                ));
1767            }
1768            // Hex-string convenience hashers (#382): hash a Str directly,
1769            // return the digest as a lowercase hex Str. Equivalent to
1770            // `crypto.hex_encode(crypto.shaN(bytes_from_str(s)))` but
1771            // saves the two-step incantation for the common case.
1772            for name in &["sha256_str", "sha512_str"] {
1773                fields.insert((*name).into(), Ty::function(
1774                    vec![Ty::str()],
1775                    EffectSet::empty(),
1776                    Ty::str(),
1777                ));
1778            }
1779            // HMAC: (key :: Bytes, data :: Bytes) -> Bytes
1780            for name in &["hmac_sha256", "hmac_sha512"] {
1781                fields.insert((*name).into(), Ty::function(
1782                    vec![Ty::bytes(), Ty::bytes()],
1783                    EffectSet::empty(),
1784                    Ty::bytes(),
1785                ));
1786            }
1787            // ed25519 asymmetric signatures (#643). A secret key is its 32-byte
1788            // seed (generate via `crypto.random(32)`); all three ops are pure.
1789            //   ed25519_public_key(secret :: Bytes) -> Result[Bytes, Str]
1790            //   ed25519_sign(secret :: Bytes, message :: Bytes) -> Result[Bytes, Str]
1791            //   ed25519_verify(public :: Bytes, message :: Bytes, sig :: Bytes) -> Bool
1792            fields.insert("ed25519_public_key".into(), Ty::function(
1793                vec![Ty::bytes()],
1794                EffectSet::empty(),
1795                Ty::Con("Result".into(), vec![Ty::bytes(), Ty::str()]),
1796            ));
1797            fields.insert("ed25519_sign".into(), Ty::function(
1798                vec![Ty::bytes(), Ty::bytes()],
1799                EffectSet::empty(),
1800                Ty::Con("Result".into(), vec![Ty::bytes(), Ty::str()]),
1801            ));
1802            fields.insert("ed25519_verify".into(), Ty::function(
1803                vec![Ty::bytes(), Ty::bytes(), Ty::bytes()],
1804                EffectSet::empty(),
1805                Ty::bool(),
1806            ));
1807            // Whether `bytes` decompresses to a valid point on the Edwards25519
1808            // curve (#93 follow-up: Solana Program Derived Address search --
1809            // a PDA is valid iff the candidate 32 bytes are NOT a valid point,
1810            // so callers try bump seeds until this returns false).
1811            //   ed25519_is_valid_point(bytes :: Bytes) -> Bool
1812            fields.insert("ed25519_is_valid_point".into(), Ty::function(
1813                vec![Ty::bytes()],
1814                EffectSet::empty(),
1815                Ty::bool(),
1816            ));
1817            // P-256 ECDSA / ES256 (#651). The JWT/SD-JWT signature
1818            // algorithm for AP2 agent keys; `lex-jose` builds the
1819            // token layer on top of these primitives. Key bytes are
1820            // raw: a secret key is the 32-byte scalar, a public key is
1821            // the 33-byte SEC1 compressed point; JWK serialization
1822            // lives downstream in `lex-jose`.
1823            //
1824            //   p256_generate()                       -> [random] Result[Bytes, Str]
1825            //   p256_public_key(sk :: Bytes)          -> Result[Bytes, Str]
1826            //   p256_sign(sk :: Bytes, msg :: Bytes)  -> Result[Bytes, Str]
1827            //   p256_verify(pk :: Bytes, msg :: Bytes, sig :: Bytes) -> Bool
1828            //
1829            // `p256_generate` mints fresh key material from the OS RNG,
1830            // so it carries the same fine-grained `[random]` effect as
1831            // `crypto.random` — every key-minting call stays visible to
1832            // `lex audit --effect random`. (The issue sketched `[env]`;
1833            // `[random]` is the dedicated effect for OS randomness in
1834            // this codebase, so we use that for consistency.)
1835            // `sign`/`verify` are pure: signing hashes `msg` with
1836            // SHA-256 internally (standard ES256) and the signature is
1837            // DER-encoded.
1838            fields.insert("p256_generate".into(), Ty::function(
1839                vec![],
1840                EffectSet::singleton("random"),
1841                Ty::Con("Result".into(), vec![Ty::bytes(), Ty::str()]),
1842            ));
1843            fields.insert("p256_public_key".into(), Ty::function(
1844                vec![Ty::bytes()],
1845                EffectSet::empty(),
1846                Ty::Con("Result".into(), vec![Ty::bytes(), Ty::str()]),
1847            ));
1848            fields.insert("p256_sign".into(), Ty::function(
1849                vec![Ty::bytes(), Ty::bytes()],
1850                EffectSet::empty(),
1851                Ty::Con("Result".into(), vec![Ty::bytes(), Ty::str()]),
1852            ));
1853            fields.insert("p256_verify".into(), Ty::function(
1854                vec![Ty::bytes(), Ty::bytes(), Ty::bytes()],
1855                EffectSet::empty(),
1856                Ty::bool(),
1857            ));
1858            // secp256k1 ECDSA + recovery (#655). The EVM curve — backs
1859            // EIP-712 typed-data signing (EIP-3009 / x402 `exact`) and
1860            // Ethereum address derivation. Unlike `p256_*`/`ed25519_*`,
1861            // the sign/verify ops here take a **pre-hashed 32-byte
1862            // digest** (EIP-712 already hashes), hence the `_digest`
1863            // suffix — they do NOT hash the input again.
1864            //
1865            // - Secret key: 32-byte scalar.
1866            // - Public key: 65-byte UNCOMPRESSED SEC1 point (0x04‖X‖Y),
1867            //   so an address is `keccak256(pk[1..])[12..]` with no
1868            //   decompression step. (p256 returns compressed; the EVM
1869            //   convention is uncompressed.)
1870            // - Signature: 65 bytes `r(32)‖s(32)‖v(1)`, v ∈ {27,28}
1871            //   (Ethereum), low-S normalized (EIP-2).
1872            //
1873            //   keccak256(data :: Bytes) -> Bytes
1874            //   secp256k1_generate()                          -> [random] Result[Bytes, Str]
1875            //   secp256k1_public_key(sk :: Bytes)             -> Result[Bytes, Str]
1876            //   secp256k1_sign_digest(sk :: Bytes, digest :: Bytes) -> Result[Bytes, Str]
1877            //   secp256k1_recover(digest :: Bytes, sig :: Bytes)    -> Result[Bytes, Str]
1878            //   secp256k1_verify(pk :: Bytes, digest :: Bytes, sig :: Bytes) -> Bool
1879            //
1880            // `secp256k1_generate` mints from the OS RNG, so it carries
1881            // the same `[random]` effect as `crypto.random` / `p256_generate`.
1882            fields.insert("keccak256".into(), Ty::function(
1883                vec![Ty::bytes()],
1884                EffectSet::empty(),
1885                Ty::bytes(),
1886            ));
1887            fields.insert("secp256k1_generate".into(), Ty::function(
1888                vec![],
1889                EffectSet::singleton("random"),
1890                Ty::Con("Result".into(), vec![Ty::bytes(), Ty::str()]),
1891            ));
1892            fields.insert("secp256k1_public_key".into(), Ty::function(
1893                vec![Ty::bytes()],
1894                EffectSet::empty(),
1895                Ty::Con("Result".into(), vec![Ty::bytes(), Ty::str()]),
1896            ));
1897            fields.insert("secp256k1_sign_digest".into(), Ty::function(
1898                vec![Ty::bytes(), Ty::bytes()],
1899                EffectSet::empty(),
1900                Ty::Con("Result".into(), vec![Ty::bytes(), Ty::str()]),
1901            ));
1902            fields.insert("secp256k1_recover".into(), Ty::function(
1903                vec![Ty::bytes(), Ty::bytes()],
1904                EffectSet::empty(),
1905                Ty::Con("Result".into(), vec![Ty::bytes(), Ty::str()]),
1906            ));
1907            fields.insert("secp256k1_verify".into(), Ty::function(
1908                vec![Ty::bytes(), Ty::bytes(), Ty::bytes()],
1909                EffectSet::empty(),
1910                Ty::bool(),
1911            ));
1912            // base64 / hex
1913            fields.insert("base64_encode".into(), Ty::function(
1914                vec![Ty::bytes()], EffectSet::empty(), Ty::str()));
1915            fields.insert("base64_decode".into(), Ty::function(
1916                vec![Ty::str()], EffectSet::empty(),
1917                Ty::Con("Result".into(), vec![Ty::bytes(), Ty::str()])));
1918            // URL-safe base64 (#382): the alphabet swaps `+/` for `-_`
1919            // and omits padding. Required by JWT, signed-cookie, and
1920            // most token-bearing URL paths.
1921            fields.insert("base64url_encode".into(), Ty::function(
1922                vec![Ty::bytes()], EffectSet::empty(), Ty::str()));
1923            fields.insert("base64url_decode".into(), Ty::function(
1924                vec![Ty::str()], EffectSet::empty(),
1925                Ty::Con("Result".into(), vec![Ty::bytes(), Ty::str()])));
1926            fields.insert("hex_encode".into(), Ty::function(
1927                vec![Ty::bytes()], EffectSet::empty(), Ty::str()));
1928            fields.insert("hex_decode".into(), Ty::function(
1929                vec![Ty::str()], EffectSet::empty(),
1930                Ty::Con("Result".into(), vec![Ty::bytes(), Ty::str()])));
1931            // base58 (#658) — Bitcoin/Solana alphabet, no checksum. Solana
1932            // addresses, mints, signatures and the x402 `exact` payload are
1933            // base58; this is the Solana analog of keccak/secp256k1 (#655).
1934            fields.insert("base58_encode".into(), Ty::function(
1935                vec![Ty::bytes()], EffectSet::empty(), Ty::str()));
1936            fields.insert("base58_decode".into(), Ty::function(
1937                vec![Ty::str()], EffectSet::empty(),
1938                Ty::Con("Result".into(), vec![Ty::bytes(), Ty::str()])));
1939            // Constant-time equality (for HMAC verification etc.).
1940            // `eq` / `eq_str` (#382) are the recommended spelling;
1941            // `constant_time_eq` stays as a deprecated alias.
1942            fields.insert("constant_time_eq".into(), Ty::function(
1943                vec![Ty::bytes(), Ty::bytes()], EffectSet::empty(), Ty::bool()));
1944            fields.insert("eq".into(), Ty::function(
1945                vec![Ty::bytes(), Ty::bytes()], EffectSet::empty(), Ty::bool()));
1946            fields.insert("eq_str".into(), Ty::function(
1947                vec![Ty::str(), Ty::str()], EffectSet::empty(), Ty::bool()));
1948            // Cryptographically-secure random bytes — OS RNG, not the
1949            // deterministic `rand.int_in` stub. The new `[random]`
1950            // effect is fine-grained on purpose so reviewers can find
1951            // every token-generating call via `lex audit --effect
1952            // random`.
1953            fields.insert("random".into(), Ty::function(
1954                vec![Ty::int()],
1955                EffectSet::singleton("random"),
1956                Ty::bytes(),
1957            ));
1958            // random_str_hex (#382): the most common token-mint pattern
1959            // — N random bytes rendered as 2N lowercase hex chars.
1960            // Suitable for session ids, request ids, OAuth `state`,
1961            // CSRF tokens; not suitable as a JWT signing key (use raw
1962            // `random` for that).
1963            fields.insert("random_str_hex".into(), Ty::function(
1964                vec![Ty::int()],
1965                EffectSet::singleton("random"),
1966                Ty::str(),
1967            ));
1968
1969            // AEAD: authenticated encryption with associated data
1970            // (#382 AEAD slice). Both algorithms use a 12-byte nonce
1971            // and a 16-byte authentication tag. `seal` returns the
1972            // structured `AeadResult { ciphertext, tag }`; `open`
1973            // returns `Result[Bytes, Str]` so authentication failures
1974            // surface as `Err`, not a panic.
1975            //
1976            // - **AES-GCM** (`aes_gcm_seal/open`): AES-128/192/256-GCM,
1977            //   key length determined by the supplied key bytes (16, 24,
1978            //   or 32). NIST-recommended; hardware-accelerated on most CPUs.
1979            // - **ChaCha20-Poly1305** (`chacha20_poly1305_seal/open`):
1980            //   Always a 32-byte key. Equivalent security to AES-GCM
1981            //   without needing AES-NI hardware; preferred on constrained
1982            //   targets.
1983            let aead_t = || Ty::Con("AeadResult".into(), vec![]);
1984            // Seal: returns Result[AeadResult, Str] rather than bare
1985            // AeadResult so input-validation errors (wrong key length,
1986            // wrong nonce length) surface as `Err` to the Lex caller
1987            // instead of panicking the VM. AES-GCM expects 16/24/32-byte
1988            // keys; ChaCha20-Poly1305 expects exactly 32. Both expect a
1989            // 12-byte nonce.
1990            for name in &["aes_gcm_seal", "chacha20_poly1305_seal"] {
1991                fields.insert((*name).into(), Ty::function(
1992                    // (key, nonce, aad, plaintext) -> Result[AeadResult, Str]
1993                    vec![Ty::bytes(), Ty::bytes(), Ty::bytes(), Ty::bytes()],
1994                    EffectSet::empty(),
1995                    Ty::Con("Result".into(), vec![aead_t(), Ty::str()]),
1996                ));
1997            }
1998            for name in &["aes_gcm_open", "chacha20_poly1305_open"] {
1999                fields.insert((*name).into(), Ty::function(
2000                    // (key, nonce, aad, ciphertext, tag) -> Result[Bytes, Str]
2001                    vec![Ty::bytes(), Ty::bytes(), Ty::bytes(), Ty::bytes(), Ty::bytes()],
2002                    EffectSet::empty(),
2003                    Ty::Con("Result".into(), vec![Ty::bytes(), Ty::str()]),
2004                ));
2005            }
2006
2007            // AES-CBC, UNAUTHENTICATED (#760). Retained on the same footing
2008            // as `md5` above: present because protocols someone else designed
2009            // demand it, never because it is a good way to protect anything.
2010            // New code wants `aes_gcm_seal`.
2011            //
2012            // The `_raw` suffix is deliberate and load-bearing. There is no
2013            // MAC here, so ciphertext is malleable and decryption is a
2014            // padding oracle for anyone who can feed it input. A name that
2015            // sat naturally beside `aes_gcm_seal` would eventually be reached
2016            // for by someone storing a secret, which is the failure this
2017            // naming exists to make awkward.
2018            //
2019            //   aes_cbc_encrypt_raw(key, iv, plaintext)  -> Result[Bytes, Str]
2020            //   aes_cbc_decrypt_raw(key, iv, ciphertext) -> Result[Bytes, Str]
2021            //
2022            // Key length selects the variant (16/24/32 -> AES-128/192/256),
2023            // matching how `aes_gcm_seal` already infers from the key slice.
2024            // The IV is always 16 bytes. Padding is PKCS#7, applied on
2025            // encrypt and validated on decrypt — a bad pad is `Err`, not a
2026            // panic and not silently-truncated plaintext.
2027            for name in &["aes_cbc_encrypt_raw", "aes_cbc_decrypt_raw"] {
2028                fields.insert((*name).into(), Ty::function(
2029                    vec![Ty::bytes(), Ty::bytes(), Ty::bytes()],
2030                    EffectSet::empty(),
2031                    Ty::Con("Result".into(), vec![Ty::bytes(), Ty::str()]),
2032                ));
2033            }
2034
2035            // KDFs: key-derivation functions (#382 KDF slice). All three
2036            // return `Result[Bytes, Str]` so caller-controlled inputs
2037            // (iteration count, output length, argon2id work factors)
2038            // that violate the underlying primitive's contract surface
2039            // as `Err` rather than panicking the VM. None require a new
2040            // effect — these are pure derivations.
2041            //
2042            // - **`pbkdf2_sha256(password, salt, iterations, len)`** —
2043            //   RFC 8018 PBKDF2 with HMAC-SHA256. Use ≥ 600_000 iterations
2044            //   for password storage (OWASP 2024). Older deployments
2045            //   pinning < 100_000 should rotate.
2046            // - **`hkdf_sha256(ikm, salt, info, len)`** — RFC 5869 extract+
2047            //   expand. Use for deriving multiple keys from a single
2048            //   high-entropy input (TLS, Noise, JWT-key rotation).
2049            //   Output length capped at 255 × 32 = 8160 bytes.
2050            // - **`argon2id(password, salt, t_cost, m_cost, len)`** —
2051            //   RFC 9106 Argon2id. Recommended for *new* password
2052            //   hashing. OWASP 2024 baseline: `t_cost=2, m_cost=19456`
2053            //   (19 MiB), or use `lex-crypto`'s vetted wrapper.
2054            fields.insert("pbkdf2_sha256".into(), Ty::function(
2055                // (password, salt, iterations, len) -> Result[Bytes, Str]
2056                vec![Ty::bytes(), Ty::bytes(), Ty::int(), Ty::int()],
2057                EffectSet::empty(),
2058                Ty::Con("Result".into(), vec![Ty::bytes(), Ty::str()]),
2059            ));
2060            fields.insert("hkdf_sha256".into(), Ty::function(
2061                // (ikm, salt, info, len) -> Result[Bytes, Str]
2062                vec![Ty::bytes(), Ty::bytes(), Ty::bytes(), Ty::int()],
2063                EffectSet::empty(),
2064                Ty::Con("Result".into(), vec![Ty::bytes(), Ty::str()]),
2065            ));
2066            fields.insert("argon2id".into(), Ty::function(
2067                // (password, salt, t_cost, m_cost, len) -> Result[Bytes, Str]
2068                vec![Ty::bytes(), Ty::bytes(), Ty::int(), Ty::int(), Ty::int()],
2069                EffectSet::empty(),
2070                Ty::Con("Result".into(), vec![Ty::bytes(), Ty::str()]),
2071            ));
2072
2073            Some(Ty::Record(fields))
2074        }
2075        "deque" => {
2076            // Persistent double-ended queue. Push/pop O(1) on both
2077            // ends; iteration order is front-to-back.
2078            // Type variable: 0 = T.
2079            let dt   = || Ty::Con("Deque".into(), vec![Ty::Var(0)]);
2080            let pair = || Ty::Tuple(vec![Ty::Var(0), dt()]);
2081            let mut fields = IndexMap::new();
2082            // new :: () -> Deque[T]
2083            fields.insert("new".into(), Ty::function(
2084                vec![], EffectSet::empty(), dt()));
2085            // size :: Deque[T] -> Int
2086            fields.insert("size".into(), Ty::function(
2087                vec![dt()], EffectSet::empty(), Ty::int()));
2088            // is_empty :: Deque[T] -> Bool
2089            fields.insert("is_empty".into(), Ty::function(
2090                vec![dt()], EffectSet::empty(), Ty::bool()));
2091            // push_back / push_front :: Deque[T], T -> Deque[T]
2092            for n in &["push_back", "push_front"] {
2093                fields.insert((*n).into(), Ty::function(
2094                    vec![dt(), Ty::Var(0)], EffectSet::empty(), dt()));
2095            }
2096            // pop_back / pop_front :: Deque[T] -> Option[(T, Deque[T])]
2097            for n in &["pop_back", "pop_front"] {
2098                fields.insert((*n).into(), Ty::function(
2099                    vec![dt()], EffectSet::empty(),
2100                    Ty::Con("Option".into(), vec![pair()])));
2101            }
2102            // peek_back / peek_front :: Deque[T] -> Option[T]
2103            for n in &["peek_back", "peek_front"] {
2104                fields.insert((*n).into(), Ty::function(
2105                    vec![dt()], EffectSet::empty(),
2106                    Ty::Con("Option".into(), vec![Ty::Var(0)])));
2107            }
2108            // from_list :: List[T] -> Deque[T]
2109            fields.insert("from_list".into(), Ty::function(
2110                vec![Ty::List(Box::new(Ty::Var(0)))],
2111                EffectSet::empty(), dt()));
2112            // to_list :: Deque[T] -> List[T]
2113            fields.insert("to_list".into(), Ty::function(
2114                vec![dt()], EffectSet::empty(),
2115                Ty::List(Box::new(Ty::Var(0)))));
2116            Some(Ty::Record(fields))
2117        }
2118        "log" => {
2119            // Structured logging behind a [log] effect. Emit ops route
2120            // through a runtime-configured sink (stderr by default;
2121            // can be redirected via set_sink). Configuration ops
2122            // mutate the global sink and so are gated [io].
2123            let result_str = |t: Ty| Ty::Con("Result".into(), vec![t, Ty::str()]);
2124            let mut fields = IndexMap::new();
2125            for level in &["debug", "info", "warn", "error"] {
2126                fields.insert((*level).into(), Ty::function(
2127                    vec![Ty::str()],
2128                    EffectSet::singleton("log"),
2129                    Ty::Unit,
2130                ));
2131            }
2132            // set_level :: Str -> [io] Result[Unit, Str]
2133            fields.insert("set_level".into(), Ty::function(
2134                vec![Ty::str()],
2135                EffectSet::singleton("io"),
2136                result_str(Ty::Unit)));
2137            // set_format :: Str -> [io] Result[Unit, Str]
2138            fields.insert("set_format".into(), Ty::function(
2139                vec![Ty::str()],
2140                EffectSet::singleton("io"),
2141                result_str(Ty::Unit)));
2142            // set_sink :: Str -> [io, fs_write] Result[Unit, Str]
2143            fields.insert("set_sink".into(), Ty::function(
2144                vec![Ty::str()],
2145                EffectSet {
2146                    concrete: [crate::types::EffectKind::bare("io"), crate::types::EffectKind::bare("fs_write")].into_iter().collect(),
2147                    var: None,
2148                },
2149                result_str(Ty::Unit)));
2150            Some(Ty::Record(fields))
2151        }
2152        "datetime" => {
2153            // Instant and Duration are nominal opaque Ints under the
2154            // hood (nanoseconds-since-UTC-epoch and signed nanoseconds
2155            // respectively); the type checker tracks the distinction
2156            // even though both values look like Int at runtime.
2157            //
2158            // Tz is the variant
2159            //     Utc | Local | Offset(Int) | Iana(Str)
2160            // registered as a built-in nominal type in
2161            // `TypeEnv::new_with_builtins`. The pre-v1 stringly Tz
2162            // ("UTC"/"Local"/IANA-name/"+05:30") is no longer accepted
2163            // — passing a `Str` to `to_components` is now a type
2164            // error.
2165            let inst   = || Ty::Con("Instant".into(), vec![]);
2166            let dur    = || Ty::Con("Duration".into(), vec![]);
2167            let tz     = || Ty::Con("Tz".into(), vec![]);
2168            let result_str = |t: Ty| Ty::Con("Result".into(), vec![t, Ty::str()]);
2169            let dt_t = || {
2170                let mut fs = IndexMap::new();
2171                fs.insert("year".into(),    Ty::int());
2172                fs.insert("month".into(),   Ty::int());
2173                fs.insert("day".into(),     Ty::int());
2174                fs.insert("hour".into(),    Ty::int());
2175                fs.insert("minute".into(),  Ty::int());
2176                fs.insert("second".into(),  Ty::int());
2177                fs.insert("nano".into(),    Ty::int());
2178                fs.insert("tz_offset_minutes".into(), Ty::int());
2179                Ty::Record(fs)
2180            };
2181            let mut fields = IndexMap::new();
2182            fields.insert("now".into(), Ty::function(
2183                vec![], EffectSet::singleton("time"), inst()));
2184            fields.insert("parse_iso".into(), Ty::function(
2185                vec![Ty::str()], EffectSet::empty(), result_str(inst())));
2186            fields.insert("format_iso".into(), Ty::function(
2187                vec![inst()], EffectSet::empty(), Ty::str()));
2188            fields.insert("parse".into(), Ty::function(
2189                vec![Ty::str(), Ty::str()], EffectSet::empty(), result_str(inst())));
2190            fields.insert("format".into(), Ty::function(
2191                vec![inst(), Ty::str()], EffectSet::empty(), Ty::str()));
2192            fields.insert("to_components".into(), Ty::function(
2193                vec![inst(), tz()], EffectSet::empty(), result_str(dt_t())));
2194            fields.insert("from_components".into(), Ty::function(
2195                vec![dt_t()], EffectSet::empty(), result_str(inst())));
2196            fields.insert("add".into(), Ty::function(
2197                vec![inst(), dur()], EffectSet::empty(), inst()));
2198            fields.insert("diff".into(), Ty::function(
2199                vec![inst(), inst()], EffectSet::empty(), dur()));
2200            fields.insert("duration_seconds".into(), Ty::function(
2201                vec![Ty::float()], EffectSet::empty(), dur()));
2202            fields.insert("duration_minutes".into(), Ty::function(
2203                vec![Ty::int()], EffectSet::empty(), dur()));
2204            fields.insert("duration_days".into(), Ty::function(
2205                vec![Ty::int()], EffectSet::empty(), dur()));
2206            // #331: comparison ops on Instant.
2207            fields.insert("before".into(), Ty::function(
2208                vec![inst(), inst()], EffectSet::empty(), Ty::bool()));
2209            fields.insert("after".into(), Ty::function(
2210                vec![inst(), inst()], EffectSet::empty(), Ty::bool()));
2211            // compare :: Instant, Instant -> Int  (-1 / 0 / +1)
2212            fields.insert("compare".into(), Ty::function(
2213                vec![inst(), inst()], EffectSet::empty(), Ty::int()));
2214            Some(Ty::Record(fields))
2215        }
2216        // #331: duration module — scalar extraction from Duration values.
2217        "duration" => {
2218            let dur = || Ty::Con("Duration".into(), vec![]);
2219            let mut fields = IndexMap::new();
2220            // Scalar extraction from a Duration (nanoseconds under the
2221            // hood). Each truncates toward zero. `seconds` shipped with
2222            // #331; #681 rounds out the unit set so a Duration built in
2223            // days via `datetime.duration_days` can be read back in the
2224            // same units rather than only as seconds.
2225            // millis / seconds / minutes / hours / days :: Duration -> Int
2226            for name in &["millis", "seconds", "minutes", "hours", "days"] {
2227                fields.insert((*name).into(), Ty::function(
2228                    vec![dur()], EffectSet::empty(), Ty::int()));
2229            }
2230            Some(Ty::Record(fields))
2231        }
2232        "approval" => {
2233            // Human-in-the-loop host boundary. `request(scope, reason)`
2234            // blocks until an operator answers via the configured
2235            // `ApprovalSink` (default: reject — a handler must opt in
2236            // with `with_approval_sink`, or `lex run` must be given an
2237            // interactive one). `scope` selects the approval channel
2238            // ("payment", "deploy", ...) and is checked at call time
2239            // against `--allow-approval`, mirroring how `net`'s host
2240            // arg is checked against `--allow-net-host` rather than
2241            // being encoded in the declared effect type.
2242            let mut fields = IndexMap::new();
2243            // request :: Str, Str -> [approval] Result[Str, Str]
2244            fields.insert("request".into(), Ty::function(
2245                vec![Ty::str(), Ty::str()],
2246                EffectSet::singleton("approval"),
2247                Ty::Con("Result".into(), vec![Ty::str(), Ty::str()])));
2248            Some(Ty::Record(fields))
2249        }
2250        "process" => {
2251            // Streaming subprocess. The opaque `ProcessHandle` type
2252            // is an Int handle into a process-wide registry holding
2253            // the `Child` plus its stdout/stderr `BufReader`s.
2254            let ph = || Ty::Con("ProcessHandle".into(), vec![]);
2255            let result_str = |t: Ty| Ty::Con("Result".into(), vec![t, Ty::str()]);
2256            let opts_t = || {
2257                let mut fs = IndexMap::new();
2258                fs.insert("cwd".into(),
2259                    Ty::Con("Option".into(), vec![Ty::str()]));
2260                fs.insert("env".into(),
2261                    Ty::Con("Map".into(), vec![Ty::str(), Ty::str()]));
2262                fs.insert("stdin".into(),
2263                    Ty::Con("Option".into(), vec![Ty::bytes()]));
2264                Ty::Record(fs)
2265            };
2266            let exit_t = || {
2267                let mut fs = IndexMap::new();
2268                fs.insert("code".into(), Ty::int());
2269                fs.insert("signaled".into(), Ty::bool());
2270                Ty::Record(fs)
2271            };
2272            let output_t = || {
2273                let mut fs = IndexMap::new();
2274                fs.insert("stdout".into(), Ty::str());
2275                fs.insert("stderr".into(), Ty::str());
2276                fs.insert("exit_code".into(), Ty::int());
2277                Ty::Record(fs)
2278            };
2279            let mut fields = IndexMap::new();
2280            // spawn :: Str, List[Str], Opts -> [proc] Result[ProcessHandle, Str]
2281            fields.insert("spawn".into(), Ty::function(
2282                vec![Ty::str(), Ty::List(Box::new(Ty::str())), opts_t()],
2283                EffectSet::singleton("proc"),
2284                result_str(ph())));
2285            // read_stdout_line / read_stderr_line :: ProcessHandle -> [proc] Option[Str]
2286            for n in &["read_stdout_line", "read_stderr_line"] {
2287                fields.insert((*n).into(), Ty::function(
2288                    vec![ph()], EffectSet::singleton("proc"),
2289                    Ty::Con("Option".into(), vec![Ty::str()])));
2290            }
2291            // wait :: ProcessHandle -> [proc] ProcessExit
2292            fields.insert("wait".into(), Ty::function(
2293                vec![ph()], EffectSet::singleton("proc"), exit_t()));
2294            // kill :: ProcessHandle, Str -> [proc] Result[Unit, Str]
2295            fields.insert("kill".into(), Ty::function(
2296                vec![ph(), Ty::str()],
2297                EffectSet::singleton("proc"),
2298                result_str(Ty::Unit)));
2299            // exit :: Int -> [proc_exit] Unit
2300            //
2301            // Sets the status `lex run` terminates with, so a Lex
2302            // program can be called by a shell script for its verdict
2303            // (#754). Its own effect kind rather than `proc`: running a
2304            // subprocess and ending your caller's process are different
2305            // authorities, and a program allowed to shell out should not
2306            // thereby be allowed to decide what its invoker sees.
2307            //
2308            // The declared return is `Unit` because the language has no
2309            // bottom type; nothing after a successful `exit` runs. The
2310            // call unwinds the VM rather than terminating the process
2311            // where it stands, so `lex run` still finalises its trace
2312            // and writes its attestations before exiting — a program
2313            // that exits is still a run that happened.
2314            //
2315            // Only the *first* exit is honoured. A program that calls
2316            // exit twice has already stopped at the first.
2317            fields.insert("exit".into(), Ty::function(
2318                vec![Ty::int()],
2319                EffectSet::singleton("proc_exit"),
2320                Ty::Unit));
2321            // run :: Str, List[Str] -> [proc] Result[ProcessOutput, Str]
2322            // Blocking convenience that captures stdout/stderr fully
2323            // and returns once the child exits. For programs that
2324            // need streaming, use spawn + read_*_line + wait.
2325            fields.insert("run".into(), Ty::function(
2326                vec![Ty::str(), Ty::List(Box::new(Ty::str()))],
2327                EffectSet::singleton("proc"),
2328                result_str(output_t())));
2329            Some(Ty::Record(fields))
2330        }
2331        "fs" => {
2332            // Filesystem walk + mutate. Walk-style ops (exists, walk,
2333            // glob, …) declare [fs_walk] — distinct from [fs_read]
2334            // (which is content reads via io.read), so reviewers can
2335            // separately track directory traversal vs file-content
2336            // exposure. Mutating ops (mkdir_p, remove, copy) declare
2337            // [fs_write]. Path scoping uses --allow-fs-read for walk
2338            // (a directory listing is an information disclosure on
2339            // the same path tree) and --allow-fs-write for mutations.
2340            let stat_t = || {
2341                let mut fs = IndexMap::new();
2342                fs.insert("size".into(), Ty::int());
2343                fs.insert("mtime".into(), Ty::int());
2344                fs.insert("is_dir".into(), Ty::bool());
2345                fs.insert("is_file".into(), Ty::bool());
2346                Ty::Record(fs)
2347            };
2348            let result_str = |t: Ty| Ty::Con("Result".into(), vec![t, Ty::str()]);
2349            let mut fields = IndexMap::new();
2350            // Content read/write [fs_read] / [fs_write]
2351            //
2352            // These are what make [fs_read] an effect a program can
2353            // actually produce. Before them the only content I/O in the
2354            // language was io.read / io.write under [io], so "does this
2355            // touch the filesystem" was not answerable from an effect row
2356            // and `lex audit --effect fs_read` matched almost nothing
2357            // (#882). Path scoping is unchanged — the same
2358            // --allow-fs-read / --allow-fs-write allowlists, via the same
2359            // handler helpers the io.* ops now call.
2360            //
2361            // fs.read_to_string :: Str -> [fs_read] Result[Str, Str]
2362            fields.insert("read_to_string".into(), Ty::function(
2363                vec![Ty::str()],
2364                EffectSet::singleton("fs_read"),
2365                result_str(Ty::str())));
2366            // fs.write :: (Str, Str) -> [fs_write] Result[Unit, Str]
2367            fields.insert("write".into(), Ty::function(
2368                vec![Ty::str(), Ty::str()],
2369                EffectSet::singleton("fs_write"),
2370                result_str(Ty::Unit)));
2371
2372            // Walk-style queries [fs_walk]
2373            fields.insert("exists".into(), Ty::function(
2374                vec![Ty::str()], EffectSet::singleton("fs_walk"), Ty::bool()));
2375            fields.insert("is_file".into(), Ty::function(
2376                vec![Ty::str()], EffectSet::singleton("fs_walk"), Ty::bool()));
2377            fields.insert("is_dir".into(), Ty::function(
2378                vec![Ty::str()], EffectSet::singleton("fs_walk"), Ty::bool()));
2379            fields.insert("stat".into(), Ty::function(
2380                vec![Ty::str()], EffectSet::singleton("fs_walk"),
2381                result_str(stat_t())));
2382            fields.insert("list_dir".into(), Ty::function(
2383                vec![Ty::str()], EffectSet::singleton("fs_walk"),
2384                result_str(Ty::List(Box::new(Ty::str())))));
2385            fields.insert("walk".into(), Ty::function(
2386                vec![Ty::str()], EffectSet::singleton("fs_walk"),
2387                result_str(Ty::List(Box::new(Ty::str())))));
2388            fields.insert("glob".into(), Ty::function(
2389                vec![Ty::str()], EffectSet::singleton("fs_walk"),
2390                result_str(Ty::List(Box::new(Ty::str())))));
2391            // Mutations [fs_write]
2392            fields.insert("mkdir_p".into(), Ty::function(
2393                vec![Ty::str()], EffectSet::singleton("fs_write"),
2394                result_str(Ty::Unit)));
2395            fields.insert("remove".into(), Ty::function(
2396                vec![Ty::str()], EffectSet::singleton("fs_write"),
2397                result_str(Ty::Unit)));
2398            fields.insert("copy".into(), Ty::function(
2399                vec![Ty::str(), Ty::str()],
2400                EffectSet {
2401                    concrete: [crate::types::EffectKind::bare("fs_walk"), crate::types::EffectKind::bare("fs_write")].into_iter().collect(),
2402                    var: None,
2403                },
2404                result_str(Ty::Unit)));
2405            Some(Ty::Record(fields))
2406        }
2407        "kv" => {
2408            // Embedded key-value store. The opaque `Kv` type is
2409            // backed by an Int handle into a process-wide registry.
2410            let kv_t = || Ty::Con("Kv".into(), vec![]);
2411            let mut fields = IndexMap::new();
2412            // open :: Str -> [kv, fs_write] Result[Kv, Str]
2413            fields.insert("open".into(), Ty::function(
2414                vec![Ty::str()],
2415                EffectSet {
2416                    concrete: [crate::types::EffectKind::bare("kv"), crate::types::EffectKind::bare("fs_write")].into_iter().collect(),
2417                    var: None,
2418                },
2419                Ty::Con("Result".into(), vec![kv_t(), Ty::str()])));
2420            // close :: Kv -> [kv] Unit
2421            fields.insert("close".into(), Ty::function(
2422                vec![kv_t()],
2423                EffectSet::singleton("kv"),
2424                Ty::Unit));
2425            // get :: Kv, Str -> [kv] Option[Bytes]
2426            fields.insert("get".into(), Ty::function(
2427                vec![kv_t(), Ty::str()],
2428                EffectSet::singleton("kv"),
2429                Ty::Con("Option".into(), vec![Ty::bytes()])));
2430            // put :: Kv, Str, Bytes -> [kv] Result[Unit, Str]
2431            fields.insert("put".into(), Ty::function(
2432                vec![kv_t(), Ty::str(), Ty::bytes()],
2433                EffectSet::singleton("kv"),
2434                Ty::Con("Result".into(), vec![Ty::Unit, Ty::str()])));
2435            // delete :: Kv, Str -> [kv] Result[Unit, Str]
2436            fields.insert("delete".into(), Ty::function(
2437                vec![kv_t(), Ty::str()],
2438                EffectSet::singleton("kv"),
2439                Ty::Con("Result".into(), vec![Ty::Unit, Ty::str()])));
2440            // contains :: Kv, Str -> [kv] Bool
2441            fields.insert("contains".into(), Ty::function(
2442                vec![kv_t(), Ty::str()],
2443                EffectSet::singleton("kv"),
2444                Ty::bool()));
2445            // list_prefix :: Kv, Str -> [kv] List[Str]
2446            fields.insert("list_prefix".into(), Ty::function(
2447                vec![kv_t(), Ty::str()],
2448                EffectSet::singleton("kv"),
2449                Ty::List(Box::new(Ty::str()))));
2450            Some(Ty::Record(fields))
2451        }
2452        "moe" => {
2453            // MoE expert-store placement ops (lex-moe#25). Native
2454            // EffectHandler dispatch on the `moe` kind — see
2455            // lex-moe's crates/moe-policy/src/lib.rs `MoeHost` for
2456            // the Rust-side implementation these signatures describe.
2457            // A single process-wide tier cache backs every op, so
2458            // unlike `kv` there's no open/close handle to thread.
2459            let usage_entry_t = || {
2460                let mut f = IndexMap::new();
2461                f.insert("hash".into(), Ty::str());
2462                f.insert("size".into(), Ty::int());
2463                f.insert("temp".into(), Ty::float());
2464                f.insert("hits".into(), Ty::int());
2465                f.insert("loads".into(), Ty::int());
2466                Ty::Record(f)
2467            };
2468            let stats_t = || {
2469                let mut f = IndexMap::new();
2470                f.insert("hits".into(), Ty::int());
2471                f.insert("misses".into(), Ty::int());
2472                f.insert("prefetched".into(), Ty::int());
2473                f.insert("evictions".into(), Ty::int());
2474                f.insert("resident_bytes".into(), Ty::int());
2475                Ty::Record(f)
2476            };
2477            let mut fields = IndexMap::new();
2478            // pin(hash :: Str) -> [moe] Unit
2479            fields.insert("pin".into(), Ty::function(
2480                vec![Ty::str()],
2481                EffectSet::singleton("moe"),
2482                Ty::Unit));
2483            // unpin(hash :: Str) -> [moe] Unit
2484            fields.insert("unpin".into(), Ty::function(
2485                vec![Ty::str()],
2486                EffectSet::singleton("moe"),
2487                Ty::Unit));
2488            // prefetch_hint(hashes :: List[Str]) -> [moe] Int  (loads spawned)
2489            fields.insert("prefetch_hint".into(), Ty::function(
2490                vec![Ty::List(Box::new(Ty::str()))],
2491                EffectSet::singleton("moe"),
2492                Ty::int()));
2493            // usage_snapshot() -> [moe] List[{hash, size, temp, hits, loads}]
2494            fields.insert("usage_snapshot".into(), Ty::function(
2495                vec![],
2496                EffectSet::singleton("moe"),
2497                Ty::List(Box::new(usage_entry_t()))));
2498            // stats() -> [moe] {hits, misses, prefetched, evictions, resident_bytes}
2499            fields.insert("stats".into(), Ty::function(
2500                vec![],
2501                EffectSet::singleton("moe"),
2502                stats_t()));
2503            Some(Ty::Record(fields))
2504        }
2505        "vcs" => {
2506            // Content-addressed blob store (#5 / M6.1b). `put_blob` returns the
2507            // lowercase hex SHA-256 of the content — the SAME id as
2508            // `crypto.sha256_str`, so blobs are interchangeable with loom's
2509            // SQLite-backed artifacts by id. `ref_set`/`ref_get` bind a name
2510            // (namespace + key) to a blob sha, e.g. namespace "loom/sprint-{id}",
2511            // key = node id (branch-per-sprint, #5). The on-disk layout matches
2512            // lex-store's blob CAS (<root>/blobs, <root>/blobrefs).
2513            //
2514            // fs_write/fs_read appear in the effect rows so `lex audit` sees the
2515            // disk touch; the store root is internal (~/.lex/store or
2516            // $LEX_STORE_ROOT) so no user-path allowlist applies.
2517            let mut fields = IndexMap::new();
2518            let vcs_w = || EffectSet {
2519                concrete: [crate::types::EffectKind::bare("vcs"),
2520                           crate::types::EffectKind::bare("fs_write")]
2521                    .into_iter().collect(),
2522                var: None,
2523            };
2524            let vcs_r = || EffectSet {
2525                concrete: [crate::types::EffectKind::bare("vcs"),
2526                           crate::types::EffectKind::bare("fs_read")]
2527                    .into_iter().collect(),
2528                var: None,
2529            };
2530            let res = |ok: Ty| Ty::Con("Result".into(), vec![ok, Ty::str()]);
2531
2532            // put_blob :: Str -> [vcs, fs_write] Result[Str, Str]  (returns sha)
2533            fields.insert("put_blob".into(), Ty::function(
2534                vec![Ty::str()], vcs_w(), res(Ty::str())));
2535            // get_blob :: Str -> [vcs, fs_read] Result[Str, Str]
2536            fields.insert("get_blob".into(), Ty::function(
2537                vec![Ty::str()], vcs_r(), res(Ty::str())));
2538            // has_blob :: Str -> [vcs, fs_read] Bool
2539            fields.insert("has_blob".into(), Ty::function(
2540                vec![Ty::str()], vcs_r(), Ty::bool()));
2541            // ref_set :: Str, Str, Str -> [vcs, fs_write] Result[Unit, Str]
2542            fields.insert("ref_set".into(), Ty::function(
2543                vec![Ty::str(), Ty::str(), Ty::str()], vcs_w(), res(Ty::Unit)));
2544            // ref_get :: Str, Str -> [vcs, fs_read] Result[Str, Str]  (key -> sha)
2545            fields.insert("ref_get".into(), Ty::function(
2546                vec![Ty::str(), Ty::str()], vcs_r(), res(Ty::str())));
2547            Some(Ty::Record(fields))
2548        }
2549        "sql" => {
2550            // Embedded SQL (SQLite via rusqlite). The opaque `Db` type is
2551            // backed by an Int handle into a process-wide registry (#362).
2552            //
2553            // Params use the typed `SqlParam` ADT (PStr|PInt|PFloat|PBool|PNull)
2554            // registered in env.rs, so callers don't have to stringify values.
2555            //
2556            // Transactions: sql.begin(db) → SqlTx; sql.commit/rollback(tx).
2557            // exec_tx / query_tx mirror exec / query but operate on a SqlTx.
2558            //
2559            // Row decoders: get_str / get_int / get_float / get_bool extract
2560            // typed columns from a row record by name.
2561            let db_t  = || Ty::Con("Db".into(), vec![]);
2562            let tx_t  = || Ty::Con("SqlTx".into(), vec![]);
2563            let sp_t  = || Ty::Con("SqlParam".into(), vec![]);
2564            let params_t = || Ty::List(Box::new(sp_t()));
2565            let mut fields = IndexMap::new();
2566
2567            // SqlError = { message, code, detail } — populated with
2568            // SQLSTATE (Postgres) or symbolic SQLite error name (#380).
2569            let se_t = || Ty::Con("SqlError".into(), vec![]);
2570
2571            // open :: Str -> [sql, fs_write] Result[Db, SqlError]
2572            fields.insert("open".into(), Ty::function(
2573                vec![Ty::str()],
2574                EffectSet {
2575                    concrete: [crate::types::EffectKind::bare("sql"),
2576                               crate::types::EffectKind::bare("fs_write")]
2577                        .into_iter().collect(),
2578                    var: None,
2579                },
2580                Ty::Con("Result".into(), vec![db_t(), se_t()])));
2581
2582            // close :: Db -> [sql] Unit
2583            fields.insert("close".into(), Ty::function(
2584                vec![db_t()],
2585                EffectSet::singleton("sql"),
2586                Ty::Unit));
2587
2588            // exec :: Db, Str, List[SqlParam] -> [sql] Result[Int, SqlError]
2589            fields.insert("exec".into(), Ty::function(
2590                vec![db_t(), Ty::str(), params_t()],
2591                EffectSet::singleton("sql"),
2592                Ty::Con("Result".into(), vec![Ty::int(), se_t()])));
2593
2594            // query[T] :: Db, Str, List[SqlParam] -> [sql] Result[List[T], SqlError]
2595            fields.insert("query".into(), Ty::function(
2596                vec![db_t(), Ty::str(), params_t()],
2597                EffectSet::singleton("sql"),
2598                Ty::Con("Result".into(), vec![
2599                    Ty::List(Box::new(Ty::Var(0))),
2600                    se_t(),
2601                ])));
2602
2603            // query_iter[T] :: Db, Str, List[SqlParam] -> [sql] Result[Iter[T], SqlError]
2604            // Streaming variant of `query` (#379). Rows are pulled from
2605            // the server one at a time via an mpsc-backed cursor —
2606            // memory stays bounded regardless of result-set size.
2607            // Other ops on the same `Db` handle block until the cursor
2608            // is drained (single connection per Db).
2609            fields.insert("query_iter".into(), Ty::function(
2610                vec![db_t(), Ty::str(), params_t()],
2611                EffectSet::singleton("sql"),
2612                Ty::Con("Result".into(), vec![
2613                    Ty::Con("Iter".into(), vec![Ty::Var(0)]),
2614                    se_t(),
2615                ])));
2616
2617            // begin :: Db -> [sql] Result[SqlTx, SqlError]
2618            fields.insert("begin".into(), Ty::function(
2619                vec![db_t()],
2620                EffectSet::singleton("sql"),
2621                Ty::Con("Result".into(), vec![tx_t(), se_t()])));
2622
2623            // commit :: SqlTx -> [sql] Result[Unit, SqlError]
2624            fields.insert("commit".into(), Ty::function(
2625                vec![tx_t()],
2626                EffectSet::singleton("sql"),
2627                Ty::Con("Result".into(), vec![Ty::Unit, se_t()])));
2628
2629            // rollback :: SqlTx -> [sql] Result[Unit, SqlError]
2630            fields.insert("rollback".into(), Ty::function(
2631                vec![tx_t()],
2632                EffectSet::singleton("sql"),
2633                Ty::Con("Result".into(), vec![Ty::Unit, se_t()])));
2634
2635            // exec_tx :: SqlTx, Str, List[SqlParam] -> [sql] Result[Int, SqlError]
2636            fields.insert("exec_tx".into(), Ty::function(
2637                vec![tx_t(), Ty::str(), params_t()],
2638                EffectSet::singleton("sql"),
2639                Ty::Con("Result".into(), vec![Ty::int(), se_t()])));
2640
2641            // query_tx[T] :: SqlTx, Str, List[SqlParam] -> [sql] Result[List[T], SqlError]
2642            fields.insert("query_tx".into(), Ty::function(
2643                vec![tx_t(), Ty::str(), params_t()],
2644                EffectSet::singleton("sql"),
2645                Ty::Con("Result".into(), vec![
2646                    Ty::List(Box::new(Ty::Var(0))),
2647                    se_t(),
2648                ])));
2649
2650            // Row decoders: get_X[T] :: T, Str -> Option[X]
2651            // T is polymorphic so these work on any row record shape.
2652            fields.insert("get_str".into(), Ty::function(
2653                vec![Ty::Var(0), Ty::str()],
2654                EffectSet::empty(),
2655                Ty::Con("Option".into(), vec![Ty::str()])));
2656            fields.insert("get_int".into(), Ty::function(
2657                vec![Ty::Var(0), Ty::str()],
2658                EffectSet::empty(),
2659                Ty::Con("Option".into(), vec![Ty::int()])));
2660            fields.insert("get_float".into(), Ty::function(
2661                vec![Ty::Var(0), Ty::str()],
2662                EffectSet::empty(),
2663                Ty::Con("Option".into(), vec![Ty::float()])));
2664            fields.insert("get_bool".into(), Ty::function(
2665                vec![Ty::Var(0), Ty::str()],
2666                EffectSet::empty(),
2667                Ty::Con("Option".into(), vec![Ty::bool()])));
2668
2669            Some(Ty::Record(fields))
2670        }
2671        "redis" => {
2672            // Thin Redis client (#533). ConnRedis is an opaque handle backed by a
2673            // process-wide registry (same pattern as Db in std.sql). All ops carry
2674            // [net] — Redis is a TCP service; no separate [redis] effect.
2675            //
2676            // subscribe / psubscribe return Unit because they are blocking
2677            // infinite loops, consistent with net.serve_fn and ws.serve.
2678            //
2679            // subscribe/psubscribe open a *dedicated* connection internally —
2680            // Redis disallows non-Pub/Sub commands on a subscribed connection.
2681            let conn_t = || Ty::Con("ConnRedis".into(), vec![]);
2682            let mut fields = IndexMap::new();
2683
2684            // connect :: Str -> [net] Result[ConnRedis, Str]
2685            // url: "redis://host:6379" or "rediss://host:6380" (TLS)
2686            fields.insert("connect".into(), Ty::function(
2687                vec![Ty::str()],
2688                EffectSet::singleton("net"),
2689                Ty::Con("Result".into(), vec![conn_t(), Ty::str()])));
2690
2691            // close :: ConnRedis -> [net] Unit
2692            fields.insert("close".into(), Ty::function(
2693                vec![conn_t()],
2694                EffectSet::singleton("net"),
2695                Ty::Unit));
2696
2697            // ---- Key-value -----------------------------------------------
2698
2699            // get :: ConnRedis, Str -> [net] Option[Str]
2700            fields.insert("get".into(), Ty::function(
2701                vec![conn_t(), Ty::str()],
2702                EffectSet::singleton("net"),
2703                Ty::Con("Option".into(), vec![Ty::str()])));
2704
2705            // set :: ConnRedis, Str, Str -> [net] Unit
2706            fields.insert("set".into(), Ty::function(
2707                vec![conn_t(), Ty::str(), Ty::str()],
2708                EffectSet::singleton("net"),
2709                Ty::Unit));
2710
2711            // set_ex :: ConnRedis, Str, Str, Int -> [net] Unit
2712            fields.insert("set_ex".into(), Ty::function(
2713                vec![conn_t(), Ty::str(), Ty::str(), Ty::int()],
2714                EffectSet::singleton("net"),
2715                Ty::Unit));
2716
2717            // del :: ConnRedis, Str -> [net] Unit
2718            fields.insert("del".into(), Ty::function(
2719                vec![conn_t(), Ty::str()],
2720                EffectSet::singleton("net"),
2721                Ty::Unit));
2722
2723            // exists :: ConnRedis, Str -> [net] Bool
2724            fields.insert("exists".into(), Ty::function(
2725                vec![conn_t(), Ty::str()],
2726                EffectSet::singleton("net"),
2727                Ty::bool()));
2728
2729            // expire :: ConnRedis, Str, Int -> [net] Unit
2730            fields.insert("expire".into(), Ty::function(
2731                vec![conn_t(), Ty::str(), Ty::int()],
2732                EffectSet::singleton("net"),
2733                Ty::Unit));
2734
2735            // ---- Pub/Sub -------------------------------------------------
2736
2737            // publish :: ConnRedis, Str, Str -> [net] Int
2738            // Returns the number of subscribers that received the message.
2739            fields.insert("publish".into(), Ty::function(
2740                vec![conn_t(), Ty::str(), Ty::str()],
2741                EffectSet::singleton("net"),
2742                Ty::int()));
2743
2744            // subscribe :: ConnRedis, Str, (Str, Str ->[E] Unit) -> [net] Unit
2745            // Blocking loop; handler receives (channel, message) on each message.
2746            // Uses a dedicated connection — Redis disallows non-Pub/Sub commands
2747            // on a subscribed connection. Handler carries an open effect row so
2748            // callers can use io, net, sql, etc. inside the closure.
2749            let handler2 = Ty::function(
2750                vec![Ty::str(), Ty::str()],
2751                EffectSet::open_var(0),
2752                Ty::Unit);
2753            fields.insert("subscribe".into(), Ty::function(
2754                vec![conn_t(), Ty::str(), handler2],
2755                EffectSet::singleton("net"),
2756                Ty::Unit));  // Unit
2757
2758            // psubscribe :: ConnRedis, Str, (Str, Str, Str ->[E] Unit) -> [net] Unit
2759            // Pattern-subscribe; handler receives (pattern, channel, message).
2760            // Handler carries an open effect row (same rationale as subscribe).
2761            let handler3 = Ty::function(
2762                vec![Ty::str(), Ty::str(), Ty::str()],
2763                EffectSet::open_var(1),
2764                Ty::Unit);
2765            fields.insert("psubscribe".into(), Ty::function(
2766                vec![conn_t(), Ty::str(), handler3],
2767                EffectSet::singleton("net"),
2768                Ty::Unit));  // Unit
2769
2770            // ---- List ----------------------------------------------------
2771
2772            // lpush :: ConnRedis, Str, Str -> [net] Int
2773            fields.insert("lpush".into(), Ty::function(
2774                vec![conn_t(), Ty::str(), Ty::str()],
2775                EffectSet::singleton("net"),
2776                Ty::int()));
2777
2778            // rpush :: ConnRedis, Str, Str -> [net] Int
2779            fields.insert("rpush".into(), Ty::function(
2780                vec![conn_t(), Ty::str(), Ty::str()],
2781                EffectSet::singleton("net"),
2782                Ty::int()));
2783
2784            // brpop :: ConnRedis, Str, Int -> [net] Option[Str]
2785            // Blocking right-pop; returns None on timeout. timeout=0 blocks
2786            // indefinitely (the runtime does not treat this as a hung effect).
2787            fields.insert("brpop".into(), Ty::function(
2788                vec![conn_t(), Ty::str(), Ty::int()],
2789                EffectSet::singleton("net"),
2790                Ty::Con("Option".into(), vec![Ty::str()])));
2791
2792            // llen :: ConnRedis, Str -> [net] Int
2793            fields.insert("llen".into(), Ty::function(
2794                vec![conn_t(), Ty::str()],
2795                EffectSet::singleton("net"),
2796                Ty::int()));
2797
2798            // ---- Hash ----------------------------------------------------
2799
2800            // hset :: ConnRedis, Str, Str, Str -> [net] Unit
2801            fields.insert("hset".into(), Ty::function(
2802                vec![conn_t(), Ty::str(), Ty::str(), Ty::str()],
2803                EffectSet::singleton("net"),
2804                Ty::Unit));
2805
2806            // hget :: ConnRedis, Str, Str -> [net] Option[Str]
2807            fields.insert("hget".into(), Ty::function(
2808                vec![conn_t(), Ty::str(), Ty::str()],
2809                EffectSet::singleton("net"),
2810                Ty::Con("Option".into(), vec![Ty::str()])));
2811
2812            // hdel :: ConnRedis, Str, Str -> [net] Unit
2813            fields.insert("hdel".into(), Ty::function(
2814                vec![conn_t(), Ty::str(), Ty::str()],
2815                EffectSet::singleton("net"),
2816                Ty::Unit));
2817
2818            // hgetall :: ConnRedis, Str -> [net] List[(Str, Str)]
2819            fields.insert("hgetall".into(), Ty::function(
2820                vec![conn_t(), Ty::str()],
2821                EffectSet::singleton("net"),
2822                Ty::List(Box::new(Ty::Tuple(vec![Ty::str(), Ty::str()])))));
2823
2824            Some(Ty::Record(fields))
2825        }
2826        "parser" => {
2827            // #217: structured parser combinators. Parser values are
2828            // tagged Records at runtime (`{ kind, ... }`), opaque at
2829            // the language level via `Ty::Con("Parser", [T])`.
2830            //
2831            // Surface:
2832            //   - primitives: char, string, digit, alpha, whitespace, eof
2833            //   - combinators: seq, alt, many, optional, map, and_then
2834            //   - run :: Parser[T], Str -> Result[T, ParseErr]
2835            //
2836            // `map` and `and_then` were deferred from #217's v1 because
2837            // their closure arguments carried call-site identity that
2838            // broke the canonical-parsers acceptance criterion. With
2839            // closure body-hash equality landed in #222, that concern
2840            // is gone, and #221 wires them in. The interpreter for
2841            // `parser.run` has been moved to `lex-bytecode::parser_runtime`
2842            // so it can invoke closures from `Map` / `AndThen` nodes.
2843            let pt = |t: Ty| Ty::Con("Parser".into(), vec![t]);
2844            let parse_err = || {
2845                let mut fs = IndexMap::new();
2846                fs.insert("pos".into(), Ty::int());
2847                fs.insert("message".into(), Ty::str());
2848                Ty::Record(fs)
2849            };
2850            let mut fields = IndexMap::new();
2851            // char :: Str -> Parser[Str] (single-char Str literal)
2852            fields.insert("char".into(), Ty::function(
2853                vec![Ty::str()], EffectSet::empty(), pt(Ty::str())));
2854            // string :: Str -> Parser[Str]
2855            fields.insert("string".into(), Ty::function(
2856                vec![Ty::str()], EffectSet::empty(), pt(Ty::str())));
2857            // digit :: () -> Parser[Str]
2858            fields.insert("digit".into(), Ty::function(
2859                vec![], EffectSet::empty(), pt(Ty::str())));
2860            // alpha :: () -> Parser[Str]
2861            fields.insert("alpha".into(), Ty::function(
2862                vec![], EffectSet::empty(), pt(Ty::str())));
2863            // whitespace :: () -> Parser[Str]
2864            fields.insert("whitespace".into(), Ty::function(
2865                vec![], EffectSet::empty(), pt(Ty::str())));
2866            // eof :: () -> Parser[Unit]
2867            fields.insert("eof".into(), Ty::function(
2868                vec![], EffectSet::empty(), pt(Ty::Unit)));
2869            // seq :: Parser[A], Parser[B] -> Parser[(A, B)]
2870            fields.insert("seq".into(), Ty::function(
2871                vec![pt(Ty::Var(0)), pt(Ty::Var(1))],
2872                EffectSet::empty(),
2873                pt(Ty::Tuple(vec![Ty::Var(0), Ty::Var(1)]))));
2874            // alt :: Parser[T], Parser[T] -> Parser[T]
2875            // PEG-style ordered choice: the second alternative is
2876            // tried only if the first fails.
2877            fields.insert("alt".into(), Ty::function(
2878                vec![pt(Ty::Var(0)), pt(Ty::Var(0))],
2879                EffectSet::empty(),
2880                pt(Ty::Var(0))));
2881            // many :: Parser[T] -> Parser[List[T]]
2882            // Zero-or-more. Stops as soon as the inner parser fails
2883            // OR doesn't advance the position (avoids infinite loop
2884            // on empty matches).
2885            fields.insert("many".into(), Ty::function(
2886                vec![pt(Ty::Var(0))],
2887                EffectSet::empty(),
2888                pt(Ty::List(Box::new(Ty::Var(0))))));
2889            // optional :: Parser[T] -> Parser[Option[T]]
2890            fields.insert("optional".into(), Ty::function(
2891                vec![pt(Ty::Var(0))],
2892                EffectSet::empty(),
2893                pt(Ty::Con("Option".into(), vec![Ty::Var(0)]))));
2894            // map :: Parser[T], (T) -> [E] U -> [E] Parser[U]
2895            // The closure runs at parse time when the Parser is run.
2896            // Effect-polymorphic on the closure: any effect the
2897            // closure declares propagates to the surrounding `run`.
2898            fields.insert("map".into(), Ty::function(
2899                vec![
2900                    pt(Ty::Var(0)),
2901                    Ty::function(vec![Ty::Var(0)], EffectSet::open_var(2), Ty::Var(1)),
2902                ],
2903                EffectSet::open_var(2),
2904                pt(Ty::Var(1))));
2905            // and_then :: Parser[T], (T) -> [E] Parser[U] -> [E] Parser[U]
2906            // Monadic bind: closure inspects the parsed value and
2907            // returns the next parser to run.
2908            fields.insert("and_then".into(), Ty::function(
2909                vec![
2910                    pt(Ty::Var(0)),
2911                    Ty::function(vec![Ty::Var(0)], EffectSet::open_var(3),
2912                        pt(Ty::Var(1))),
2913                ],
2914                EffectSet::open_var(3),
2915                pt(Ty::Var(1))));
2916            // run :: Parser[T], Str -> Result[T, ParseErr]
2917            // ParseErr = { pos :: Int, message :: Str }
2918            fields.insert("run".into(), Ty::function(
2919                vec![pt(Ty::Var(0)), Ty::str()],
2920                EffectSet::empty(),
2921                Ty::Con("Result".into(), vec![Ty::Var(0), parse_err()])));
2922            Some(Ty::Record(fields))
2923        }
2924        "cli" => {
2925            // #224 Rubric port: argparse-equivalent for end-user
2926            // programs. Spec values are tagged `Json` records (opaque
2927            // to the language but inspectable). Construction via the
2928            // `flag` / `option` / `positional` / `spec` builders;
2929            // parse + introspection / help via the remaining ops.
2930            let json = || Ty::Con("Json".into(), vec![]);
2931            let opt_str = || Ty::Con("Option".into(), vec![Ty::str()]);
2932            let mut fields = IndexMap::new();
2933            // flag :: Str -> Option[Str] -> Str -> Json
2934            //   long_name -> short -> help -> CliArg
2935            fields.insert("flag".into(), Ty::function(
2936                vec![Ty::str(), opt_str(), Ty::str()],
2937                EffectSet::empty(),
2938                json()));
2939            // option :: Str -> Option[Str] -> Str -> Option[Str] -> Json
2940            //   long_name -> short -> help -> default -> CliArg
2941            fields.insert("option".into(), Ty::function(
2942                vec![Ty::str(), opt_str(), Ty::str(), opt_str()],
2943                EffectSet::empty(),
2944                json()));
2945            // positional :: Str -> Str -> Bool -> Json
2946            //   name -> help -> required -> CliArg
2947            fields.insert("positional".into(), Ty::function(
2948                vec![Ty::str(), Ty::str(), Ty::bool()],
2949                EffectSet::empty(),
2950                json()));
2951            // spec :: Str -> Str -> List[Json] -> List[Json] -> Json
2952            //   name -> help -> args -> subcommands -> CliSpec
2953            fields.insert("spec".into(), Ty::function(
2954                vec![Ty::str(), Ty::str(),
2955                     Ty::List(Box::new(json())),
2956                     Ty::List(Box::new(json()))],
2957                EffectSet::empty(),
2958                json()));
2959            // parse :: Json -> List[Str] -> Result[Json, Str]
2960            //   spec -> argv -> Result[CliParsed, error]
2961            fields.insert("parse".into(), Ty::function(
2962                vec![json(), Ty::List(Box::new(Ty::str()))],
2963                EffectSet::empty(),
2964                Ty::Con("Result".into(), vec![json(), Ty::str()])));
2965            // envelope :: Bool -> Str -> T -> Json
2966            //   ok -> command -> data -> ACLI-shaped envelope.
2967            // `data` is polymorphic so callers don't have to round-
2968            // trip through `json.parse` for trivial payloads.
2969            fields.insert("envelope".into(), Ty::function(
2970                vec![Ty::bool(), Ty::str(), Ty::Var(0)],
2971                EffectSet::empty(),
2972                json()));
2973            // describe :: Json -> Json — machine-readable spec dump
2974            fields.insert("describe".into(), Ty::function(
2975                vec![json()],
2976                EffectSet::empty(),
2977                json()));
2978            // help :: Json -> Str — human-readable help text
2979            fields.insert("help".into(), Ty::function(
2980                vec![json()],
2981                EffectSet::empty(),
2982                Ty::str()));
2983            Some(Ty::Record(fields))
2984        }
2985        "regex" => {
2986            // The compiled `Regex` is stored as a `Str` at runtime
2987            // (the pattern source) plus a process-wide cache of the
2988            // actual `regex::Regex`. So `Regex` is a nominal type at
2989            // the language level but its value is just the pattern.
2990            let regex_t = || Ty::Con("Regex".into(), vec![]);
2991            let match_t = || {
2992                let mut fs = IndexMap::new();
2993                fs.insert("text".into(), Ty::str());
2994                fs.insert("start".into(), Ty::int());
2995                fs.insert("end".into(), Ty::int());
2996                fs.insert("groups".into(), Ty::List(Box::new(Ty::str())));
2997                Ty::Record(fs)
2998            };
2999            let mut fields = IndexMap::new();
3000            // compile :: Str -> Result[Regex, Str]
3001            fields.insert("compile".into(), Ty::function(
3002                vec![Ty::str()], EffectSet::empty(),
3003                Ty::Con("Result".into(), vec![regex_t(), Ty::str()])));
3004            // is_match :: Regex, Str -> Bool
3005            fields.insert("is_match".into(), Ty::function(
3006                vec![regex_t(), Ty::str()], EffectSet::empty(), Ty::bool()));
3007            // is_match_str :: Str, Str -> Bool
3008            // Compiles the first argument as a pattern and matches against the second.
3009            // Returns false on invalid pattern instead of propagating an error.
3010            fields.insert("is_match_str".into(), Ty::function(
3011                vec![Ty::str(), Ty::str()], EffectSet::empty(), Ty::bool()));
3012            // find :: Regex, Str -> Option[Match]
3013            fields.insert("find".into(), Ty::function(
3014                vec![regex_t(), Ty::str()], EffectSet::empty(),
3015                Ty::Con("Option".into(), vec![match_t()])));
3016            // find_all :: Regex, Str -> List[Match]
3017            fields.insert("find_all".into(), Ty::function(
3018                vec![regex_t(), Ty::str()], EffectSet::empty(),
3019                Ty::List(Box::new(match_t()))));
3020            // replace :: Regex, Str, Str -> Str
3021            fields.insert("replace".into(), Ty::function(
3022                vec![regex_t(), Ty::str(), Ty::str()], EffectSet::empty(), Ty::str()));
3023            // replace_all :: Regex, Str, Str -> Str
3024            fields.insert("replace_all".into(), Ty::function(
3025                vec![regex_t(), Ty::str(), Ty::str()], EffectSet::empty(), Ty::str()));
3026            // split :: Regex, Str -> List[Str]
3027            fields.insert("split".into(), Ty::function(
3028                vec![regex_t(), Ty::str()], EffectSet::empty(),
3029                Ty::List(Box::new(Ty::str()))));
3030            Some(Ty::Record(fields))
3031        }
3032        "http" => {
3033            // Rich HTTP client. `[net]` for the wire ops, pure for
3034            // the builders / decoders. `--allow-net-host` gates per
3035            // request. Multipart upload + streaming response bodies
3036            // are deferred to v1.5; the v1 surface covers the
3037            // common cases (auth, headers, query, timeouts, JSON /
3038            // text decoding).
3039            let req_t  = || Ty::Con("HttpRequest".into(), vec![]);
3040            let resp_t = || Ty::Con("HttpResponse".into(), vec![]);
3041            let err_t  = || Ty::Con("HttpError".into(), vec![]);
3042            let result_he = |t: Ty| Ty::Con("Result".into(), vec![t, err_t()]);
3043            let str_str_map = || Ty::Con("Map".into(), vec![Ty::str(), Ty::str()]);
3044            let mut fields = IndexMap::new();
3045            // -- wire ops (effectful) --
3046            // send :: HttpRequest -> [net] Result[HttpResponse, HttpError]
3047            fields.insert("send".into(), Ty::function(
3048                vec![req_t()],
3049                EffectSet::singleton("net"),
3050                result_he(resp_t()),
3051            ));
3052            // get :: Str -> [net] Result[HttpResponse, HttpError]
3053            fields.insert("get".into(), Ty::function(
3054                vec![Ty::str()],
3055                EffectSet::singleton("net"),
3056                result_he(resp_t()),
3057            ));
3058            // post :: Str, Bytes, Str -> [net] Result[HttpResponse, HttpError]
3059            fields.insert("post".into(), Ty::function(
3060                vec![Ty::str(), Ty::bytes(), Ty::str()],
3061                EffectSet::singleton("net"),
3062                result_he(resp_t()),
3063            ));
3064            // -- pure builders (record transforms) --
3065            // with_header :: HttpRequest, Str, Str -> HttpRequest
3066            fields.insert("with_header".into(), Ty::function(
3067                vec![req_t(), Ty::str(), Ty::str()],
3068                EffectSet::empty(),
3069                req_t(),
3070            ));
3071            // with_auth :: HttpRequest, Str, Str -> HttpRequest
3072            // (Renders `<scheme> <token>` into the `Authorization`
3073            // header — `Bearer <jwt>`, `Basic <b64>`, etc.)
3074            fields.insert("with_auth".into(), Ty::function(
3075                vec![req_t(), Ty::str(), Ty::str()],
3076                EffectSet::empty(),
3077                req_t(),
3078            ));
3079            // with_query :: HttpRequest, Map[Str, Str] -> HttpRequest
3080            // (Appends a `?k=v&...` query string; values are URL-
3081            // encoded so `&` / `=` / spaces in values don't escape.)
3082            fields.insert("with_query".into(), Ty::function(
3083                vec![req_t(), str_str_map()],
3084                EffectSet::empty(),
3085                req_t(),
3086            ));
3087            // with_timeout_ms :: HttpRequest, Int -> HttpRequest
3088            fields.insert("with_timeout_ms".into(), Ty::function(
3089                vec![req_t(), Ty::int()],
3090                EffectSet::empty(),
3091                req_t(),
3092            ));
3093            // -- pure decoders --
3094            // json_body[T] :: HttpResponse -> Result[T, HttpError]
3095            // Polymorphic on the parsed shape, matching `json.parse`.
3096            fields.insert("json_body".into(), Ty::function(
3097                vec![resp_t()],
3098                EffectSet::empty(),
3099                result_he(Ty::Var(0)),
3100            ));
3101            // text_body :: HttpResponse -> Result[Str, HttpError]
3102            fields.insert("text_body".into(), Ty::function(
3103                vec![resp_t()],
3104                EffectSet::empty(),
3105                result_he(Ty::str()),
3106            ));
3107            // stream_lines :: Str, Map[Str, Str], Str -> [net] Result[Stream[Str], Str]
3108            // Streaming HTTP POST that yields the response body line-by-line
3109            // for SSE / NDJSON endpoints. Returns a lazy `Stream[Str]` (#683):
3110            // each `stream.next` pulls exactly one line off the socket as it
3111            // arrives, so an endpoint that holds the connection open and emits
3112            // events over time is consumed incrementally instead of blocking
3113            // until close. Connection errors at request time surface as
3114            // `Err(Str)`; a mid-stream read error / close ends the stream
3115            // (next `stream.next` returns `None`). Consume with `std.stream`
3116            // (`stream.next` / `stream.collect`), which carries `[stream]`.
3117            fields.insert("stream_lines".into(), Ty::function(
3118                vec![
3119                    Ty::str(),
3120                    Ty::Con("Map".into(), vec![Ty::str(), Ty::str()]),
3121                    Ty::str(),
3122                ],
3123                EffectSet::singleton("net"),
3124                Ty::Con("Result".into(), vec![
3125                    Ty::Con("Stream".into(), vec![Ty::str()]),
3126                    Ty::str(),
3127                ]),
3128            ));
3129            Some(Ty::Record(fields))
3130        }
3131        "yaml" => {
3132            // YAML config parser. Same shape as `std.toml`: parse
3133            // is polymorphic, output Value layout matches std.json
3134            // (Str/Int/Float/Bool/List/Record). Anchors and tags
3135            // are flattened by serde_yaml's deserializer.
3136            let mut fields = IndexMap::new();
3137            fields.insert("parse".into(), Ty::function(
3138                vec![Ty::str()], EffectSet::empty(),
3139                Ty::Con("Result".into(), vec![Ty::Var(0), Ty::str()]),
3140            ));
3141            // Tactical fix for #168 — caller-supplied required-field
3142            // list. See std.json's parse_strict for context.
3143            fields.insert("parse_strict".into(), Ty::function(
3144                vec![Ty::str(), Ty::List(Box::new(Ty::str()))],
3145                EffectSet::empty(),
3146                Ty::Con("Result".into(), vec![Ty::Var(0), Ty::str()]),
3147            ));
3148            fields.insert("stringify".into(), Ty::function(
3149                vec![Ty::Var(0)], EffectSet::empty(),
3150                Ty::Con("Result".into(), vec![Ty::str(), Ty::str()]),
3151            ));
3152            Some(Ty::Record(fields))
3153        }
3154        "dotenv" => {
3155            // .env-style files. parse :: Str -> Result[Map[Str,Str], Str].
3156            // Returns a map (not a polymorphic record) because
3157            // dotenv files don't carry shape — every value is a
3158            // string and keys aren't statically known.
3159            let mut fields = IndexMap::new();
3160            fields.insert("parse".into(), Ty::function(
3161                vec![Ty::str()], EffectSet::empty(),
3162                Ty::Con("Result".into(), vec![
3163                    Ty::Con("Map".into(), vec![Ty::str(), Ty::str()]),
3164                    Ty::str(),
3165                ]),
3166            ));
3167            Some(Ty::Record(fields))
3168        }
3169        "csv" => {
3170            // CSV rows-as-lists. parse :: Str -> Result[List[List[Str]], Str].
3171            // Header awareness is left to the caller — row 0 is
3172            // whatever the file has. A `parse_with_headers` that
3173            // returns List[Map[Str,Str]] is a natural follow-up.
3174            let row_ty = Ty::List(Box::new(Ty::str()));
3175            let rows_ty = Ty::List(Box::new(row_ty.clone()));
3176            let mut fields = IndexMap::new();
3177            fields.insert("parse".into(), Ty::function(
3178                vec![Ty::str()], EffectSet::empty(),
3179                Ty::Con("Result".into(), vec![rows_ty.clone(), Ty::str()]),
3180            ));
3181            fields.insert("stringify".into(), Ty::function(
3182                vec![rows_ty], EffectSet::empty(),
3183                Ty::Con("Result".into(), vec![Ty::str(), Ty::str()]),
3184            ));
3185            Some(Ty::Record(fields))
3186        }
3187        "test" => {
3188            // Tiny assertion library (#proposed-stdlib). Each helper
3189            // returns Result[Unit, Str] so a test is itself a fn
3190            // returning Result. Callers compose suites in user code
3191            // (a List of (name, () -> Result[Unit, Str]) pairs +
3192            // list.fold to accumulate verdicts). Property generators
3193            // and a Rust-side Suite type are deferred to v2.
3194            let mut fields = IndexMap::new();
3195            // assert_eq[a, b] :: T -> T -> Result[Unit, Str]
3196            // (T constrained equal by unification on the two args)
3197            let unit_result = || Ty::Con("Result".into(), vec![Ty::Unit, Ty::str()]);
3198            fields.insert("assert_eq".into(), Ty::function(
3199                vec![Ty::Var(0), Ty::Var(0)], EffectSet::empty(), unit_result(),
3200            ));
3201            fields.insert("assert_ne".into(), Ty::function(
3202                vec![Ty::Var(0), Ty::Var(0)], EffectSet::empty(), unit_result(),
3203            ));
3204            fields.insert("assert_true".into(), Ty::function(
3205                vec![Ty::bool()], EffectSet::empty(), unit_result(),
3206            ));
3207            fields.insert("assert_false".into(), Ty::function(
3208                vec![Ty::bool()], EffectSet::empty(), unit_result(),
3209            ));
3210            Some(Ty::Record(fields))
3211        }
3212        "toml" => {
3213            // TOML config parser. Mirrors `std.json`'s shape: parse
3214            // is polymorphic so callers annotate the expected
3215            // record / list / scalar shape and the type checker
3216            // unifies. The parsed TOML maps to the same Lex Value
3217            // shape as JSON does:
3218            //
3219            //   TOML String   → Value::Str
3220            //   TOML Integer  → Value::Int
3221            //   TOML Float    → Value::Float
3222            //   TOML Boolean  → Value::Bool
3223            //   TOML Array    → Value::List
3224            //   TOML Table    → Value::Record
3225            //   TOML Datetime → Value::Str (RFC 3339, lossless)
3226            //
3227            // The Datetime → Str fallback is the one info-losing
3228            // step; callers who want a real `Instant` can pipe the
3229            // string through `datetime.parse_iso`.
3230            let mut fields = IndexMap::new();
3231            // parse :: Str -> Result[T, Str]
3232            fields.insert("parse".into(), Ty::function(
3233                vec![Ty::str()], EffectSet::empty(),
3234                Ty::Con("Result".into(), vec![Ty::Var(0), Ty::str()]),
3235            ));
3236            // parse_strict :: (Str, List[Str]) -> Result[T, Str]
3237            // Tactical fix for #168 — caller passes the field
3238            // names T requires; runtime returns Err if any are
3239            // missing from the parsed table instead of letting
3240            // field access panic later.
3241            fields.insert("parse_strict".into(), Ty::function(
3242                vec![Ty::str(), Ty::List(Box::new(Ty::str()))],
3243                EffectSet::empty(),
3244                Ty::Con("Result".into(), vec![Ty::Var(0), Ty::str()]),
3245            ));
3246            // stringify :: T -> Result[Str, Str]
3247            // Returns Result (not Str) because not every Lex Value
3248            // has a TOML representation — top-level scalars,
3249            // closures, mixed-key maps etc. surface as Err rather
3250            // than panic.
3251            fields.insert("stringify".into(), Ty::function(
3252                vec![Ty::Var(0)], EffectSet::empty(),
3253                Ty::Con("Result".into(), vec![Ty::str(), Ty::str()]),
3254            ));
3255            Some(Ty::Record(fields))
3256        }
3257        // `std.agent` (#184) — runtime primitives whose effects
3258        // separate (a) which LLM surface (`llm_local` vs
3259        // `llm_cloud`), (b) which peer protocol (`a2a`), and
3260        // (c) which tool boundary (`mcp`). The wire formats land
3261        // in downstream crates (`soft-agent`, `soft-a2a`) and
3262        // in #185 for MCP; what's typed here is the boundary
3263        // alone — agent code can be type-checked as
3264        // `[llm_local, a2a]` and will fail if it tries to reach
3265        // `[llm_cloud]` even before the wire layer is finished.
3266        "agent" => {
3267            let mut fields = IndexMap::new();
3268            // local_complete :: Str -> [llm_local] Result[Str, Str]
3269            fields.insert("local_complete".into(), Ty::function(
3270                vec![Ty::str()],
3271                EffectSet::singleton("llm_local"),
3272                Ty::Con("Result".into(), vec![Ty::str(), Ty::str()]),
3273            ));
3274            // cloud_complete :: Str -> [llm_cloud] Result[Str, Str]
3275            fields.insert("cloud_complete".into(), Ty::function(
3276                vec![Ty::str()],
3277                EffectSet::singleton("llm_cloud"),
3278                Ty::Con("Result".into(), vec![Ty::str(), Ty::str()]),
3279            ));
3280            // send_a2a :: (Str, Str) -> [a2a] Result[Str, Str]
3281            //              peer payload                   reply
3282            fields.insert("send_a2a".into(), Ty::function(
3283                vec![Ty::str(), Ty::str()],
3284                EffectSet::singleton("a2a"),
3285                Ty::Con("Result".into(), vec![Ty::str(), Ty::str()]),
3286            ));
3287            // call_mcp :: (Str, Str, Str) -> [mcp] Result[Str, Str]
3288            //              server tool args_json         result_json
3289            fields.insert("call_mcp".into(), Ty::function(
3290                vec![Ty::str(), Ty::str(), Ty::str()],
3291                EffectSet::singleton("mcp"),
3292                Ty::Con("Result".into(), vec![Ty::str(), Ty::str()]),
3293            ));
3294            // cloud_stream :: Str -> [llm_cloud] Result[Stream[Str], Str]
3295            // (#305 slice 3). Streaming counterpart to cloud_complete.
3296            // The result is `Result[Stream[Str], Str]` rather than a
3297            // bare Stream so transport errors surface synchronously
3298            // at handshake time; per-chunk errors collapse the
3299            // stream to early termination.
3300            fields.insert("cloud_stream".into(), Ty::function(
3301                vec![Ty::str()],
3302                EffectSet::singleton("llm_cloud"),
3303                Ty::Con("Result".into(), vec![
3304                    Ty::Con("Stream".into(), vec![Ty::str()]),
3305                    Ty::str(),
3306                ]),
3307            ));
3308            Some(Ty::Record(fields))
3309        }
3310        "stream" => {
3311            // #305 slice 3: opaque consumer-side operations on
3312            // `Stream[T]`. Producers live elsewhere (`agent.cloud_stream`
3313            // for now); future producers (`http.get_stream`, etc.)
3314            // will register the same Stream[T] surface.
3315            let mut fields = IndexMap::new();
3316            // next :: Stream[T] -> [stream] Option[T]
3317            // One pull. `None` signals end-of-stream (consumed by
3318            // the producer's lazy generator).
3319            fields.insert("next".into(), Ty::function(
3320                vec![Ty::Con("Stream".into(), vec![Ty::Var(0)])],
3321                EffectSet::singleton("stream"),
3322                Ty::Con("Option".into(), vec![Ty::Var(0)]),
3323            ));
3324            // collect :: Stream[T] -> [stream] List[T]
3325            // Drain to a list. Eager; blocks until the producer
3326            // signals end-of-stream.
3327            fields.insert("collect".into(), Ty::function(
3328                vec![Ty::Con("Stream".into(), vec![Ty::Var(0)])],
3329                EffectSet::singleton("stream"),
3330                Ty::List(Box::new(Ty::Var(0))),
3331            ));
3332            Some(Ty::Record(fields))
3333        }
3334        // -- std.decimal (#574): exact decimal arithmetic with explicit rounding.
3335        // `Decimal = { coefficient :: Int, exponent :: Int }` where the value
3336        // is `coefficient × 10^exponent`.  All arithmetic is exact (no IEEE 754
3337        // approximation); rounding only happens at `round_to`, which demands an
3338        // explicit mode string ("HalfUp" | "HalfDown" | "HalfEven" |
3339        // "Down" | "Up" | "Ceiling" | "Floor").
3340        "decimal" => {
3341            // Local helper: the Decimal record type.
3342            let decimal_ty = || {
3343                let mut f = IndexMap::new();
3344                f.insert("coefficient".into(), Ty::int());
3345                f.insert("exponent".into(), Ty::int());
3346                Ty::Record(f)
3347            };
3348            let mut fields = IndexMap::new();
3349            // Constructors
3350            // decimal :: (Int, Int) -> Decimal — coefficient, exponent
3351            fields.insert("decimal".into(), Ty::function(
3352                vec![Ty::int(), Ty::int()], EffectSet::empty(), decimal_ty()));
3353            // zero :: () -> Decimal — 0 × 10^0
3354            fields.insert("zero".into(), Ty::function(
3355                vec![], EffectSet::empty(), decimal_ty()));
3356            // one :: () -> Decimal — 1 × 10^0
3357            fields.insert("one".into(), Ty::function(
3358                vec![], EffectSet::empty(), decimal_ty()));
3359            // from_int :: Int -> Decimal — lift integer, exponent=0
3360            fields.insert("from_int".into(), Ty::function(
3361                vec![Ty::int()], EffectSet::empty(), decimal_ty()));
3362            // Arithmetic — all exact, no rounding
3363            // add :: (Decimal, Decimal) -> Decimal
3364            fields.insert("add".into(), Ty::function(
3365                vec![decimal_ty(), decimal_ty()], EffectSet::empty(), decimal_ty()));
3366            // sub :: (Decimal, Decimal) -> Decimal
3367            fields.insert("sub".into(), Ty::function(
3368                vec![decimal_ty(), decimal_ty()], EffectSet::empty(), decimal_ty()));
3369            // mul :: (Decimal, Decimal) -> Decimal — exponents add
3370            fields.insert("mul".into(), Ty::function(
3371                vec![decimal_ty(), decimal_ty()], EffectSet::empty(), decimal_ty()));
3372            // Comparison — three-way: -1 / 0 / 1
3373            // compare :: (Decimal, Decimal) -> Int
3374            fields.insert("compare".into(), Ty::function(
3375                vec![decimal_ty(), decimal_ty()], EffectSet::empty(), Ty::int()));
3376            // Predicates
3377            fields.insert("is_zero".into(), Ty::function(
3378                vec![decimal_ty()], EffectSet::empty(), Ty::bool()));
3379            fields.insert("is_positive".into(), Ty::function(
3380                vec![decimal_ty()], EffectSet::empty(), Ty::bool()));
3381            fields.insert("is_negative".into(), Ty::function(
3382                vec![decimal_ty()], EffectSet::empty(), Ty::bool()));
3383            // Transformers
3384            // normalize :: Decimal -> Decimal — remove trailing zeros
3385            fields.insert("normalize".into(), Ty::function(
3386                vec![decimal_ty()], EffectSet::empty(), decimal_ty()));
3387            // negate :: Decimal -> Decimal
3388            fields.insert("negate".into(), Ty::function(
3389                vec![decimal_ty()], EffectSet::empty(), decimal_ty()));
3390            // abs :: Decimal -> Decimal
3391            fields.insert("abs".into(), Ty::function(
3392                vec![decimal_ty()], EffectSet::empty(), decimal_ty()));
3393            // round_to :: (Decimal, Int, Str) -> Decimal
3394            //   target_exp: the exponent to round to (e.g. -2 → 2 decimal places)
3395            //   mode: "HalfUp" | "HalfDown" | "HalfEven" | "Down" | "Up" | "Ceiling" | "Floor"
3396            fields.insert("round_to".into(), Ty::function(
3397                vec![decimal_ty(), Ty::int(), Ty::str()],
3398                EffectSet::empty(), decimal_ty()));
3399            // to_str :: Decimal -> Str — decimal notation, e.g. "123.45"
3400            fields.insert("to_str".into(), Ty::function(
3401                vec![decimal_ty()], EffectSet::empty(), Ty::str()));
3402            // pow10 :: Int -> Int — 10^n; n must be in [0, 18]
3403            fields.insert("pow10".into(), Ty::function(
3404                vec![Ty::int()], EffectSet::empty(), Ty::int()));
3405            Some(Ty::Record(fields))
3406        }
3407        _ => None,
3408    }
3409}
3410
3411/// Resolve `import "std.foo" as alias` to a module name (e.g. "io").
3412pub fn module_for_import(reference: &str) -> Option<&'static str> {
3413    let suffix = reference.strip_prefix("std.")?;
3414    Some(match suffix {
3415        "io" => "io",
3416        "str" => "str",
3417        "int" => "int",
3418        "float" => "float",
3419        "list" => "list",
3420        "result" => "result",
3421        "option" => "option",
3422        "json" => "json",
3423        "flow" => "flow",
3424        "tuple" => "tuple",
3425        "time" => "time",
3426        "rand" => "rand",
3427        "random" => "random",
3428        "env" => "env",
3429        "bytes" => "bytes",
3430        "net" => "net",
3431        "tls" => "tls",
3432        "chat" => "chat",
3433        "math" => "math",
3434        "map" => "map",
3435        "set" => "set",
3436        "iter" => "iter",
3437        "crypto" => "crypto",
3438        "regex" => "regex",
3439        "parser" => "parser",
3440        "deque" => "deque",
3441        "kv" => "kv",
3442        "moe" => "moe",
3443        "sql" => "sql",
3444        "fs" => "fs",
3445        "process" => "process",
3446        "approval" => "approval",
3447        "datetime" => "datetime",
3448        "duration" => "duration",
3449        "log" => "log",
3450        "http" => "http",
3451        "toml" => "toml",
3452        "yaml" => "yaml",
3453        "dotenv" => "dotenv",
3454        "csv" => "csv",
3455        "test" => "test",
3456        "agent" => "agent",
3457        "cli" => "cli",
3458        "stream" => "stream",
3459        "conc" => "conc",
3460        "arrow" => "arrow",
3461        "df" => "df",
3462        "redis" => "redis",
3463        "decimal" => "decimal",
3464        "vcs" => "vcs",
3465        _ => return None,
3466    })
3467}
3468
3469/// Every module name `module_scope` resolves, in that function's match
3470/// order. Adding a match arm there means adding the name HERE too —
3471/// `lex docs --stdlib-index` renders docs/AGENT.md's generated stdlib
3472/// index from this list, and `lex doc-sync --check` in CI fails when the
3473/// docs drift from what this enumeration produces (#746).
3474pub const MODULE_NAMES: &[&str] = &[
3475    "io", "str", "int", "math", "float", "list", "bytes", "time", "rand", "random", "env", "net",
3476    "tls", "chat", "conc", "arrow", "df", "json", "result", "option", "tuple", "map", "set",
3477    "iter", "flow", "crypto", "deque", "log", "datetime", "duration", "approval", "process", "fs",
3478    "kv", "vcs", "sql", "redis", "parser", "cli", "regex", "http", "yaml", "dotenv", "csv", "test",
3479    "toml", "agent", "stream", "decimal", "moe",
3480];
3481
3482#[cfg(test)]
3483mod module_names_tests {
3484    use super::*;
3485    use crate::env::TypeEnv;
3486
3487    /// Every listed module must resolve to a non-empty record of
3488    /// builtins — a typo'd or removed name here would silently drop a
3489    /// module from the generated stdlib index.
3490    #[test]
3491    fn every_listed_module_resolves() {
3492        let env = TypeEnv::default();
3493        for name in MODULE_NAMES {
3494            match module_scope(name, &env) {
3495                Some(Ty::Record(fields)) => {
3496                    assert!(!fields.is_empty(), "module `{name}` resolved to an empty record");
3497                }
3498                other => panic!("module `{name}` did not resolve to a record: {other:?}"),
3499            }
3500        }
3501    }
3502
3503    /// The list and `module_for_import` must agree: everything listed
3504    /// is reachable via `import "std.<name>"`.
3505    #[test]
3506    fn listed_modules_are_importable() {
3507        for name in MODULE_NAMES {
3508            assert!(
3509                module_for_import(&format!("std.{name}")).is_some(),
3510                "module `{name}` is listed but not importable as std.{name}"
3511            );
3512        }
3513    }
3514}