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