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            // fs.append :: (Str, Str) -> [fs_write] Result[Unit, Str]
2372            //
2373            // Without this, adding a line to a file means read-all,
2374            // concatenate, write-all — so an append-only log costs O(n) bytes
2375            // per entry and O(n^2) over its life. Measured on a running
2376            // hash-chained ledger: 1.8 TB written in a week to store 87 MB
2377            // (#899). Sharding into many files trades a write problem for a
2378            // correctness one, and reaching for a database to add a line to a
2379            // log is a service dependency inside programs whose argument is a
2380            // small auditable surface.
2381            //
2382            // Same effect as `write` and the same `--allow-fs-write` gate: it
2383            // modifies a file, and nothing about appending makes it need less
2384            // authority. It deliberately does NOT imply read — an appender
2385            // that cannot read what it writes to is a genuinely smaller
2386            // authority for a log-only component.
2387            fields.insert("append".into(), Ty::function(
2388                vec![Ty::str(), Ty::str()],
2389                EffectSet::singleton("fs_write"),
2390                result_str(Ty::Unit)));
2391
2392            // Walk-style queries [fs_walk]
2393            fields.insert("exists".into(), Ty::function(
2394                vec![Ty::str()], EffectSet::singleton("fs_walk"), Ty::bool()));
2395            fields.insert("is_file".into(), Ty::function(
2396                vec![Ty::str()], EffectSet::singleton("fs_walk"), Ty::bool()));
2397            fields.insert("is_dir".into(), Ty::function(
2398                vec![Ty::str()], EffectSet::singleton("fs_walk"), Ty::bool()));
2399            fields.insert("stat".into(), Ty::function(
2400                vec![Ty::str()], EffectSet::singleton("fs_walk"),
2401                result_str(stat_t())));
2402            fields.insert("list_dir".into(), Ty::function(
2403                vec![Ty::str()], EffectSet::singleton("fs_walk"),
2404                result_str(Ty::List(Box::new(Ty::str())))));
2405            fields.insert("walk".into(), Ty::function(
2406                vec![Ty::str()], EffectSet::singleton("fs_walk"),
2407                result_str(Ty::List(Box::new(Ty::str())))));
2408            fields.insert("glob".into(), Ty::function(
2409                vec![Ty::str()], EffectSet::singleton("fs_walk"),
2410                result_str(Ty::List(Box::new(Ty::str())))));
2411            // Mutations [fs_write]
2412            fields.insert("mkdir_p".into(), Ty::function(
2413                vec![Ty::str()], EffectSet::singleton("fs_write"),
2414                result_str(Ty::Unit)));
2415            fields.insert("remove".into(), Ty::function(
2416                vec![Ty::str()], EffectSet::singleton("fs_write"),
2417                result_str(Ty::Unit)));
2418            fields.insert("copy".into(), Ty::function(
2419                vec![Ty::str(), Ty::str()],
2420                EffectSet {
2421                    concrete: [crate::types::EffectKind::bare("fs_walk"), crate::types::EffectKind::bare("fs_write")].into_iter().collect(),
2422                    var: None,
2423                },
2424                result_str(Ty::Unit)));
2425            Some(Ty::Record(fields))
2426        }
2427        "kv" => {
2428            // Embedded key-value store. The opaque `Kv` type is
2429            // backed by an Int handle into a process-wide registry.
2430            let kv_t = || Ty::Con("Kv".into(), vec![]);
2431            let mut fields = IndexMap::new();
2432            // open :: Str -> [kv, fs_write] Result[Kv, Str]
2433            fields.insert("open".into(), Ty::function(
2434                vec![Ty::str()],
2435                EffectSet {
2436                    concrete: [crate::types::EffectKind::bare("kv"), crate::types::EffectKind::bare("fs_write")].into_iter().collect(),
2437                    var: None,
2438                },
2439                Ty::Con("Result".into(), vec![kv_t(), Ty::str()])));
2440            // close :: Kv -> [kv] Unit
2441            fields.insert("close".into(), Ty::function(
2442                vec![kv_t()],
2443                EffectSet::singleton("kv"),
2444                Ty::Unit));
2445            // get :: Kv, Str -> [kv] Option[Bytes]
2446            fields.insert("get".into(), Ty::function(
2447                vec![kv_t(), Ty::str()],
2448                EffectSet::singleton("kv"),
2449                Ty::Con("Option".into(), vec![Ty::bytes()])));
2450            // put :: Kv, Str, Bytes -> [kv] Result[Unit, Str]
2451            fields.insert("put".into(), Ty::function(
2452                vec![kv_t(), Ty::str(), Ty::bytes()],
2453                EffectSet::singleton("kv"),
2454                Ty::Con("Result".into(), vec![Ty::Unit, Ty::str()])));
2455            // delete :: Kv, Str -> [kv] Result[Unit, Str]
2456            fields.insert("delete".into(), Ty::function(
2457                vec![kv_t(), Ty::str()],
2458                EffectSet::singleton("kv"),
2459                Ty::Con("Result".into(), vec![Ty::Unit, Ty::str()])));
2460            // contains :: Kv, Str -> [kv] Bool
2461            fields.insert("contains".into(), Ty::function(
2462                vec![kv_t(), Ty::str()],
2463                EffectSet::singleton("kv"),
2464                Ty::bool()));
2465            // list_prefix :: Kv, Str -> [kv] List[Str]
2466            fields.insert("list_prefix".into(), Ty::function(
2467                vec![kv_t(), Ty::str()],
2468                EffectSet::singleton("kv"),
2469                Ty::List(Box::new(Ty::str()))));
2470            Some(Ty::Record(fields))
2471        }
2472        "moe" => {
2473            // MoE expert-store placement ops (lex-moe#25). Native
2474            // EffectHandler dispatch on the `moe` kind — see
2475            // lex-moe's crates/moe-policy/src/lib.rs `MoeHost` for
2476            // the Rust-side implementation these signatures describe.
2477            // A single process-wide tier cache backs every op, so
2478            // unlike `kv` there's no open/close handle to thread.
2479            let usage_entry_t = || {
2480                let mut f = IndexMap::new();
2481                f.insert("hash".into(), Ty::str());
2482                f.insert("size".into(), Ty::int());
2483                f.insert("temp".into(), Ty::float());
2484                f.insert("hits".into(), Ty::int());
2485                f.insert("loads".into(), Ty::int());
2486                Ty::Record(f)
2487            };
2488            let stats_t = || {
2489                let mut f = IndexMap::new();
2490                f.insert("hits".into(), Ty::int());
2491                f.insert("misses".into(), Ty::int());
2492                f.insert("prefetched".into(), Ty::int());
2493                f.insert("evictions".into(), Ty::int());
2494                f.insert("resident_bytes".into(), Ty::int());
2495                Ty::Record(f)
2496            };
2497            let mut fields = IndexMap::new();
2498            // pin(hash :: Str) -> [moe] Unit
2499            fields.insert("pin".into(), Ty::function(
2500                vec![Ty::str()],
2501                EffectSet::singleton("moe"),
2502                Ty::Unit));
2503            // unpin(hash :: Str) -> [moe] Unit
2504            fields.insert("unpin".into(), Ty::function(
2505                vec![Ty::str()],
2506                EffectSet::singleton("moe"),
2507                Ty::Unit));
2508            // prefetch_hint(hashes :: List[Str]) -> [moe] Int  (loads spawned)
2509            fields.insert("prefetch_hint".into(), Ty::function(
2510                vec![Ty::List(Box::new(Ty::str()))],
2511                EffectSet::singleton("moe"),
2512                Ty::int()));
2513            // usage_snapshot() -> [moe] List[{hash, size, temp, hits, loads}]
2514            fields.insert("usage_snapshot".into(), Ty::function(
2515                vec![],
2516                EffectSet::singleton("moe"),
2517                Ty::List(Box::new(usage_entry_t()))));
2518            // stats() -> [moe] {hits, misses, prefetched, evictions, resident_bytes}
2519            fields.insert("stats".into(), Ty::function(
2520                vec![],
2521                EffectSet::singleton("moe"),
2522                stats_t()));
2523            Some(Ty::Record(fields))
2524        }
2525        "vcs" => {
2526            // Content-addressed blob store (#5 / M6.1b). `put_blob` returns the
2527            // lowercase hex SHA-256 of the content — the SAME id as
2528            // `crypto.sha256_str`, so blobs are interchangeable with loom's
2529            // SQLite-backed artifacts by id. `ref_set`/`ref_get` bind a name
2530            // (namespace + key) to a blob sha, e.g. namespace "loom/sprint-{id}",
2531            // key = node id (branch-per-sprint, #5). The on-disk layout matches
2532            // lex-store's blob CAS (<root>/blobs, <root>/blobrefs).
2533            //
2534            // fs_write/fs_read appear in the effect rows so `lex audit` sees the
2535            // disk touch; the store root is internal (~/.lex/store or
2536            // $LEX_STORE_ROOT) so no user-path allowlist applies.
2537            let mut fields = IndexMap::new();
2538            let vcs_w = || EffectSet {
2539                concrete: [crate::types::EffectKind::bare("vcs"),
2540                           crate::types::EffectKind::bare("fs_write")]
2541                    .into_iter().collect(),
2542                var: None,
2543            };
2544            let vcs_r = || EffectSet {
2545                concrete: [crate::types::EffectKind::bare("vcs"),
2546                           crate::types::EffectKind::bare("fs_read")]
2547                    .into_iter().collect(),
2548                var: None,
2549            };
2550            let res = |ok: Ty| Ty::Con("Result".into(), vec![ok, Ty::str()]);
2551
2552            // put_blob :: Str -> [vcs, fs_write] Result[Str, Str]  (returns sha)
2553            fields.insert("put_blob".into(), Ty::function(
2554                vec![Ty::str()], vcs_w(), res(Ty::str())));
2555            // get_blob :: Str -> [vcs, fs_read] Result[Str, Str]
2556            fields.insert("get_blob".into(), Ty::function(
2557                vec![Ty::str()], vcs_r(), res(Ty::str())));
2558            // has_blob :: Str -> [vcs, fs_read] Bool
2559            fields.insert("has_blob".into(), Ty::function(
2560                vec![Ty::str()], vcs_r(), Ty::bool()));
2561            // ref_set :: Str, Str, Str -> [vcs, fs_write] Result[Unit, Str]
2562            fields.insert("ref_set".into(), Ty::function(
2563                vec![Ty::str(), Ty::str(), Ty::str()], vcs_w(), res(Ty::Unit)));
2564            // ref_get :: Str, Str -> [vcs, fs_read] Result[Str, Str]  (key -> sha)
2565            fields.insert("ref_get".into(), Ty::function(
2566                vec![Ty::str(), Ty::str()], vcs_r(), res(Ty::str())));
2567            Some(Ty::Record(fields))
2568        }
2569        "sql" => {
2570            // Embedded SQL (SQLite via rusqlite). The opaque `Db` type is
2571            // backed by an Int handle into a process-wide registry (#362).
2572            //
2573            // Params use the typed `SqlParam` ADT (PStr|PInt|PFloat|PBool|PNull)
2574            // registered in env.rs, so callers don't have to stringify values.
2575            //
2576            // Transactions: sql.begin(db) → SqlTx; sql.commit/rollback(tx).
2577            // exec_tx / query_tx mirror exec / query but operate on a SqlTx.
2578            //
2579            // Row decoders: get_str / get_int / get_float / get_bool extract
2580            // typed columns from a row record by name.
2581            let db_t  = || Ty::Con("Db".into(), vec![]);
2582            let tx_t  = || Ty::Con("SqlTx".into(), vec![]);
2583            let sp_t  = || Ty::Con("SqlParam".into(), vec![]);
2584            let params_t = || Ty::List(Box::new(sp_t()));
2585            let mut fields = IndexMap::new();
2586
2587            // SqlError = { message, code, detail } — populated with
2588            // SQLSTATE (Postgres) or symbolic SQLite error name (#380).
2589            let se_t = || Ty::Con("SqlError".into(), vec![]);
2590
2591            // open :: Str -> [sql, fs_write] Result[Db, SqlError]
2592            fields.insert("open".into(), Ty::function(
2593                vec![Ty::str()],
2594                EffectSet {
2595                    concrete: [crate::types::EffectKind::bare("sql"),
2596                               crate::types::EffectKind::bare("fs_write")]
2597                        .into_iter().collect(),
2598                    var: None,
2599                },
2600                Ty::Con("Result".into(), vec![db_t(), se_t()])));
2601
2602            // close :: Db -> [sql] Unit
2603            fields.insert("close".into(), Ty::function(
2604                vec![db_t()],
2605                EffectSet::singleton("sql"),
2606                Ty::Unit));
2607
2608            // exec :: Db, Str, List[SqlParam] -> [sql] Result[Int, SqlError]
2609            fields.insert("exec".into(), Ty::function(
2610                vec![db_t(), Ty::str(), params_t()],
2611                EffectSet::singleton("sql"),
2612                Ty::Con("Result".into(), vec![Ty::int(), se_t()])));
2613
2614            // query[T] :: Db, Str, List[SqlParam] -> [sql] Result[List[T], SqlError]
2615            fields.insert("query".into(), Ty::function(
2616                vec![db_t(), Ty::str(), params_t()],
2617                EffectSet::singleton("sql"),
2618                Ty::Con("Result".into(), vec![
2619                    Ty::List(Box::new(Ty::Var(0))),
2620                    se_t(),
2621                ])));
2622
2623            // query_iter[T] :: Db, Str, List[SqlParam] -> [sql] Result[Iter[T], SqlError]
2624            // Streaming variant of `query` (#379). Rows are pulled from
2625            // the server one at a time via an mpsc-backed cursor —
2626            // memory stays bounded regardless of result-set size.
2627            // Other ops on the same `Db` handle block until the cursor
2628            // is drained (single connection per Db).
2629            fields.insert("query_iter".into(), Ty::function(
2630                vec![db_t(), Ty::str(), params_t()],
2631                EffectSet::singleton("sql"),
2632                Ty::Con("Result".into(), vec![
2633                    Ty::Con("Iter".into(), vec![Ty::Var(0)]),
2634                    se_t(),
2635                ])));
2636
2637            // begin :: Db -> [sql] Result[SqlTx, SqlError]
2638            fields.insert("begin".into(), Ty::function(
2639                vec![db_t()],
2640                EffectSet::singleton("sql"),
2641                Ty::Con("Result".into(), vec![tx_t(), se_t()])));
2642
2643            // commit :: SqlTx -> [sql] Result[Unit, SqlError]
2644            fields.insert("commit".into(), Ty::function(
2645                vec![tx_t()],
2646                EffectSet::singleton("sql"),
2647                Ty::Con("Result".into(), vec![Ty::Unit, se_t()])));
2648
2649            // rollback :: SqlTx -> [sql] Result[Unit, SqlError]
2650            fields.insert("rollback".into(), Ty::function(
2651                vec![tx_t()],
2652                EffectSet::singleton("sql"),
2653                Ty::Con("Result".into(), vec![Ty::Unit, se_t()])));
2654
2655            // exec_tx :: SqlTx, Str, List[SqlParam] -> [sql] Result[Int, SqlError]
2656            fields.insert("exec_tx".into(), Ty::function(
2657                vec![tx_t(), Ty::str(), params_t()],
2658                EffectSet::singleton("sql"),
2659                Ty::Con("Result".into(), vec![Ty::int(), se_t()])));
2660
2661            // query_tx[T] :: SqlTx, Str, List[SqlParam] -> [sql] Result[List[T], SqlError]
2662            fields.insert("query_tx".into(), Ty::function(
2663                vec![tx_t(), Ty::str(), params_t()],
2664                EffectSet::singleton("sql"),
2665                Ty::Con("Result".into(), vec![
2666                    Ty::List(Box::new(Ty::Var(0))),
2667                    se_t(),
2668                ])));
2669
2670            // Row decoders: get_X[T] :: T, Str -> Option[X]
2671            // T is polymorphic so these work on any row record shape.
2672            fields.insert("get_str".into(), Ty::function(
2673                vec![Ty::Var(0), Ty::str()],
2674                EffectSet::empty(),
2675                Ty::Con("Option".into(), vec![Ty::str()])));
2676            fields.insert("get_int".into(), Ty::function(
2677                vec![Ty::Var(0), Ty::str()],
2678                EffectSet::empty(),
2679                Ty::Con("Option".into(), vec![Ty::int()])));
2680            fields.insert("get_float".into(), Ty::function(
2681                vec![Ty::Var(0), Ty::str()],
2682                EffectSet::empty(),
2683                Ty::Con("Option".into(), vec![Ty::float()])));
2684            fields.insert("get_bool".into(), Ty::function(
2685                vec![Ty::Var(0), Ty::str()],
2686                EffectSet::empty(),
2687                Ty::Con("Option".into(), vec![Ty::bool()])));
2688
2689            Some(Ty::Record(fields))
2690        }
2691        "redis" => {
2692            // Thin Redis client (#533). ConnRedis is an opaque handle backed by a
2693            // process-wide registry (same pattern as Db in std.sql). All ops carry
2694            // [net] — Redis is a TCP service; no separate [redis] effect.
2695            //
2696            // subscribe / psubscribe return Unit because they are blocking
2697            // infinite loops, consistent with net.serve_fn and ws.serve.
2698            //
2699            // subscribe/psubscribe open a *dedicated* connection internally —
2700            // Redis disallows non-Pub/Sub commands on a subscribed connection.
2701            let conn_t = || Ty::Con("ConnRedis".into(), vec![]);
2702            let mut fields = IndexMap::new();
2703
2704            // connect :: Str -> [net] Result[ConnRedis, Str]
2705            // url: "redis://host:6379" or "rediss://host:6380" (TLS)
2706            fields.insert("connect".into(), Ty::function(
2707                vec![Ty::str()],
2708                EffectSet::singleton("net"),
2709                Ty::Con("Result".into(), vec![conn_t(), Ty::str()])));
2710
2711            // close :: ConnRedis -> [net] Unit
2712            fields.insert("close".into(), Ty::function(
2713                vec![conn_t()],
2714                EffectSet::singleton("net"),
2715                Ty::Unit));
2716
2717            // ---- Key-value -----------------------------------------------
2718
2719            // get :: ConnRedis, Str -> [net] Option[Str]
2720            fields.insert("get".into(), Ty::function(
2721                vec![conn_t(), Ty::str()],
2722                EffectSet::singleton("net"),
2723                Ty::Con("Option".into(), vec![Ty::str()])));
2724
2725            // set :: ConnRedis, Str, Str -> [net] Unit
2726            fields.insert("set".into(), Ty::function(
2727                vec![conn_t(), Ty::str(), Ty::str()],
2728                EffectSet::singleton("net"),
2729                Ty::Unit));
2730
2731            // set_ex :: ConnRedis, Str, Str, Int -> [net] Unit
2732            fields.insert("set_ex".into(), Ty::function(
2733                vec![conn_t(), Ty::str(), Ty::str(), Ty::int()],
2734                EffectSet::singleton("net"),
2735                Ty::Unit));
2736
2737            // del :: ConnRedis, Str -> [net] Unit
2738            fields.insert("del".into(), Ty::function(
2739                vec![conn_t(), Ty::str()],
2740                EffectSet::singleton("net"),
2741                Ty::Unit));
2742
2743            // exists :: ConnRedis, Str -> [net] Bool
2744            fields.insert("exists".into(), Ty::function(
2745                vec![conn_t(), Ty::str()],
2746                EffectSet::singleton("net"),
2747                Ty::bool()));
2748
2749            // expire :: ConnRedis, Str, Int -> [net] Unit
2750            fields.insert("expire".into(), Ty::function(
2751                vec![conn_t(), Ty::str(), Ty::int()],
2752                EffectSet::singleton("net"),
2753                Ty::Unit));
2754
2755            // ---- Pub/Sub -------------------------------------------------
2756
2757            // publish :: ConnRedis, Str, Str -> [net] Int
2758            // Returns the number of subscribers that received the message.
2759            fields.insert("publish".into(), Ty::function(
2760                vec![conn_t(), Ty::str(), Ty::str()],
2761                EffectSet::singleton("net"),
2762                Ty::int()));
2763
2764            // subscribe :: ConnRedis, Str, (Str, Str ->[E] Unit) -> [net] Unit
2765            // Blocking loop; handler receives (channel, message) on each message.
2766            // Uses a dedicated connection — Redis disallows non-Pub/Sub commands
2767            // on a subscribed connection. Handler carries an open effect row so
2768            // callers can use io, net, sql, etc. inside the closure.
2769            let handler2 = Ty::function(
2770                vec![Ty::str(), Ty::str()],
2771                EffectSet::open_var(0),
2772                Ty::Unit);
2773            fields.insert("subscribe".into(), Ty::function(
2774                vec![conn_t(), Ty::str(), handler2],
2775                EffectSet::singleton("net"),
2776                Ty::Unit));  // Unit
2777
2778            // psubscribe :: ConnRedis, Str, (Str, Str, Str ->[E] Unit) -> [net] Unit
2779            // Pattern-subscribe; handler receives (pattern, channel, message).
2780            // Handler carries an open effect row (same rationale as subscribe).
2781            let handler3 = Ty::function(
2782                vec![Ty::str(), Ty::str(), Ty::str()],
2783                EffectSet::open_var(1),
2784                Ty::Unit);
2785            fields.insert("psubscribe".into(), Ty::function(
2786                vec![conn_t(), Ty::str(), handler3],
2787                EffectSet::singleton("net"),
2788                Ty::Unit));  // Unit
2789
2790            // ---- List ----------------------------------------------------
2791
2792            // lpush :: ConnRedis, Str, Str -> [net] Int
2793            fields.insert("lpush".into(), Ty::function(
2794                vec![conn_t(), Ty::str(), Ty::str()],
2795                EffectSet::singleton("net"),
2796                Ty::int()));
2797
2798            // rpush :: ConnRedis, Str, Str -> [net] Int
2799            fields.insert("rpush".into(), Ty::function(
2800                vec![conn_t(), Ty::str(), Ty::str()],
2801                EffectSet::singleton("net"),
2802                Ty::int()));
2803
2804            // brpop :: ConnRedis, Str, Int -> [net] Option[Str]
2805            // Blocking right-pop; returns None on timeout. timeout=0 blocks
2806            // indefinitely (the runtime does not treat this as a hung effect).
2807            fields.insert("brpop".into(), Ty::function(
2808                vec![conn_t(), Ty::str(), Ty::int()],
2809                EffectSet::singleton("net"),
2810                Ty::Con("Option".into(), vec![Ty::str()])));
2811
2812            // llen :: ConnRedis, Str -> [net] Int
2813            fields.insert("llen".into(), Ty::function(
2814                vec![conn_t(), Ty::str()],
2815                EffectSet::singleton("net"),
2816                Ty::int()));
2817
2818            // ---- Hash ----------------------------------------------------
2819
2820            // hset :: ConnRedis, Str, Str, Str -> [net] Unit
2821            fields.insert("hset".into(), Ty::function(
2822                vec![conn_t(), Ty::str(), Ty::str(), Ty::str()],
2823                EffectSet::singleton("net"),
2824                Ty::Unit));
2825
2826            // hget :: ConnRedis, Str, Str -> [net] Option[Str]
2827            fields.insert("hget".into(), Ty::function(
2828                vec![conn_t(), Ty::str(), Ty::str()],
2829                EffectSet::singleton("net"),
2830                Ty::Con("Option".into(), vec![Ty::str()])));
2831
2832            // hdel :: ConnRedis, Str, Str -> [net] Unit
2833            fields.insert("hdel".into(), Ty::function(
2834                vec![conn_t(), Ty::str(), Ty::str()],
2835                EffectSet::singleton("net"),
2836                Ty::Unit));
2837
2838            // hgetall :: ConnRedis, Str -> [net] List[(Str, Str)]
2839            fields.insert("hgetall".into(), Ty::function(
2840                vec![conn_t(), Ty::str()],
2841                EffectSet::singleton("net"),
2842                Ty::List(Box::new(Ty::Tuple(vec![Ty::str(), Ty::str()])))));
2843
2844            Some(Ty::Record(fields))
2845        }
2846        "parser" => {
2847            // #217: structured parser combinators. Parser values are
2848            // tagged Records at runtime (`{ kind, ... }`), opaque at
2849            // the language level via `Ty::Con("Parser", [T])`.
2850            //
2851            // Surface:
2852            //   - primitives: char, string, digit, alpha, whitespace, eof
2853            //   - combinators: seq, alt, many, optional, map, and_then
2854            //   - run :: Parser[T], Str -> Result[T, ParseErr]
2855            //
2856            // `map` and `and_then` were deferred from #217's v1 because
2857            // their closure arguments carried call-site identity that
2858            // broke the canonical-parsers acceptance criterion. With
2859            // closure body-hash equality landed in #222, that concern
2860            // is gone, and #221 wires them in. The interpreter for
2861            // `parser.run` has been moved to `lex-bytecode::parser_runtime`
2862            // so it can invoke closures from `Map` / `AndThen` nodes.
2863            let pt = |t: Ty| Ty::Con("Parser".into(), vec![t]);
2864            let parse_err = || {
2865                let mut fs = IndexMap::new();
2866                fs.insert("pos".into(), Ty::int());
2867                fs.insert("message".into(), Ty::str());
2868                Ty::Record(fs)
2869            };
2870            let mut fields = IndexMap::new();
2871            // char :: Str -> Parser[Str] (single-char Str literal)
2872            fields.insert("char".into(), Ty::function(
2873                vec![Ty::str()], EffectSet::empty(), pt(Ty::str())));
2874            // string :: Str -> Parser[Str]
2875            fields.insert("string".into(), Ty::function(
2876                vec![Ty::str()], EffectSet::empty(), pt(Ty::str())));
2877            // digit :: () -> Parser[Str]
2878            fields.insert("digit".into(), Ty::function(
2879                vec![], EffectSet::empty(), pt(Ty::str())));
2880            // alpha :: () -> Parser[Str]
2881            fields.insert("alpha".into(), Ty::function(
2882                vec![], EffectSet::empty(), pt(Ty::str())));
2883            // whitespace :: () -> Parser[Str]
2884            fields.insert("whitespace".into(), Ty::function(
2885                vec![], EffectSet::empty(), pt(Ty::str())));
2886            // eof :: () -> Parser[Unit]
2887            fields.insert("eof".into(), Ty::function(
2888                vec![], EffectSet::empty(), pt(Ty::Unit)));
2889            // seq :: Parser[A], Parser[B] -> Parser[(A, B)]
2890            fields.insert("seq".into(), Ty::function(
2891                vec![pt(Ty::Var(0)), pt(Ty::Var(1))],
2892                EffectSet::empty(),
2893                pt(Ty::Tuple(vec![Ty::Var(0), Ty::Var(1)]))));
2894            // alt :: Parser[T], Parser[T] -> Parser[T]
2895            // PEG-style ordered choice: the second alternative is
2896            // tried only if the first fails.
2897            fields.insert("alt".into(), Ty::function(
2898                vec![pt(Ty::Var(0)), pt(Ty::Var(0))],
2899                EffectSet::empty(),
2900                pt(Ty::Var(0))));
2901            // many :: Parser[T] -> Parser[List[T]]
2902            // Zero-or-more. Stops as soon as the inner parser fails
2903            // OR doesn't advance the position (avoids infinite loop
2904            // on empty matches).
2905            fields.insert("many".into(), Ty::function(
2906                vec![pt(Ty::Var(0))],
2907                EffectSet::empty(),
2908                pt(Ty::List(Box::new(Ty::Var(0))))));
2909            // optional :: Parser[T] -> Parser[Option[T]]
2910            fields.insert("optional".into(), Ty::function(
2911                vec![pt(Ty::Var(0))],
2912                EffectSet::empty(),
2913                pt(Ty::Con("Option".into(), vec![Ty::Var(0)]))));
2914            // map :: Parser[T], (T) -> [E] U -> [E] Parser[U]
2915            // The closure runs at parse time when the Parser is run.
2916            // Effect-polymorphic on the closure: any effect the
2917            // closure declares propagates to the surrounding `run`.
2918            fields.insert("map".into(), Ty::function(
2919                vec![
2920                    pt(Ty::Var(0)),
2921                    Ty::function(vec![Ty::Var(0)], EffectSet::open_var(2), Ty::Var(1)),
2922                ],
2923                EffectSet::open_var(2),
2924                pt(Ty::Var(1))));
2925            // and_then :: Parser[T], (T) -> [E] Parser[U] -> [E] Parser[U]
2926            // Monadic bind: closure inspects the parsed value and
2927            // returns the next parser to run.
2928            fields.insert("and_then".into(), Ty::function(
2929                vec![
2930                    pt(Ty::Var(0)),
2931                    Ty::function(vec![Ty::Var(0)], EffectSet::open_var(3),
2932                        pt(Ty::Var(1))),
2933                ],
2934                EffectSet::open_var(3),
2935                pt(Ty::Var(1))));
2936            // run :: Parser[T], Str -> Result[T, ParseErr]
2937            // ParseErr = { pos :: Int, message :: Str }
2938            fields.insert("run".into(), Ty::function(
2939                vec![pt(Ty::Var(0)), Ty::str()],
2940                EffectSet::empty(),
2941                Ty::Con("Result".into(), vec![Ty::Var(0), parse_err()])));
2942            Some(Ty::Record(fields))
2943        }
2944        "cli" => {
2945            // #224 Rubric port: argparse-equivalent for end-user
2946            // programs. Spec values are tagged `Json` records (opaque
2947            // to the language but inspectable). Construction via the
2948            // `flag` / `option` / `positional` / `spec` builders;
2949            // parse + introspection / help via the remaining ops.
2950            let json = || Ty::Con("Json".into(), vec![]);
2951            let opt_str = || Ty::Con("Option".into(), vec![Ty::str()]);
2952            let mut fields = IndexMap::new();
2953            // flag :: Str -> Option[Str] -> Str -> Json
2954            //   long_name -> short -> help -> CliArg
2955            fields.insert("flag".into(), Ty::function(
2956                vec![Ty::str(), opt_str(), Ty::str()],
2957                EffectSet::empty(),
2958                json()));
2959            // option :: Str -> Option[Str] -> Str -> Option[Str] -> Json
2960            //   long_name -> short -> help -> default -> CliArg
2961            fields.insert("option".into(), Ty::function(
2962                vec![Ty::str(), opt_str(), Ty::str(), opt_str()],
2963                EffectSet::empty(),
2964                json()));
2965            // positional :: Str -> Str -> Bool -> Json
2966            //   name -> help -> required -> CliArg
2967            fields.insert("positional".into(), Ty::function(
2968                vec![Ty::str(), Ty::str(), Ty::bool()],
2969                EffectSet::empty(),
2970                json()));
2971            // spec :: Str -> Str -> List[Json] -> List[Json] -> Json
2972            //   name -> help -> args -> subcommands -> CliSpec
2973            fields.insert("spec".into(), Ty::function(
2974                vec![Ty::str(), Ty::str(),
2975                     Ty::List(Box::new(json())),
2976                     Ty::List(Box::new(json()))],
2977                EffectSet::empty(),
2978                json()));
2979            // parse :: Json -> List[Str] -> Result[Json, Str]
2980            //   spec -> argv -> Result[CliParsed, error]
2981            fields.insert("parse".into(), Ty::function(
2982                vec![json(), Ty::List(Box::new(Ty::str()))],
2983                EffectSet::empty(),
2984                Ty::Con("Result".into(), vec![json(), Ty::str()])));
2985            // envelope :: Bool -> Str -> T -> Json
2986            //   ok -> command -> data -> ACLI-shaped envelope.
2987            // `data` is polymorphic so callers don't have to round-
2988            // trip through `json.parse` for trivial payloads.
2989            fields.insert("envelope".into(), Ty::function(
2990                vec![Ty::bool(), Ty::str(), Ty::Var(0)],
2991                EffectSet::empty(),
2992                json()));
2993            // describe :: Json -> Json — machine-readable spec dump
2994            fields.insert("describe".into(), Ty::function(
2995                vec![json()],
2996                EffectSet::empty(),
2997                json()));
2998            // help :: Json -> Str — human-readable help text
2999            fields.insert("help".into(), Ty::function(
3000                vec![json()],
3001                EffectSet::empty(),
3002                Ty::str()));
3003            Some(Ty::Record(fields))
3004        }
3005        "regex" => {
3006            // The compiled `Regex` is stored as a `Str` at runtime
3007            // (the pattern source) plus a process-wide cache of the
3008            // actual `regex::Regex`. So `Regex` is a nominal type at
3009            // the language level but its value is just the pattern.
3010            let regex_t = || Ty::Con("Regex".into(), vec![]);
3011            let match_t = || {
3012                let mut fs = IndexMap::new();
3013                fs.insert("text".into(), Ty::str());
3014                fs.insert("start".into(), Ty::int());
3015                fs.insert("end".into(), Ty::int());
3016                fs.insert("groups".into(), Ty::List(Box::new(Ty::str())));
3017                Ty::Record(fs)
3018            };
3019            let mut fields = IndexMap::new();
3020            // compile :: Str -> Result[Regex, Str]
3021            fields.insert("compile".into(), Ty::function(
3022                vec![Ty::str()], EffectSet::empty(),
3023                Ty::Con("Result".into(), vec![regex_t(), Ty::str()])));
3024            // is_match :: Regex, Str -> Bool
3025            fields.insert("is_match".into(), Ty::function(
3026                vec![regex_t(), Ty::str()], EffectSet::empty(), Ty::bool()));
3027            // is_match_str :: Str, Str -> Bool
3028            // Compiles the first argument as a pattern and matches against the second.
3029            // Returns false on invalid pattern instead of propagating an error.
3030            fields.insert("is_match_str".into(), Ty::function(
3031                vec![Ty::str(), Ty::str()], EffectSet::empty(), Ty::bool()));
3032            // find :: Regex, Str -> Option[Match]
3033            fields.insert("find".into(), Ty::function(
3034                vec![regex_t(), Ty::str()], EffectSet::empty(),
3035                Ty::Con("Option".into(), vec![match_t()])));
3036            // find_all :: Regex, Str -> List[Match]
3037            fields.insert("find_all".into(), Ty::function(
3038                vec![regex_t(), Ty::str()], EffectSet::empty(),
3039                Ty::List(Box::new(match_t()))));
3040            // replace :: Regex, Str, Str -> Str
3041            fields.insert("replace".into(), Ty::function(
3042                vec![regex_t(), Ty::str(), Ty::str()], EffectSet::empty(), Ty::str()));
3043            // replace_all :: Regex, Str, Str -> Str
3044            fields.insert("replace_all".into(), Ty::function(
3045                vec![regex_t(), Ty::str(), Ty::str()], EffectSet::empty(), Ty::str()));
3046            // split :: Regex, Str -> List[Str]
3047            fields.insert("split".into(), Ty::function(
3048                vec![regex_t(), Ty::str()], EffectSet::empty(),
3049                Ty::List(Box::new(Ty::str()))));
3050            Some(Ty::Record(fields))
3051        }
3052        "http" => {
3053            // Rich HTTP client. `[net]` for the wire ops, pure for
3054            // the builders / decoders. `--allow-net-host` gates per
3055            // request. Multipart upload + streaming response bodies
3056            // are deferred to v1.5; the v1 surface covers the
3057            // common cases (auth, headers, query, timeouts, JSON /
3058            // text decoding).
3059            let req_t  = || Ty::Con("HttpRequest".into(), vec![]);
3060            let resp_t = || Ty::Con("HttpResponse".into(), vec![]);
3061            let err_t  = || Ty::Con("HttpError".into(), vec![]);
3062            let result_he = |t: Ty| Ty::Con("Result".into(), vec![t, err_t()]);
3063            let str_str_map = || Ty::Con("Map".into(), vec![Ty::str(), Ty::str()]);
3064            let mut fields = IndexMap::new();
3065            // -- wire ops (effectful) --
3066            // send :: HttpRequest -> [net] Result[HttpResponse, HttpError]
3067            fields.insert("send".into(), Ty::function(
3068                vec![req_t()],
3069                EffectSet::singleton("net"),
3070                result_he(resp_t()),
3071            ));
3072            // get :: Str -> [net] Result[HttpResponse, HttpError]
3073            fields.insert("get".into(), Ty::function(
3074                vec![Ty::str()],
3075                EffectSet::singleton("net"),
3076                result_he(resp_t()),
3077            ));
3078            // post :: Str, Bytes, Str -> [net] Result[HttpResponse, HttpError]
3079            fields.insert("post".into(), Ty::function(
3080                vec![Ty::str(), Ty::bytes(), Ty::str()],
3081                EffectSet::singleton("net"),
3082                result_he(resp_t()),
3083            ));
3084            // -- pure builders (record transforms) --
3085            // with_header :: HttpRequest, Str, Str -> HttpRequest
3086            fields.insert("with_header".into(), Ty::function(
3087                vec![req_t(), Ty::str(), Ty::str()],
3088                EffectSet::empty(),
3089                req_t(),
3090            ));
3091            // with_auth :: HttpRequest, Str, Str -> HttpRequest
3092            // (Renders `<scheme> <token>` into the `Authorization`
3093            // header — `Bearer <jwt>`, `Basic <b64>`, etc.)
3094            fields.insert("with_auth".into(), Ty::function(
3095                vec![req_t(), Ty::str(), Ty::str()],
3096                EffectSet::empty(),
3097                req_t(),
3098            ));
3099            // with_query :: HttpRequest, Map[Str, Str] -> HttpRequest
3100            // (Appends a `?k=v&...` query string; values are URL-
3101            // encoded so `&` / `=` / spaces in values don't escape.)
3102            fields.insert("with_query".into(), Ty::function(
3103                vec![req_t(), str_str_map()],
3104                EffectSet::empty(),
3105                req_t(),
3106            ));
3107            // with_timeout_ms :: HttpRequest, Int -> HttpRequest
3108            fields.insert("with_timeout_ms".into(), Ty::function(
3109                vec![req_t(), Ty::int()],
3110                EffectSet::empty(),
3111                req_t(),
3112            ));
3113            // -- pure decoders --
3114            // json_body[T] :: HttpResponse -> Result[T, HttpError]
3115            // Polymorphic on the parsed shape, matching `json.parse`.
3116            fields.insert("json_body".into(), Ty::function(
3117                vec![resp_t()],
3118                EffectSet::empty(),
3119                result_he(Ty::Var(0)),
3120            ));
3121            // text_body :: HttpResponse -> Result[Str, HttpError]
3122            fields.insert("text_body".into(), Ty::function(
3123                vec![resp_t()],
3124                EffectSet::empty(),
3125                result_he(Ty::str()),
3126            ));
3127            // stream_lines :: Str, Map[Str, Str], Str -> [net] Result[Stream[Str], Str]
3128            // Streaming HTTP POST that yields the response body line-by-line
3129            // for SSE / NDJSON endpoints. Returns a lazy `Stream[Str]` (#683):
3130            // each `stream.next` pulls exactly one line off the socket as it
3131            // arrives, so an endpoint that holds the connection open and emits
3132            // events over time is consumed incrementally instead of blocking
3133            // until close. Connection errors at request time surface as
3134            // `Err(Str)`; a mid-stream read error / close ends the stream
3135            // (next `stream.next` returns `None`). Consume with `std.stream`
3136            // (`stream.next` / `stream.collect`), which carries `[stream]`.
3137            fields.insert("stream_lines".into(), Ty::function(
3138                vec![
3139                    Ty::str(),
3140                    Ty::Con("Map".into(), vec![Ty::str(), Ty::str()]),
3141                    Ty::str(),
3142                ],
3143                EffectSet::singleton("net"),
3144                Ty::Con("Result".into(), vec![
3145                    Ty::Con("Stream".into(), vec![Ty::str()]),
3146                    Ty::str(),
3147                ]),
3148            ));
3149            Some(Ty::Record(fields))
3150        }
3151        "yaml" => {
3152            // YAML config parser. Same shape as `std.toml`: parse
3153            // is polymorphic, output Value layout matches std.json
3154            // (Str/Int/Float/Bool/List/Record). Anchors and tags
3155            // are flattened by serde_yaml's deserializer.
3156            let mut fields = IndexMap::new();
3157            fields.insert("parse".into(), Ty::function(
3158                vec![Ty::str()], EffectSet::empty(),
3159                Ty::Con("Result".into(), vec![Ty::Var(0), Ty::str()]),
3160            ));
3161            // Tactical fix for #168 — caller-supplied required-field
3162            // list. See std.json's parse_strict for context.
3163            fields.insert("parse_strict".into(), Ty::function(
3164                vec![Ty::str(), Ty::List(Box::new(Ty::str()))],
3165                EffectSet::empty(),
3166                Ty::Con("Result".into(), vec![Ty::Var(0), Ty::str()]),
3167            ));
3168            fields.insert("stringify".into(), Ty::function(
3169                vec![Ty::Var(0)], EffectSet::empty(),
3170                Ty::Con("Result".into(), vec![Ty::str(), Ty::str()]),
3171            ));
3172            Some(Ty::Record(fields))
3173        }
3174        "dotenv" => {
3175            // .env-style files. parse :: Str -> Result[Map[Str,Str], Str].
3176            // Returns a map (not a polymorphic record) because
3177            // dotenv files don't carry shape — every value is a
3178            // string and keys aren't statically known.
3179            let mut fields = IndexMap::new();
3180            fields.insert("parse".into(), Ty::function(
3181                vec![Ty::str()], EffectSet::empty(),
3182                Ty::Con("Result".into(), vec![
3183                    Ty::Con("Map".into(), vec![Ty::str(), Ty::str()]),
3184                    Ty::str(),
3185                ]),
3186            ));
3187            Some(Ty::Record(fields))
3188        }
3189        "csv" => {
3190            // CSV rows-as-lists. parse :: Str -> Result[List[List[Str]], Str].
3191            // Header awareness is left to the caller — row 0 is
3192            // whatever the file has. A `parse_with_headers` that
3193            // returns List[Map[Str,Str]] is a natural follow-up.
3194            let row_ty = Ty::List(Box::new(Ty::str()));
3195            let rows_ty = Ty::List(Box::new(row_ty.clone()));
3196            let mut fields = IndexMap::new();
3197            fields.insert("parse".into(), Ty::function(
3198                vec![Ty::str()], EffectSet::empty(),
3199                Ty::Con("Result".into(), vec![rows_ty.clone(), Ty::str()]),
3200            ));
3201            fields.insert("stringify".into(), Ty::function(
3202                vec![rows_ty], EffectSet::empty(),
3203                Ty::Con("Result".into(), vec![Ty::str(), Ty::str()]),
3204            ));
3205            Some(Ty::Record(fields))
3206        }
3207        "test" => {
3208            // Tiny assertion library (#proposed-stdlib). Each helper
3209            // returns Result[Unit, Str] so a test is itself a fn
3210            // returning Result. Callers compose suites in user code
3211            // (a List of (name, () -> Result[Unit, Str]) pairs +
3212            // list.fold to accumulate verdicts). Property generators
3213            // and a Rust-side Suite type are deferred to v2.
3214            let mut fields = IndexMap::new();
3215            // assert_eq[a, b] :: T -> T -> Result[Unit, Str]
3216            // (T constrained equal by unification on the two args)
3217            let unit_result = || Ty::Con("Result".into(), vec![Ty::Unit, Ty::str()]);
3218            fields.insert("assert_eq".into(), Ty::function(
3219                vec![Ty::Var(0), Ty::Var(0)], EffectSet::empty(), unit_result(),
3220            ));
3221            fields.insert("assert_ne".into(), Ty::function(
3222                vec![Ty::Var(0), Ty::Var(0)], EffectSet::empty(), unit_result(),
3223            ));
3224            fields.insert("assert_true".into(), Ty::function(
3225                vec![Ty::bool()], EffectSet::empty(), unit_result(),
3226            ));
3227            fields.insert("assert_false".into(), Ty::function(
3228                vec![Ty::bool()], EffectSet::empty(), unit_result(),
3229            ));
3230            Some(Ty::Record(fields))
3231        }
3232        "toml" => {
3233            // TOML config parser. Mirrors `std.json`'s shape: parse
3234            // is polymorphic so callers annotate the expected
3235            // record / list / scalar shape and the type checker
3236            // unifies. The parsed TOML maps to the same Lex Value
3237            // shape as JSON does:
3238            //
3239            //   TOML String   → Value::Str
3240            //   TOML Integer  → Value::Int
3241            //   TOML Float    → Value::Float
3242            //   TOML Boolean  → Value::Bool
3243            //   TOML Array    → Value::List
3244            //   TOML Table    → Value::Record
3245            //   TOML Datetime → Value::Str (RFC 3339, lossless)
3246            //
3247            // The Datetime → Str fallback is the one info-losing
3248            // step; callers who want a real `Instant` can pipe the
3249            // string through `datetime.parse_iso`.
3250            let mut fields = IndexMap::new();
3251            // parse :: Str -> Result[T, Str]
3252            fields.insert("parse".into(), Ty::function(
3253                vec![Ty::str()], EffectSet::empty(),
3254                Ty::Con("Result".into(), vec![Ty::Var(0), Ty::str()]),
3255            ));
3256            // parse_strict :: (Str, List[Str]) -> Result[T, Str]
3257            // Tactical fix for #168 — caller passes the field
3258            // names T requires; runtime returns Err if any are
3259            // missing from the parsed table instead of letting
3260            // field access panic later.
3261            fields.insert("parse_strict".into(), Ty::function(
3262                vec![Ty::str(), Ty::List(Box::new(Ty::str()))],
3263                EffectSet::empty(),
3264                Ty::Con("Result".into(), vec![Ty::Var(0), Ty::str()]),
3265            ));
3266            // stringify :: T -> Result[Str, Str]
3267            // Returns Result (not Str) because not every Lex Value
3268            // has a TOML representation — top-level scalars,
3269            // closures, mixed-key maps etc. surface as Err rather
3270            // than panic.
3271            fields.insert("stringify".into(), Ty::function(
3272                vec![Ty::Var(0)], EffectSet::empty(),
3273                Ty::Con("Result".into(), vec![Ty::str(), Ty::str()]),
3274            ));
3275            Some(Ty::Record(fields))
3276        }
3277        // `std.agent` (#184) — runtime primitives whose effects
3278        // separate (a) which LLM surface (`llm_local` vs
3279        // `llm_cloud`), (b) which peer protocol (`a2a`), and
3280        // (c) which tool boundary (`mcp`). The wire formats land
3281        // in downstream crates (`soft-agent`, `soft-a2a`) and
3282        // in #185 for MCP; what's typed here is the boundary
3283        // alone — agent code can be type-checked as
3284        // `[llm_local, a2a]` and will fail if it tries to reach
3285        // `[llm_cloud]` even before the wire layer is finished.
3286        "agent" => {
3287            let mut fields = IndexMap::new();
3288            // local_complete :: Str -> [llm_local] Result[Str, Str]
3289            fields.insert("local_complete".into(), Ty::function(
3290                vec![Ty::str()],
3291                EffectSet::singleton("llm_local"),
3292                Ty::Con("Result".into(), vec![Ty::str(), Ty::str()]),
3293            ));
3294            // cloud_complete :: Str -> [llm_cloud] Result[Str, Str]
3295            fields.insert("cloud_complete".into(), Ty::function(
3296                vec![Ty::str()],
3297                EffectSet::singleton("llm_cloud"),
3298                Ty::Con("Result".into(), vec![Ty::str(), Ty::str()]),
3299            ));
3300            // send_a2a :: (Str, Str) -> [a2a] Result[Str, Str]
3301            //              peer payload                   reply
3302            fields.insert("send_a2a".into(), Ty::function(
3303                vec![Ty::str(), Ty::str()],
3304                EffectSet::singleton("a2a"),
3305                Ty::Con("Result".into(), vec![Ty::str(), Ty::str()]),
3306            ));
3307            // call_mcp :: (Str, Str, Str) -> [mcp] Result[Str, Str]
3308            //              server tool args_json         result_json
3309            fields.insert("call_mcp".into(), Ty::function(
3310                vec![Ty::str(), Ty::str(), Ty::str()],
3311                EffectSet::singleton("mcp"),
3312                Ty::Con("Result".into(), vec![Ty::str(), Ty::str()]),
3313            ));
3314            // cloud_stream :: Str -> [llm_cloud] Result[Stream[Str], Str]
3315            // (#305 slice 3). Streaming counterpart to cloud_complete.
3316            // The result is `Result[Stream[Str], Str]` rather than a
3317            // bare Stream so transport errors surface synchronously
3318            // at handshake time; per-chunk errors collapse the
3319            // stream to early termination.
3320            fields.insert("cloud_stream".into(), Ty::function(
3321                vec![Ty::str()],
3322                EffectSet::singleton("llm_cloud"),
3323                Ty::Con("Result".into(), vec![
3324                    Ty::Con("Stream".into(), vec![Ty::str()]),
3325                    Ty::str(),
3326                ]),
3327            ));
3328            Some(Ty::Record(fields))
3329        }
3330        "stream" => {
3331            // #305 slice 3: opaque consumer-side operations on
3332            // `Stream[T]`. Producers live elsewhere (`agent.cloud_stream`
3333            // for now); future producers (`http.get_stream`, etc.)
3334            // will register the same Stream[T] surface.
3335            let mut fields = IndexMap::new();
3336            // next :: Stream[T] -> [stream] Option[T]
3337            // One pull. `None` signals end-of-stream (consumed by
3338            // the producer's lazy generator).
3339            fields.insert("next".into(), Ty::function(
3340                vec![Ty::Con("Stream".into(), vec![Ty::Var(0)])],
3341                EffectSet::singleton("stream"),
3342                Ty::Con("Option".into(), vec![Ty::Var(0)]),
3343            ));
3344            // collect :: Stream[T] -> [stream] List[T]
3345            // Drain to a list. Eager; blocks until the producer
3346            // signals end-of-stream.
3347            fields.insert("collect".into(), Ty::function(
3348                vec![Ty::Con("Stream".into(), vec![Ty::Var(0)])],
3349                EffectSet::singleton("stream"),
3350                Ty::List(Box::new(Ty::Var(0))),
3351            ));
3352            Some(Ty::Record(fields))
3353        }
3354        // -- std.decimal (#574): exact decimal arithmetic with explicit rounding.
3355        // `Decimal = { coefficient :: Int, exponent :: Int }` where the value
3356        // is `coefficient × 10^exponent`.  All arithmetic is exact (no IEEE 754
3357        // approximation); rounding only happens at `round_to`, which demands an
3358        // explicit mode string ("HalfUp" | "HalfDown" | "HalfEven" |
3359        // "Down" | "Up" | "Ceiling" | "Floor").
3360        "decimal" => {
3361            // Local helper: the Decimal record type.
3362            let decimal_ty = || {
3363                let mut f = IndexMap::new();
3364                f.insert("coefficient".into(), Ty::int());
3365                f.insert("exponent".into(), Ty::int());
3366                Ty::Record(f)
3367            };
3368            let mut fields = IndexMap::new();
3369            // Constructors
3370            // decimal :: (Int, Int) -> Decimal — coefficient, exponent
3371            fields.insert("decimal".into(), Ty::function(
3372                vec![Ty::int(), Ty::int()], EffectSet::empty(), decimal_ty()));
3373            // zero :: () -> Decimal — 0 × 10^0
3374            fields.insert("zero".into(), Ty::function(
3375                vec![], EffectSet::empty(), decimal_ty()));
3376            // one :: () -> Decimal — 1 × 10^0
3377            fields.insert("one".into(), Ty::function(
3378                vec![], EffectSet::empty(), decimal_ty()));
3379            // from_int :: Int -> Decimal — lift integer, exponent=0
3380            fields.insert("from_int".into(), Ty::function(
3381                vec![Ty::int()], EffectSet::empty(), decimal_ty()));
3382            // Arithmetic — all exact, no rounding
3383            // add :: (Decimal, Decimal) -> Decimal
3384            fields.insert("add".into(), Ty::function(
3385                vec![decimal_ty(), decimal_ty()], EffectSet::empty(), decimal_ty()));
3386            // sub :: (Decimal, Decimal) -> Decimal
3387            fields.insert("sub".into(), Ty::function(
3388                vec![decimal_ty(), decimal_ty()], EffectSet::empty(), decimal_ty()));
3389            // mul :: (Decimal, Decimal) -> Decimal — exponents add
3390            fields.insert("mul".into(), Ty::function(
3391                vec![decimal_ty(), decimal_ty()], EffectSet::empty(), decimal_ty()));
3392            // Comparison — three-way: -1 / 0 / 1
3393            // compare :: (Decimal, Decimal) -> Int
3394            fields.insert("compare".into(), Ty::function(
3395                vec![decimal_ty(), decimal_ty()], EffectSet::empty(), Ty::int()));
3396            // Predicates
3397            fields.insert("is_zero".into(), Ty::function(
3398                vec![decimal_ty()], EffectSet::empty(), Ty::bool()));
3399            fields.insert("is_positive".into(), Ty::function(
3400                vec![decimal_ty()], EffectSet::empty(), Ty::bool()));
3401            fields.insert("is_negative".into(), Ty::function(
3402                vec![decimal_ty()], EffectSet::empty(), Ty::bool()));
3403            // Transformers
3404            // normalize :: Decimal -> Decimal — remove trailing zeros
3405            fields.insert("normalize".into(), Ty::function(
3406                vec![decimal_ty()], EffectSet::empty(), decimal_ty()));
3407            // negate :: Decimal -> Decimal
3408            fields.insert("negate".into(), Ty::function(
3409                vec![decimal_ty()], EffectSet::empty(), decimal_ty()));
3410            // abs :: Decimal -> Decimal
3411            fields.insert("abs".into(), Ty::function(
3412                vec![decimal_ty()], EffectSet::empty(), decimal_ty()));
3413            // round_to :: (Decimal, Int, Str) -> Decimal
3414            //   target_exp: the exponent to round to (e.g. -2 → 2 decimal places)
3415            //   mode: "HalfUp" | "HalfDown" | "HalfEven" | "Down" | "Up" | "Ceiling" | "Floor"
3416            fields.insert("round_to".into(), Ty::function(
3417                vec![decimal_ty(), Ty::int(), Ty::str()],
3418                EffectSet::empty(), decimal_ty()));
3419            // to_str :: Decimal -> Str — decimal notation, e.g. "123.45"
3420            fields.insert("to_str".into(), Ty::function(
3421                vec![decimal_ty()], EffectSet::empty(), Ty::str()));
3422            // pow10 :: Int -> Int — 10^n; n must be in [0, 18]
3423            fields.insert("pow10".into(), Ty::function(
3424                vec![Ty::int()], EffectSet::empty(), Ty::int()));
3425            Some(Ty::Record(fields))
3426        }
3427        _ => None,
3428    }
3429}
3430
3431/// Resolve `import "std.foo" as alias` to a module name (e.g. "io").
3432pub fn module_for_import(reference: &str) -> Option<&'static str> {
3433    let suffix = reference.strip_prefix("std.")?;
3434    Some(match suffix {
3435        "io" => "io",
3436        "str" => "str",
3437        "int" => "int",
3438        "float" => "float",
3439        "list" => "list",
3440        "result" => "result",
3441        "option" => "option",
3442        "json" => "json",
3443        "flow" => "flow",
3444        "tuple" => "tuple",
3445        "time" => "time",
3446        "rand" => "rand",
3447        "random" => "random",
3448        "env" => "env",
3449        "bytes" => "bytes",
3450        "net" => "net",
3451        "tls" => "tls",
3452        "chat" => "chat",
3453        "math" => "math",
3454        "map" => "map",
3455        "set" => "set",
3456        "iter" => "iter",
3457        "crypto" => "crypto",
3458        "regex" => "regex",
3459        "parser" => "parser",
3460        "deque" => "deque",
3461        "kv" => "kv",
3462        "moe" => "moe",
3463        "sql" => "sql",
3464        "fs" => "fs",
3465        "process" => "process",
3466        "approval" => "approval",
3467        "datetime" => "datetime",
3468        "duration" => "duration",
3469        "log" => "log",
3470        "http" => "http",
3471        "toml" => "toml",
3472        "yaml" => "yaml",
3473        "dotenv" => "dotenv",
3474        "csv" => "csv",
3475        "test" => "test",
3476        "agent" => "agent",
3477        "cli" => "cli",
3478        "stream" => "stream",
3479        "conc" => "conc",
3480        "arrow" => "arrow",
3481        "df" => "df",
3482        "redis" => "redis",
3483        "decimal" => "decimal",
3484        "vcs" => "vcs",
3485        _ => return None,
3486    })
3487}
3488
3489/// Every module name `module_scope` resolves, in that function's match
3490/// order. Adding a match arm there means adding the name HERE too —
3491/// `lex docs --stdlib-index` renders docs/AGENT.md's generated stdlib
3492/// index from this list, and `lex doc-sync --check` in CI fails when the
3493/// docs drift from what this enumeration produces (#746).
3494pub const MODULE_NAMES: &[&str] = &[
3495    "io", "str", "int", "math", "float", "list", "bytes", "time", "rand", "random", "env", "net",
3496    "tls", "chat", "conc", "arrow", "df", "json", "result", "option", "tuple", "map", "set",
3497    "iter", "flow", "crypto", "deque", "log", "datetime", "duration", "approval", "process", "fs",
3498    "kv", "vcs", "sql", "redis", "parser", "cli", "regex", "http", "yaml", "dotenv", "csv", "test",
3499    "toml", "agent", "stream", "decimal", "moe",
3500];
3501
3502#[cfg(test)]
3503mod module_names_tests {
3504    use super::*;
3505    use crate::env::TypeEnv;
3506
3507    /// Every listed module must resolve to a non-empty record of
3508    /// builtins — a typo'd or removed name here would silently drop a
3509    /// module from the generated stdlib index.
3510    #[test]
3511    fn every_listed_module_resolves() {
3512        let env = TypeEnv::default();
3513        for name in MODULE_NAMES {
3514            match module_scope(name, &env) {
3515                Some(Ty::Record(fields)) => {
3516                    assert!(!fields.is_empty(), "module `{name}` resolved to an empty record");
3517                }
3518                other => panic!("module `{name}` did not resolve to a record: {other:?}"),
3519            }
3520        }
3521    }
3522
3523    /// The list and `module_for_import` must agree: everything listed
3524    /// is reachable via `import "std.<name>"`.
3525    #[test]
3526    fn listed_modules_are_importable() {
3527        for name in MODULE_NAMES {
3528            assert!(
3529                module_for_import(&format!("std.{name}")).is_some(),
3530                "module `{name}` is listed but not importable as std.{name}"
3531            );
3532        }
3533    }
3534}