SCUA

Manual

Changelog

What changed in each release of SCUA that you, as a script author or a host embedder, are likely to care about. Bug fixes and internal plumbing that don't change how you write or run code are left out.

Versions follow MAJOR.MINOR.PATCH. While SCUA is pre-1.0, minor releases may include breaking changes; those are called out explicitly.

#0.38.0 — 2026-10-22

#Added

  • A host can drive a script a frame at a time. For a game loop or any host with its own: scua_set_drain_mode(p, SCUA_DRAIN_EXPLICIT) makes a host call run only the call, and scua_run_turns(p, &budget, &report, …) runs the script's tasks and partitions within a budget of turns or wall time, carrying what doesn't fit to the next frame. scua_set_clock and scua_advance_clock put the script on the host's clock, so wait(500ms) spans frames and pausing the game pauses the script. scua_set_fault_hook reports each fault a partition or task survives, once, and scua_post_text lets any thread post a message to a partition, delivered at the owner's next drain. A host function a partition calls can now make and read the host's runtime-owned resources too. examples/embed_frames.c shows the whole loop.
  • An embedded script's partitions can run on several cores. scua_set_workers(p, 4) before the load, and each scua_run_turns runs the script's partitions on the host's thread and three workers. Each host function says how it may be called: scua_register_module_mode takes main-thread (the default, still run only on the host's thread), parallel-read, deferred or any-thread. Deferred calls are applied by scua_apply_deferred in the same order on any number of workers, and a physics callback that calls back into the script keeps working in the serial phase. Engine objects a worker makes are initialized on the host's thread unless their type says otherwise. SCUA_ABI_VERSION is 3. examples/embed_multicore.c is a frame with six agents on four threads.
  • A project lists the commands it may run in scua.toml, and --allow-exec grants them. Put argv templates under [exec] commands = [...] and launch with --allow-exec: exec.run and exec.spawn run those commands and no others. The list travels with the program, so a launcher can show it, and only the project's own scua.toml is read: one inside a locked package's folder that lists commands is refused, so a dependency can never grant commands to the program that uses it. A program can't edit its own list either: when --allow-fs or the per-user folder reaches the file holding it, the launch stops with E-EXEC-POLICY and suggests a folder of the program's own, such as --allow-fs=./data. A policy file's exec list follows the same rule.
  • An unknown option is called one. scua --sandbox agent.scua says unknown option `--sandbox`. Did you mean `--allow-sandbox`? and exits 2.
  • durable.format(blob) and durable.current_format(): the format a blob was written in, read without decoding it, and the format this release writes, so a program can refuse a newer file by name. The persistence guide now states the promise: a blob written by 0.26 or later loads in every later release, and durable.decode names an empty, foreign or newer blob instead of one message for all three.
  • actors.stats().errors: how many turns ended with an Error that no ask was waiting for, such as a ? in a told handler.
  • parse and the typed decoders take their options from a top-level const. Write const STRICT = { unknown = "refuse" } once and pass parse(v, T, STRICT). The options still have to be a table literal the compiler can read, so a let or a function's local is refused.
  • json.encode(v, { canonical = true }): one spelling for one value, for signing or comparing JSON. Every object's keys in byte order at every level, no whitespace, numbers in their usual single spelling. It works with a type too, json.encode(v, T, { canonical = true }), and can't be combined with pretty.
  • A field's where takes a reason: title: string where title.len() >= 1 else "a title is required". The reason leads the fault's message, as a record-level where … else already did, and scua schema publishes it as the field's description.
  • scua schema maps more where rules. A comparison with a constant (days > 0, 0 <= n, score < 1.5) becomes minimum, exclusiveMinimum, maximum or exclusiveMaximum; a length comparison (s.len() >= 1, len(tags) < 5) becomes minLength/maxLength, or minItems/maxItems on a list; and an and of mapped rules maps both. Before, only ranges and membership were mapped.
  • --max-total-ops=N bounds a whole run. It counts the same steps as --max-ops, but over every turn of the main script and its partitions, and a wait does not start it again. A tool that loops with a sleep between rounds, which --max-ops never stopped, now stops after N steps in all, with exit status 6. --max-ops is unchanged: it still bounds a single turn.
  • sys.script_path() and sys.executable(): the absolute paths of the running script and of the scua binary, so a tool can print the exact command that launches it. No grant is needed; a sandbox cannot call them.
  • fs.lock(path, { timeout, shared }): a lock that other running programs respect, released by fs.close and by the operating system when the program ends, however it ends. Two programs that change the same files take it around the change; a crashed holder never leaves it stuck.
  • --user-shared lets several runs use the per-user folder at once, each launched with it: two windows of one tool server. Without it, one run holds the folder at a time, as before.
  • time.wall() and time.mono_ms(): clocks that move while a turn runs. now() stays put within a turn and a wait(d) moves it by exactly d, which keeps a program's output repeatable but cannot time real work or hold a lease. time.mono_ms() is a monotonic count of milliseconds and time.wall() the current instant, both read on every call. Under --fast and scua test they follow the virtual clock.
  • fs.rename(from, to, { sync = true }) returns once the rename is on the disk: it flushes the folders the rename changed, so a power cut afterwards cannot bring back the old name. With a write made { sync = true }, this completes the save that survives a power cut: write a temp file, then rename it into place.

#Changed

  • --allow-exec-any covers exec.run and exec.spawn too. A program that may run any command may run a listed one; when a list is declared as well, those calls still check against it. A program that calls only exec.run and has no grant is now pointed at --allow-exec and the [exec] list.
  • A scua.toml inside a locked package's folder is not a project. A script run from a vendored package's folder belongs to the project whose lock vendored it, whatever file sits beside it.
  • --allow-user=ID says when nothing checks the id. With no [app] id in scua.toml, a mistyped id would get a new, empty folder; the run now warns once and suggests the line to add.
  • E-USER-LOCKED names the missing --user-shared. When the runs holding the folder share it and this launch doesn't, the message says this launch is the one that needs the flag.
  • A 500 the server answers says which failure it was. When a handler faults or returns an Error, http.serve now answers {"error":"internal server error","ref":"r-…"} (JSON) instead of an empty body, and the server's log line for that request names the same reference. The fault text is still withheld from the client unless --serve-debug; a 500 the handler returns itself is unchanged.
  • A package's capability list now names the grants its functions need, not only the modules it imports. Calling sys.readline or sys.readpassword adds tty; http.serve, http.stats, net.listen, net.accept, net.local_port and udp.bind add serve; exec.run_any and exec.spawn_any add exec_any. Passing a module itself around as a value adds every grant its functions need. Before, a package that served HTTP listed only http, and one that read the terminal listed nothing. A budget that allows a package's modules but not these grants now refuses it, so add the grant names (tty, serve, exec_any) beside the module names in that budget. No package's identity changes.
  • A wait or yield in a partition handler faults at the call. The rule is unchanged (a handler suspends through ask, a full tell, wait_for or select), but the fault now happens where the wait is written, with its line. So a pcall or try around it catches it, where before the turn suspended and the partition faulted outside the handler's own recovery. A task the handler spawns still waits as before.
  • E-USER-LOCKED exits with status 9, not 2: the launch was right and the folder is busy, so a tool that starts scua can retry on 9. Its message names --user-shared.
  • A waiting fs.lock holds only the code that waits. In an http.serve handler, a message handler, or under --io=async, a lock that has to wait parks, so the server keeps answering other requests while one waits. Its timeout is measured on a steady clock, so changing the computer's time no longer shortens or lengthens it.
  • A { sync = true } write also flushes the file's folder, and on macOS a sync reaches the drive's own storage (F_FULLFSYNC) rather than stopping at its cache, so a newly created file is still there after a power cut. fs.sync(f) reaches the drive's storage on macOS too.
  • fs.lock reports a failed lock call as io, keeping unsupported for a file system that has no locks at all, and a shared lock now works on network drives where Linux emulates the lock.
  • fs.lock's Error says whether trying again can help. A failure that will happen every time has its own kind: denied (a permission or read-only folder), not_found (a folder in the path does not exist) and bad_path (the path is a folder, or cannot be a file). io is kept for the rest, so a program retries only what might pass. Locking a folder now says to lock a file inside it, such as data/.lock.
  • fs.write's Error is { kind, message } too, with the same kinds. exists means an exclusive write found the file already there, so a program claiming a name can tell "someone else got it first" from a real failure without reading the text. denied, not_found, bad_path and io mean what they mean for fs.lock. Show e.message where you printed the error before. Writing to a folder now says it is a folder, and a path through a file says so, on every system.
  • --allow-fs names a folder that must exist. A missing folder, or a file, is refused before the program runs (--allow-fs=gen: no such folder; create it first), whether it comes from the flag, a policy file or --allow-all. Every write used to fail later as "permission denied".
  • A mount that holds the writable folder is described the right way round. --mount=game --allow-fs=game/gen is refused as before, and now says the mount contains the granted folder.

#Fixed

  • An embedded script reports each actor fault once. After one partition faulted, every later scua_pcall returned SCUA_ERR_OTHER with that same fault, even when nothing went wrong; now only the call that ran the faulting turn does.
  • A task spawned in an embedded script runs. A spawn at the script's top level, or inside a function the host called with scua_pcall, was dropped without running; it now runs before the call returns, and the script's clock carries on from one call to the next instead of starting again at 0.
  • A long exec refusal keeps what it says. A not_declared message with a long path in it was replaced by "the request failed"; it is now cut short with "…" and still names the closest declared command.
  • --allow-file names a missing file as missing, and a folder as a folder, instead of "cannot read this file". scua --help now lists every key the file can carry, exec and user among them.
  • The no-per-user-folder hint points at your [app] id when the run can write scua.toml and so doesn't repeat it, instead of offering an example id.
  • Shape types are checked at run time, one level, as records are. A type written as a table ({ id: string }) or named with type R = { id: string } checked nothing at a typed let, a parameter or a return, so a value with id = 1, or a plain int, went through. It now gets the record's check: a table, with each declared field at its kind (extra fields allowed).
  • A record built from a literal checks a field whose value is only known at run time. -> Note returning { id = id } with an untyped id holding 1 built a Note whose string field held an int; it is now refused, naming the field. Literals and values of a known type cost nothing extra.
  • scua test charges a told handler's failure to the test that told it. A tell returns before its handler runs, so a failing assert_eq in the handler was reported as a file-level ERROR while the test said PASS, and a handler that ended with an Error (let _ = r?) was not reported at all. After each test the runner now waits up to 2 seconds of the test's clock for the handlers it told, and fails the test with the handler's own message. A fault in a handler the file's top level told is the file's; to check that a handler refuses a message, ask it and assert on the reply.
  • json.encode(v, T, opts) takes a module's type or a collection type before the options. json.encode(r, report.Score, { pretty = true }) and json.encode(xs, { Item }, { pretty = true }) were refused; the two-argument form already worked.
  • A record's field defaults work wherever the record is built. A default that named the declaring module's const, its enum, an enum it reached through an import or a type alias (goal: int = DEFAULT_GOAL, dir: PayDirection = PayDirection.Both) was an undefined name when another file built the record: let q: quest.Quest = {}, or parse(v, Config) for a config holding one. A const holding a table is still the declaring module's own; build such a default there.
  • actors.settle(timeout) returns at its timeout under scua test and --fast. With a partition that waits on a timer forever, it never returned.
  • pcall of a built-in or module function gives its result and catches its fault. pcall(sys.platform) gave Ok of the whole sys table, pcall(g) for let g = sys.platform gave Ok(nil), and a fault inside the function, such as sys.readline's over-long line, went straight past the pcall. Wrapping it in a function (pcall(fn() return sys.platform() end)) always worked.
  • sys.readline keeps the input after a line longer than 1 MiB. It faulted on the long line and threw away what it had just read with it, so the lines that followed went missing: from a file, all of them; over a pipe, those in the same 4 KiB read. Now only the long line is skipped, the fault says so, and the next read returns the line after it.
  • Clearer messages.
    • Reading a host file in a run that has --mount and no --allow-fs names the file's res: path when it is mounted, rather than suggesting --allow-fs, which would also allow writes and deletes.
    • A grant or limit written after the script file (scua tool.scua --allow-tty) went to the program without a word; scua now warns that its own flags go before the file.
    • Reading lib.name from a module with no return is a compile error naming the module and the fix, rather than "fields only exist on tables and records, not on nil values" at run time.
    • Two different --allow-fs flags are refused; the second used to replace the first silently.
  • fs.write(…, { exclusive = true }) never shows a half-written file. It created the file and then filled it, so another program could read it empty. The content now goes to a hidden file beside it, which is renamed into place only if the name is still free.
  • Per-user folder messages. --allow-user=dev.you suggested dev.dev.you; it now suggests dev.you.mytool. Reading a host path in a run whose only folder is user: said write user:/etc/hosts, a spelling user: never takes; it now names the file's user: path and says what --allow-fs would grant.
  • A script inside a package can import that package. Running a package's own script from your project, scua deps/acme/tool/tool_dev.scua with import tool, was refused because the package seemed to be in two places ("provided by more than one search root"). The script's folder is now the package's checked copy, and the script itself is checked against the package's release record: an edited one is refused (E-FILE-HASH-MISMATCH), as an edited module is. The message's suggested way out, --mod-override, was spelled in a form the command line refused and skips the lock's hash check; --mod-override NAME=DIR now works with a space too, is listed in scua --help, and the message says it is unchecked.
  • A callable field named keys or values can be called as a method. ctx.keys("note:") on a value whose type wasn't known at compile time failed with "undefined name 'keys'", although ctx.get and the like worked.
  • A refused run names every grant the program needs, in the narrowest form that runs it. It used to name one per run (--allow-tty, then --allow-serve, then --allow-fs); the message now adds the full list and the command to run. A server is told --allow-serve alone, a program that uses user: paths in any of its files is told --allow-user=ID (with the id from [app] id when the project declares one), and any other file access is --allow-fs=<dir>, never a bare --allow-fs, which from a host that launches at / would be the whole disk.
  • Several programs creating the same new file at once no longer fail now and then. On macOS, a few in a hundred fs.lock calls on a lock file that didn't exist yet, made by several programs starting together, failed with "no such file or directory" (so did fs.write and fs.open). They now create or open the file every time.
  • Clearer messages for seven everyday mistakes.
    • Inside a sandbox, a standard-library function the sandbox isn't given now says so: `time.mono_ms` is not available inside a sandbox; `now()` is, and it does not move during a turn, instead of "is nil — there is no mono_ms there", which read like a typo.
    • slice (and [a:b]) on something that isn't an array or bytes names what it got (`slice` (and `[a:b]`) takes an array or bytes, but got a table), and so does a bound that isn't an int, where both used to be a bare "type error".
    • A method call that finds nothing names the method and the value's type (`<value>.find(…)`: this table has no field `find`, and there is no method or global function named `find`).
    • scua schema reports a syntax error with its line, in the words a run uses, and checks the project's scua-version first, as every other way into a project does.
    • Arithmetic on something that isn't a number names both sides (cannot subtract a datetime and an int), and for a datetime says how to move or measure one: time.add(dt, ms), time.diff(later, earlier) or dt.to_ms(). It used to be a bare "type error".
    • A match used as a value with a do … end arm says that each arm there is one expression, and how to write several steps instead, rather than "expected an expression (near 'do')".
    • A misspelt module function suggests the name it starts, so str.rep asks "did you mean str.repeat?".
  • sys.executable()'s description says it resolves symlinks, so a launch line built from it names one installed version; write scua to follow upgrades.
  • hex.encode takes bytes as well as text, as base64.encode does, so a digest or a key encodes without a detour through a string.
  • An exclusive write that loses a race loses it only to a real file. Programs claiming the same new name in the same millisecond could be told "already exists" before anyone had written it, and then fail to read the file they had just been told was there. A loser now always finds the winner's file.

#0.37.0 — 2026-10-20

#Added

  • actors.stats().faults: how many handler faults partitions have survived so far.
  • parse(value, T) turns loose data into a T, all the way down. It replaces as(value, T). T is any type an annotation takes: parse(v, Save), parse(v, { Item }), parse(v, Pt?), parse(v, lib.Pt). It builds new records, fills defaults, runs migrate hooks and every where rule at every level, including map values, { Enum } elements and collections of collections, and never changes the value it is given. A const of the type that needs nothing converted comes back as itself, at no cost. See The boundary rule.
  • Decoders take a type. json.decode(text, T), durable.decode(blob, T), toml.parse(text, T) and ini.parse(text, T) give Ok(T), checked and converted as parse would, or an Error holding the fault record, bad bytes included, so one Error(e) -> log(e.message) arm handles a corrupt file and a save that doesn't fit. The checker knows what json.decode(text, Save)? gives. Without the type, nothing changes.
  • Encoders take a type. durable.encode(value, version, T) and json.encode(value, T) check the value against T all the way down before encoding: Ok(bytes) (or text), or an Error naming the path to the bad value, and nothing encoded. A value with no saved form, such as a function left in a field typed any, is an Error too: state.ui.on_click is a function, which has no saved form. Where a typed encode is written, the checker warns at each field of the type that could hold one.
  • Refusing unknown fields. parse(v, T, { unknown = "refuse" }), and the same option on the typed decoders, refuse a key no field names, at every level, naming the nearest field: raw.pity.hrad is not a field of Pity; did you mean hard?. The default still leaves them behind.
  • A fault names where. A rejected crossing or deep check says which one and the path to the value: parse(raw, State): raw.ledger[1].id is int, expected string, parameter 's' of 'sys': s.gold is string, expected int. The fault record has path (raw.ledger[1].id) and at (ledger[1].id, the path from inside the value); a typed decoder's bad bytes carry line and column.
  • The deep mode. --check=deep (or SCUA_CHECK=deep) makes every annotation check all the way down, as parse does, without converting. It only adds faults; use it for tests and CI. See Checking typed data all the way down.
  • scua init game [name]: a small game that saves the player's progress the safe way, with a save test.
  • scua fmt --fix renames every built-in as(…) in a file to parse(…).
  • A decimal or money field reads its JSON text. "1.5" and "19.99 USD", as json.encode writes them, are accepted by a record field of that type, so json.decode(json.encode(v), T) round-trips.
  • Typed message handlers and constructors. on Buy(item: Item) and partition Shop(c: Config) check their arguments on entry, as a function's typed parameters do, and the checker checks a tell's, an ask's and a construction's arguments against them.
  • A per-user folder for a program's saves and settings: user: paths. --allow-user=ID grants the folder the platform keeps for the program: %APPDATA%\ID on Windows, ~/Library/Application Support/ID on macOS, and $XDG_DATA_HOME/ID (by default ~/.local/share/ID) on Linux. --user-dir=PATH grants a folder you name, such as save beside the game while you develop it. The program writes fs.write("user:saves/slot1.json", …), and every fs function takes user: paths, beside the folder --allow-fs grants, so a tool can hold the user's project and its own settings at once. The new how-to, Save the player's progress, has a save recipe that survives a crash mid-save, a damaged file and an older build meeting a newer save, the launchers to ship, and Steam Cloud.
    • A name a program creates in user: must be valid on Windows, macOS and Linux alike, since the folder moves between them: no :, <>"|?*, trailing dot or space, or device name such as con. A literal that breaks the rule is a compile error, and a built one an Error. fs.find lists a name on disk that breaks it with bad_name = true, and every function that writes refuses it. A user: literal with an upper-case letter gets a warning, since a Mac treats Settings.toml and settings.toml as one file.
    • fs.find's records are now documented in full: path, name, rel (the path below the directory searched), is_dir and bad_name.
    • An app ID has three or more lower-case parts, such as com.example.mygame, and a mistyped one is refused with the fix: E-USER-ID: --allow-user=com.example.my-game: an app id has no '-' — did you mean com.example.mygame?. A project may declare its ID as [app] id in scua.toml; a launch that names another stops with E-USER-MISMATCH.
    • scua user path ID prints where the folder is, and creates nothing.
    • fs.mounts().user is { id, path, created }, or nil without the folder.
    • A program that uses user: paths still runs without the folder: a note on stderr says so before it starts, and a user: call it reaches stops the run with status 4, naming the flag to add.
    • A capability file can say user = "ID" or user-dir = "PATH", honoured only from outside every folder the run can write. --allow-all never grants the folder.
    • A launch that cannot work stops before anything runs, with status 2 and an E-USER-… code first on the line, such as --allow-fs=user:ID (E-USER-FLAG) or a per-user folder that contains the --allow-fs folder (E-USER-OVERLAP).
    • The first run creates the folder, mode 0700, and says so on stderr: scua: created the per-user folder for com.example.mygame: …. It writes a .scua-user marker holding the id (hidden on Windows), so scua never writes into another program's folder: an existing folder without one stops the run with E-USER-FOREIGN. scua user adopt ID shows such a folder and what is in it, and --yes marks it as the id's; a shipped launcher passes --user-adopt, for the folder Steam Cloud downloads on a second machine. No fs call, through any folder, reads, writes or removes the marker.
    • One run holds a folder at a time. A second run waits up to about 5 seconds, saying so after one, then stops with E-USER-LOCKED, so a double launch cannot tear a save.
    • The folder and every folder above it must be the user's (or root's) and not writable by other accounts (E-USER-OWNER, naming the folder and its mode). A USB stick or SD card, whose file system stores no modes, is checked for its owner only. --user-dir creates only its last folder, so an unplugged drive is E-USER-NOPARENT rather than an empty folder on the system disk, and a link to a missing folder is E-USER-LINK.
    • An actor snapshot records the id the folder was granted under, never its path, and a revive gives the folder back only to a run that holds one under the same id, wherever it is, so a save revives on another machine. 0.36 and earlier revive such a snapshot without the folder.
    • scua test gives every test file a new, private per-user folder in the temporary folder, under the project's [app] id, so code that saves can be tested and never touches the real saves. It is removed when the file passes and kept, with its path printed, when a test fails having written there. A test file can now import fs whether or not the project has a res/ folder; host files stay out of reach.
    • Under WebAssembly, --user-dir=/saves names a directory the host preopened, by its name inside the module (wasmtime run --dir=./saves::/saves … --user-dir=/saves). The folder is not locked there, since WASI has no file locks, and the run says so.
    • A bundle that carries a scua.toml with its [app] id among its own files is checked against that id like a program in its project: another --allow-user id stops with E-USER-MISMATCH, naming the bundle. Only [app] id is read from it.
  • For embedders: scua_grant_user, scua_revoke_user, scua_app_id and scua_user_folder. A host grants user: for an app ID (the platform's folder for it) or a folder it names, with the same creation, marker, lock and ownership checks as a run, and gets one return code per E-USER-… code, the code and reason in errbuf. The new SCUA_ERR_USER_LOCKED (21) is a folder another run holds; SCUA_USER_NOWAIT returns it at once instead of waiting about 5 seconds, and SCUA_USER_ADOPT marks an existing platform folder. scua_use_project, scua_eval_file and scua_load_script_file read the project's [app] id (scua_app_id), and a grant under another id is refused. The lock is released when the last partition holding the grant lets it go.
    • SCUA_IO_CAP_USER (8): a host I/O platform that resolves user: paths itself declares it with scua_io_platform_caps. Without it a user: read or write runs inline rather than parking, so a reactor written before user: existed never writes a file named user:….
    • scua_io_platform_caps now reads only the bits scua.h defines (1, 2, 4 and 8). It used to copy the whole word into the runtime's own flags, so a bit above 4 switched on parking the C side cannot complete.
  • An if whose arms each round a float runs at the same speed whichever arm is taken. Rounding halves away from zero, if r >= 0.0 then q = floor(r + 0.5) else q = -floor(0.5 - r) end, now compiles to code with no branch on the sign, so data whose signs change at random no longer pays for mispredicted branches. When the value comes from the loop's counter, as in r = (i & 1023) * 0.37 - 100.0, its sign changes in a pattern the processor predicts, so the loop keeps the branch and runs only the arm it needs. The packages team's JPEG encoder quantises this way: an encode at quality 85 now runs as fast as their hand-tuned copy, and one at quality 100 takes a tenth fewer cycles. A loop whose arms round with different functions, such as round in one and ceil in the other, now runs compiled even when one arm never runs.
  • Importing a large package takes less memory. A program's syntax trees are a third smaller. Importing the packages team's 13-module image package peaks at 13.7 MB, down from 16.1 MB, and a running program keeps 2 MB less.
  • --serve-check checks a server in CI without starting it. scua --allow-serve=ADDR:PORT --serve-check app.scua runs the program up to its first http.serve, applies every check the server makes before it starts (a handler or a function in context that reaches a let, module state written before serving, the options and the bind), and exits without opening the port. When they pass it prints one line naming the handler and the bind, and exits 0. A refusal prints the message and exits with the status it has without the flag. A program that never reaches http.serve exits with the new status 8, so a CI step cannot pass without checking anything. See Check a server in CI without starting it.
  • One response can set several cookies. A response header's value may be an array of strings, and each item goes out as a header line of its own: "set-cookie" = ["session=abc123; HttpOnly", "theme=dark"] sends two set-cookie lines, never one line joined with commas, which browsers would read as a single cookie. It works for any header the server does not set itself, and every item is checked as a single value is. See Setting headers.
  • scua test --allow-fork. A test of code that forks an actor (actors.revive(blob, { fork = true })) can now be given the fork grant, as a run is.

#Changed

  • Breaking: annotations check one level; parse, the typed decoders and the typed encoders check all the way down. A typed parameter, return, let, state or payload now checks the value's kind and, for a record, the kind of each of its own fields and its own where. It no longer walks nested records or collection elements, so handing a 50,000-entry state to a typed function costs what an empty one does. What moves:
    • Data loaded with a decode followed by a typed let used to be checked at every level; now only the top. Decode into the type (json.decode(text, Save)?) or parse it where it arrives. The checker warns (shallow-check) at a typed let or return of an any value into a record with nested parts.
    • as(value, T) is now parse(value, T). as still works, with a warning; the next release refuses it. scua fmt --fix file.scua renames them.
    • A scalar let (let n: int = x) is checked when the value isn't known to be an int; it was erased.
    • A typed handler or constructor parameter is checked; it was erased.
    • A money field or parameter is checked; it accepted anything.
    • A literal written into a record-typed slot is built as the record, sealed, with defaults filled: a push or insert into a { R }, an element or field store, a { R } list literal, a record default written as {}, and a literal argument to a typed parameter of a function in the same file. The last no longer needs parse to fill defaults. Untyped code that added keys to such a literal now faults.
    • sys.parse_args builds through parse, and its errors name the path: sys.parse_args(Args): args.port is string, expected int.
    • Fault messages from a crossing name it and the path, and a missing defaulted field says use parse(value, T) to fill its default. Code that matched old fault text needs updating.
  • A loop in an actor or request handler that calls a module function compiles. str.count(…) and the other module functions the JIT calls directly kept a handler's loop out of the optimizing tier, though the same loop compiled in the main script on arm64. It now compiles in both, on arm64 and on x86-64, where such a loop did not compile in the main script either.
  • Loops that carry many values or make calls compile on x86-64. A loop that keeps more running values from one iteration to the next than x86-64 has registers for, or whose values must outlive a call it makes, such as a float total around a function call, now keeps some of them in memory and compiles. It used to run in the slower baseline tier. A JPEG encoder's Huffman coding loop runs in a quarter of its old time on x86-64, and a DCT row pass in three quarters. Loops that already compiled keep exactly the code they had.
  • More float-heavy loops compile on x86-64. A loop that keeps more float values live than the processor has registers for, such as a DCT's inner loop over eight column values, now parks some of them in memory and compiles, where it used to run in the slower baseline tier. The repro runs in 17% fewer cycles. Loops that already compiled keep exactly the code they had.
  • The JIT adapts when the values a function or loop gets change. A function is compiled from the arguments of a real call, so a default written as if x == nil then x = 5 end compiles for the nil its callers pass: 5 million such calls take 0.03 s, where the interpreter takes 0.06 s. Compiled code that keeps meeting values it was not compiled for (a loop compiled while an argument was nil that now gets a record, or a function compiled for numbers now called with nil) is compiled again from the values it gets now, and --jit-stats counts the loops this happens to as loop-retires.
  • A response header whose value is not a string is a 500. http.serve used to leave such a header out of the response and answer as if nothing were wrong, so headers = { "x-count" = 5 } sent no x-count at all. It now answers 500 and writes a line to stderr naming the header and the type it got: response header `x-count` is an int — a header value must be a string, or an array of strings to send the header once for each. Convert the value with tostring before you set it. A header key that is not a string, such as h[1] = "x", was skipped the same way and is now a 500 too: a response header name must be a string; got an int key.
  • On Windows, an fs path with a : anywhere in it is refused. There a colon names a hidden stream inside a file (notes:v2.txt) or a path relative to a drive's current folder (C:x), which can be outside the granted root. Every fs call answers such a path with an Error saying so, and creates nothing.
  • fs.mounts().state is "res" where it was "res only". Every value now names the folders a run can reach, joined by + in the order host, res, user: "host+res", "res", and with the per-user folder "user", "res+user" and "host+res+user", or "neither". A program that compared state with "res only" should compare with "res".
  • A capability file scua refuses stops the run with status 2, not 1. An unreadable --allow-file, one that is not TOML, an unknown key or a value of the wrong kind is a bad argument to scua, and 1 is the status a script uses for "ran and answered no".
  • --blob-keys refuses a keys file with a second hard link while --allow-fs (or the per-user folder) is granted, since the other link could be inside the granted folder. It already refused a file inside it, by its real path only.
  • An fs path that starts with the granted folder's own name says so. With --allow-fs=saves, paths are relative to saves, so fs.write("saves/slot1.json", …) looks for saves/saves/. The error used to say the directory saves does not exist; it now says paths are relative to the granted folder and suggests slot1.json.

#Fixed

  • scua test fails a test whose partition faulted. A handler the test drove that faulted, such as an assert_eq inside an on handler, left the test reported as PASS, an ERROR line after the results, "0 failed" and exit status 0. The test now FAILs with the fault's message, and a partition that faults after its test has returned fails the file.
  • json.decode refuses a number it cannot hold exactly. An integer past int's range (12345678901234567890) became a float without a word, so a client's request id came back changed; now it is an Error that names it. A number past a float's range (1e400) became Infinity, which json.encode then refused; now it is an Error too.
  • exec finds a program on your PATH. The manual said it did, but a program installed only outside /usr/bin and /bin (such as /opt/homebrew/bin/npx) was not found unless the child was given a PATH. A bare program name is now looked up on the PATH scua was started with.
  • http.serve sends no content-length on a 204. A 204 No Content or 304 Not Modified response carries neither a body nor content-length, as HTTP requires.
  • Two manual corrections. sys.readline pauses the rest of the program while it waits (spawned tasks, their timers and partitions resume when the line arrives); the page used to say timers keep running. scua schema maps a bound written as a range (s.len() in 1:64), not a comparison (s.len() >= 1).
  • An ask's reply no longer arrives changed. When a partition was waiting on several replies at once (map_all of asks, say) and collected garbage before it had taken one that had already arrived, that reply could come back as different data: another reply's rows, "", a function, or false in place of an int. It happened deterministically in some programs with the JIT off. Replies that have arrived are now kept through collections until they are taken.
  • http.serve no longer sends a body after a HEAD response. It wrote the handler's body after the headers, which a client keeping the connection open read as the start of the next response. A HEAD now gets the headers a GET would, with the body's length as content-length, and no body. scua serve answers a HEAD with the file's real length, where it used to say 0.
  • fs reads and writes work again in the WebAssembly build. Under a WASI host such as wasmtime, every fs.read_text, fs.write and other call on a file returned permission denied, while fs.list(".") still worked. The command in the files how-to now does what it says. The preopen is still the boundary: .., an absolute path and a symlink pointing out of the folder are refused.
  • A revived actor can no longer get a wider fs folder than the run reviving it. A snapshot records the folder its partition held, and a revive keeps it only when it lies inside the reviver's own. That test compared spelling, so a recorded folder written as <root>/../elsewhere, or a symlink inside the root pointing out of it, passed. A recorded folder with a .. part, or one whose real location is outside the reviver's, now revives with no fs grant. A recorded folder that really is inside is kept, as before.

#0.36.0 — 2026-10-15

#Added

  • Loops compile with small helper functions in place, including helpers that have a short while loop, set fields of a record passed to them or append to an array. A bit writer such as a JPEG encoder's put_bits(w, code, size), called inside a loop or inside an if there, now runs as part of the loop's native code instead of making a call each time. A short while written in the loop itself, such as while nb >= 8 do … end, compiles with the loop too. The packages team's 12-megapixel JPEG encoder now encodes at quality 100 in 1.8 s; 0.35.0 took 9.0 s.
  • A loop that counts a number's bits, while a > 0 do a = a >> 1; n = n + 1 end, runs as one step, and a short while inside a compiled loop costs little when it stops after a trip or does not run. With both, the packages team's JPEG encoder takes about a quarter fewer instructions to encode at quality 100.
  • Loops compile when they call a function with up to 15 arguments, or pass nil to one, including nil for a parameter the function then sets to a default.
  • Float loops over several arrays run faster. A loop that updates four float arrays element by element, such as a particle step, takes about three quarters of its old time on x86 and five sixths on ARM.
  • --jit-stats lists the ten lines that make the most runtime helper calls, with the helper each one calls and its share of the total, so you can see which line of a slow loop does the work.
  • Loops with everyday code shapes now compile to native code. A module-level const or let read in the loop, min, max and abs, floor, round and ceil or a shift inside an if, ~, a nil test on an argument, and a call to a short helper function all used to keep a loop out of the JIT, or turn each use into a call. On a JPEG encoder these took 18 million deoptimisations per image down to about five thousand.
  • More loops compile to native code: loops in long functions, nil tests and calls inside an if, arrays read from a record's field, and a while inside an if that seldom runs. On x86, loops with many float constants (a colour conversion's coefficients) compile too. The same JPEG encoder now spends about a fifth of the helper calls it did, and encodes in well under half the time.
  • Loops with a short for inside them compile as a whole. When the inner loop's bounds are numbers written in the code and it runs at most eight times, as with an 8×8 block's rows and columns, its body is copied into the loop around it and the two compile together. On ARM machines a loop that calls several functions compiles as well. --jit-stats also counts entry-fails=, the times a compiled loop was skipped because the values it was compiled for had changed.
  • --jit-stats names each loop the JIT couldn't compile, with its line and the reason, such as "a while loop nested inside this one". Make scripts run faster lists the shapes that still keep a loop out and what to do about each.
  • fs.seek(f, offset, { from }) moves an open file's position. Read part of a large file without the rest, or go back and fill in a header once you know what it says. from is "start", "current" or "end", and the new position comes back, so fs.seek(f, 0, { from = "current" }) says where a handle is. Packed res: files seek too: a stored one jumps, and a compressed one decodes up to the position.
  • fs.open(path, { mode = "update" }) writes into a file that is already there, keeping its contents. Together with fs.seek it changes bytes in place, such as a size field in a file's header, without rewriting the rest. It never creates a file, so a wrong path is an Error.
  • fs.write_chunk(f, data, { from, to }) writes a range of a value. The bytes go out straight from the value, so a large buffer can be written in pieces without bytes.slice copies.
  • bytes.deflater compresses data that arrives in pieces. Feed it with bytes.deflate_push(d, data) (a bytes value or a { u8 }, or a range of one) and end it with bytes.deflate_finish(d); the result is exactly what bytes.deflate gives for the pieces joined. A 12-megapixel image encoded a band of rows at a time peaks at about twice its pixel bytes, where building the filtered image first took three.
  • fs.rename(from, to, { replace = false }) never replaces an existing file or directory. The check and the move are one step, so when two writers race to save under the same name, exactly one wins, and a save that must not overwrite needs no placeholder file that a killed run could leave behind.
  • bytes.deflate(data, opts?) compresses DEFLATE data, the counterpart of bytes.inflate. It writes zlib (the default, as PNG needs), gzip or raw streams at level 0 to 9, takes a bytes value or a { u8 } array (or a from..to range of one), and gives the same bytes for the same input on the same version. A 12-megapixel image's rows compress in under two seconds at level 6.
  • bytes.inflate reads a stream split across pieces of its input, and can decode into a { u8 }. { ranges = [[start, end], ...] } decodes the stream those pieces of the data make, in order, so a PNG's image data is read straight out of its chunks instead of being joined into a copy first. { as = "u8" } gives the result as a { u8 } array, written in place when you also pass size, so code that works on the pixels needs no bytes.to_array copy. Together they decode a 36 MB image's pixels in its file size plus the 36 MB, where joining and converting took about 100 MB more.
  • bytes.inflate(data, { size = n }) builds its result in place. When you know the decompressed size (a PNG header gives it), the output is written straight into a value of that size, so inflating a large image needs its compressed and decompressed sizes and nothing more, and data that comes to any other length is an Error. gzip input uses the length recorded in the file the same way.

#Changed

  • bytes.inflate describes damaged data in words. The message says what is wrong, such as "a code table leaves codes undefined", where it used to end with the decoder's internal name for it.
  • An optional record type checks its value, and so does a record nested in another. A parameter, return value, let or field declared P? used to let any table through, so a field with a default that the table left out read as nil. It now gets the same check as P whenever the value isn't nil. A record nested inside the value is checked the same way at every depth, where before only its fields without a default were. Both fault with field 'b' of P is missing; use as(value, P) to fill its default, and a literal argument that leaves such a field out is refused when the program is checked. as(value, P) still fills defaults at every depth.
  • http.serve's startup line names the workers that serve requests. With the default scheduler every request runs on one thread, so the line now says 1 worker (8 with SCUA_SCHEDULER=threaded) where it used to say 8 workers. Under --output=json the serving event also carries "scheduler".

#Fixed

  • fs.write and fs.write_chunk no longer copy what they write. Each held a full copy of its argument outside the heap while it wrote, so writing 36 MB took 36 MB more memory. A write from inside a partition handler still copies, because the write carries on while the handler does.
  • bytes.inflate checks a zlib stream's header. A header whose check bits were wrong was accepted and the data decoded, and a stream that needs a preset dictionary was misread. Both are now an Error: the first says the header is bad, the second that preset dictionaries are not supported.
  • http.read(stream, max, { timeout_ms }) bounds the read. The option was accepted and ignored, so a stream that went quiet waited however short a limit it was given. A read that gets no data in time is Error({ kind = "timeout" }), and the stream is closed, as after any failed read.
  • actors.drop and sandbox.stop say when a partition is busy. Inside a handler they do not wait for another partition's turn, and a partition in one answered Ok(false), as if it had already stopped, while it kept running. It is now Error({ kind = "busy" }), as actors.snapshot already was, with a message saying how to retry. The manual now covers actors.snapshot's wait and drop options.
  • bytes.deflate holds its output once. A large result is compressed straight into its own value; it used to be built in a buffer on the side and copied in, so 36 MB of noise peaked at 110 MB where it now takes 76.
  • bytes.inflate refuses an option it doesn't know, and a max_bytes that isn't a byte count. { max_byts = 10 } or { max_bytes = "10" } used to be dropped, which left the output with no limit. It also no longer copies its whole input before decoding, which had added the input's size to the peak.
  • --serve-stats-every=N prints its line again. In 0.35.0 it printed nothing, because requests had moved into request partitions and the count it read was no longer kept. It now prints a line every N requests, with each scheduler, showing the bytes the requests allocated in the partitions that ran them, the collections there, and those partitions' live memory.
  • A function in context that reads a main-script let is refused before the server starts. With context = { get = fn() return app end } and let app = …, the server used to start and then answer every request that called it with a 500. http.serve now refuses it, as it already refused a handler that reads one: the error names where the function is (`context.get`) and lists each variable it reads with what to do, such as making it a const. This holds for a top-level fn passed as a value and for a function nested anywhere inside context, a constant's tables included.
  • A misspelt option is refused instead of ignored. fs.write("notes.txt", text, { exclusiv = true }) used to drop the option it didn't recognise and overwrite the file that exclusive was there to protect. It now faults before writing anything, with fs.write: unknown option 'exclusiv' — did you mean 'exclusive'?, or with the options it does take when no name is close. A wrong value type already faulted; a wrong name now does too. The same check now covers fs.read, fs.read_text, fs.write, fs.open, fs.find, fs.grep, fs.grep_text, json.encode, base64.encode, exec.run, exec.run_any, exec.spawn, exec.spawn_any, http.read, udp.recv, udp.recv_batch, actors.snapshot, actors.revive, sandbox.run, sandbox.actor, cluster.open, cluster.join, map_all and a frame's search, alongside the http and net calls that already had it. crypto.aes_ctr and crypto.pbkdf2 return it as an Error of kind misuse, because no crypto call faults. exec.spawn and exec.spawn_any now refuse stdin, which only exec.run and exec.run_any use.
  • A fault about a very large value is reported as that fault. assert_eq, assert_ne or a match with no arm, given a value close to the 256 MiB per-value limit, used to stop the whole run with runtime error: ObjectTooLarge or OutOfMemory (exit 7). The fault is now raised as usual (exit 5, and pcall catches it), and a value shown in a fault message is cut after 64 KiB with a count of what was left out, such as aaaa… (268369864 more bytes).
  • A long bytes value prints its first 16 bytes and a count. print, tostring, interpolation and fault messages showed every byte of a bytes value as hex, so a record holding 64 KB printed 131,088 characters. Past 256 bytes it now prints like a long typed array does: bytes(00000000000000000000000000000000, … 65520 more). bytes.to_hex still gives all of it.
  • A file or directory name over 255 bytes says the name is too long. fs.write, fs.read, fs.stat, fs.mkdir, fs.rename and the other fs calls answered Error(<path>: i/o error). They now say the name is too long, and fs.rename names the end that is too long.
  • Size-limit messages quote the limit of the kind of value they are about. A bytes value can hold 268,435,432 bytes and a string 268,435,424, because a string's header is 8 bytes bigger. fs.read quoted the string limit for a bytes read, and a .. that grew too large quoted 268,435,455, the size of a whole value with its header. Each message now says one bytes value or one text value and quotes that kind's limit.

#New, for evaluation: res: and pack files

  • A program's own data has its own path prefix, res:. The fs verbs read it from a stack of read-only layers:

    • at the bottom, the project's res/ folder (or --res=PATH);
    • above it, each --mount=PACK or --mount=DIR, a later one winning;
    • on top, each pack the program mounts with fs.mount before it starts its first actor.

    Host paths are never mixed in, so a user's files can never replace a tool's shipped templates. res: names match case exactly everywhere. See Ship a program's data with it.

  • A pack is a zip archive, stored or deflated, and is checked before anything is read from it. A pack that fails a check stops the run with exit status 2 and an E-PACK-… code naming the check.

  • New verbs and commands:

    • fs.mount, fs.mounts and fs.where;
    • ranged reads, fs.read(path, { offset, len });
    • rel on every fs.find record;
    • scua res build | list | check;
    • scua serve site.pak;
    • --show-res.
  • An application bundle can carry its data. scua now reads a .scuapp that holds the app's res: files (bundle format 4). They are its bottom layer, checked against the SHA-256 the bundle records for each file, and they are not read into memory at launch, so large assets cost nothing until read. scua res list and scua res check take a .scuapp too. A bundle with assets runs without --allow-fs and reads only its own files. scua-pkg 0.3.25 writes these bundles with bundle --app.

  • res/ read as a host path is pointed out. While the project has a res/ folder:

    • a literal res/… path in a read is a compile warning that names the res: path;
    • a computed one gets the same warning under scua debug.

    A host read that misses names the res: path when the program's resources have the file. A literal path that no run accepts (res://x, res:/x, RES:x, user:x) is a compile error, exit 3.

  • fs.open streams a packed file, stored or deflated, with fs.read_line and fs.read_chunk, so a large file is read a piece at a time and is never held whole. The read that reaches the end checks the file's CRC, and returns Error("damaged: …") where it would have returned the end.

  • Pack names are compared the way macOS and Windows compare them. A pack is refused when two names differ only in case, under Unicode's full case folding (Straße and STRASSE), or are one name written two ways (é, and e with a separate accent). scua res build warns about a name that is not in NFC, and --nfc writes every name composed. A miss names the other spelling when the pack has one.

  • A host gives a script its data. scua_mount adds a pack file or a folder as a res: layer, and scua_mount_memory a pack in the host's memory, with a release callback that runs exactly once. scua_res_layers lists the layers and scua_res_reset drops them. scua_eval_file and scua_load_script_file add the script's project res/ folder, as the scua command does. Mounting the host's own executable serves a pack appended to it, so a game ships as one file. See Embed SCUA in a host.

  • A save remembers the packs it ran with. actors.snapshot records the res: layers, and actors.revive refuses a save whose layers are not all mounted, with Error({ kind = "res_missing", missing = [...] }), unless you pass { res_missing = "allow" } or run with --res-missing=allow. A changed layer is reported in the load report, or refused with { refuse_res_changed = true } or --strict-res. A host reads the same through scua_load_report version 3, scua_actor_stats version 2 and scua_set_res_revive. A save from before this release records nothing and revives as before.

  • scua test reads res:. Tests read the project's res/ folder with no flag, and scua test takes --mount and --res, relative to the project root, to test against built packs. Host files stay out of reach: a test run grants no --allow-fs.

  • Under --io=async, res: reads park the way host reads do: fs.read, fs.read_text and an open handle's reads run on the I/O pool and return the same values, errors included.

  • A policy file can list res: mounts: mount = ["base.pak", "patches/*.pak"] in the --allow-file file, read as --mount reads them. A --mount on the command line replaces the list.

  • --blob-keys refuses a keys file a res: layer can reach: one inside a mounted folder, or one that is itself a mounted pack. exec refuses a cwd that is a res: path.

  • Faster CRC-32. hash.crc32, gzip, and the check on every whole res: read use the CPU's CRC instructions where it has them: about 30 GB/s on Apple silicon and 20 GB/s on recent x86, against 0.6 GB/s before. Other CPUs, and wasm, get a table method at 2–3 GB/s. The values are unchanged.

  • Changed: fs.list and fs.find on host paths now answer in natural order, as res: listings do: runs of digits compare as numbers, so e1m2 comes before e1m10, and fs.find lists a directory before what is inside it. The order used to be whatever the OS gave, which differed between systems.

  • Changed: a host path whose first part looks like name: (notes:v2.txt, user:save.json) is now refused as reserved, and so is a path that joins a res: path into a host path. With a resource view but no --allow-fs, import fs compiles, and reading a host file then stops the run with exit status 4.

#0.35.0 — 2026-10-10

#Added

  • actors.pool(n, make): one address for n partitions. It starts n partitions by calling make (for example fn() return Db(url) end) and returns one send-only address for all of them. An ask sent to it goes to a free member, so n asks run at once, and the reply goes straight back to the asker. When every member is busy, messages wait in the pool's queue and each ask's own timeout still applies. A member that faults is replaced by calling make again, and the ask it was answering gets Error({ kind = "fault" }). See A pool of partitions.

  • http.serve(handler, { health = "/healthz" }) answers a health check without running your handler. It answers 200 while the server is serving and 503 once a stop has begun; health_sheds = true also answers 503 just after requests were turned away. See A health check for the load balancer.

  • bytes.inflate(data, opts?) decompresses DEFLATE data: zlib streams such as PNG image data (the default), .gz files with { format = "gzip" } and zip entries with { format = "raw" }. It returns Ok(bytes), or an Error that says what was wrong with damaged or truncated input, and { max_bytes = N } caps the output as it grows. It is many times faster than an inflate written in SCUA. See Compressed data.

  • Typed buffers in bulk. fill(n, v, "f32") builds a typed array directly at its final size, a third of the memory of filling a plain array and converting it. copy_range(dst, at, src, start, end) copies a run of elements in one block between arrays of the same kind, overlapping ranges included. bytes.to_array(b, "u8") gives a { u8 } in one copy. elem_kind(xs) tells you a typed array's element kind ("u8", "f32", …) without converting anything. See fill.

  • hash.crc32(data, start, end) checksums part of a buffer without copying it out first.

  • sys.parse_args takes a list of positionals. A files: { string } field collects every positional left, so convert *.png works; with a list field in the record, defaulted fields are options set by name. { unknown = "refuse" } makes an undeclared key in a JSON body an error, and scua schema publishes additionalProperties: false for that record. See Read command-line arguments.

  • fs.write(path, data, { exclusive = true }), fs.open(path, { mode = "create" }) and fs.sync(f). An exclusive write or a "create" open fails if the file already exists, in the same step that creates it, which is how a "don't overwrite" option can be kept against another program. { sync = true } and fs.sync wait for the data to reach the disk. See Replacing a file without losing it.

#Breaking: typed boundaries

  • A typed parameter, return or let refuses a record value that is missing a field with a default. fn take(r: P) called with { a = 5 }, where P declares b: int = 2, used to run with r.b nil and fail later with a bare type error. A literal like that is now a compile error, and loose data such as decoded JSON is refused at the boundary, both saying field 'b' of P is missing; use as(value, P) to fill its default. Convert with as(value, P), which fills defaults, wherever the data may lack them. A record literal written directly into a typed let or return still fills its defaults.

#Breaking: http.serve

  • Every request runs on its own, in every mode. A handler starts from the same place each time, and nothing one request changes is there for the next:
    • a handler can't reach the main script's lets; the server refuses to start and lists each one. A table it only reads belongs in a const, and anything else goes in context;
    • a module's state starts as declared on every request;
    • state that lasts between requests lives in a partition passed in context;
    • --max-mem bounds each request: one over the limit gets a 500 on its own. See Each request runs on its own and Keep state in a server.
  • A handler can wait. ask … timeout, wait_for, select, http.get, fs.read and net.read wait for that request alone while the server answers others. They used to give a 500.

#Breaking: typed buffers and enums in JSON

  • json.encode and toml.encode write a payload-free enum by its name. json.encode({ kind = Kind.UnsupportedFormat }) gives {"kind":"UnsupportedFormat"}, where it used to give {"kind":1}. A record field declared with the enum still reads the number back, so data you wrote before still loads. A program on the other end that reads the number needs to read the name instead. A flags set is still written as its bitmask. See Enums in JSON.
  • A parameter or return declared { u8 }, { f32 } or another element type is checked. It wasn't: fn f(p: { u8 }) accepted a { u16 } holding 300, or a plain [300, 2], and p[0] was 300. Now a value that doesn't fit faults, naming the parameter and the value: parameter 'p' of 'f' is a typed {u8} array: element 0 is 300, and it holds whole numbers from 0 to 255. Passing nil to one that isn't declared optional ({ u8 }?) faults too.
  • A typed annotation no longer changes an array someone else holds. let p: { u8 } = d inside a function, or a record field declared { f32 }, used to convert d itself, so a caller's { u16 } turned into a { u8 } and then refused push(1000). Now an array that already has the declared element type is used as it is, the same array, and any other array whose values all fit is copied into a new one; the original is left exactly as it was. So a function that fills a buffer you pass it needs that buffer to have the declared type already: pass it a plain array and the function fills a copy, which you won't see. See Element-typed buffers.
  • An { f32 } refuses a number too large for an f32. Storing 1e40 used to store infinity without a word. It now faults (can't add … to a typed {f32} array: it is beyond the range of f32 (about ±3.4e38)), on push, a[i] = v, insert, a literal and a typed annotation alike. inf and nan written as such still store, and ordinary rounding (0.1 reads back as 0.10000000149011612) is unchanged. The lanes of vec2, vec3 and vec4 are not affected: like float arithmetic, vector math can still overflow to infinity.

#Changed

  • Compiled loops are much faster. A counted for loop now checks its operation budget once per block of iterations rather than on every one, % and // by a constant are shorter, and shorter still when the value can't be negative. A loop such as for i in 0:n do s = s + i % 7 end runs in less than half the time, and an http.serve handler that spends its time in a loop like that answers about 1.8 times as many requests. --max-ops still stops a program at exactly the same step and line.
  • while loops and counted loops run faster under the JIT. On x86, a while i < n loop that sums i % 7 and steps i by one now takes less than half the time it did, close to the same loop written with for. A for loop that adds up its index takes about 30% less time, and a loop that does several % by a constant and keeps totals with if (the orders benchmark) about 15% less. --max-ops still stops a program at exactly the same step and line.
  • Compiled code no longer drops back to the interpreter to call a built-in function such as json.encode or bytes.from_string, to tell a partition, to start a for loop, or to return from a request or message handler. A typical handler now runs compiled from start to finish.
  • Hot loops that call built-in functions or use string literals stay in the fastest compiled code. A loop in a partition's or a request handler's code that builds strings from a literal, such as last = `alpha{i}` , runs in about half the time on x86, and a loop that calls built-ins such as tostring and abs about 10% faster. --max-ops still stops a program at exactly the same step and line, and a built-in that fails reports the same message and line as before.
  • --jit-stats counts every worker on the threaded scheduler. The [jit] and [gc] lines counted only the main thread, so a server's requests, which run on the workers, were missing from them.
  • fs.rename no longer replaces a read-only file, and a file it replaces keeps its permissions. Renaming over a 0444 file used to succeed and leave a 0644 one. It is now an Error; fs.remove the file first if replacing it is intended.
  • A typed array longer than 256 elements prints its first 16 elements and a count of the rest, in print, tostring, string interpolation and error messages. Printing a record that holds an image used to write megabytes of numbers. Plain arrays still print in full.
  • bytes.join and bytes.from_array hold their result once while they build it, where they used to hold it twice: a 16 MB join peaks about 16 MB lower.
  • fs errors name the path: Error(notes/todo.txt: no such file or directory). A path under a directory that does not exist no longer suggests fs.mkdir when you were only reading it.
  • A run stopped by --max-ops inside an imported module names that module's file and line. It named the main script with the module's line number.
  • Size limits are reported correctly. A typed array is refused past 4,294,967,295 elements, not the 16,777,214 the message used to give, and a bytes value past 268,435,432 bytes names that limit.
  • The --max-mem guide explains peak memory: the process can reach about twice the live data the cap measures. See --max-mem.
  • A missing field with a where names itself. With record R2 { n: int where n in 0:10 = 3 }, calling fn f(o: R2) as f({}) faulted with a bare type error. A typed parameter checks the value it's given and doesn't fill defaults, so n was missing; the fault now says so (field 'n' of R2 is missing, so its `where` constraint can't hold …) and suggests as(value, R2), which fills them.
  • http.serve reads each request's headers in a single pass, about 5% more requests a second on small requests.
  • http.serve uses about 10% less CPU per request. It copies and allocates less for each request and response, json.encode and bytes.from_string copy less, and compiled code no longer drops back to the interpreter at each string, bytes or table literal in a handler.
  • Resuming a coroutine or a handler costs less on Linux and Windows: its registers are cleared with wide stores, and one that calls deeper functions keeps its buffers between resumes.
  • Programs whose partitions keep the scheduler's helper threads busy no longer start and stop those threads on every pass. They start once, use no CPU while idle, and stop when the program ends.

#Fixed

  • A recursive function that calls a helper containing a loop no longer fails with "call depth exceeded". In fn rec(n, acc) if n < 1 then return acc end return rec(n - 1, acc + helper(n)) end, where helper has a while loop, the default JIT could lose the value of n - 1 once rec was optimized, so the recursion never reached n < 1 and stopped with stack overflow: call depth exceeded 4000 frames. It now gives the same answer as --jit=off.
  • A hot loop that reads a live string, list or table no longer risks a crash or a wrong value. Under the default JIT, a loop such as for i in 0:n do let s = TUNING.name … t.v … end could keep using where t was before the read made the partition's memory grow, and so read memory that had moved. Loops that read only number or boolean live keys were never affected, and they run exactly as fast as before.
  • http.serve(handler, { bind = … }) no longer risks a crash as it starts. It could read the bind text after starting the server had moved it in memory.

#0.34.1 — 2026-10-06

#Changed

  • Package identities are corrected for six shapes, so code using them gets new hashes. The package manager identifies each function, record and other definition by a hash of what it does, so that a review or an advisory follows the code rather than a version number. That hash was wrong in these places, and each is fixed:

    • std.len(x) was recorded as using a package named std. It is the built-in now, and where your file doesn't declare its own len it has the same identity as len(x).
    • live.publish(…) and live.version(…) were recorded as using a package named live.
    • A partition's constructor parameters were recorded as packages named after themselves, and partition P(a) had the same identity as partition P(a, b).
    • A module's state x = 1 had the same identity as let x = 1.
    • An import was identified by the name you gave it, not the module it loads: with import json as m in one file and import toml as m in another, fn f(s) return m.parse(s) end had one identity. A package import now records the package itself as the dependency, not the local name. A file that imports a package under another name and also declares something with the package's own name (import util as u beside fn util) can no longer be hashed; rename one of the two.
    • A top-level let a, len = 1, 2 was ignored: a function calling len was identified as calling the built-in, although the file's len hides it. Each name of such a let is now a definition of its own, and code that reads it depends on it.

    Nothing else moves. The identity version the package manager records in a lock is now 2, so a lock written against an earlier version names the difference instead of reporting changed code.

  • A function held in a const is the same function again. In 0.34.0, const F = f made F == f false, because the constant holds a frozen copy. Two functions are now equal when they're the same function of your program, however you reached it: through a const or a table, in a message to a partition, or from a revived snapshot. A library that marks what it builds with a function and checks the mark later (a compiled grammar, say) accepts its own value from a const again. A closure that captures variables is still equal only to itself, and built-ins compare as before. See Comparing functions.

  • A function can be a table key. One that captures no variables, or a built-in, keys a table by which function it is, so t[f] and t[F] are one entry after const F = f, and the table keeps its entries through a send, a snapshot and a revive, even under a program that has added or reordered functions. A closure that captures variables is refused as a key, and the message says which kind can be one.

  • Fixed: a partition whose state table was keyed by partition references couldn't be revived. The snapshot was refused on revive as damaged ("did not survive intact"). It now revives with its keys.

  • Fixed: filling a module's const after declaring it got advice a module refuses. When no function read the constant (const INFO = {}, then a loop fills it), the error said to build it as a let, which a module's top level can't hold. It now says to build it in a function and keep what the function returns: fn build_info() … end, then const INFO = build_info(). The same advice now comes when the module's top level fills the constant through a function the compiler can't see into.

  • A module that calls one of its own exported names over a built-in now warns at the call. A module that exports fn error(message) and also writes error(fault) to re-raise calls its own function, not the built-in. The warning sits on the call and says which function it reaches, and how to reach the built-in (std.error(…)). A module that calls the name in several places gets one warning listing the lines. A name the module exports but never calls bare still gives no warning, and nothing changes for the main script or for names a module keeps to itself.

#0.34.0 — 2026-10-06

This release changes what a partition can reach, what const means, and how actors are named, saved and answered. Most programs that use partitions need a few edits; the compiler names each one and says how to fix it. Partitions now share the program's code, constants and standard library instead of each holding a copy, so an idle one costs about 2.3 KB where it cost 77 to 92 KB, and live values let you retune a running program. Saves have a new format: one written by an earlier version is refused, and a revived partition reads the current program's functions and constants.

#Breaking: what a partition can reach

  • A partition can't reach the main script's variables. A handler, a state line, or any function they call that reads or writes a top-level let is a compile error. The error names the variable, the path to it and a fix: make it const, pass it in as a constructor argument, or keep it in state. A call through a function value the compiler can't follow stops the program with the same message. An embedding's world is a main-script name like any other. See What a partition can reach.
  • Creating a partition no longer copies the main script's variables into it. It still sees the program's functions, modules, constants and the standard library, and reads each when it first uses it. A partition that read a large let through a function used to carry its own copy (a 500-entry table cost about 48 KB more per partition).
  • A module's top level can't hold a let. Use const, or state for a value each partition keeps for itself. state at the main script's top level is refused.
  • A module's top level can't fill or change one of its own state values. Each partition's copy starts from the declaration, so a loop there filled only the importing script's copy, and every partition read the value as declared, with no error. Build it in a function and start the state from it: state INDEX = build_index(). A function the top level calls that writes the state stops the program with the same message; a write inside one of the module's functions, called later, is unchanged. See State in a module.
  • on Start is reserved. It takes no arguments, runs once when the partition is created and before any message, and never runs on a revive. A handler that used Start as an ordinary message name needs another name.
  • wait_for takes a message name written out. wait_for(x) with a variable is a compile error.
  • A sandbox's standard library is the pure part only: no actors, no host, no ids.next.

#Breaking: constants

  • Every value a module exports is frozen when the module's top level finishes, not only values from a const. return { sizes = [1, 2, 3] } used to let one importer's lib.sizes[0] = 99 change it for every file that imports the library; that write now stops with the message a constant export gives, naming what you wrote and a copy to change (let my_sizes = lib.sizes[0:]). Exported functions stay callable. A library whose own function changes a table it also exports keeps that value in state and exports a function that reads it.
  • A library's exported tables can't be changed by a file that imports them. A table or array a module exports from a const is frozen, so chart.palette[0] = "green" stops the program; it used to change the library's own behaviour for every importer. The error names your expression and a copy to change: let my_palette = chart.palette[0:].
  • const freezes the value, at every depth. A write the compiler can see is a compile error, and any other write stops the program; both name the constant. const xs = [] followed by xs.push(1) is an error now. Use let, or build the value in a let and write const XS = freeze(xs). See const freezes the value.
  • A constant holds data only. A partition reference, a capability, a host object or resource, a builder, or a function that captures variables is refused where the constant is declared, by name and with where inside it the value sits.
  • A top-level const name is declared once in its file.
  • A module's exports are read-only from outside, by every path.

#Breaking: names and built-ins

  • Built-ins can't be assigned to. print = … is a compile error.
  • A top-level declaration named like a built-in hides it in the whole file, call forms included, and the compiler warns (no flag turns the warning into an error). std.<name> always reaches the built-in, and std is now a reserved word, so std(xs) for a standard deviation is written xs.std() or std.std(xs). Using a hidden built-in's name above its let or const is an error.
  • The named colours moved into a module. import color and then color.red(), color.navy() and the rest; color_red() is a compile error that names color.red and the import. color_hex, rgb, srgb and hsv are unchanged. See Colors.
  • (Narrow) live NAME = … at the main script's top level is now a declaration of a live value.
  • Reading a top-level value declared further down the file is a compile error. A read that runs before the declaration has run stops the program with the declaration's line, where it used to give nil. Top-level functions are hoisted, so calling one above its fn works.

#Breaking: saves

  • Saves from earlier versions are refused. An actor snapshot or an embedded session saved by 0.33 or earlier is refused with old_format (-SCUA_ERR_FORMAT from the C API), and the message says an older version of SCUA saved it. Nothing converts one: revive it on the version that wrote it and export what you need as data. Earlier versions can't revive a save from this one either.
  • A revived partition reads the current program. A save holds the partition's own data and nothing else of your program, so a function added since the save is there, a changed constant has its new value, and a function you removed refuses the revive only if the partition holds it. A table the partition stored from a top-level constant (state items = ITEMS) gets the current program's table; a list element follows only if it has an id field. See What a revived partition sees of your constants.
  • A revived partition holds only the capabilities both the save and the reviving code hold. A save records what its partition was allowed to do, and a revive grants the overlap.
  • The main script's top-level names are its own. A module or a frame verb that calls a built-in gets the built-in, even when the main script declares a function or variable of the same name: a main-script fn tostring or fn take used to change what they got.

#Breaking: actors

  • Forking a saved partition needs its own grant: actors.revive(blob, { fork = true }) now needs --allow-fork (embedding: scua_grant_fork); without it a fork returns Error({ kind = "not_granted" }). A plain revive needs nothing new. Partitions inherit the grant from the code that created them; sandboxes never have it, and --allow-all does not include it.
  • actors.freeze is renamed actors.snapshot. The old name is a compile error that names the new one. freeze(value) is now the function that freezes a value.
  • Every error the runtime gives an actor has one shape, Error({ kind, message }), with kind saying what happened: gone, fault, no_reply, interrupted, timeout, limit or misuse among them, and already_live or stale_generation from a revive. When a handler's own reply is an Error whose kind is one of these, the asker sees it as upstream_<kind>, so e.kind == "gone" always means the runtime found the partition gone. A handler that faults while answering an ask now answers Error({ kind = "fault", message = … }) instead of Error(message).
  • An ask to a partition that was dropped or hibernated gets Error({ kind = "gone" }) at once, instead of waiting out its timeout. An ask a partition was waiting on when it was snapshotted comes back as Error({ kind = "interrupted" }) after a revive: check whether the request took effect before asking again.
  • Only one copy of a partition runs at a time. Reviving a blob whose partition is already running is refused with already_live; pass { fork = true } for a copy with its own identity. A blob older than one this process already revived or dropped is refused with stale_generation.
  • References print as their locator, <actor 3f1c09a2b7d45e60>, in output and logs, the same in every process and after every revive. They used to print a slot number that could repeat.
  • A send-only reference can't snapshot or drop. actors.snapshot, actors.drop and sandbox.stop on one give Error({ kind = "misuse" }).
  • (Rare) A partition's memory is capped at 16 GiB, down from 32.

#Breaking: embedding

  • Signed session saves: with deployment keys set (scua_set_blob_keys), scua_persist signs a session save with HMAC-SHA256, as actor snapshots are signed. Load it with the new scua_load_keyed, which refuses a save that changed after it was written, one signed with a key you no longer hold, and an unkeyed save unless you pass SCUA_BLOB_ACCEPT_UNKEYED. scua_load refuses a signed save. Unkeyed saves are unchanged. scua blob-info --keys FILE checks a session save too.
  • Partitions are named by scua_actor_id (slot, generation and runtime epoch) instead of a bare uint32_t, and SCUA_ABI_VERSION is 2. Every entry point that took or returned an actor id (scua_persist_actor, scua_load_actor, scua_actor_tell_text, the scua_io_park* and scua_io_wake_* calls) uses it, and scua_actor_list lists the live ones. A partition's slot is reused after it is dropped, and an id for the old one is refused rather than reaching the new one: a tell to a dropped partition is counted as a dead letter, and an id this runtime never issued is SCUA_ERR_RANGE. Rebuild your host against the new header.
  • New result codes: SCUA_ERR_ALREADY_LIVE (16) and SCUA_ERR_STALE_GENERATION (17) from a load.
  • A sandbox never has the host's registered functions; calling one there fails with "cannot call a host function here".
  • scua_array_push and scua_table_set return SCUA_ERR_TYPE on a frozen value.
  • A partition needs the sandbox grant to create sandboxes, as it needs any other grant: it has it when the code that created it did.

#New

  • freeze(value) returns a frozen copy of a value, for building a constant step by step in a let.
  • Module state: a module can keep a value per partition with state name = …, saved with the partition. A library function that uses module state can sit in a const. See State in a module.
  • Constructor arguments: partition Shop(catalog, owner) keeps what it needs in state lines (state items = catalog) and is created with Shop(PRICES, "Mara"). See Constructor arguments.
  • on Start() runs once in a new partition before its first message. See on Start.
  • ids.next() gives a 64-bit integer id. Each partition counts up from its own random start, so ids from different partitions and processes don't collide in practice, and no partition has to ask another for one. See Generate unique ids.
  • References are values: two references to one partition are equal and are the same table key, and json.encode writes one as "actor:<locator>". See References: who is who.
  • actors.me() is a partition's own address, as a send-only reference: whoever you hand it to can send you messages and nothing else. actors.send_only(r) makes such a copy of a reference you hold. actors.locator(r) gives the text a reference prints as. actors.sender() says who sent the message being handled: another partition's locator, "main", "host", or "unknown" when the runtime can't tell.
  • Dead letters are reported. A message to a partition that isn't running is counted and written to stderr with its sender and target, rate-limited to a few lines and then one a second. An embedding host can take them with scua_set_dead_letter_hook.
  • actors.stats() (main script only) and scua_get_actor_stats return the runtime's counts: partitions running, slots, the ready queue, dead letters, suppressed log lines, and revives refused by reason.
  • ask inside map_all and wait_all arms sends every request at once and collects the answers in order. A partition may have up to 64 asks waiting; one past that gets Error({ kind = "limit" }) at once. See Asking several partitions at once.
  • Fenced revives: actors.revive(blob, { generation = g }) and scua_load_actor_fenced take the number your store handed out when it claimed the session, and later snapshots count on from it, so a process that kept running with an old copy can't overwrite the new one. See Hibernate a session.
  • Reviving is a right a partition is given. The main script has it, and a partition gets it from the code that created it; a partition without it gets not_granted.
  • live values hold settings the main script or the host can replace while the program runs: live TUNING = { goblin_hp = 25, speed = 1.0 } at the main script's top level. The keys and their types are fixed where it is declared. TUNING.goblin_hp copies one key out of the current version, from anywhere. live.publish(TUNING, { goblin_hp = 50 }) replaces the keys it is given, several values in one step, and returns Ok(version) or an Error (invalid, limit); live.version(TUNING) says which version is being read. A handler reads one version from its start to its next wait. Publishing is the main script's job: doing it from code a partition can reach is a compile error. See Live values.
  • Snapshots remember the live versions they read. A revive into a process that is behind is made and reported (live_behind in the load report and in actors.stats()); actors.revive(blob, { refuse_live_behind = true }) refuses it with Error({ kind = "live_behind" }).
  • Embedding live values: scua_live_publish publishes from JSON, scua_live_set_start makes partitions wait for, or refuse to start without, a first publish (SCUA_ERR_UNPUBLISHED, 19), a publish past the retained versions is SCUA_ERR_LIMIT (18), and scua_get_actor_stats adds live_versions, live_behind and live_refused.
  • A save revives after you add a field to a record it holds, when the field has a constant default ([] and {} count) or is optional: every held instance gains it. Reordering a record's fields is fine too, and so is adding a state line that starts from [] or {}. The same goes for an embedded session (scua_load_script). Removing or retyping a field, or adding one without a default, is still refused, naming the field, and so is any field change to a record with where rules.
  • --mem-stats reports the longest collection (gc-pause-max-us), and the background collections of idle partitions (trims, trim-pause-max-us).
  • Signed saves. --blob-keys=FILE signs every actor snapshot with your deployment's key (HMAC-SHA256) and revives only snapshots that verify; the others are refused with integrity. Every key in the file verifies and the one marked sign signs, so keys rotate without stranding saves. --accept-unkeyed-blobs also revives saves made without a key while a deployment moves over. The run refuses to start if a script could read the keys file. Embedding: scua_set_blob_keys, SCUA_ERR_INTEGRITY (20), and loads_by_key, loads_other_keys and loads_unkeyed in the actor stats. An unsigned save carries a checksum, which catches damage. See --blob-keys.
  • --strict-constants (embedding: scua_set_strict_constants) refuses a revive whose partition stored a value from a constant that is gone or has changed shape (constant_gone, shape_changed), where by default the partition keeps its stored copy and the load report counts it in constants_kept.
  • A save from a newer version holding a part this version must read is refused with unknown_section; a part it may skip rides along to the next snapshot, and is dropped (and counted in sections_discarded) once the partition has run since the revive.
  • scua blob-info shows a save's identity (as its locator), whether it was signed, and the parts it is made of; --keys FILE checks a save, or a directory of them, against your keys.
  • Reserved words can be used as field names after a dot and as table keys. A missing import of a standard module is named in the error.

#Changed

  • A library's exported name that matches a built-in (fn distance, exported as { distance = distance }) no longer prints the "hides the built-in" warning. Names a library keeps to itself, and the main script's names, still warn.
  • Programs with many actors use far fewer memory mappings on Linux: 100,000 working actors hold 48 instead of about 40,000, well under the default limit of 65,530. A working actor holds about 16 KB less on macOS, and actors are created and run faster.
  • Interpreted code that counts into a dictionary (counts[w] = counts[w] + 1) runs about 8% faster on Apple silicon, and string-keyed dictionary reads, global reads and floor cost less.
  • Fixed: a dictionary of more than 16 keys updated at the moment it would grow iterated in a different order compiled than interpreted.
  • A dependency made for an older scua names both commands: scua upgrade @scope/name, then scua vendor to bring it into deps/. If deps/ still has the same release afterwards, none made for this scua exists yet, and the project stays on scua 0.33 until its maintainer publishes one.
  • Changing a frozen value through your own variable (let p = lib.palette then p.push(…), or a function parameter) names that variable and offers a copy that runs, such as p = p[0:]. It never tells you to "use let" when you already have one. let lib = import("lib") and const lib = import("lib") get the same "the lib module exports" message as import lib.
  • delete on a frozen table says it is frozen and gives a copy; it used to say "cannot delete from a module".
  • A module loaded with import("m") has every top-level let reported in one compile, as import m does.
  • Every module let, and every renamed colour, is reported in one compile, across every file your program imports, so a module moves over in one pass.
  • A package written for an older scua names the upgrade. An error in a file under deps/ names the package and its version and says to run scua upgrade @scope/name, rather than suggesting an edit its hash check would refuse.
  • Advice that compiles. Assigning to a module's const from one of its functions says to use state; color.hex(…) says color_hex is still a built-in; a literal let changed once at a module's top level is told to make that line the declaration.
  • Warnings that a name hides a built-in print when the run ends, after the program's output, its error or its sys.fail reason, so the first line of stderr is always about the program. Each shows once, with one line number; scua test prints a compile warning once per run.
  • The main script's collected heap is smaller: the standard library and the program's constants live in the program image, so a script's live-after-gc is about 34 KB lower.
  • { kind, message } is the shape of the actor runtime's errors and of errors that cross an ask or a sandbox boundary; pcall and try elsewhere still give the fault's message as a string.
  • Fixed: --mem-stats printed error("boom") as a bare error; the fault now comes first, with its message.
  • Fixed: scua check x.scua said "cannot read file". A word that names neither a command nor a file is now an unknown command, with the nearest real one when there is one.
  • Creating a partition copies nothing of the program. Constants, the built-in functions and the pure standard modules are shared by every partition instead of copied into each, and anything else a partition reads from the main script is copied the first time it reads it. A partition that reads a 1 MB constant holds about 5 KB of its own instead of about 950 KB. Reading constants is as fast as before, compiled or interpreted.
  • A partition's memory starts small and grows as it needs. It starts at about 1 KB from the general allocator rather than a memory mapping of its own, and what only a running partition needs (its mailbox, its compiled-code tables, the bookkeeping for a waiting turn) is made when it is first sent something. An idle partition costs about 2.3 KB of memory, down from about 92 KB on macOS and 77 KB on x86-64 Linux, and creating one takes about 0.5 µs on Apple silicon and 1.5 µs on x86-64, down from about 51 and 107. One that has handled a message holds about 12 KB. Each partition collects garbage on its own schedule: one that does a little work often holds about 70 KB instead of about 1.5 MB. A partition left idle with garbage while others keep working is collected in the background, a few at a time.
  • Partitions share the program's compiled code. Each function is compiled once, by the first partition that runs it often enough, and every partition then runs that copy with its own caches and counters. A partition in a program with 150 functions, all of which it calls, holds about 34 KB instead of about 450 KB and is created in 4 µs instead of 116 (macOS). --jit-stats reports shared-compiled=.
  • A message costs the same however many idle partitions exist. Only partitions with something to do are visited: an ask beside 100,000 idle partitions takes about 0.5 µs on macOS and 1.1 µs on x86-64 Linux, where it took about 45 ms and 81 ms.
  • Saves are smaller and faster. An idle partition's save is about 2 KB, down from about 72 KB; a 16-record session saves in 6.7 KB instead of 124 KB, and takes 0.005 ms to snapshot (was 0.097) and 0.05 ms to revive (was 0.17). A snapshot copies the partition and leaves the running one as it was, compiled code included, where it used to compact it in place.
  • Dropping and reviving partitions no longer grows memory. A program that opened and closed 100,000 partitions peaked at about 12 MB, where it reached over 200 MB.
  • for … in loops over arrays now run as compiled code: a loop over an array of records is several times faster with the JIT, which it used to run slower than the interpreter.
  • A loop that reads a top-level const keeps running as compiled code after a garbage collection. On x86-64 it used to drop to a much slower path for the rest of the run (about 13× per element in a partition's tick), and reading an element of a constant array went through a slow call in code the JIT hadn't fully optimised.
  • A partition state named like its module's state gets a warning.
  • A local variable named like a call form no longer changes what xs.len() means.
  • An error about calling a hidden built-in names your declaration and std.<name>.
  • An error raised in one partition no longer risks crashing the next partition that collects garbage on the same thread.
  • On WebAssembly, toml.parse, ini.parse, dotenv.parse, sandbox.run, sandbox.actor, durable.decode, actors.revive, the data-table verbs (group_by, group_sum, cumsum, value_counts, search) and a wait_all arm's error could read memory the heap had just moved away from; they no longer do.
  • SCUA_ACTOR_ORDER=shuffle:SEED runs a program with partitions visited in a seeded random order, to find code that depends on an order the runtime doesn't promise.

#0.33.0 — 2026-10-05

#Breaking

  • Breaking: inside filter and with, a name you declared yourself is an error beside a column of the same name, even when it is also a built-in name. fn year next to a year column used to read the column without a word, while fn total2 next to a total2 column was already refused. Both are refused now, as the guide says. To update: write row.year for the column, or rename your function.
  • Breaking: --max-mem (and an embedder's mem_cap) is one total for the whole program, actors included. Each actor's memory used to get the whole cap to itself, so 5,000 actors under --max-mem=1M ran to completion holding over 300 MB. Now every actor's memory counts against the same cap from the moment it is created, and creating one past the cap stops the run with memory limit exceeded. To update: a program that creates actors under a tight cap may need to raise it; --mem-stats shows the total as all-live. See --max-mem.

#Security

  • A sandbox can no longer bring an actor to life with actors.revive. Reviving needs no capability and builds a running partition from a blob, which was the one way left for sandboxed code to start a partition after spawning was blocked. It is now refused inside a sandbox. Separately, a spawned or revived partition now takes its capabilities from the code that created it rather than from the whole run, so it can never hold more than its creator.

#Changed

  • A sandbox exchanges only data on every path. A function or an actor reference was already refused in a sandbox's initial state and by sandbox.run. It is now refused in a tell or ask to a sandboxed actor and in its reply. Code inside a sandbox also can't start actors, with actor(…) or with a partition of its own; spawn still works inside it. See Run code in a sandbox.
  • A value that can't be sent between partitions is named. The error says which argument and where inside it (argument 2 of `Take` (at `.cb`) is a function that captures variables). When it is a top-level value that stops every actor starting, the error names the value and a function that uses it.
  • --max-ops counts creating an actor as one step, like the call it reads as. It counted nothing.
  • sys.exit, sys.emit and sys.fail in a partition's handler end that handler's turn and keep the partition's state. sys.emit writes its result. sys.fail(reason, code?), and sys.exit with a non-zero code, now also fail the run: the program runs to its end, then exits with that code (the first handler to fail it decides) and writes {"error": reason} to stderr, as at the top level, so a tool whose work runs in a partition can report failure. An ask whose handler ends with sys.exit or sys.emit before replying gets an Error instead of waiting for its timeout. They used to wipe the partition's state and print nothing, or, depending on timing, end the whole program at once. A handler's return Error(e) still leaves the exit status alone. See Partitions and the actor model.
  • sys.fail, sys.emit and sys.exit in a spawned task stop that task and nothing else, and otherwise do what they do in a handler: sys.emit writes its JSON, and sys.fail is reported on stderr straight away and fails the run. In a task they used to vanish: sys.fail printed nothing and the run exited 0. See sys.fail, sys.emit and sys.exit in a task.
  • A number too big or too small for a typed array is reported as that, with the range the array holds: can't add 300 to a typed {u8} array: it holds whole numbers from 0 to 255. The error used to say "a value of type int", as if an int were the wrong type. A bad element in a typed array literal ([1, 300]) no longer reports a push you never wrote: push and literals both say "add", and stores and insert say "store" and "insert".
  • Actors can use the cluster module with the run's secret. They used an empty secret, so with --allow-cluster=SECRET an actor's cluster.open stopped the main program opening the same table.

#Fixed

  • A while loop no longer leaves a stale value in a variable it only assigns. A variable assigned inside a while loop but not read until after it could keep an earlier, wrong value once the loop ran long enough to be optimized: x = i * 2 in the loop, then print(x) after it, printed 8190 instead of 199998. It is now the same with the optimizer on or off. for loops were not affected.
  • insert into a typed array refuses a value that doesn't fit, as push does. It stored a wrapped or meaningless value instead: 300 inserted into a { u8 } array became 44.
  • Large top-level files compile. A file with a few hundred top-level functions, or a big data table of distinct keys, could stop compiling at its next message send, Ok(…), match arm, state field or method call with "too many distinct constants", and the error's advice pointed at where the constants already were. These now compile. The remaining limit, 65,535 distinct constants in one function, is reported at the line that reaches it, with what to do.
  • Hot loops inside an actor run at full JIT speed. A loop in a handler that called top-level functions or built-ins could run slower with the JIT on than with it off; it now runs as fast as the same loop at the top level.
  • Many revived or long-running actors no longer run out of memory early. Reviving a few thousand actors, or running a few thousand that had each garbage-collected, failed with OutOfMemory well before RAM ran out.
  • Each actor decides when to garbage-collect from its own memory use, so small actors no longer hold megabytes of garbage after a large actor has run.
  • Actors in a script embedded through the C API cost about a quarter of the memory and time they did, the same as under the scua command.
  • A table column named print or world can be used by its bare name inside filter and with.
  • Defining your own push function no longer makes filter return no rows.
  • cluster writes from several actors at once under --workers could be lost, or crash the run.
  • Programs with many background tasks that wait no longer stop after about ten million wake-ups with "a task is looping on plain yield". The check now fires only on a task that keeps yielding without ever waiting (including wait(0) loops), and the error points at that task's yield line with how it got there, rather than naming only the file.
  • Functions with a record-typed parameter (fn update(e: Npc)) no longer pay for the type check on every call: they run about as fast as untyped ones (about 3× faster interpreted and 4× with the JIT), and can be optimized into their caller's loop. This holds for fields of every type (bool, string, bytes, key, decimal and big included) and for records of any width: one with more than 16 fields no longer re-checks every field by name on each call (up to 100× faster). --jit=always checks them as fast as the default does.
  • Loops over { i32 }, { u32 }, { u16 }, { u8 } and { f32 } arrays run as fast as { f64 } and plain arrays: up to 40× faster with the JIT and about 2× without it.
  • Building a typed array with push ({ i32 }, { u32 }, { u16 }, { u8 }, { f32 }) is about 3× faster, and pushing numbers onto a plain array is faster too.
  • The JIT no longer allocates a small record that a loop builds and only reads back (let p = { x = i, y = i * 2 } and then p.x + p.y). Such loops run about 5× faster on Apple Silicon and 15× on x86-64, level with or ahead of LuaJIT, and no longer trigger garbage collections. --jit-stats reports sink-mints, how many of these records had to be built after all because the loop left optimized code while one was in use.
  • In a hot loop, reading an { f32 } element straight after writing it could return the old value.
  • On x86-64 (Linux and Windows), a hot loop the optimizing JIT leaves partway through, for example when several actors share the CPU, gets back into optimized code within a few iterations instead of a few thousand: two actors running the same numeric loop went from 240 ms to 27 ms. On every platform, the first such exit no longer costs a second warm-up.

#0.32.0 — 2026-10-04

#Breaking: the compiler checks Results

  • Breaking: using a Result where its value is needed is a compile error. Ok(…) and Error(…) are wrappers, and the checker now follows them: from Ok, Error and pcall, from every standard-library function that returns one, from your own functions that return one, and through a variable. Passing one to a function that wants the value (bytes.to_hex(plain), len(r)), using it with an operator (r + 1, r < 3), reading a field or calling a method on it (resp.status), looping over it, or giving it to a typed let, parameter, return or record field used to stop the program when that line ran, often only on the failure path. Now it is refused before anything runs, and the message shows the fixed line. To update: take the value out first: bytes.to_hex(plain?) inside a function, or match plain Ok(v) -> bytes.to_hex(v) Error(e) -> … end.

  • Breaking: putting a Result into text is a compile error. A template hole, .., format, a builder's add and sys.emit used to write Ok(…) or Error(…) into the text and carry on, so a failure could end up stored in a file or sent as an answer. print, log lines and tostring still show a Result as it is. To update: `stored: {r?}`, or match it first.

  • Breaking: a function that can return an Error must return Ok or Error on every path. That is a function that uses ?, or one with a return that gives a Result. In it:

    • a plain return 5 is a compile error: write return Ok(5);
    • return f()? is one too, because the ? takes the value out of a Result you could pass on as it is: write return f();
    • when the function doesn't use ?, it must not be able to run off its end or return with no value, because that gives nil: end it with return Ok(nil);
    • a -> int (or any type but Result or any) on a function that uses ? is refused: declare it -> Result.

    Changing what a function returns changes what its callers get, so the message names the callers, in the same file and in the package's other files, and they change in the same edit; the callers themselves are not checked against the function until it is fixed. A function literal that only passes the Result on (fn(req) return reply(req) end) has nothing to change. The common case is the "returns an Error, or nil when all is well" helper: it gives Ok(nil) instead, and each caller's if bad != nil then return bad end becomes helper(…)?. Change both, or neither: with only the helper changed, every != nil test treats success as a failure. Comparing a Result with nil is now a compile error for that reason; the message names matches Error and matches Ok. So is a test's assert_eq(helper(…), nil) (write assert_eq(helper(…), Ok(nil))), and comparing a Result with a plain value (get(1) == 1), which is always false.

    A function whose documented contract is a mixed result (a callback that may answer true, nil or an Error, a fake transport that answers a wrong value on purpose) keeps it when declared -> any, a function literal too (fn(x) -> any … end): nothing is refused and nothing is tested when it returns, and its callers get an unchecked value. Every message that says to wrap a return in Ok offers it last.

  • Breaking: a match with Ok(…) or Error(…) arms must cover both. An arm with a when guard, or with a literal inside (Error("timeout")), covers only what it says. When the checker knows the Ok holds a bool, as it does for fs.paths_alias and for a function of your own whose every Ok holds a comparison (return Ok(x > 5)), Ok(true) and Ok(false) together cover it. A bare Ok -> arm matched every value, an Error too, and is now refused. To update: add Error(e) -> … (or Ok(v) -> …), or a _ -> … arm; write Ok(_) -> for any Ok.

  • Breaking: a variable that holds a Result can't be given a plain value the checker can see. let z = crypto.random_bytes(32) followed by z = shared in an Ok(shared) arm, or by z = bytes.from_array(…), or let data = json.decode(s) followed by data = {}, used to stop the program on that line when it ran, and only on the path that reached it. It is now refused before anything runs, and the message shows the fix: take the value out where the variable is declared, let z = crypto.random_bytes(32)?, or give the new value its own name. A value the checker cannot type is still tested when it runs.

  • Breaking, rarely: more Result mistakes are refused before anything runs.

    • ? on a value that is never a Result (hash.sha256(x)?) is a compile error. It used to stop the program when it ran.
    • Passing a Result to a function whose parameter has no type is refused when the function uses that parameter as a value (reads a field, does arithmetic, puts it into text), in your file or in a module you import, however many calls down: the error is at your call. A function that tests the parameter first (type(p), matches, match, ?, ??), itself or in a helper, is left alone. A test that passes a Result on purpose declares it any: let wrapped: any = load(). assert(r) on a Result is refused like if r.
    • In a module (a file you import), ? and a return of a Result at the top level are compile errors: a module's top level gives its exports, never a Result.
    • Assigning to a field of an imported module (lib.check = fn(…) … end) is refused, as it already was for a built-in module.
    • actor(f, Ok(…)) is refused: an actor's state is never a Result.
    • A literal algorithm, curve or digest name a crypto function does not know is a compile error with the nearest name: crypto.verify(m, sig, pub, "ES265") says "did you mean "ES256"?".

#Breaking: Results at run time

  • Breaking: ? and return Error(e) at the top level of the script you run stop it like sys.fail(e): {"error": …} on stderr, exit status 1, and whatever was printed stays printed. A top-level return Error(e) used to exit 0, silently. Under scua test, a test file whose top level stops this way fails (the top level returned Error: …), its tests don't run, and the other files still do. A sandbox's load turn that stops this way gives the host Error({ kind = "failed", message, value, line }); it used to say only faulted and "failed to start", which lost the reason. To update: a script or CI job that relied on exit status 0 after a top-level return Error(e) should check for exit status 1, or match the value and return normally.

  • Breaking: ?? on a Result gives the value inside, or the default. json.decode(s) ?? {} now gives the decoded value, or {} when decoding failed. ?? gives the default whenever there is no value: on nil, on an Error, and on an Ok(nil). It used to replace only nil, so an Error and an Ok(…) came back unchanged. To update: if you relied on r ?? d keeping a Result, write r or match it.

  • Breaking: a function that uses ? and runs off its end gives Ok(nil). So does a bare return in it. It used to give nil, which is neither Ok nor Error, so a caller's match on it found no arm. A function declared -> any is left as it was. To update: a caller that looked for nil matches Ok(_) instead; to keep nil, declare the function -> any.

  • Breaking: _ binds nothing. _ = f() and let _ = f() both throw the value away on purpose, with no warning; for _, v in …, a _ parameter and a _ pattern bind nothing either, and any of them may repeat. Reading _ (print(_)) is a compile error. To update: give the value a name.

  • Breaking: the run time tests what the checker could not see. A function that returns Ok or Error and returns a value the checker could not type stops with a located fault if that value is a plain one; so does a variable that holds a Result when it is given a plain value. Before, the plain value went on to the caller, which usually failed further away. To update: wrap the value in Ok, or declare the function -> any if a mixed result is its contract.

  • Breaking: a Result that reaches text while the program runs stops it with a clear message. The checker refuses the Results it can see in a template, .., format, add and sys.emit; now the run time catches the ones it cannot see, such as a Result passed through a parameter with no type, or kept in an array or a table, at any depth. It covers every place a value becomes text that goes on as data: those five, str.join, render, to_csv and a frame's to_table. Where Ok(…) or Error(…) used to be written into the text and the program carried on, you now get this template got an Error (invalid JSON at line 1, column 2) where it needs text, at the line that built the text, as a fault you can catch. print, log lines and tostring still show a Result in full, and json.encode still writes it as {"kind": …, "value": …}. To update: take the value out with ? or match before it becomes text, or write tostring(r) where showing Ok(…) or Error(…) is the point. A test of the success path finds nearly every such place, because an Ok is refused as well as an Error.

  • Breaking: a module whose top level gives an Ok or an Error makes the import fault, naming the module. A module's value is its exports table; one that gave a Result used to become the importer's value, and failed at the first field read. To update: handle the Result inside the module with match, and end the module by returning its exports table.

  • Breaking: an actor's state is never a Result. An actor(f, state) function written with ? or return Ok(…) has its Ok's value taken as the new state, and an Error keeps the old state, as an on handler's does. A plain function that returns an Ok as the new state stops that turn with a message; actor(f, Ok(…)) is refused when it runs. To update: return the state itself, or write the function with ?.

  • Breaking, rarely: misuse and limit errors from another partition or a sandbox arrive as upstream_misuse and upstream_limit, as timeout already arrived as upstream_timeout, so a caller can tell its own mistake from one on the far side of an ask or a sandbox. To update: a caller that checks an ask reply or a sandbox's answer for kind == "misuse" or "limit" reads "upstream_misuse" or "upstream_limit".

  • Breaking: assigning to a field of an imported module (lib.check = …) is refused in a --!dynamic file too, where the checker, which already refused it, does not run. To update: keep your own value in a local instead; the module's exports are shared by every file that imports it.

  • Breaking: sys.fail writes an Ok in its reason without its contents. sys.fail({ last = r }) with r an Ok writes {"error":{"last":{"kind":"Ok"}}}: what fails a script goes to stderr and on to log services, and an Ok may hold a decrypted secret. An Error keeps its value. --debug-values, scua test and scua debug show the contents. To update: nothing, unless something reads the value back out of the failure; pass --debug-values there, or put the value itself in the reason.

#Breaking: crypto

  • Breaking: every crypto call returns Ok or Error, and none of them can stop your program. A token's algorithm name, a key from a key set, a nonce or an iteration count read from a file can no longer make a crypto call stop the script, the request or the actor turn that made it: each problem is an Error you handle, and crypto.verify answers false. Eight calls that returned their value directly now return Ok(value). To update, take the value out with ?, which works in a function and at the top level of a script (where an Error stops it with exit 1), or match the call:

    • crypto.random_bytes(n) → crypto.random_bytes(n)?
    • crypto.keypair(seed, alg?) → crypto.keypair(seed, alg)?
    • crypto.sign(message, secret, alg?) → crypto.sign(message, secret, alg)?
    • crypto.aes_gcm_encrypt(key, nonce, plaintext, aad?) → crypto.aes_gcm_encrypt(…)?
    • crypto.aes_cbc_hmac_encrypt(key, iv, plaintext, aad?) → crypto.aes_cbc_hmac_encrypt(…)?
    • crypto.aes_key_wrap(kek, key) → crypto.aes_key_wrap(kek, key)?
    • crypto.rsa_encrypt(data, public, alg) → crypto.rsa_encrypt(data, public, alg)?
    • hash.hmac(algorithm, key, data) → hash.hmac(algorithm, key, data)?

    A function that returned one of these values as it was, return crypto.sign(…), now returns Ok or Error: declare it -> Result and give its callers a ?, or take the value out inside it. Most places that still use the old value are refused before anything runs, with the fixed line shown; the rest get a warning (below). The calls that already returned a Result (aes_gcm_decrypt, aes_cbc_hmac_decrypt, aes_key_unwrap, rsa_decrypt, ecdh, check_key, aes_ctr and pbkdf2) now also return an Error where they used to stop on a bad key, an unknown algorithm or curve, or an argument that is not bytes. See the crypto reference and examples/crypto-results.scua. A package that must also run on scua 0.31, where these forms do not exist, wraps each call instead: see Supporting scua 0.31 and 0.32 in one package.

  • Breaking: a crypto Error holds { kind, message }. kind says whose problem it is: "rejected" for outside data, a key or an algorithm name that did not check out, "misuse" for a mistake in your program's own value (a count, the nonce you encrypt with), "limit" for pbkdf2's work limit, and "unavailable" for a host with no entropy. A key, a seed or an algorithm name is always "rejected", so a hostile input can never pass for a bug in your program, and misuse arriving through an ask is upstream_misuse. Under scua test and scua debug, a misuse also prints a warning naming the call. At the top level, ? on one writes {"error":{"kind":"misuse","message":"random_bytes: n must be 1 to 1024, got 0"}} to stderr. aes_gcm_decrypt, aes_cbc_hmac_decrypt, aes_key_unwrap, rsa_decrypt, ecdh, check_key, aes_ctr and pbkdf2 used to give a string. To update: where you show or log the reason, write {e.message} instead of {e}; branch on e.kind.

  • Breaking: a function can't mix its own Errors with crypto's passed on by ?. A function that returns Error({ kind = "bad_key", … }) on one path and crypto.sign(…)? on another used to hand its callers crypto's { kind, message } too, with crypto's kinds (rejected) where they expected yours. It is now refused before anything runs. To update: match the crypto call and return your own Error: Error(e) -> return Error({ kind = "bad_key", message = e.message }).

  • Breaking: hash.equal is false when either side is empty, or is not bytes or a string. An empty signature is never a valid one, and a missing header is nil, so a webhook check can no longer accept an empty X-Signature: against a secret that failed to load, and it never stops the program. hash.equal(b"", b"") used to be true. To update: nothing, unless you compared two empty values on purpose; compare those with ==.

#Breaking: embedding and saved sessions

  • Breaking, for embedders: a failure the script chose is SCUA_ERR_FAILED (15). scua_eval and scua_eval_file used to return SCUA_OK for sys.fail, sys.exit(n) with n not 0, and a top-level return Error, and sys.fail wrote its body to the host process's stderr. Now they return SCUA_ERR_FAILED with the reason (or exit N) in errbuf, __return holds a top-level Error's value, and nothing goes to the host's stderr. sys.exit(0) is still SCUA_OK. SCUA_ABI_VERSION is now 1. To update: treat SCUA_ERR_FAILED as the script reporting a failure, and read the reason from errbuf. See Embed in a host.

  • Breaking, for saved sessions: drain parked turns before upgrading. The changes above compile any function that uses _, ? or ?? to different bytecode, so its identity changes. A session parked inside such a function, waiting on I/O, is discarded when it is loaded by this release, as for any changed function, and whatever that turn had already changed stays changed. Most turns that park are in ? functions, so nearly all parked turns are affected. Let parked turns finish (or stop taking new work and wait) before you upgrade. The load report now logs at warn when it discards any.

#Breaking: other

  • Breaking, rarely: lines printed by different partitions can come out in a different order from run to run. Partitions now run on several cores by default (see below). Lines one partition prints keep their order, and messages from one sender still arrive in the order they were sent. To update: if you compare a program's output with a saved copy and it has more than one partition, add --workers=1 to that run (or set SCUA_WORKERS=1). scua test and scua debug already run on one worker.

#New and changed

  • Your partitions run on several cores by default. scua now spreads partitions across one thread per full-speed core, up to eight (--workers=auto), so a program whose partitions compute or wait on the network gets the speed-up without a flag. Each partition still handles one message at a time, messages from one sender still arrive in the order they were sent, and the lines one partition prints keep their order. Lines from different partitions can come out in a different order from run to run; when you need the same output every time, pass --workers=1 (or set SCUA_WORKERS=1), which scua test and scua debug always use. Programs with no partitions are unaffected. See Several cores by default; one core is repeatable.

  • Chatty partitions run as fast on several cores as on one. Partitions that pass many small messages — a request and its reply, a few pairs taking turns, a fan-out of quick jobs — now run at the default worker count within a few per cent of --workers=1, where they used to take up to several times as long. SCUA keeps a round of light work on one thread and hands messages over directly, and brings in the other cores once a round has enough work to share, which it measures as your program runs; CPU-heavy partitions keep their multi-core speed-up. Nothing changes in what a program may rely on: messages from one sender still arrive in the order they were sent.

  • Reading a whole file now takes about as much memory as the file. fs.read and fs.read_text read the file straight into the value they hand you, so loading a 50 MB file peaks at about 50 MB above what your script already uses, where it used to need about twice that. It holds for files of every size, for text and raw bytes, and inside an actor running with --io=async, where a large read used to cost three times the file. Large reads are a little faster too: a 200 MB fs.read takes 25 ms instead of 40. A file that changes size while it is being read still comes back exactly as it is at the end of the read, and { max_bytes = N } refuses an oversized file before reading any of it. Sending a large string or bytes value to another actor also no longer makes an extra temporary copy of it on the way.

  • A table you send costs the receiving partition only its own size. tell, ask and its reply, a new partition's starting state, and a sandbox's input and answer now build each table at its final size in one step. A 5,000-entry table sent to a partition used to take about 685 KB there until that partition next tidied its memory, which an idle one might never do; it now takes the table's 422 KB. Every new partition also starts about a third smaller (64 KB instead of 97 KB), and sending a large table, or creating many partitions, is 10–20% faster. A table also keeps its order when you send it: the receiver's for k, v in t visits the keys in the same order as the sender's.

  • fs.read and fs.read_text read files that do not report a size, such as those under /proc and /sys on Linux, all the way to the end. They used to come back empty.

  • Arrays arrive as they were sent. An array of enum values sent to another partition keeps its values: [Colour.Green] used to arrive as an unnamed value that no longer equalled Colour.Green. And a list of numbers such as [1, 2, 3] accepts a push("x") on the receiving side, as it does on the sending side, instead of stopping with a type error.

  • New warnings for a Result you probably meant to handle. They never stop the program.

    • A call whose Result nothing looks at (fs.write("out.txt", text) on its own line): a failure there went unnoticed.
    • A bare crypto.verify(…) or hash.equal(…), and a bare ask statement.
    • A Result stored in a named table field, or given to json.encode. A field meant to hold one takes it through a name declared any: let outcome: any = run(job).
    • A function using ? that returns Ok(value) on one path and can also run off its end.
    • if bad != nil then return bad end after let bad = check(x)?, where check gives Ok(nil): the guard can never return.

    To drop a Result on purpose, write let _ = f(); the warning goes.

    exec.close_stdin, exec.kill and http.serve now say in their signatures that they can return an Error (they always could), so a bare call to one is warned about too.

  • scua points out crypto code written before these calls returned Results. Where a crypto call's Result is passed on without its value being taken out (handed to another function, stored in a field or an array, wrapped in Ok(…), compared, sent, or returned from a helper that returned the value before), you get a warning naming the call and the fix: `crypto.random_bytes` returns Ok or Error, and this passes the Result on to `b64`, not the bytes. Take the bytes out first: `crypto.random_bytes(n)?`. It shows in your own files in full, and for a package you depend on as one line, @scope/name 1.2.3 was written before crypto calls returned Ok or Error, and 37 of its calls may fail when they run, even under --no-warnings: update that package. A helper that passes the Result on on purpose says so with -> Result. And a default given to a crypto Result with ??, such as hash.hmac(…) ?? b"", gets a warning: a made-up key, nonce, MAC or signature is never right.

  • Run-time type errors name a Result. parameter 'b' of 'store' is an Error (a Result), expected bytes replaces … is a value, expected bytes, so the message says what to do.

  • The checker now checks the code inside partition handlers. An on or ask handler's body gets the checks a function's body gets: a Result used where its value is needed, or put into a template, is a compile error there too, and a dropped Result warns. A handler keeps its own rules: ? and return Error(e) end the turn, a return is the reply, and the end of a handler needs no return. Inside a handler the suggested fix reads -- an Error ends this turn. None of the examples or of the 51 packages we checked needed a change.

  • --no-warnings keeps compile warnings out of your log. For a tool or an agent that reads every line of stderr: errors still print and still stop the run, and the warnings don't. A warning in a --!strict file still prints, since it stops the run, and so does the one-line notice that a package you depend on needs updating. It also quiets the warnings scua test and scua debug give while a program runs, and your editor keeps showing every warning. It asks for the opposite of --deny-warnings and --dep-warnings, so passing it with either is a usage error. See --no-warnings.

  • A project can turn warnings off for everyone who runs it. Put warnings = false under [run] in its scua.toml, or under [run.<profile>] for one profile, and scua, scua test and scua debug keep compile warnings off stderr there, with the same exceptions as --no-warnings. The command line wins without an error: --warnings shows them again, and --deny-warnings or --dep-warnings show and count them as usual, so a quiet project's CI can still refuse warnings. A script with no scua.toml is unchanged. The manifest now has its own reference page, scua.toml.

  • scua test prints each warning once. A call such as tonumber([1]) that warns before the run and again while the test runs was reported a second time, on the line after the call. The run-time warning now names the call's own line and is recognised as the same one.

  • A money value has .code() and .amount() methods. price.code() gives its currency code ("USD") and price.amount() its exact amount as a decimal, on any money value, including one read from a CSV column, with no import. They are money.code and money.amount, which still work. price.code() used to stop with "fields only exist on tables and records, not on money values". Without the parentheses, price.code is still an error, and it now says to write price.code(). See Handle money and examples/money.scua.

  • A field on other plain values says what to do instead. d.year on a datetime says to write .year(); a decimal says it is a number; and a field a vector or color does not have lists the components it does have, where it used to say only "type error".

  • A partition that faults over and over no longer holds every fault in memory. Each survived fault is written to stderr when it happens, and the run still exits with status 5 if there was one, but every fault was also kept until the program ended, along with its whole error(…) value. A server whose partitions a client could make fault grew for as long as it ran. Now only the first is kept, and the rest are counted. scua test and an embedding host still get the first fault, and the exit status is the same.

  • Fixed: a program with many partitions could hang, or compute a wrong result, on several cores. When several partitions compiled the same hot loop at the same moment, one of them could get wrong machine code for it. Usually that loop then never finished, and the program kept a core busy and printed nothing more; sometimes the loop stopped early and the program finished normally with a wrong number. It showed up on x86-64 machines, at six or more workers. Compiling a loop no longer depends on what the other cores are doing. If you added --jit=off or --workers=1 to get around a hang like this, you can take it out.

#0.31.0 — 2026-10-02

  • Breaking: crypto.aes_ctr returns Ok(bytes) or Error(reason). A key or counter block of the wrong length used to stop the program. Counter mode is one call for encrypting and decrypting, and when you decrypt a file the file chooses both lengths (an encrypted zip's header picks the key size), so a damaged or hostile file could stop the program reading it. Now it gets an Error you can handle. A key or counter that isn't bytes at all still faults. To update: inside a function, add ? (let plain = crypto.aes_ctr(key, counter, data)?), or match the result. See examples/counter-mode.scua.

  • Breaking: an ask that times out now gives Error({ kind = "timeout", message = "ask timeout" }), and only a timeout can. It used to be Error("ask timeout"), which a handler could also return, by mistake or by passing on the result of its own ask, and you could not tell that from your own request expiring. A handler's reply still reaches you exactly as it returned it, except that an Error whose payload says kind = "timeout" arrives as kind = "upstream_timeout", so e.kind == "timeout" always means this ask's own deadline passed. match … Error(e) still matches a timeout. To update: code that compared the payload to the text "ask timeout", or printed it, should read e.kind (or e.message) instead. See The timeout form.

  • Breaking, rarely: an actor(fn, state) function that returns an Error keeps its old state. The Error used to become the actor's new state. Now the turn ends there, the state stays as it was, and the Error is written to stderr (see "A handler that ends its turn with an Error" below). To update: if the Error was meant to be the new state, wrap it: return { last = Error(e) }.

  • Breaking: a save frozen while an actor held an Error as its state may refuse to load. A save written by an earlier build while an actor held such an Error as its state can't be revived; it now says so plainly: cannot load this save: it holds an `Error` where the state of partition `Store` belongs (it used to say "the state of partition (none)"). Such an actor kept failing on every message anyway. To update: revive from an earlier save, or start the actor fresh. actors.freeze now refuses to write a save like that, with Error({ kind = "bad_state", … }), rather than handing back bytes no revive could load.

  • Breaking: fault messages name an Ok without showing what it holds. match: no arm matched (got an Ok), a condition got an Ok, assert_eq failed: expected an Ok, got an Ok, and Ok(…) inside a table or list, so a fault log can go to a logging service without carrying what a successful call returned, such as a decrypted value. An Error keeps its message (got Error(bad input)), and other values are shown as before. print, .. and templates are unchanged, and scua test, the debugger and --debug-values show the full value. To update: a log filter that read a value out of a fault message should look for got an Ok. Under scua test, scua debug and the debugger, a failing assert_eq still shows both values; anywhere else it says expected an Ok, got an Ok, so pass --debug-values to see them.

  • Breaking, rarely: calling a function a built-in module does not have is now a compile error. import str then str.len("abc") used to compile and then stop at run time with "a binding named len replaces the built-in", although no such binding existed. It is now refused before anything runs, the way a module of your own already was, and the message says what to write instead: "str has no function len — len is a built-in, so call it directly: len(…)". A misspelling suggests the nearest name (str.uper → str.upper), and a function that lives in another module names it (crypto.sha256 → hash.sha256). To update: a reference to a missing member on a path that never runs, which used to be harmless, now stops the build; delete it, or test sys.version() if it was a feature check. When the call is made through a value the compiler cannot follow, the run-time message now names the field that is missing instead of blaming a binding.

  • Breaking, rarely: a Result used as a condition is caught before running across modules too. if pins.check_pin("0000") then, where pins is a module whose check_pin always returns Ok or Error, is now a compile error, as the same line already was when check_pin was in your own file. It used to be caught only when the line ran. To update: such a line on a path that never ran used to be harmless and now stops the build; test it with pins.check_pin("0000") matches Ok, or match the result.

  • Breaking, rarely: json.decode says where the input went wrong. Its Error used to be the bare text invalid JSON. It now gives the line and column, as in invalid JSON at line 3, column 14, and an object that repeats a key, such as {"a":1,"a":2}, is reported as that rather than as malformed. Such an object is still refused. The message still begins invalid JSON. To update: code that compared the Error to the exact text invalid JSON should test str.starts_with(e, "invalid JSON").

  • Breaking, rarely: --max-ops counts every call and every loop iteration, as one total. Without the JIT, a budget used to bound only the larger of the two counts for most of a run's last few thousand steps, so a function's calls could hide behind its loop: a run finished under a budget smaller than the steps it took, and two code paths that differed only in their calls measured the same. The interpreter and the JIT now agree to the step, and a run that takes S steps finishes under --max-ops=S+1. To update: a script that finished just under a tight budget before may now reach it; raise the budget by a few thousand. The help and the CLI reference now say exactly what a step is, and that the budget is a safety limit, not a measure of cost.

  • Every compile error is reported at once. scua, scua test and scua debug list every type error the checker finds, in file and line order, followed by its warnings, so you can fix a file in one pass. Past 50 lines, one more line says how many there were in all.

  • --deny-warnings makes a compile warning stop the run. For CI and agent harnesses: if any file of your own has a compile warning, every diagnostic is printed and scua exits with status 3 before anything runs (scua: nothing ran: --deny-warnings makes a warning fail the run). Warnings given while a program runs never stop it. scua test --deny-warnings fails a test file that warns.

  • --!strict gives one file that rule. A compile warning in a file whose first line is --!strict stops the run, whatever the command line says, and the editor shows it as an error.

  • Warnings from the packages you depend on stay out of your way. A module from your lock (deps/) or from a package in an application bundle has its compile warnings kept quiet, since you can't fix them except by upgrading, and they never stop your run under --deny-warnings or the package's own --!strict. --dep-warnings shows them, and --dep-warnings --deny-warnings counts them, for a deploy gate that refuses a dependency that warns.

  • --debug-values shows full values in fault messages. When you need to see what an Ok held to understand a fault and can't run the debugger, pass --debug-values: match: no arm matched (got Ok(hunter2)), the assert_eq and assert_ne texts, a condition given a Result, a typed parameter's fault value, and the line for a handler turn that ended with an Error all show the value in full. It works from the command line only (never from scua.toml, a profile, an environment variable or a bundle), and it says so on stderr before anything runs. scua test and the debugger show full values without it.

  • A handler that ends its turn with an Error keeps the actor's state, and the actor goes on working. ? in a partition's on handler, or in the function an actor(fn, state) or a sandbox.actor runs, used to store the Error as the actor's state, so one bad message (a malformed JSON body, say) made every later message to that actor fail. Now the turn ends there, the state stays as it was, and the actor takes its next message normally. Nobody is waiting for an on handler's answer, so the Error is written to stderr when it happens, the way a 500 from http.serve is, without changing the exit status: scua: app.scua:7: partition Store, message Put: the turn ended with Error(invalid JSON at line 1, column 2); state kept. A written-out return Error(e) in an on handler does the same; it used to be dropped without a word. In an ask handler the Error is the reply: the caller gets it, instead of waiting out a timeout, and nothing is logged. As with a fault, what the handler changed before the ? stays changed, so put the ? first. See "When a handler returns an Error" in the partitions guide. An actor(fn, state) function that returned an Error as its new state on purpose is covered in the breaking entries above.

  • A fault in a spawned task stops that task and nothing else. The other tasks, your partitions and a running http.serve carry on. It used to end the whole program, so a background job that hit one bad input took a server down with it. The fault is reported on stderr when it happens, and the program still exits with the fault status (5) when it finishes. An embedding host still gets the first such fault back from the call that ran the script, now once the other tasks have finished. See When a spawned task faults.

  • Faults a running program survives are reported when they happen. An http.serve handler that faults, or answers 500 by returning an Error, writes one line to stderr naming the request (scua: app.scua:12: http.serve GET /orders/7 answered 500: …); the client still gets a bare 500 unless you pass --serve-debug. A partition whose handler faults is reported as it happens, with the partition's name, rather than when the program exits, and each fault is reported once. To keep a flood of bad requests from filling a disk, at most 20 reports are written at once and then one a second, with a count of any that were held back. See Faults a running program survives.

  • Documented: a handler that faults keeps what it already changed. A turn is not rolled back, so state written before the fault stays written. See When a handler faults.

  • Returned Errors and faults are rate-limited separately on stderr. Each kind gets up to 20 lines at once, then one a second, and its own count of what it held back (scua: 280 more reports of returned Errors were not written …), so a flood of bad messages that each end a turn with an Error can't push a real fault out of the log.

  • AES-192. Every AES call takes a 24-byte key as well as a 16- or 32-byte one: crypto.aes_ctr, crypto.aes_gcm_encrypt/aes_gcm_decrypt, and crypto.aes_key_wrap/unwrap (A192KW). crypto.aes_cbc_hmac_encrypt/decrypt take a 48-byte key for A192CBC-HS384. Encrypted zip files can use AES-192 and JWE names all three.

  • crypto.pbkdf2(digest, password, salt, iterations, length) derives a key from a password (PBKDF2 over HMAC-SHA1, SHA-256, SHA-384 or SHA-512), as encrypted zip, PDF and password stores need. Written in a script it cost about 22 steps per HMAC, so a large encrypted zip ran out a --max-ops budget. It now counts one step per HMAC, and a budget still stops a huge iteration count, before any work is done. It returns Ok(key). PBKDF2 runs the whole count once for every digest-sized block of the key, and by default it runs up to 10,000,000 iterations in all (2,500,000 for SHA-384 and SHA-512, whose iterations cost about four times as much): about a second at most, and comfortably more than formats use. A count read from stored data that is over that, or below 1, comes back as an Error you can handle, and the message says how to raise the limit when the data is yours: pass { max_iterations = n } as a sixth argument. It runs about twice as fast as a textbook PBKDF2, because it prepares the HMAC key once per call rather than once per iteration. See examples/password-key.scua.

  • crypto.aes_gcm_decrypt and crypto.aes_cbc_hmac_decrypt return an Error for a nonce or iv of the wrong length instead of stopping the program. When you decrypt, the nonce or iv usually comes with the data, such as a JWE token's iv, so a bad one is bad input, like a damaged ciphertext. A key of the wrong length, and a wrong nonce or iv when encrypting, still stop with a fault.

  • A project can name the oldest scua it runs on. Put scua-version = "0.31" under [package] in scua.toml, and a scua older than that tells you to upgrade before it reads any of your code, instead of running it with older rules: scua: this project needs scua 0.31 or later (scua-version in its scua.toml); this is 0.30.2. Upgrade scua. The requirements of the packages you use reach your lock through scua-pkg 0.3.20, so a package that needs a newer scua is named, with the choice to pin it instead. Running a file, scua debug, scua test and an application bundle all check it and exit with status 3; an embedding host's scua_use_project returns E-SCUA-TOO-OLD. A script with no scua.toml is never checked. See Requiring a minimum scua.

  • scua tool.scuapp runs an application bundle. A bundle is a whole tool in one file, written by scua-pkg bundle --app main.scua: its own .scua files, the packages its lock resolved, the lock, and a digest for each. scua now runs one directly, so a machine running your tool needs only scua, not scua-pkg and not a deps/ folder. Before anything compiles, the whole bundle is checked: first one SHA-256 over the entire file, then every file against its digest and its package's release record, every release record against its root, the lock against the packages it carries, and every path. A bundle that fails is refused with an E-APP-FORMAT or E-APP-CORRUPT code and nothing runs. Imports resolve exactly as the same files did on disk. The bundle grants nothing: capabilities come from your command line, and --allow-fs=. means the folder you run it from. When grants are missing, the message names every one the bundle says it needs, and the flags to pass. --mod-path, --mod-override, --profile and scua debug are refused with a bundle. scua reads bundle format 3, which needs a scua-pkg newer than 0.3.19: a bundle built with scua-pkg 0.3.19 (format 2) is refused, and must be rebuilt with scua-pkg bundle --app. Try scua --allow-fs=. examples/tally.scuapp README.md, and see Ship a tool as one file.

  • Embedders: scua_eval_file and scua_load_script_file given an application bundle now return SCUA_ERR_FORMAT with a message saying what it is, instead of a syntax error about byte 0x02.

  • http.serve accepts Ok(response) as the response. A handler that uses ? can end with return Ok({ status = 200, body = … }), and a proxy can return what http.request gave it. It used to answer those with a 500. A returned Error is still a 500, and its log line now says what happened: http.serve GET /orders/9 answered 500: the handler returned Error(no such order).

  • The compiler can now warn without stopping you, and its first warning is tonumber given a table, array, record or bool, which always gives nil: scua: app.scua:12: warning: `tonumber` is given a table …. A warning prints on stderr and the program still runs (and scua test still passes); editors show it as a warning. tonumber itself is unchanged and never stops a running program. Under scua test, scua debug and the VS Code/Zed debugger, a call that gets one of those while running says so too, once per call site, when the compiler couldn't see it coming.

  • A long-running program no longer grows with work it has already finished. Two causes, both fixed. Every actor stopped with actors.drop or sandbox.stop used to keep about 2 KB until the program exited, so a server that hibernates a session per request (actors.freeze, actors.drop, actors.revive) grew by that much per request. It now keeps a few bytes. And on Linux, on a machine with more than two CPUs, a program running on one worker (the default) could grow for as long as it kept allocating and freeing memory of different sizes, because freed memory was set aside where the program would not look for it again. On a four-CPU machine a freeze/drop/revive loop went from 13 MB to 69 MB over 12,800 rounds; it now stays at 13 MB. --mem-stats now counts only running actors in partitions and all-live.

  • A string or a template inside a backtick {…} hole is read as one piece. scua fmt (and format-on-save in your editor) refused `{b64(bytes.from_string("{"))}..AA` as "a block or bracket is never closed", because it counted the brace inside "{" as the template's. The compiler had smaller gaps of the same kind: `{f("\"")}` (an escaped quote in a string in a hole), `{`a:b`}` (a colon in a nested template, read as a format spec), and a } inside a nested template or a --[[ … ]] comment in a hole were all compile errors or the wrong string. All of them now work, and error lines after a template whose hole spans several lines are numbered correctly.

  • Fixed: a function of your own can take the name of a prelude function again. In 0.30.0, a top-level fn with, fn filter or any other name the standard prelude also defines stopped with "with is declared twice (first at line 22)", pointing at a line of the prelude as if it were in your file. Your function replaces the prelude's, as it did before 0.30.0; declaring the same name twice in your own file is still an error.

  • Fixed: scua fmt formats a file that uses on, ask, select, migrate, contract or partition as an ordinary name. A line such as on["a"] = true or ask.x = 1, or a statement that sends with ask p.V() timeout 1s and ignores the reply, was taken for the start of a handler, so scua fmt refused a file that runs, saying a block was never closed. These words now open a block only where they declare one.

  • Fixed: if ask p.Ready() timeout 1s then compiles in an ordinary function. The compiler assumed every ask replies Ok or Error and refused it, though the same line ran inside a handler. A reply is the handler's own value, so a true/false reply is a normal condition.

  • Fixed: an ask, wait_for or select inside a map_all or wait_all arm now gives its line. In a partition's handler, such an arm's slot held an Error with line = 0. The arm is still refused, since an arm may only pause for wait or I/O, but the Error now carries the line of the ask and says what to do instead: ask from the handler itself, before or after the map_all/wait_all. Its kind is now user, as for an ask made outside a handler.

  • Fixed: scua test always prints the FAIL line for a failed test. A failure whose message was longer than about 1 KiB printed nothing at all, while the summary still counted it, so you saw "0 passed, 1 failed" and no test name. A message that spanned several lines showed only its first line. Now every failure names its test and shows its message, with later lines indented, and a message over 8 KiB is cut with … [message cut: N bytes in all] at the end.

  • Assigning nil to a binding that started with a value now says how to allow it. A binding takes the type of its first value, so let n = 5 then n = nil is refused. The error now adds the annotation that allows it: "annotate it int?", or, for a table written as a literal, "annotate it any, or declare a record for its shape and annotate it Name?".

  • Enum.from_int(n) on a number that names no variant now points at try_from_int. The fault reads "from_int: 7 names no variant of Color — when a number may be unknown, Color.try_from_int(n) gives nil instead", so the failable form is one read away when the number comes from outside.

  • Editor highlighting reads strings the way the compiler does. In Zed and VS Code, every escape the compiler accepts now highlights as one (\e, \', \`, \{, \}, \u{…} and a backslash at the end of a line), and an escaped quote or backtick no longer ends the string there. In a backtick string, {{ and }} show as literal braces, a lone } is text, and a {…} hole ends at its own closing brace, not at the first } inside a table, a string or a nested backtick string within it. A format spec ({x:.2f}) is marked as one. Outside strings, Zed's highlighting no longer goes wrong for the rest of a file after a variable named b, tell, ask or handle used as an ordinary name, a capitalised table key or parameter name, or a float written with only an exponent (5e-324).

  • Zed highlights a match whose arms mix calls and return. A match with a first arm like Ok(conn) -> serve(conn) and a later Error(e) -> return e lost its highlighting from the return on. The code samples on scua.dev now also highlight backtick strings as the compiler reads them: {{ and }} are literal braces, a "}" inside a {…} hole no longer ends it, and escapes are marked.

  • Documented: calling a function the compiler can't see (a parameter, or a value typed any) drops extra arguments and makes missing ones nil, as it always has; as(value, Record) leaves behind keys the record doesn't declare, on purpose, so newer data still loads; and json.decode refuses a repeated key because two readers can disagree on what it means.

  • The reference text for sys.fail and sys.emit says what they do with earlier output. It said earlier print output was discarded. It isn't: it stays on stdout, ahead of the result or the diagnostic, or goes to stderr under --output=json.

  • The exit-status table in the CLI reference says what 1 and 3 cover. 1 is any failure your script chose to report with sys.fail or sys.exit(1), a clean "not found" being the usual one, and sys.fail's {"error": …} says which. 3 means nothing ran: the script didn't compile, a compile warning stopped it under --deny-warnings or --!strict, or the project needs a newer scua.

#0.30.0 — 2026-09-28

  • Breaking: a condition refuses a Result or a flags set. if verify(token) then, where verify returns Ok or Error, was always true, so it let every token through; assert(check(x)) always passed the same way. It now stops with an error that shows the fix: handle both cases with match, or test with r matches Ok / r matches Error. When the checker can see that a function always returns Ok or Error, the error comes before the program runs. A flags set is refused the same way (if perms & Perm.Write was always true, even for an empty set): write Perm.Write in perms, or perms & (Perm.Read | Perm.Write) != Perm.none for "any of these". This covers if, elseif, while, if-expressions, not, the left side of and/or, match guards, where refinements, contract clauses, assert, render's @when and debugger breakpoint conditions (a refused breakpoint condition doesn't pause and reports "condition error" in the debug console). Everything else, including 0, "", empty collections and enum variants, behaves as before. For a default when a value is missing, ?? is the operator to use. Embedders: scua_to_bool and scua_arg_bool keep reporting plain truthiness.

  • Breaking: Ok and Error are reserved. A contract, enum variant, flags member, record, type alias, interface, partition handler or message named Ok or Error is now a compile error, such as "Error is reserved for results — rename this record". Rename it (ParseError, Accepted).

  • Breaking: boolean options must be true or false. An option a built-in reads as a switch used to accept any value, so json.encode(x, { pretty = "false" }) pretty-printed, sort_by(f, "n", "true") sorted ascending, and base64.encode(s, { url_safe = 1 }) used the standard alphabet. This covers:

    • json.encode's pretty;
    • the switches of fs.find and fs.grep (recursive, gitignore, ignore_case, word, regex, only_matching, files_with_matches, count_only, matches, invert);
    • sort_by's descending;
    • base64.encode's url_safe and pad;
    • crypto.aes_ctr's little_endian.

    Anything else is now an error that names the option: "option pretty of json.encode must be true or false, got a string". Leaving an option out still uses its default.

  • Breaking: a function name can be declared twice only as methods on different record types. A second top-level fn with the same name used to replace the first without a word, so with fn describe(l: Length) and fn describe(s: string) both declared, describe(len) ran the string version and failed on its parameter. Next to two record methods of that name, the compiler crashed instead. Both are now a compile error at the second declaration, such as "describe is already a method on Length (line 2), so it can't also be a plain function". Rename one of the two. fn area(c: Circle) beside fn area(r: Rect) works as before.

  • Breaking: a declared return type is now checked while the program runs. fn f(v) -> bool used to be checked only where the checker could follow the value, so something that reached the return through untyped code (a decoded JSON field, a callback) left the function as whatever it was. Now the return stops with a clear error, such as "return value of 'f' is string, expected bool". The rules are the ones parameters got in 0.29.0: an int satisfies float or number, T? accepts nil, and each value of a -> (int, string) return is checked. Falling off the end of a function declared -> int returns nil and now stops there too; add the missing return, or declare -> int?. An enum or record return type gets the check a parameter of that type gets.

  • Breaking (embedders): a module registered with scua_register_module is read-only to scripts. It is now protected like a built-in module, so gfx.draw_rectangle = nil stops with an error, in the main program and in every actor. It used to be an ordinary table that any script could change for all the others. A host that wants scripts to have a writable table should pass a plain table as a value instead.

  • Breaking: only as(value, Record) turns data into a record. A typed let, parameter, return or field now only checks a value that already exists: it no longer fills a missing defaulted field or runs a migrate hook, and the value keeps whatever type it had. Loading old or decoded data is spelled let hero = as(save, Player), which always gives you a new Player: fields copied, missing defaults filled, migrate run for old data, at every depth, then checked. A literal where the type is written (let p: Point = { x = 1, y = 2 }, a return from -> Point, a Point field) still builds a record, as before. Fix: where you loaded saves or decoded JSON through a typed let, add as(...); without it, a defaulted field the data lacks reads nil.

  • Breaking: a literal passed to a typed parameter is checked, not built. With fn heal(p: Player), the call heal({ name = "Ed", hp = 10 }) used to fill Player's defaults; now the literal is only checked, so a defaulted field it leaves out reads nil inside heal and the value has no Player methods. Fix: convert at the call, heal(as({ name = "Ed", hp = 10 }, Player)).

  • Breaking: a received record is sealed. Adding a field to a record you received in a message now stops with "cannot add field seen to Take: a record has only the fields its declaration lists", as it always did for a record you built yourself (it used to arrive as a plain table). Received records with more than 16 fields also iterate, and json.encode, in declared order rather than hash order. Fix: copy the fields you want into a table of your own, or declare the field.

  • Breaking: a record inside a function or a block is an error. It used to be silently ignored, so its name did not exist. It now stops with "records are declared at the top level of a file". Fix: move the declaration to the top level.

  • Breaking: a revive checks each record type the actor holds, by name. A saved actor is refused only when a record type it actually holds changed or was renamed, and the refusal names it and both field lists: "record Player changed its fields since this save: saved {…}, now {…}". Adding, removing or reordering record types the actor does not hold no longer refuses the save, and reordering declarations or overloads no longer breaks the methods of a revived record. Adding a field (even with a default), removing, reordering or retyping one, or tightening a where rule, does refuse it; renaming a record or moving it to another module does too. For a partition's own state, a new field with a constant initial value is filled on revive and reordering is fine; removing a field, or adding one whose initial value is computed, is refused, naming the partition. The error kind for these is now identity_refused, not schema_mismatch: code that branches on e.kind == "schema_mismatch" after actors.revive must accept identity_refused too.

  • Breaking: saved actors move to a new format, one way. An actor frozen by 0.28 or 0.29 is refused with the kind old_format, naming both formats, and nothing this version freezes revives on 0.29. Bytes that are cut short, fail their integrity check, or are not an actor at all now have their own kinds (truncated, corrupt, bad_blob), and a blob from a later release is newer_format. actors.generation still reads a 0.28 or 0.29 blob's generation.

    Upgrading with saved actors. There is no reader for the old format and no way back, so move frozen actors across the upgrade as data. A server that can finish or wake its actors before upgrading needs none of this.

    1. On the old release, add an ask Export() return { … the state fields … } end handler to each partition you have saved, and declare that partition first in the file (0.29 can revive an edited file into the first partition it declares). Revive each blob, ask it for its state, and store the answer with durable.encode.
    2. Keep the old binary and the old blobs until step 3 has worked.
    3. After upgrading, create a fresh actor and hand it the decoded state, converted with as: tell fresh.Import(as(durable.decode(bytes)?, ShopState)).
  • Breaking: a saved session records its built-in functions by name. A saved session (scua_persist and scua_load) stored each built-in function it held as a position in the standard library's list, so after an upgrade that added functions, a session that had kept hex.encode resumed calling a different function, with no error. Saves now record built-ins by name and re-bind them on load, for sessions and saved actors alike, so a newer SCUA that adds functions no longer refuses or misreads an older save. Saves written by 0.29 or earlier cannot be resumed by this version: they are refused on load, since nothing in them says what their built-ins were. Finish or export a session under the version that saved it before you upgrade.

  • Breaking: insecure_skip_verify needs a real true. net.dial_tls and net.start_tls read it as "anything but nil or false", so passing an Error, a string or a number asked to skip certificate checks. Anything other than true or false is now an error that names what it got. Skipping verification still also needs --allow-net-insecure=HOST.

  • Breaking (embedders): a saved session keeps its records' types. A record in a partition saved with scua_persist comes back as the same record type, with its methods, when scua_load_script loads a program over it. It used to come back as a record with no type, so an overloaded method on it failed. Breaking: if the program changed a record type the save holds, scua_load_script now returns SCUA_ERR_FORMAT and its error text names the record and both field lists, where it used to load and fail later. Fix: load the session with the program that saved it and move the data across with as.

  • Breaking: records keep their type in a shared cluster table. A record stored with cluster.set is read back, on every node, as the same record type with its methods; it used to come back as a plain table. Breaking: a node whose program declares that record with different fields, or not at all, now refuses the value: cluster.get stops with an error naming the record and both field lists, and cluster.watch, cluster.subtree and cluster.history give Error({ kind = "identity_refused" }) in its place. Fix: during a rolling deploy that changes a stored record, write the new shape under a new key until every node is upgraded. Upgrading from 0.29 or earlier: an older node can't read any record written by this release and treats it as corrupt, so upgrade every node at the same time, or store plain tables rather than records until the last node is upgraded.

  • Partitions that pass messages back and forth no longer slow down when you ask for more cores. With --workers=N, a round of work used to wake every thread you asked for, even when only one partition had anything to do. Two partitions passing 200,000 messages between them took 0.12 s on one core, 0.4 to 0.8 s on four and 30 to 50 s on fourteen (on a 14-core Mac). Each round now wakes only as many threads as it has partitions with work, so the same program takes 0.15 s on four or fourteen, and work that does spread out (a partition per user, each with real work) is as fast as before.

  • --workers=auto picks the thread count for you: one per full-speed core, at most 8, leaving out Apple's efficiency cores and staying inside a Linux container's CPU limit. SCUA_WORKERS sets the same thing from the environment (a number or auto), for a run you cannot add a flag to; the flag wins when both are given. The default is still one core.

  • x matches C is always true or false. It used to hand back its last clause's value as it was, so with contract Held(x) x end, 5 matches Held gave 5 and nil matches Held gave nil, and a clause that produced a Result let the Result through. The last clause is now tested like the others. Code that used the value of matches as anything but a yes or no should use the clause's expression directly.

  • x matches Ok and x matches Error test a Result. They give a plain boolean, false for anything that isn't a Result, so if http.get(url) matches Ok then reads the way it means. Any other name after matches is a contract, as before.

  • Under --io=async, net.read and net.write let other work run while they wait. A read that has nothing to read yet, or a write the peer is not taking yet, parks the task, handler or wait_all arm that made it, and the rest of the program carries on. Four queries on four pooled connections now take about one round trip instead of four. Calls on one connection in the same direction still complete in the order they were made, so two readers of one connection get exactly the bytes they got before. A limit set with net.set_timeout is measured in real time, even under --fast. Reads and writes on a TLS connection park too; the TLS handshake itself still runs in place, and so does anything inside an http.serve handler. See examples/net_overlap.scua.

  • Under --io=async, net.dial and net.dial_tls park while they connect. Ten dials that each take a second to connect now take about a second together. A host name is looked up on one of two resolver threads the whole program shares, one lookup at a time per partition so a partition whose names never resolve cannot hold both; when 64 lookups are already waiting, a dial returns Error("net.dial: the resolver is busy") at once instead of queueing without end. A dial to an IP address needs no lookup.

  • Under --io=async, the top level of a script and the tasks it starts with spawn park on I/O too. Until now only a partition's handler could: three spawned tasks that each made a 0.3 s request took 0.9 s with or without the flag, and a top-level wait_all ran its arms one after another. Both now overlap (about 0.3 s). fs, http, exec and net calls are all covered. A task spawned inside a handler still waits in place. --io=blocking, the default, is unchanged.

  • Under --io=async, lines printed by tasks running at the same time can come out in a different order than without the flag, because each task resumes when its own I/O finishes. The values the program computes are the same. If a test compares printed output, collect results with wait_all and print them in one place, or run it without the flag.

  • Three new limits for parked net calls, each with a flag: --net-max-parked=N (calls one partition may have waiting, default 1024), --net-max-queued-write=BYTES (unsent bytes parked writes may hold on one connection, default 16 MiB) and --net-max-registrations=N (sockets watched at once, default 16384). Going past one returns an Error that names its flag. A parked write's copy of its data counts toward --max-mem. --mem-stats prints a [net] line with the high-water mark of each.

  • You can see what a program is waiting on. Under --io=async, net calls still waiting when the program exits, faults or is stopped with Ctrl-C or SIGTERM are named on stderr, like scua: 1 net call still waiting at exit (net.read 127.0.0.1:5432 …). A call that has waited 30 seconds says so at info level (on by default), and --log=debug shows each park, resume and queue.

  • A write that stops partway now blocks the writes behind it. Under --io=async, when a net.write times out after sending part of its data, or its task is stopped while it waits, every later write on that connection returns Error("net.write: this connection's writes are broken after a write stopped partway — close it") instead of adding its bytes to the middle of a half-sent message. Reads still work, so you can still read the server's error reply.

  • An actor waiting on I/O while a server runs resumes at once. Under --io=async with a live http.serve, a finished file read or request could sit for up to 50 ms before its actor or task resumed. It now resumes as soon as the call finishes.

  • --mem-stats and --jit-stats now print when an actor faults. That exit skipped them, so the telemetry you asked for was missing from exactly the runs you would want to look at.

  • For embedders: net.accept parks only on a platform that declares the new net_stream capability. It used to park on any async platform, and a C host received a request it had no way to complete with a connection. Without the capability it now waits in place, like every other net call. A capability call made from a host callback no longer parks under an async platform either; it waits in place, where before it stopped with "can't suspend inside a host callback".

  • ffi.call passes NaN and infinity to f64 and f32 parameters. They were refused with "expected f64, got float", so a C function that takes infinity, such as sqlite3_bind_double, could not be given one. Declare a parameter { type = "f64", finite = true } when the C side must not see them, and they are refused before the call. Needs a build with FFI enabled.

  • fs.read_line ends a line at a lone \r too, the same breaks str.lines splits on. A file that uses \r alone for line endings (old Mac files, some exporters) used to come back as one long line, with the \rs inside it. \n and \r\n read as before.

  • hash.equal(a, b) checks a signature safely. It compares two signatures or tokens in constant time. Comparing with == stops at the first byte that differs, and an attacker who can time your responses can use that to build a valid webhook or JWT signature one byte at a time. Use hash.equal(hash.hmac("sha256", secret, body), received) to verify one.

  • A variant with no payload prints as its name. In an enum where some variants carry a payload, like enum Event { Quit, KeyPress(int) }, print(Event.Quit) showed Event.Quit(nil). It now shows Event.Quit, the way it is written. Anything that compared against the printed text sees the shorter form.

  • A syntax error in an imported module is reported where it is. It used to say app.scua:1: module 'lib' has a syntax error, pointing at the import line. It now names the module's own file and line and what is wrong, exactly as an error in the file you ran does: lib.scua:5: syntax error: expected an expression (near 'return').

  • let args: Args = sys.parse_args(Args) works. Writing the type on the binding made parse_args report a supplied option as missing ("value is missing required field 'path'"), while the same line without : Args worked. Both spellings now give the same record.

  • "attempt to call a non-function" says what you called. It now names the variable or field and what it held, such as "g is nil". When a top-level binding has replaced a built-in, it says so: a file with let len = 0 got the bare message from every len(x) in it, and now gets "len is int here — a binding named len replaces the built-in, so rename it".

  • scua test says how to test code that needs a capability. A test that reached a module importing fs, net or http was told to "grant it with --allow-fs", a flag scua test refuses. It now says that tests get no capabilities, and to pass the capability in as a parameter so the test can hand over a stand-in, the pattern in the guide to testing.

  • A regex search follows the same CPU budget rule as the rest of your script. Under a --max-ops you set, a search that runs the budget out now stops the run like any other step: the same message, which tells you how to raise it, and the same exit code, and try cannot catch it. It used to be a catchable error with its own exit code. Without --max-ops, a very long search is no longer stopped at all, matching everything else since the built-in limit stopped killing scripts.

  • RSA and ECDSA signatures, and AES encryption. crypto.sign, crypto.verify and crypto.keypair take an algorithm name as an optional last argument: "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256" or "ES384", the names JWT uses. So a script can check a token signed by Google, Auth0, Okta or Microsoft Entra, and issue its own. Keys are the fields of a JWK decoded to bytes, such as { n = …, e = … } for an RSA public key. Leaving the name off still means Ed25519. crypto.verify answers false, never an error, for a malformed key or signature, and crypto.check_key says why a key would be refused. crypto.aes_gcm_encrypt and crypto.aes_gcm_decrypt encrypt and authenticate data, and crypto.aes_ctr covers formats that use counter mode, such as encrypted zip files. For encrypted JWTs there are also crypto.rsa_encrypt / crypto.rsa_decrypt (RSA-OAEP), crypto.aes_key_wrap / crypto.aes_key_unwrap and crypto.aes_cbc_hmac_encrypt / crypto.aes_cbc_hmac_decrypt and crypto.ecdh (P-256, P-384 and X25519 key agreement). RSA keys may be any size from 2048 to 4096 bits.

  • Building callbacks in a loop is fast again with the JIT. A loop that creates a function each time round, such as registering event handlers or building map/filter callbacks that capture a value, used to drop out of compiled code twice per function it made, and ran about 45% slower with the JIT on than with it off. It now stays compiled, and is as fast as or faster than the interpreter.

  • Finished spawned tasks no longer use memory for the life of the program. Each task that ever ran left a 16-byte slot behind, so a server that spawns a task per request grew for as long as it ran, and so did the saved size of an actor that spawns. The slots of finished tasks are now reclaimed, and memory stays flat however many tasks a program has run. Tasks still run in the same order as before.

  • Embedding: scua_use_project. A host can now run a script against a project's dependencies with the same checks the command line makes. Point it at the project directory and later scua_eval/scua_eval_file calls resolve imports through the project's lock, refusing a vendored file that has changed since it was fetched (E-FILE-HASH-MISMATCH) instead of running it. Until now the only way to reach a dependency from a host was scua_add_module_path on its deps/ directory, which checks nothing, so switch to the new call. See Use a project's dependencies.

  • Fixed: closing a listener or datagram socket while net.accept or udp.recv waits on it under --io=async. Closing it (or stopping the actor that owns it) freed the socket out from under the waiting call, which could then read freed memory, never return, or on Linux wait on a socket number that had since been reused. The waiting call now returns Error("the listener is closed") or Error("the socket is closed") within a tenth of a second. The same applies to a file, an HTTP response stream or a child process closed while a read on it (or exec.wait) is waiting: exec.close still stops the process at once, and the waiting read ends as end-of-file.

  • Fixed: a wait inside wait_all never finishing while another arm waited on I/O. In a partition handler under --io=async, a wait_all with one arm on net.accept and another that called wait(200) before dialling in hung forever: the timer never fired while the accept was outstanding. It now fires on time.

  • Fixed: a fault an actor raised before it was frozen is still reported. If an actor faulted and was then frozen with actors.freeze, the report at the end of the run could crash the program, and if the actor was also dropped the fault was never printed at all and the run exited 0. The fault is now reported with its location, and the run exits with the usual fault status.

  • Fixed: a module's functions can no longer be replaced inside an actor. str.upper = nil was refused in the main program but allowed inside any actor, including one revived from a saved blob, and on a module passed to an actor in a message. One actor could then break a module for every other piece of code in that actor. Module records are now read-only everywhere.

  • Fixed: a revived actor could run another partition's code. With two or more partitions in one file, freezing an actor of a later partition and reviving it after editing that partition's handlers bound it to the FIRST partition instead: its handlers ran over the frozen actor's state, and nothing reported it. A revived actor now always runs its own partition's code, edited or not. If a save no longer matches anything unambiguously (a partition renamed or removed), actors.revive returns an Error naming it, as before. Saves written by 0.29 and earlier still revive under an unchanged program; if the partition's code has changed since, they are refused with an Error saying so, because those saves cannot tell partitions apart once their code changes. Revive such a save under the program that wrote it, then save it again.

  • ? on something that isn't a Result says so. let a = f()? where f returned nil, a table or a number used to stop with a bare "type error". It now reads "? needs an Ok or Error value, got nil", at the ?, with a hint on what to return instead.

  • sys.parse_args no longer waits forever on a silent stdin. A tool run with no arguments from a harness that attached stdin and never wrote to it or closed it hung until something killed it. Now it reads a JSON body only from a file, or from a pipe that sends data or closes within 100 ms; otherwise it uses the defaults. A writer that starts sending later than that is ignored, so redirect a slow body from a file instead. sys.stdin() still reads everything.

  • Fixed: sys.parse_args(Args) used inside a larger expression. Written straight into a string template, a call argument, a table or list, a .. or a return, it overwrote the expression's other parts: `hi {sys.parse_args(Args).name}` printed worldworld, and f(x, sys.parse_args(Args).name) stopped with "attempt to call a non-function". Binding the result to a let first always worked, and still does.

  • An enum field reads back from JSON. json.encode writes a payload-free enum as its number, scua schema published its names, and a record decoded from either was refused, so nothing round-tripped. Now a record field declared with the enum accepts the variant's number or its name (1 or "Replayed"), and a flags field its bitmask or a list of member names, wherever data becomes a record: decoded JSON, sys.parse_args (--mode Fast or --mode 0) and as(value, R). A number or name the enum doesn't have is refused with the ones it does. A function parameter or a typed let still needs a real variant, as before; convert with from_int. scua schema publishes both forms. The encoder still writes numbers. Planned: it will switch to names after a warning period, so a consumer that accepts both is ready now.

  • Fixed: a fault inside list was reported at a line of your file that doesn't exist. Calling list.filter(5, …) reported yourfile.scua:20, line 20 of the built-in list module, even in a two-line file. It is now reported at your own call, and the backtrace shows at filter (built-in list.scua:20). The same is fixed for a fault inside an actor handler, and the debugger no longer shows a built-in or prelude frame as a line of your program.

  • Records keep their type when they cross to an actor. A record sent with tell, returned from an ask, nested anywhere inside another value, forwarded between actors, or kept in an actor's state across actors.freeze and actors.revive is still that record, and its methods work. Before, it arrived as a plain table, and an overloaded method such as area on it failed with "area expects a shape value". A record built inside an actor after the actor was frozen dispatches again too, and records with no fields (record Ready {}) now have methods of their own.

  • A method call on the wrong kind of value says what went wrong. When a method exists for some records and the value is none of them, the error now names them and what the value is instead: "area has versions for Circle and Rect; this value is a plain table, not one of those records", with the fix (as(value, Circle)) when the value is data that needs converting.

  • scua blob-info <file> shows what a save holds. Point it at the bytes actors.freeze returned, or at a saved session, and it prints the formats beside the ones this build reads, the generation, whether a turn was parked, and each record type the save holds with its fields, without running anything. It is the question to ask before a deploy: which saves will this change to Player refuse? From a script, actors.blob_version(blob) reads the format alone.

  • Embedding: every load says what it did. scua_last_load_report fills a scua_load_report for the last scua_load_actor, or scua_load_script over saved data: functions matched exactly and rebound onto edited code, paused turns dropped, record types rebound, new state fields filled. The same line goes to your log sinks at info, and actors.revive sends it there too. scua_actor_format_version and scua_actor_blob_format_version name both sides of an actor format refusal.

  • Fixed: rand.int over a range wider than half the ints. rand.int(g, -9223372036854775807 - 1, 9223372036854775807), or any range wider than about 9.2 × 10^18, overflowed while computing the range's size. It gave the right numbers only by accident, and could stop a program in a checked build. Every range now draws evenly, including the full range of ints.

#0.29.0 — 2026-09-25

  • Breaking: rand.seed treats a seed as a whole number. A float that holds a whole number now seeds exactly like it, so rand.seed(2026.0) and rand.seed(4052 / 2) give the same numbers as rand.seed(2026), and -0.0 is 0. They used to give unrelated sequences, which is what made data built from a seed read out of JSON differ from the same seed typed in. A program that seeded with a float like 1.0 gets a different sequence than before. A fraction, NaN or infinity is now an error instead of quietly picking a sequence.

  • Breaking: the str case and letter helpers understand every language. str.upper, str.lower, str.title and str.capitalize convert accented, Greek, Cyrillic and other letters (str.lower("ÉCOLE") is "école"), and str.is_alpha, str.is_alnum and str.is_space answer true for letters, digits and spaces in any script. They used to understand English letters only, so code that split text into words with them quietly dropped every other letter. English text gives exactly the same results as before. str.is_digit still accepts only 0–9, the digits int.parse reads. Check protocol words with the new str.ascii_lower / str.ascii_upper. A few non-ASCII letters now fold onto ASCII ones (İ to i, the Kelvin sign to k, ı to I, ſ to S), and str.is_space accepts Unicode spaces. So code that matches header names, HTML tags or keywords through str.lower accepts look-alikes: str.lower("X-APİ-KEY") == "x-api-key". The ASCII versions change only A–Z.

  • Breaking: a parameter's type is now checked while the program runs. fn f(x: int) used to check only the calls the compiler could see, so a value that arrived through untyped code (read from JSON, from tonumber, passed as a callback) reached x as whatever it was: 1.5, NaN, a string. Now the call stops with a clear error, such as "parameter 'x' of 'f' is float, expected int". An int still satisfies a float or number parameter, int? still accepts nil, and a default still fills a missing argument. Code that relied on passing the wrong kind through will now stop at that call. The check is a single type test, and in our measurements a typed function runs as fast as the same function untyped. Caught with try/rescue or pcall, the fault is a record, like a where fault: read e.message (and e.value for what arrived), not e as a string.

  • Breaking: a call to a re-exported function must pass all its arguments. If a module re-exported a function (return { g = other.g }), calling it with too few arguments compiled and the missing ones were silently nil. It is now checked like any other call into a module, so a call that leaves out trailing arguments, such as web.text("ok") for fn text(s, status), now fails to compile with "this function takes 2 arguments, but 1 was given". To keep short calls working, give the trailing parameters defaults: fn text(s, status = nil). The same holds for an export named like a built-in function (ask, len, push, contains, length, …): m.len(1) used to skip the check entirely.

  • Breaking: saved partitions from earlier versions can't be loaded by this one. The fix for slow table copying (below) moves where a table stores its keys, and a saved partition keeps them where they were, so an older save is refused with a clear error rather than read wrongly. Durable saves are not affected.

  • Breaking: a variable of your own that is also a column is now an error inside filter and with. It used to mean the variable and silently ignore the column. The error names both ways to write it: row.region for the column, or a renamed variable or a fn(r) … end predicate for the variable. For a table built with frame({ … }) you get this when the program compiles; for one read from a file, when the filter runs.

  • Breaking: declaring the same state name twice in one partition is now an error. It used to compile, and the second declaration silently replaced the first. You now get partition 'P' declares state a twice at the partition. Remove or rename one of them.

  • Breaking: a type annotation checks records inside records everywhere. let t: lib.Top = json.decode(input)? with a record type from another module used to check nothing below the top level: a nested value that broke a where rule got through silently, while passing the same value to one of the library's own functions was refused. A list of records written out in code ({ items = [{ … }] }) skipped its checks the same way, even for types declared in the same file. Both are now checked all the way down. That includes a field whose type comes from a module the library itself imports, and every level of a recursive type such as a linked list. A failure deep inside a recursive type is now reported at the line of your annotation, where it used to show no line number. This can stop a program that ran before, if it was holding data its own annotations say is invalid.

  • Raw network connections have time limits. net.dial, net.dial_tls and net.start_tls now give up after 30 seconds, where a server that accepted the connection and never answered used to hang the program for good. Pass { timeout_ms = 5s } to any of them to pick a different limit, or 0 for none, as with http. Reading and writing still wait as long as it takes, because some connections are meant to sit quiet, but net.set_timeout(conn, 5s) now limits each read and write on a connection, and nil removes the limit. Running out of time is an ordinary Error naming the call and the limit, such as net.read: timed out after 5s, and a read that times out leaves the connection open. Dialling a name that doesn't exist now says could not resolve the host instead of could not connect, and a misspelled option to net.dial_tls or net.start_tls is now an error instead of being ignored.

  • Re-raising a caught error keeps where it happened. try … rescue e … error(e) end used to report the re-raise line and start the backtrace there, losing the real cause. Now an error raised again from its rescue block, directly or through a function the block calls, is reported where it first happened, with its original backtrace. Raising a new value is reported where you raise it.

  • A bytes value can go inside {…} in a backtick string. `got {b}` shows it the way print does (got bytes(6869)) instead of stopping with an error about joining text. Joining bytes and text with .. is still an error, so they can't be mixed by accident.

  • Fixed: POSIX character classes in a regular expression matched nothing. A pattern like [[:space:]] or [[:alpha:]] used to compile without complaint and then never match anything, not even a space, so a check built on one (an email format, say) was quietly always false. The POSIX classes now work inside a bracket ([:alpha:], [:digit:], [:space:], [:punct:] and the rest, plus [:^alpha:] for the opposite). Like \w \s \d they cover ASCII characters only. A name that isn't one of them, like [[:letters:]], is now an error when the pattern is compiled.

  • Fixed: str.is_identifier said yes to reserved words. str.is_identifier("end") was true, so code that quoted only "non-identifier" names wrote out end = …, which doesn't parse. Reserved words now answer false.

  • Fixed: in scua test, one test that stopped the whole run failed every test in the file. A test that ran out of memory or called sys.exit took the file's other results with it, including the passes before it. Now only that test fails, with a plain explanation, and the tests after it still run.

  • Slicing and indexing long non-ASCII text is fast. str.slice, s[i] and str.find with a start offset used to take longer the further into a string you went, when the string held characters outside ASCII (Cyrillic, Chinese, emoji, accents). Walking through such a text in pieces got quadratically slower: 800,000 Cyrillic characters in 800-character windows took about a second. It now takes about a hundredth of that, the same as plain ASCII.

  • Fixed: copying a big table by looping over it was very slow. Building a new table from an existing one with for k, v in src do out[k] = v end (and every merge, copy or "all but these keys" helper written that way) took time that grew with the square of the table's size: 200,000 keys took about 3 seconds. It now takes about as long as building the table in the first place. One visible side effect: the order in which a large table's keys come out of a for loop may differ from before. That order was never promised (see Collections).

  • Fixed: a column named after a built-in was ignored inside filter and with. With a column called year, count, total, product or any other built-in name, f.filter(year == 2024) compared the built-in function with a number and quietly matched no rows. A bare name inside these verbs is now the column whenever the table has one, so adding new built-ins can no longer change what your filters return. A name you call, like abs(delta), is still the function.

  • row inside filter and with means the current row, so any column can be spelled out, including names that aren't valid identifiers: row.amount, row["unit price"].

  • Fixed: a variable from an enclosing function was read as a column inside filter and with, and the predicate failed with a bare "type error".

  • An error raised inside filter, with or another built-in written in SCUA is now reported at your own line that called it, instead of at a line number that isn't in your file.

  • Partitions with many state fields are faster. A partition with more than 16 state fields read and wrote them about three times more slowly than a smaller one. The number of fields no longer matters.

  • Loops over arrays of numbers are faster with the JIT on. A loop that reads or writes an array of ints or floats, or an array kept in a table field (such as a partition's state arrays), could run more slowly with the JIT than without it. It is now about twice as fast as the interpreter on a typical simulation loop, and a simple loop over such an array is several times faster than that.

  • Partitions that exchange many small messages are faster. A partition that receives a lot of mail, such as one collecting a reply from every other partition, now works through several messages in a turn rather than one at a time, and less work is repeated for each message. A program shaped like a busy server can run many times faster. Messages from one sender still arrive in the order they were sent. The order in which output from different partitions interleaves may differ from earlier versions; that order was never promised.

  • Windows: programs with many partitions use far less memory. Each partition used to claim its full memory allowance from Windows as soon as it started, however little it held, so a program with a few dozen partitions could run the machine out of memory. A partition now takes memory as it grows, as it already did on Linux and macOS. In our tests the peak fell from about 37 GB to under 100 MB.

  • math.checked_add, math.checked_sub and math.checked_mul. Int +, - and * wrap around when a result passes the largest or smallest int, and the manual now says so (it used to claim this was an error). These three return Ok(result), or an Error when the result doesn't fit. Use them for sizes and offsets that come from a file or the network, where a wrapped value can slip past a bounds check.

  • math.log1p, math.expm1 and math.nextafter. log1p(x) and expm1(x) compute log(1 + x) and exp(x) - 1 without losing a tiny x's digits. nextafter(x, toward) gives the next float after x.

  • math.float_epsilon, and math.epsilon explained. math.epsilon is 1e-9, a tolerance for comparing results, not the gap between floats. That gap (about 2.2e-16) is the new math.float_epsilon.

  • Slicing and searching a large bytes value is fast. bytes.slice, bytes.slice_exact and bytes.find used to copy the whole buffer on every call, so reading a big buffer piece by piece got slower as the buffer grew. They now cost only what they return: 20,000 one-byte slices and searches over 4 MB went from about 2 seconds to 0.04. There is also bytes.u8(b, off), which reads one byte like b[off], alongside bytes.u16le and the other fixed-width readers.

  • str.lines splits text that uses a lone \r for line endings. Old Mac files and some exporters end lines with \r alone, and str.lines returned such text as a single line. A line now ends at \n, \r\n or \r.

  • hash.sha1 and hash.hmac. hash.hmac(algorithm, key, data) computes the keyed signature used by two-factor login codes (TOTP), JWT and webhook signatures, with "sha1", "sha256", "sha384" or "sha512". hash.sha1 is there for protocols that still require SHA-1. Don't use it to check that content is unchanged; use hash.sha256.

  • A library's record types can be given a local name and carried by its enums. type Leaf = lib.Leaf now works (it used to fail with an error that listed Leaf among the types it said weren't there), and an enum case carrying one of the library's records, like lib.Shape.Box({ w = 2, h = 3 }), can be built from any file.

  • Fixed: http.serve accepted header names HTTP does not allow. A request header whose name held a space, a non-ASCII letter (X-APİ-KEY) or whitespace before its colon was passed to your handler. Such a request is now answered with 400 Bad Request, as the HTTP standard requires, and never reaches the handler.

#0.28.0 — 2026-09-23

  • Fixed: with --max-ops, one task could run out because of another task's work. The limit is per task: however often a task is paused to let others run, it may do at most that many operations before it is stopped. But the count was shared between tasks, so two tasks each well inside the limit could be stopped together. And a task that had already used most of its allowance could get a fresh one once a sibling finished. Each task now has its own count.

  • Background tasks inside a partition take turns too. A task started with spawn inside a handler used to run to the end before anything else in that partition got a go, and starting one stopped the handler itself from being paused. Both now share time like everything else.

  • Fixed: a sandboxed script could stop your whole program by starting a background loop. When a sandbox's handler runs past its operation limit, only that sandbox is stopped. A background task it had started with spawn was not treated the same way, so running past the limit there ended the entire run. It is now stopped on its own, like the handler.

  • A long message handler no longer holds up every other partition. A handler that runs for a while is now paused every so often so other partitions' messages get handled in between, the same way tasks already take turns. The paused handler carries on where it stopped, and a partition still handles one message at a time. A sandboxed partition's operation limit covers the whole message, pauses included, so pausing cannot be used to run past it. A program with only one partition is never paused.

  • Tasks now take turns in compiled code too. The runtime shares time between tasks by pausing a long-running one every so often so the others get a go. That only worked while your code was being interpreted. Once a loop was compiled, which is the default, it ran to the end and every other task waited for it. spawn a short task and then run a long loop, and the short task now gets in during the loop whether the loop is compiled or not. A program with only one task pays nothing for this.

  • Fixed: a long loop that built up a string could skip an iteration. A compiled loop doing s = s .. piece lost one pass through its body at each hundred-million-step mark, so a count came back short and the string was missing a piece. Like the for loop fix below, it reported nothing and printed a wrong answer. It is correct now.

  • Fixed: a very long for loop could quietly skip an iteration. A loop running more than a hundred million times lost one pass through its body at each hundred-million mark — so a count came back one or two short, and a sum was short by exactly the values it never saw. It only affected for loops, only when compiled (which is the default), and it reported nothing: the program finished and printed a wrong number. A while loop was never affected. Both are correct now, and agree with the uncompiled answer exactly.

  • Fixed: two tasks started together no longer starve each other. spawn twice and let the first one do a long piece of work, and the second used to wait for it to finish, even though the runtime is supposed to share time between them. It now shares as documented. A task that spawned another and then looped was already fine; it was the two-siblings shape that was not.

  • A busy partition no longer uses up the room another sender needed. Each partition's inbox is bounded, so a fast producer cannot grow a slow consumer's queue without limit — that has not changed. What has changed is whose room it is. The limit used to be shared: everyone sending to one partition drew on the same pool, so a chatty neighbour could fill it and leave a partition unable to send even to itself. It is now a separate allowance per sender, granted by the receiver and handed back as it works through its queue.

    In practice this means a program that got deadlock: partitions blocked on full mailboxes may now simply run. Two shapes that used to stop are ordinary now: a partition that sends itself a follow-up message while other partitions are also writing to it, and a pair that pass work back and forth while your top-level code is feeding them at the same time. In both, the messages that filled the inbox were not the ones the blocked sender was queued behind.

    It is still a bound, and a genuine jam is still reported rather than left to hang — a cycle of partitions all waiting for room that only each other could free ends the run with that same message. Two things are better about when you see it: it is reported as soon as it is true rather than only once everything else has gone quiet, and it no longer depends on how many cores the run had. A program that behaved one way with one core and another way with four now behaves the same.

  • Actors can now be frozen to bytes, dropped, and brought back — from a script. A partition's whole state lives in its own isolated heap, so it can be turned into bytes and turned back into a running actor, including a handler parked halfway through a turn. Until now that only existed for programs embedding SCUA in C. import actors gives you three verbs:

    fn park(session)
      let blob = actors.freeze(session)?    -- the bytes. The actor keeps running.
      actors.drop(session)?                 -- stop it and release its memory.
      return actors.revive(blob)            -- bring it back.
    end

    freeze is a snapshot, not a teardown — which is why drop is there. Freeze on its own gives you a backup; it is drop that turns a session nobody is using into no memory at all. A server can freeze the quiet sessions, drop them, and revive one when the next request for it arrives.

    revive brings back the same actor, not a copy: the identity travels inside the blob, so a reference you handed out before the freeze still reaches it. actors.generation(blob) is a number that climbs with every freeze and keeps climbing across a revive — store it beside the bytes and refuse a write whose generation is not higher, and a process that woke an old copy cannot overwrite newer state. See Freezing an actor and bringing it back and examples/hibernation.scua.

    Neither verb needs a capability. Getting the bytes in or out of the process still does — write them with fs, send them with net.

  • A misspelled message name is now an error instead of silence. tell box.Adld(1) against a partition that handles Add used to compile, run, do nothing and exit 0 — a partition answers the messages it knows and ignored the rest. With ask it was worse than nothing: no reply ever came, so you got a timeout, which reads as "that partition is slow" rather than "you wrote Totl".

    Where the compiler can see which partition a variable holds it is a compile error; where it cannot, the same refusal happens at runtime on the same line. Both name the nearest handler that does exist:

    scua: orders.scua:14: type error: `tell …Adld(…)`: partition 'Box' has no handler for 'Adld' — did you mean 'Add'?

    A tag a handler waits for with wait_for or a select arm counts as handled, so parking on wait_for(Resume) and being sent Resume is fine.

  • tell to a stopped actor no longer corrupts memory. Stopping an actor with sandbox.stop and then sending it a message could crash the program with ArenaTooLarge, because the reference still resolved to the stopped actor after its memory had been released. A message to an actor that is gone is now what it was always documented to be: a no-op.

  • ask with no timeout waits five seconds, and the guide now says so. It previously claimed such an ask waits forever, so anyone who believed it wrote a wrapper they did not need.

  • A server no longer stops the rest of your program. http.serve used to hold the whole process for as long as it ran: a task you had spawned never got another turn, a wait in your main body never came back, a clustered node stopped gossiping (so its peers declared it dead because it was quiet — which is exactly when it was healthiest), and a print inside a handler never reached stdout. There was no error and no non-zero exit; the program simply stopped doing everything except serving.

    Now the server and the rest of your program share one loop. Spawn your background work before the http.serve call — nothing written after it runs, because that line does not come back — and both keep going:

    spawn(fn()
      while true do
        wait(30s)
        refresh()
      end
    end)
    
    http.serve(handle, { bind = "127.0.0.1:8080" })

    net.accept still blocks its caller, as its documentation says it does.

    Three things that come with it:

    • A handler runs to completion. It cannot wait, and it cannot wait on an actor with ask; one that tries gets a 500 saying so rather than a mysterious answer. It can spawn, though, so a handler can answer now and finish the slow part afterwards.
    • A handler can send a message to an actor and it is delivered while the server keeps running. It cannot ask one: ask waits for a reply and a handler does not wait.
    • --fast and a running server are now refused together. --fast skips through waits to make a run deterministic, and a network socket is the one thing it cannot skip.
  • A handler that does real work got dramatically faster — up to 42× on the measurements that come with this change. Until now a request handler ran on the slowest path in the runtime and was never compiled by the optimizing tier, so a server's throughput collapsed as soon as its handler did anything: a handler running a 20,000-iteration loop served 1,875 requests a second, where the same handler now serves 79,642. A handler that returns a constant is unchanged — there was never anything to speed up there, which is why this went unnoticed for so long.

  • --serve-timeout now stops a runaway handler whatever speed it is running at, and stops nothing else. Two problems, both of which only a server that shares the program could show. The deadline was checked in one of the two execution tiers, so a handler that looped fast enough to be compiled ran forever while the client waited forever — the same handler answered 503 on time when it was not. And it stayed armed after the request it belonged to finished, so the next background task to do real work was killed by a deadline meant for a request that had already been answered.

  • Messages to actors are delivered as they are sent, instead of waiting for your whole program to finish. A tell used to sit in the mailbox until every background task had ended — so on a program that keeps running, which is most of them, the actor did its work at the very end or never. Sending to an actor from the top level, from a task, and from a request handler all work now, in order.

  • ask, wait_for and select now refuse outside an actor handler instead of quietly answering nil. All three wait for something only an actor handler can be woken for, and used to hand back nil when called from a task or the top level — so let answer = ask thing.Question() set answer to nothing at all, with no error and a successful exit. They now say so, and say what to use instead. ⚠️ This can turn a program that appeared to work into one that reports an error. It was never working; it was reading nil as the answer.

  • A partition cannot be saved while it is serving. The attempt is refused, naming the listener, instead of writing a snapshot of a running server that would not have reloaded correctly.

  • wait works at the top level of a scua test file, and of a script an embedding host runs — it used to fault there with "wait outside a coroutine" while the same line was fine in a program run with scua <file>.

  • scua serve — a local web server in one command. Run it in a directory and it serves that directory over HTTP, printing the URL to open:

    $ scua serve
    scua: serving . on http://127.0.0.1:8000/  (Ctrl-C to stop)

    It does not fail when the port is busy. If something already holds the port, it moves to the next free one and names the port that was taken, rather than stopping with an error:

    $ scua serve
    scua: port 8000 was busy — serving . on http://127.0.0.1:8003/  (Ctrl-C to stop)

    If everything nearby is taken it asks the operating system for any free port, so there is no "address already in use" outcome at all — only a different number to copy.

    It serves index.html when there is one and a directory listing otherwise, sets the content type for around thirty common extensions, and hides dotfiles (--all reveals them). It listens on your own machine only; --host 0.0.0.0 widens that and says so on the line it prints. Nothing it serves may be cached, so a reload always shows the edit you just made. scua serve ./public --port 3000 picks the directory and the port; a bare number is read as a port, so scua serve 3000 works too.

    It is a development server: responses are built in memory, so a very large file is expensive and an enormous one will not serve; there are no range requests, so seeking in audio or video will not work; and a HEAD reports a content length of 0 rather than the real size. It serves files only — for a server that runs your own code, http.serve is unchanged.

  • A refused bind now says which of the two things went wrong. Binding a port that is already in use and binding an address that is not on this machine used to produce the same sentence, which named both causes and left you to guess. They are separate messages now, and cluster.join's hedged "(already in use?)" has become a statement.

  • net.local_port(listener) reports the port a listener actually bound. Listening on port 0 lets the operating system pick a free port; until now a script had no way to discover which one it got, so the feature was unusable in practice. This is the counterpart to udp.local_port.

  • Four data-table verbs for gaps, running totals and relationships.

    • dropna(f) / dropna(f, name) — incomplete rows removed. Bare, a row goes if anything in it is missing; name a column and a row goes only when that column is missing, which is usually the real question. Two spellings for two questions, rather than a bag of settings to remember.
    • fillna(f, name, value) — the gaps in one column filled. The value has to fit the column exactly: fillna("amount", 0) fills a money column because zero is exactly zero, while fillna("amount", 0.1) is refused, because writing a rough value into an exact column would quietly stop it being exact. A filled 0 prints as 0.00, matching the column rather than sitting in it at a different precision.
    • cumsum(f, source, target) — a running total as a new column. You name the target because a running total is one value per row and has to land somewhere; replacing the source would destroy what you are totalling. An exact column stays exact, and the final row equals total(source).
    • corr(f, x, y) — how strongly two numeric columns move together, from -1 to 1. Only rows where both values are present count, and it answers nil rather than 0 when there are fewer than two such rows or a column never varies: no relationship has been observed, which is not the same as one of zero strength.
  • group_by takes several keys. group_by(["region", "quarter"], { revenue = "sum:amount" }) breaks down by both. Each key comes back as its own column, so the result is a frame you can filter, sort and group again — not a joined "north|Q1" string you would have to take apart first. A single key is unchanged in both spelling and speed.

  • A printed data table no longer shows a wall of digits. describe() used to report a mean as 135.95833333333334, which is hard to read in the one call whose whole purpose is being read. A printed table now keeps six meaningful digits:

    column  count  mean     std      min    p25   median  p75      max
    amount  6      135.958  101.745  45.25  65.0  100.25  180.125  310.00

    The values themselves are unchanged — this is display only. f.mean("amount") still gives you 135.95833333333334, and to_csv() is still exact, which is what makes it the machine-readable counterpart to the printed table.

    Two things it deliberately never does. It never shortens an exact number, so a money column prints in full however long it is — a decimal column that read shorter than it really is would undo the reason to use one. And it never adds digits: 0.5 prints 0.5, not 0.500000. Six meaningful digits rather than a fixed number of decimal places, so a column of small rates keeps its detail instead of flattening to zeros.

#0.27.0 — 2026-09-17

  • An enum variant's payload is now checked against the type the variant declares. A record behind a variant used to cross unexamined: Item.Text({ at = [72, 700], text = "hi" }) left the defaulted fields nil, a where refinement on the record never fired, and a literal with fields the record does not have was accepted. The same record bound by let t: TextItem = { … }, or passed to a parameter typed TextItem, was checked at every one of those points. Now both spellings behave the same: defaults are filled, refinements fire, and an unknown field is a compile error.
  • A variant arriving as JSON is checked too, not just counted. For a variant carrying one field, only the number of values was checked, so {"kind":"Text","value":[1,2]} crossed into a typed field and failed much later with a confusing message about fields on arrays. It is now refused where it crosses, naming the record it did not match. Defaults and refinements apply on this path as well.
  • A parameter or let typed with an enum is checked. fn f(p: Direction) accepted any value at all — f(42) ran — and so did let p: Direction = 42. The same declaration as a record field has always been checked. Both now refuse with parameter 'p' is int, expected Direction.
  • Breaking: the three items above refuse code that previously ran. If a program built a variant from a value that does not satisfy the declared payload type, or passed a non-variant where an enum type was declared, it now stops at that point with a located error instead of carrying the wrong value forward. Fixing it is usually a matter of building the value the declaration describes; the error names the record or enum it expected.
  • Data tables got the analytical half they were missing. A frame could total and mean a column and group_sum by a key; everything else you would reach for meant writing a loop. Now:
    • describe() — one row per numeric column with count, mean, std, min, quartiles and max. The call to make before you know what the data holds.
    • value_counts(col) — every distinct value with how often it occurs, most frequent first. The question a text column answers, where mean answers nothing.
    • group_by(key, spec) — every aggregate in one pass: orders.group_by("region", { revenue = "sum:amount", orders = "count", typical = "median:amount" }). Thirteen reducers: sum, mean, median, min, max, std, variance, count, count_distinct, first, last, product, mode. group_sum still works; it is the two-argument shorthand.
    • Column reducers — median, quantile, std, variance, min, max, count, count_distinct, first, last, product, mode. The ones that hand back an actual cell keep the column's kind, so the max of a money column is money and the mode of a text column is text. A median is exact for a decimal column: the midpoint of 20.00 and 35.00 is 27.50.
    • distinct(), drop(names), rename(map), tail(n), and sort_by now takes an array of columns for a multi-key sort. Mistyped column names and non-numeric reducers are still caught at compile time, as total always was.
  • math.fmod now means what it means everywhere else, which changes its answer for negative numbers. It was a floored remainder — math.fmod(-7, 3) gave 2 — while C, Python and JavaScript all give -1 for that. It is now truncated, matching them. The floored answer is what % gives (-7 % 3 is 2, unchanged), so both conventions are now reachable; before, fmod was a second spelling of % under a name promising the other one. If you used math.fmod with operands of opposite signs, its answer has changed — use % for the old behaviour.
  • math.log(x, base) takes an optional base: math.log(1024, 2) is 10. Bases 2 and 10 are exact, so math.log(1000, 10) is 3 and not 2.9999999999999996 (which is what computing it by hand as log(x)/log(b) gives, and what Python returns).
  • math.exp2 (log2's inverse, which was missing while log2 was there) and math.clamp01 (the 0…1 clamp interpolation code reaches for).
  • list.min, list.max and list.mean join list.sum — the obvious next calls after summing a list, which had to be hand-rolled. An empty list gives nil rather than inventing a value.
  • scua --version now says when a build is not built for speed. A release prints just scua 0.26.0, as before. A build compiled from source without -Doptimize=ReleaseFast adds a note — scua 0.26.0-dev.abc1234 (debug — unoptimised; do not benchmark) — because such a build is correct but roughly a hundred times slower on work that allocates, so timings taken from one describe the compiler settings rather than the code. Nothing is printed on an ordinary run.
  • nan == nan is false, as IEEE 754 says — it used to be true in the interpreter. The JIT already got this right, so the answer depended on whether your code had been compiled yet: a function running partly in each tier could report n == n false and n != n false in the same line. scua test runs interpreter-only, so a test asserting NaN behaviour asserted something the runtime did not do — and a package that refuses NaN values with if value != value (correct at run time) saw that branch never taken under test. Ordering (<, <=, >, >=) against NaN was already correct and is unchanged, and is(n, n) is still true: is asks whether two things are the same value, and one value is.
  • Float containers compare by value, not by bytes. [nan] == [nan] was true and [0.0] == [-0.0] was false — both from the same shortcut, and both reachable with an ordinary array of floats, not just a declared { f64 } buffer. Both now agree with what == says about the elements. Arrays of integers are unchanged.
  • A stray character now says which character. Every byte the lexer didn't recognise reported malformed number literal, whatever it was — so let a = 1; let b = 2 sent you to look at your numbers, and so did print(1); print(2), which has no number problem at all. Each is now named: unexpected character `!` , or for the one people actually type, `;` is not a statement separator — SCUA separates statements by line. A genuinely malformed number still reports as one.
  • The @ path operator checks what's on its right. t @ 2 crashed the interpreter; t @ s where s is a plain string variable quietly returned nil, as did an array. All three are now a clear error naming what arrived. Writing the path inline (cfg @ "server/host") is unchanged, and so is storing one as a value with path"server/host" — a plain string in a variable was never a path, it just failed silently. If you relied on that nil, use path"…".
  • A TLS failure against an IP address explains itself. Dialling an IP whose certificate was issued for that IP was refused with "check the servername option", which was no help because the servername was right — certificates are matched by DNS name, and an IP entry in a certificate isn't matched. The message now says that, and names the two ways round it.
  • cluster.watch inside an actor is refused instead of corrupting memory. Registering a watch from inside an actor handler wrote into the registration list of a different partition; a short program that watched at the top level and then again inside an actor died with ObjectTooLarge. It is now a clear error at the line that wrote it, explaining that a watch handler has to run as a turn in the partition that registered it — register the watch at the top level and have it tell the actor.
  • A clearer message when a value is not a table. A value crossing into a record-typed binding that is not a table used to say only "value flowing into a typed binding is not a table", the same sentence whatever was declared. It now names the record and what actually arrived: value declared TextItem is an array, not a table.

#0.26.0 — 2026-09-15

  • scua test: a test that returns an Error fails. return Error("boom"), or a ? that propagated one, ended the test early and the runner reported PASS, because only a fault counted; every assertion after the failing ? silently never ran. Such a test now fails with returned Error: and the value. Found by the packages team with a four-test probe on 0.25.0.
  • FFI: up to 256 declared native types per partition, and the fault says so. Every ffi.struct, ffi.buffer, ffi.layout, ffi.opaque and distinct ffi.pointer target takes a slot for the partition's lifetime. The table held 128 and its overflow read "FFI pilot resource or argument limit exceeded"; a large binding surface on x86-64 hit it. It now holds 256, and the refusal names the limit and the way out: declare each type once and reuse its handle.
  • A parameter typed T? may be left off. fn open(path: string, opts: Options?) can be called as open(path); opts arrives as nil, which is what the type already allowed. Everything after an omittable parameter is omittable too, as with defaults; a required parameter after a T? one keeps both required.
  • A call written f(x)? is now checked like f(x). The ? operator used to skip every check on the expression inside it — a wrong argument count or type passed unnoticed — so a library tested only through the ? idiom could ship an arity its consumers' plain calls then failed. If code relied on that gap with a parameter that is neither defaulted nor T?, it is now refused at the call.
  • Tagged enums have a JSON spelling. A variant that carries data encodes as { "kind": "Text", "value": "hi" } (value an array for several fields, omitted for none), and a decoded object of that shape becomes the variant again wherever a record field — or a { Item } array field — is declared with the enum, so match works on what a JSON caller sent. A wrong kind or a wrongly sized value is refused at the boundary, by field. scua schema publishes such a field as a oneOf over the variants, each fixed on its kind, where it used to publish an opaque description. Ok(…) and Error(…) encode the same way. Payload-free enums still cross as their number.
  • scua schema defines every record it refers to. A record two or more levels below the type you asked for was referenced with $ref but never defined under $defs, so the output was not valid JSON Schema and a consumer resolving the reference found nothing. Now every referenced record is defined, at any depth, through array fields and direct fields alike; a recursive record is defined once.
  • A record literal returned from a function declared to return that record is a real record. fn make(n: string) -> Player return { name = n } end used to hand back a plain table, so a caller in another file could not call the record's methods on it; the manual told you to build it in a typed let first. Now the literal builds the record the let would have: sealed, defaults filled, methods dispatching from any file. One consequence: code that added a brand-new field to such a value now gets cannot add a field to a sealed record, as it would for any record.
  • When a built-in wins over a same-named function, the error says so. db.frame(sql) written for a library's frame, on a value that is not the library's record, calls the built-in frame; the error used to be the built-in's own arity complaint. It now names the built-in, says no method or field of that name was called, and shows the two ways out: the module alias (sqlite.frame(db, sql)) or another export name. On the library's own record the library's method already wins.
  • A module may export a method under a different name. return { query_frame = to_frame } makes db.query_frame(sql) work; the original name still works through the module alias.
  • A record literal may leave out defaulted fields wherever a record is expected. Passing { name = "Ed", hp = 10 } to a parameter typed Player whose mana has a default is now accepted the way a typed let always accepted it; so is returning such a literal from a function declared -> Player, or assigning it to a Player variable. The runtime fills the defaults at the boundary, as before. When the literal is wrong, the error names the field — missing field 'hp' of type number, or unknown field 'naem' on Player (did you mean 'name'?) — instead of argument 1 is table {name}, expected Player.
  • A record can have as many fields as you need. The limit was 16, the width at which a plain table switches to hash-map storage, and the first use of a wider record was refused with "too many fields (max 16)". A record is a fixed layout that never needed that switch, so there is no longer a fixed limit: a binding surface with one field per bound function (26 for a SQLite wrapper, or hundreds) is a record like any other. Plain tables are unchanged.
  • Breaking: an enum belongs to the module that declares it. An enum or flags declared in a module is now a distinct type from a same-named one declared anywhere else. Two libraries can each declare enum Outcome and you can import both; you can declare your own Kind beside a library's; a value of one never matches the other's patterns and the compiler refuses to mix them. Values still print by their declared name. Two things to check before upgrading: delete any verbatim copy of a library's enum you kept to match its values (it is a separate type now, and the compiler hints to say so), and drain or re-save anything persisted that holds values of a module-declared enum — such saves and hibernated sessions are refused on load with a schema mismatch. Saves holding only your own file's enums load exactly as before. durable and cluster data written by the previous release still loads when the enum name is unambiguous in the reading program.
  • Every bytes function is a method on a bytes value. b.to_array(), b.to_string(), b.slice(a, b) and the rest work as methods, not only the two that happened to be global builtins, and calling one needs no import bytes. Reading a field on a bytes value now says where its methods come from instead of "fields only exist on tables and records".
  • A module's record types are nameable through the import alias. let err: sqlite.Error = { code = 5, message = "abort" } checks the literal against the library's declaration and builds the library's own runtime type: its methods dispatch on your instance, it is sealed, and its where refinements hold. Reading a record back never needed this (records are structural); building one did, and until now a caller either hand-wrote a near-miss table or the library had to ship a constructor. A type the module does not declare is reported with the ones it does. Alongside it, a method a library declares on its record now type-checks on a value that library returned (lib.make("x").describe()), which was refused as an unknown field before. See the modules guide.
  • Modules in subdirectories, and a module's own imports. import lib/mid names a module by its path and binds its last segment (mid, or what you write after as). A module's imports now look beside the module first: lib/mid.scua doing import helper finds lib/helper.scua, wherever the package is placed, before walking the search directories. Until now a module could only import files beside the entry file, and a nested path was reachable only through the dynamic import("…").
  • Names that used to collide silently now refuse. A module two search directories both provide is refused with both named, for every pairing (it was only refused for the lock against --mod-path). A file of yours carrying a built-in module's name is refused when that name is imported, instead of being silently ignored. An import alias that is also a top-level fn or let, an earlier import of a different module, or a built-in name (print, world, floor…) is refused with the fix spelled out (import x as <name>). Two different flags types with one name, visible in one file, are reported exactly as two different enums already were.
  • Two libraries can each declare a record Item with a describe method. A method is looked up on the record it was declared on, not by the record's name, so same-named records from different modules keep their own methods in the type checker (they always did at run time).
  • Better messages for import "sub/helper" (write it without quotes), import end (a keyword), and an import inside a function or block (it is a top-level statement).
  • Embedding: scua_add_module_path. The host-side twin of --mod-path: an import in a script run through scua_eval_file is tried against the entry file's own directory, then each directory the host added. A package's examples/ can now import the module in its parent under a host, the layout every package already uses. Host-side and not persisted, like a grant.

#0.25.0 — 2026-09-13

  • FFI: what the first real binding taught it. Six changes from driving SQLite through the dynamic FFI, all about safety and diagnostics rather than reach:

    • A cstr argument the callee keeps after the call — sqlite3_bind_text with SQLITE_STATIC — can be declared {type = "cstr", retained = true}. It then refuses a script string at the call (the copy is freed on return, and reading it later was a segfault at step) and takes ffi.native_string(lib, s), a native copy that lives as long as its handle.
    • ffi.unsafe_pointer(lib, -1): a negative integer is C's sentinel cast (SQLITE_TRANSIENT, MAP_FAILED), in place of a 20-digit decimal string.
    • Argument faults name the position, the declared kind and what arrived — argument 2: expected i64, got string — and a misspelt descriptor key or unknown type name is quoted back. A fault raised inside your callback reaches the caller as FFI callback faulted: <its message> instead of an owner-thread violation it did not commit.
    • A release symbol that returns int can say so, {symbol = "sqlite3_close", result = "i32"}, and a non-zero result faults the explicit ffi.close with the symbol and the code (or is written to stderr and host log sinks when it ran from a cascade or the finalizer). Before, a refused close looked exactly like a successful one, and the connection leaked silently.
    • Everything printed so far is written out before any native code runs, so a native crash no longer takes the lines that had already executed with it.
    • ffi.unsafe_read_bytes(pointer, count): one checked copy for a length that comes from a second C call (sqlite3_column_blob + sqlite3_column_bytes), where a view over the reused buffer gave a stale, plausible value.
  • A package importing ffi, exec or sandbox now reports that capability. The package manager's reachability list had drifted from the runtime's own module table by those three modules, so such a package could pass an empty capability budget. The list is now the one table.

  • Storing a vector into a record, an array slot or a list from a hot loop no longer crashes the JIT. p.vel = vec2(vx, vy), cells[i] = vec2(…) and points.push(vec3(…)) inside a loop the optimizing tier had taken hit an unreachable path in the compiler — a crash in debug builds and undefined behaviour in release ones. The vector is now boxed inside the compiled loop, and the loop stays on the fast tier. The same stores also accept strings and records now.

  • On x86, a hot loop followed by a table literal or a function definition compiles more often. The compiler treated those two statements as reading every variable, which kept the loop's dead temporaries alive across it and could exhaust the smaller x86 register file.

  • A loop that builds small arrays stays on the JIT's fast tier. An array literal of up to four elements — [x, y] as a pair, rows.push([id, score]), e.pos = [px, py] — used to keep the whole loop on the slower tier. The array is now built inside the compiled loop, exactly as the interpreter builds it (the same objects, the same element kind, the same collection points), and reading p[0] straight back costs no memory access at all. Literals wider than four elements, { f32 }-typed literals and vector elements stay on the slower tier.

  • Two record literals in one loop body now both compile. g.a = { x = i } followed by g.b = { y = i } kept the loop on the slower tier because the compiler reuses one temporary for both.

  • A loop that builds small records stays on the JIT's fast tier. A record literal of up to four fields — { x = i, y = i * 2 } in a physics step, { word = k, count = v } — used to keep the whole loop on the slower tier. The record is now built inside the compiled loop: same two objects, same collections at the same points, same saved partitions. The new reclit benchmark row shows the difference: about half the time on both Apple silicon and x86-64. Pushing such a record into an array (items.push({ … })) still takes the slower tier for now.

  • Building short strings in a hot loop is faster under the JIT. An interpolation of a prefix and an integer — `item_{i}`, `w{n}`, "id:" .. id, rec.name .. i, p .. n with p a variable — and a join of two short strings — name .. ": ", rec.first .. rec.last, and so a whole line like `{name}: {i}` — used to leave the compiled loop for a runtime call on every piece. The string is now built inside the compiled loop: same bytes, same collections at the same points, same saved partitions. The wordcount benchmark runs about a quarter faster on Apple silicon and a third faster on x86-64. Pieces up to 16 bytes and integers below one hundred million take the fast path; anything else, including a string that grows as the loop runs (s = s .. piece — use a builder for that), takes the call it always took.

  • Looking things up in a large table is faster. A table with more than sixteen keys, or one with non-string keys, is a hash table underneath, and the way a key's hash picked its slot let keys that differ in one trailing character (w1, w2, … w999) pile up in runs, so a lookup walked past its neighbours before reaching its own entry. The slot is now picked by a multiply that spreads such keys out. The wordcount benchmark runs about 14 % faster under the JIT and 4 % faster interpreted on Apple silicon. Tables with a fixed set of string fields were already direct and do not change. The order in which for k, v in table visits a large table's entries changes as a side effect; that order was never specified (see Collections).

  • Saved partitions from earlier releases are rejected rather than misread. The change above moves where a hash table's entries sit inside a saved blob, so a blob written by an earlier release now fails to load with a clear version error instead of answering nil for every key. Re-save any blob you want to keep; scua_format_version() reports the new number.

  • Counting things in a dictionary is faster under the JIT. A hot loop that looks a string up in a table, updates the entry and stores it back — if counts[w] == nil then counts[w] = 1 else counts[w] = counts[w] + 1 end — used to fall out of the optimizing tier at the first table access. It now compiles, and the three accesses share one lookup. The wordcount benchmark runs about a quarter faster on Apple silicon. Nothing changes for the interpreter or for tables with a fixed set of fields, which were already fast.

  • Loops that build up a string or walk a linked list now compile. A loop-carried value that is a string, a record or any other object — s = s .. piece, cur = cur.next — used to keep the whole loop on the slower tier. It now compiles like a numeric one, and the while cur != nil test is folded away inside the compiled loop.

  • On Intel and AMD, loops that call out and use many constants now compile. A loop with a string interpolation, a function call or a table update in it and a handful of numeric constants used to fall back to the slower tier on x86-64 (the compiled loop ran out of registers), while the same loop compiled on Apple silicon. Constants no longer occupy registers in such loops, so those shapes compile on both.

  • --jit-stats reports opt-exits, the number of times a compiled loop handed control back to the interpreter. It is the counter to read when a loop compiled but did not get faster: a large number means it kept leaving.

  • The JIT is now on by default. Running scua app.scua compiles hot loops and functions to native code as they warm up; you no longer need --jit=on. Programs that spend real time in loops get several times faster, and everything else is unchanged — the compiled and interpreted tiers are checked against each other byte for byte, so the output, the errors and the limits are the same either way. --jit=off runs the interpreter only and is not going away; reach for it if you ever want to rule the JIT out.

  • The JIT now runs on Windows (64-bit Intel and AMD), both of its tiers. It previously did nothing there, whatever you passed. Windows on ARM still runs the interpreter, with the same output.

  • Asking for the JIT where it cannot run now tells you. --jit=on on a build or a platform without one used to be accepted in silence — every counter in --jit-stats read zero with no explanation. It now says so on stderr and carries on with the interpreter. Nothing is printed unless you asked, so this is quiet for everyone who simply runs their program.

  • Experimental native integration for host embedders. The C API supports managed userdata, including external ownership/borrowing, dependency-aware destruction and explicit redacted persistence. Checked access sessions expose packed numeric buffers for native reads/writes, and a single-use output API lets C fill a fresh SCUA array and commit its actual length. Output faults identify the operation, element counts and lifecycle remedy. See userdata and embedding. These remain experimental interfaces; raw native pointers are not restored from saves and scripts cannot enter SCUA from an audio/foreign thread.

  • Expanded opt-in dynamic FFI. Builds made with -Dffi=true can call host-granted native libraries using integer widths, strings, typed buffers, opaque resources, output parameters, supported records and owner-thread callbacks. Explicit ownership, opt-in automatic cleanup, tracked native writes and bounded callback ownership handoff cover resource lifetimes. Manifest generation and a Clang-based importer help create declarations. macOS/Linux are supported targets; Windows x86-64 remains a Wine-tested preview. General packed/over-aligned by-value calls remain unfinished. See native libraries and FFI for setup, platform verification status and limitations. No performance-parity claim is made.

#0.24.0 — 2026-09-08

  • Log lines now go to standard error, not standard output. This is a breaking change, and the one thing to check before upgrading. info, warn and severe are diagnostics, and standard output is the program's output — a warning landing in the middle of a result something else is parsing was the wrong default. print and sys.write are unchanged and still go to standard output.

    Two things to do. If you passed --log-stderr, remove it: the flag is gone, because it now describes the default. Note that a run still passing it fails with scua: --log-stderr: cannot read file and exit status 2 — the flag is no longer recognised, so it gets read as the name of the script to run. If you see that message, this is why.

    If anything you own reads log lines off standard output — a wrapper script grepping for [warn], a CI step, a log collector — point it at standard error instead, or pass the new --log-stdout to keep the old behaviour while you migrate. scua tool.scua 2>&1 merges the two streams in the order the script wrote them.

  • New --output=json, which makes standard output a tool's result channel. With it, sys.emit writes the result and nothing else reaches standard output — print, sys.write and log lines all go to standard error instead. So a tool that prints as it works is still safe for a caller to parse, and nothing is thrown away: the printed lines are on the other stream. Without the flag nothing changes. It's a flag on the run rather than on the script, because the caller parsing the output as JSON is the one who knows it wants JSON.

  • New sys.write_err(s). The twin of sys.write, on standard error: raw bytes, no trailing newline, no [level] prefix. There was previously no way for a script to put anything on standard error without either a log level's prefix or sys.fail (which exits), so progress bars, spinners and plain status lines had nowhere to go that wasn't the output itself.

  • print output now reaches a pipe as it happens, instead of waiting. A script that printed something and then got on with a long computation showed nothing at all until it finished — the output sat in a buffer that was only emptied when the script paused or exited. A ten-second job with progress lines looked hung. Output is now handed over as soon as it is a millisecond old, or sooner once enough has built up, so a reader sees it straight away; a tight loop printing thousands of lines still batches them, so nothing got slower (large runs are marginally faster). This is the same whether output goes to a terminal, a pipe or a file — SCUA has no equivalent of Python's -u, because it never needed one. Two things to know: output still cannot appear during a single uninterrupted line of work (nothing can interrupt it to write), and a run still keeps everything it printed in memory until it exits, so a script emitting a very large report costs roughly that much memory.

  • sys.parse_args no longer reads standard input when the command line already answered it. It used to read stdin to end-of-file the moment it was called, before it had looked at your arguments at all. If your tool was launched from a loop that feeds it a list — while read f; do mytool "$f"; done < files.txt — the first run swallowed the rest of the list, and the loop stopped after one item. The list isn't JSON, so that run usually exited 2 complaining about "bad JSON on stdin" for a payload you never supplied; if the leftover text happened to be a JSON object, it exited 0 instead and simply processed one file. Neither told you the rest of your list was gone. parse_args now reads stdin only when a JSON body could still supply something — when nothing was passed on the command line at all, or when a field with no default is still unfilled after your flags and positional arguments. Tools driven by a JSON body on stdin are unaffected. --help and an unrecognised option now answer without touching stdin either. One deliberate change to be aware of: if you pass a non-empty command line that fills every required field, a JSON body supplying only optional fields is now ignored rather than read.

  • Fixed scua fmt mis-indenting everything after a table key or record field named like a keyword. A key such as on = 2, then = 3 or end = 4 on its own line (the vocabulary of a state-machine table, say), a field do: int in a record, or a member access like spec.then, was counted as opening a block that nothing closed, so every following line in the file gained two spaces, top-level code and comments included. The same happened after a fn used as a type (f: fn(int) -> int). scua fmt --check then rejected the correct file and accepted the damaged one. Both are fixed: a keyword-shaped name in key, field or type position is a name. And as a backstop, if the formatter ever finds a block or bracket still open at the end of a file, it now refuses with a message and writes nothing, rather than rewriting the file. --check reports that refusal as a failure.

  • The formatter's indentation rule is now written down. Each open bracket adds a level of its own, so a table or list opened on the same line as the call it is passed to sits two levels in and its closer one level in. That is what scua fmt always wrote; the manual's scua fmt section now says so, and the examples and the guide's snippets were reformatted to match it, so scua fmt --check passes every example.

#0.23.0 — 2026-09-06

  • Fixed a wrong result from the JIT for a helper that recurses inside another function. A function calling a self-recursive helper (fn p(n, k) return h(n, k) * 2 end with h recursing on itself) could return the wrong value once both were compiled: the innermost inlined copy of the helper handed its recursive call stale arguments. Loops calling such a helper are also fully inlined now instead of falling back to a slower form. A second case, a function calling two different self-recursive helpers (rec1(n, k) * 2 + rec0(n, k)), could recurse on a stale value or report a spurious stack overflow; both are fixed, and a new nightly fuzzer generates exactly these call shapes so they stay fixed. On x86-64 only, a loop with fifteen or more live locals that then called a function could fail with a type error or a wrong value once compiled; also fixed.

  • Tail-recursive functions run much faster in the JIT. A function whose recursive call is the last thing it does (return rec(n - 1, acc + n)) now checks its callee and its call-site guards once per entry instead of once per level, keeps its depth and stack bookkeeping as two register compares, and leaves the housekeeping only an error or a debugger would need until it is actually needed. The tail-recursion benchmark went from 48 ms to 18 ms on arm64, and plain linear recursion (n + rec(n - 1)) from 56 ms to 36 ms. On x86 the same work, plus a faster path for globals that never change and a fix that lets hot loops over records keep their inlined helpers when registers run short, brought the tail-recursion benchmark from 130 ms to 72 ms, linear recursion from 127 ms to 76 ms, and the record-method loop from 181 ms to 20 ms. Recursive closures held in a local variable or captured from an enclosing function (let rec = nil; rec = fn(n) … rec(n - 1) end) now run as fast as a global one: the inner-closure and captured-closure recursion benchmarks went from 69 ms and 58 ms to 37 ms and 38 ms on arm64, and from 195 ms and 141 ms to 77 ms and 78 ms on x86.

  • Hot loops over records now inline their helpers and hoist the record fields. A loop that calls small functions on records that never change inside it (dot(a, b), norm2(a) over two fixed points, say) now compiles as one straight-line body: the helpers are inlined two levels deep, each record is shape-checked once when the loop starts, and fields the loop never writes are loaded once and kept in registers. The record-method benchmark went from 53 ms to 7 ms on arm64. Recursive functions also got faster across the board (up to 13% on the recursion benchmarks) because the JIT keeps its call bookkeeping in registers now.

  • Methods called through a record field now take the JIT's direct-call path. A call such as self.rec(self, n - 1) or obj.step(obj) used to go through the general call machinery on every level, which on the recursive-method benchmark made the JIT slower than the interpreter. The compiler now recognises the closure stored in the field, so these calls get the same treatment as a call to a named function: the direct self-call, the inlined levels, and the fast return. The benchmark went from 587 ms to 67 ms on arm64, and a loop that calls a method on a local record now compiles too. If a field holds different functions at different times, the JIT notices after a few calls and recompiles that site with a plain call; --jit-stats reports this as callee-misses.

  • Nested ifs compile too, and a branch that has never run no longer blocks the whole function. The JIT's function tier used to give up on any function with an if inside an if; it now compiles them, including else arms and either form of nil test. Separately, a branch that has not executed yet — an error path, a default, if x == nil then return … end — used to make the whole function run in the slower tier for good, because the field access inside it had no type information. Such a branch is now compiled as an exit to the interpreter and the function is recompiled once the branch has actually run. In the generated record-method test corpus, the share of methods the fast tier accepts went from 24% to 61%.

    Three wrong results that this uncovered are fixed. A loop of the shape t = 0; if a then if b then t = 2 end; t = 5 end could read the inner value on the path where a was false. A recursive call inside an if whose result was a float could fault with "type error" under the JIT. And a function that called another function inside an if, where that call had to fall back to the interpreter, could resume at the wrong instruction. All three reproduce on 0.18.0.

  • x ?? default on a record field compiles, and a function refused once is tried again. A method reading an optional field with ?? used to fall out of the fast tier (and block the fast path for every later field read of the same record). It now compiles as a single check. And a function the JIT refused because part of it had not run yet is recompiled as soon as that part runs, instead of waiting for a retry window that a natively-called function never reaches. In the generated record-method corpus the fast tier now accepts 76% of methods (61% before, 24% at the start of this round).

  • Code inside an if runs faster under the JIT. Each compiled operation inside a branch used to carry its own "skip if the condition is false" test; a branch of sixty operations was sixty tests, all of them taken whenever the branch was not. Consecutive operations under the same condition now share one test. A recursive tree walk written with != nil measures −12% on arm64 and −8% on x86-64, a record method with a nil-tested field −8% / −4%, grid pathfinding −10% / −4%.

  • Faster on x86-64 — and a CPU requirement to go with it. The prebuilt x86-64 binaries were being compiled for the oldest x86-64 chip that has ever existed (a 2003 feature set), while every performance number we published came from a locally built binary tuned for a modern one. They are now built for x86-64-v2 — Intel Nehalem (2009) / AMD Bulldozer (2011) and newer — which measured 4.5–14.8% faster on ten of fifteen benchmark programs. Nothing about your scripts changes; they just run faster.

    If your machine is older than that, or you are on a VM presenting an old CPU model, scua now tells you so and exits instead of dying on an illegal instruction, and the package ships scua-compat — the same version of the same language, built to run on any x86-64. arm64 is unaffected and has no such floor.

  • x86-64 catches up on recursive closures and record walks. Two things the JIT already did on arm64 now happen on x86-64 too: a recursive function that reads variables from an enclosing function compiles (a helper-inside-a-function shape measures 6× faster), and a function that was first compiled before it had seen all of your data gets recompiled once it has (the tree walk above measures 2× faster on x86-64, a record method with two layouts 2.6×). A loop that picks one of two records each iteration (let t = a; if cond then t = b end) now compiles on x86-64 as it did on arm64 (6× faster on that shape).

  • Arithmetic with a float or large-integer literal is one instruction. x + 2.0 and state * 1664525 used to load the literal into a register first; the compiler now emits a single instruction carrying the constant, and the interpreter and both JIT tiers all understand it. Polynomial evaluation measures −15% in the interpreter on arm64 and order matching −5%; on x86-64 the physics and word-count programs gain 5–15% while the polynomial one loses 15%, a register- pressure effect in that interpreter loop that is recorded in the research notes.

  • A function call inside an if no longer stops the JIT compiling the whole function. Code like if p.w == nil then return dot(p, p) end used to run in the slower tier because of the call inside the branch. It now compiles, with the call taken only when the branch is. A record method written that way measures 2× faster on arm64, and a recursive tree walk written with != nil 1.8×. A small helper called inside the branch is inlined there too (another −10% on that method).

  • The interpreter copies values in one move. Loading a constant, moving a value between registers, reading a record field or an array element, and returning a value each wrote the result in four pieces; they now write it in one. Polynomial evaluation measures −10% in the interpreter on arm64, physics −6%, order matching −5%; nothing about the values or saved data changes.

  • if x != nil is as cheap as if x == nil. The interpreter compiled the != form into four steps (make a nil, compare, negate, branch) and the == form into one. Both are now a direct nil test; a recursive tree walk written with != nil measures −35% in the interpreter and matches the == nil version. (Under the JIT the == form still compiles further; the != form's compiled path is next.)

  • Building records is faster. Each field a record constructor adds ({ v = d, l = left, r = right } is three of them) used to walk the layout tree; the site now remembers the layout it produced last time and reuses it. Constructing a 131k-node tree measures −32% on arm64 and −24% on x86-64, in both the interpreter and the JIT (the JIT does the whole step in compiled code); record layouts, saved data and the on-disk format are unchanged.

  • Functions that build records and arrays stay in compiled code. Creating a record ({ v = d, l = … }) or an array inside a compiled function no longer hands the whole function back to the interpreter for that one step. A recursive tree builder measures −12% under the JIT on arm64.

  • A recursive record walk now stays in compiled code all the way down. The JIT inlines a recursive function into itself one level deep; the inlined level can now read fields and test them for nil too, so a tree walk no longer falls back to slower code at every leaf. The same binary-tree walk measures a further −8% under the JIT on arm64. Fixed on the way: a compiled recursive function that left compiled code from inside its inlined level could resume at its first instruction instead of where it left off; with a walk this made the program spin forever. It never shipped in a release, and has a regression test.

  • Recursive functions over records compile, and the JIT recompiles a function once it has seen more of your data. A tree walk — if t.l == nil then return t.v end; return t.v + sum(t.l) + sum(t.r) — now runs in compiled code (−46% under the JIT on arm64). And a function that the JIT compiled on its first call, before its field reads had seen every kind of record they would meet, used to fall out of compiled code on every mismatched call for the rest of the run; it is now recompiled, a bounded number of times, once its caches have learned the new layout. A record-method loop that hit exactly that measures −46%.

  • Small record functions called from a hot loop now compile with their field reads inlined. A loop calling dot(a, b) on two records used to inline the call but not the field reads inside it, which dropped the whole thing back to a real call. Field reads on any record the loop holds — an array element, another record's field, whichever of two records a condition picked — now compile in place. A record-method loop measures −30% under the JIT on arm64; a loop picking between two record layouts per iteration −79%.

  • The JIT keeps a loop native when the records flowing through it come in two layouts. A loop over an array whose records alternate between two shapes — say half of them carry an optional field — used to fall out of compiled code on every other element. It now guards both layouts and compiles if e.z == nil on the optional field as a test rather than a load. A 50/50 stream of that shape measures −64% under the JIT on arm64; uniform-record loops are byte-for-byte unchanged.

  • Field access on records of two different layouts no longer thrashes. A field read at one place in your code that sees two kinds of record — a tree's leaves and its interior nodes, a record with and without an optional field — was re-looking the field up on every access. The per-site cache now remembers the last two layouts, and remembers when a field is absent. A binary-tree walk measures −7% interpreted; a site alternating two layouts −28%.

  • Fixed: a recursive closure called from a hot loop could fail under the JIT on Apple silicon. A function that recurses through a captured variable (let rec = nil; rec = fn(n) ... rec(n - 1) ... end) called from a loop that had been compiled would stop with "attempt to call a non-function", a type error, or in one shape a crash, while the same code ran correctly with --jit=off. The compiled loop's caller was mistaking the callee's hand-back to the interpreter for its own and resuming the callee one step late. Fixed, with a regression test. In the same round the JIT on Apple silicon learned to read and write captured variables natively, so closures that use them no longer fall back to the interpreter on every call, and the optimising tier now compiles functions that use captured variables: the same recursive-closure shape runs about 10× faster than before, and 3× faster than the interpreter.

  • Loops that update a record's numeric fields compile to half the code on Apple silicon. The JIT was shuffling loop-carried values between registers at the bottom of every iteration and re-writing each field's type tag on every store, although it had already checked the tag once on entry. Both are gone: a field-update loop measures −36% (now level with LuaJIT), a polynomial loop another −9%.

  • Hot loops run faster under the JIT on Apple silicon. The step counter that bounds a turn's CPU budget was being updated in memory on every loop iteration; the compiled code now keeps it in a register and writes it back whenever it hands control back. Same budget, same limit, same error — just not paid for on every iteration. Measured on arm64: a float polynomial loop −33%, a field-update loop −15%, a summing loop −14%. x86-64 was never paying for this and is unchanged.

  • Recursive functions run a lot faster. Returning from a deep recursion was paying for a chain of bookkeeping on every level that the CPU could not overlap, and a few common shapes of recursive code were falling out of the fast interpreter loop altogether: a recursive helper defined inside another function (fn outer() let helper = fn(n) ... helper(n - 1) ... end ... end), a closure that calls itself through a captured variable, and an if x == nil test in a recursive walk. Measured on arm64 against the previous release: a plain 240-deep recursion −30%, fib −18%, a helper-inside-a-function recursion −49%, a binary-tree walk −31%. On x86-64 the deep recursion is −9%, fib −7%. Nothing about how you write recursion changes.

  • A long loop in a script with nothing else running no longer pauses to be fair to nobody. The runtime interrupts a long-running loop every so often so other tasks get a turn. When a script has no other tasks, that interruption had nothing to hand the turn to and was pure overhead — up to 17% on tight loops. It is now skipped when there is nobody to yield to. Scripts that do spawn tasks are unaffected: they interleave exactly as before.

  • New: bytes.join(parts, sep?), for building one buffer out of many pieces. There was no direct way to do it: bytes.concat takes exactly two arguments, so the only option was concat in a loop — which copies everything accumulated so far on every step, and gets quadratically slower as the buffer grows. Assembling 32,000 chunks of 4 KB that way took 445 seconds; bytes.join does it in 0.02, and holds 8 MB rather than 41 MB while it works.

    The separator is optional, unlike str.join's, because assembling a buffer usually doesn't want one. Pieces may be bytes or strings.

    import bytes
    let parts = [b"GET ", b"/index.html", b" HTTP/1.1"]
    print(len(bytes.join(parts)))
    $ scua join.scua
    24
  • reserve on a plain array no longer costs three times the memory it saves. reserve(xs, n) on an array that hadn't been given an element type yet allocated n boxed slots — 16 bytes each — and then the first number pushed into it switched the array to a packed layout of 8 bytes each and abandoned the reservation. You paid for both. Measured at 8 million floats, peak memory was 24 bytes an element for a steady state of 8; it is now 8, which is better than not calling reserve at all.

    Nothing about how you use it changes. The capacity is now claimed by the first push, which is the first moment the right size is knowable, and a large one goes straight to its final home instead of being copied by the collector.

  • A large piece of text is no longer copied again by every garbage collection. A big bytes value has always been held outside the ordinary heap, so collections hand it over untouched. Text was not, so a large string was re-copied for as long as it stayed alive — invisible in peak memory, which is why it went unnoticed, but real work every time. Reading a 17 MB file and transforming it went from 179 MB copied to 1.4 MB, and holding a 16 MB string live across a run went from 34.8 MB to 1.2 MB. Peak memory is unchanged; what goes away is the repeated copying.

    This applies to any large string, however it was made — fs.read_text, str.repeat, str.join, a slice of a bigger one. Nothing about the behaviour of strings changes, and a saved partition is written exactly as before.

    What it does not reach: the threshold is per string, so a program whose memory is thousands of small strings — the shape of line-by-line processing, tokenizing and parsing — sees no change.

#0.22.1 — 2026-09-03

  • Fixed: reading from an encrypted connection waited for the buffer to fill. net.read on a connection upgraded with net.start_tls blocked until it had the number of bytes you asked for, instead of returning what had arrived — so talking to a server that sends a message and waits for your reply, which is most database and messaging protocols, stalled until the connection timed out. It now returns as soon as there is something to return, exactly as it does on an unencrypted connection.

    If you worked around this by requesting exactly the bytes you still needed, that code stays correct and needs no change.

#0.22.0 — 2026-09-03

  • let x = nil now works the way it reads. Writing let x = nil and assigning a real value later used to fail — the binding took the type nil, which nothing but nil can ever be assigned to. It now takes any, so the placeholder idiom works, and it works the same in every context. const X = nil keeps the narrow type, since a constant cannot be reassigned.

    This is not a loosening of type checking anywhere else: a binding that starts with a real value keeps its inferred type and still refuses a wrong assignment.

  • insert(array, index, value). remove existed without it. index may equal len(array), which appends. Returns the array.

  • sort says what it could not order. Sorting an array of tables used to report a bare type error; it now names the type it met and suggests sorting keys or extracting the field you want to order by.

  • bytes.slice_exact(b, start, end), for decoding input you did not create. bytes.slice clamps its bounds, as slicing does everywhere in the language, so a range past the end quietly returns a shorter value — which is the wrong default when the range came from a length field in a file you are parsing. slice_exact fails instead, joining bytes.u32be and the other fixed-width readers, which already do. bytes.slice is unchanged.

  • http.open streams now count against --max-mem. See the note on handle memory above; response streams were the one open handle the limit still could not see, and they are the largest, because the decompression buffer is sized by the server's content-encoding.

  • New: encrypted raw connections, so net can reach a managed database. net.start_tls(conn) encrypts a connection you already opened, and net.dial_tls(host, port) connects and encrypts in one step. Both hand back the same kind of connection, so net.read, net.write and net.close carry on working unchanged.

    Which one you need depends on the protocol. PostgreSQL, MySQL, SMTP and IMAP negotiate encryption in-band: you connect in plaintext, exchange a request, and only then encrypt — so those use net.dial followed by net.start_tls. Redis and anything HTTPS-shaped expect encryption from the first byte, so those use net.dial_tls. Using net.dial_tls on PostgreSQL cannot work, and the error says so.

    Certificates are checked against your system's trusted authorities, and against the host you dialled. A self-signed certificate needs both { insecure_skip_verify = true } on the call and the run started with --allow-net-insecure=HOST — turning off certificate checking is a trust decision, so it takes the operator's permission and names the hosts it applies to. It relaxes less than the name suggests: the hostname is still checked.

    Works on macOS, Linux and Windows.

  • Deeper recursion: the call-depth ceiling is now 4000 frames, up from 1000. Code that walks nested data usually spends two or more frames per level, so this takes the depth of input you can handle from roughly 500 levels to roughly 2000 — enough for graph algorithms and document trees that previously needed rewriting as a loop with your own stack.

    Running out of depth is still catchable, which is the part worth knowing: try/rescue and pcall turn it into a value, so a library walking data someone else supplied can report "too deep" instead of dying. The message now says so itself, rather than leaving you to find out.

    On upgrading: a test that asserts some input is too deep may now find that it isn't. If you pinned a depth against the old ceiling — "1000 levels should fail" — that assertion can invert. State the guarantee instead of the number: that your walker reports rather than crashes, driven through your own limit, which holds whatever the runtime's ceiling is.

  • --max-mem now counts the memory that open handles hold. Each open file handle costs about 64 KiB of read buffer, and each TLS connection about 82 KiB of record buffers. That memory lives outside the script's own heap, and until now the limit did not see it — so a run holding many handles could go well past the ceiling its operator set.

    On upgrading: this can make a run that previously finished stop with a memory-limit error, without anything in your code changing. That is the limit doing what it says rather than a new restriction. If you set --max-mem and open many files or connections at once, either raise it or close handles as you finish with them — fs.close and net.close release the buffer immediately.

#0.21.0 — 2026-09-02

  • New: write a file of any size, a piece at a time. fs.open(path, { mode = "write" }) (or "append") plus fs.write_chunk(f, data). fs.write takes the whole content at once and truncates on every call, so it could not build a large file at all — now you can. Appending to a file that does not exist yet creates it. Writes go straight to disk, so a failure is reported by the call that caused it, and the error says how many bytes got through — the file is exactly its previous length plus that many, so you know what state it is in. A disk-full failure now says so, instead of the bare "i/o error" it used to give. See writing a file too big to build in memory.

  • New: read a file of any size, in a fixed amount of memory. fs.open(path) gives you a live file handle; fs.read_line(f) pulls one line at a time and fs.read_chunk(f, max) pulls raw bytes, and fs.close(f) releases it. Until now fs.read/fs.read_text were the only way in, so the memory you needed was the size of the file — and since no single value can exceed 256 MiB, a file past that could not be read at all. Now it can. See reading a file too big to load and examples/streaming_files.scua.

    The two readers signal the end of the file differently, and it matters. fs.read_chunk is finished when it returns empty bytes. fs.read_line is finished when it returns nil — because an empty line is a real line, so if "" meant "finished" a reader would stop at the first blank line in your file without saying so.

    Two things to know: close what you open — nothing enforces it during a run, and a loop that leaks handles will exhaust the process's file descriptors and fail somewhere unrelated; and a program holding an open file cannot be snapshotted, so close it before a handler that must survive one ends.

  • fs.read(path, 65536) now tells you it is wrong instead of quietly reading the whole file. The second argument is an options table — fs.read(path, { max_bytes = 65536 }) — and a bare number was being discarded, so you got the entire file with no cap and no complaint. It is now a clear error naming the right form. fs.read_text had the same hole and got the same fix. ⚠️ If you have code passing a number there, it was never doing what it looked like, and it will now fail loudly.

  • For embedders: a refused save now says which handle is blocking it. Saving a partition is refused when something live is still reachable — an open connection, a running process, an HTTP response stream — because none of those survives a reload. That refusal used to come back as the catch-all "other" error with no explanation, so there was no way to tell it from any other failure, and no clue that the fix was to close something first. There is now a dedicated SCUA_ERR_HOST_RESOURCE code and a scua_blocking_resource() call that names the specific handle. ⚠️ This error was previously reported as SCUA_ERR_OTHER; a host checking for that value needs updating.

  • New: fs.lstat(path). The same record as fs.stat, plus is_symlink, and it does not follow a final symlink. fs.stat follows links, so it describes what a link points at — which meant there was no way to ask whether a path is a link. That mattered most when replacing a file: writing to a temp file and renaming it over the target (the crash-safe way) replaces the link with a regular file and leaves the real file untouched, so the write reports success while nothing you named changed. Hard links break the same way. Check with fs.lstat first. A link pointing outside the granted root is still refused rather than described.

  • Searching a string from an offset is much faster, and no longer slower than searching all of it. str.find(s, needle, from) used to walk the text to work out where from was, and walk it again to turn the answer back into a character position — so passing an offset could cost more than scanning the whole string from the start. On plain ASCII text both walks are now unnecessary and are skipped: in a test over 8M characters, searching from an offset went from 291 ms to 41 ms (it is now free), and searching from the start went from 191 ms to 97 ms. str.rfind, str.slice, str.index, str.code, str.chars and str.pad_start/str.pad_end got the same treatment. Text with accents, CJK or emoji is unaffected and returns exactly what it did before — this only removes work that was already redundant. Loops that advance an offset through a large string were the thing this hurt most: they were quadratic, and read as perfectly ordinary code.

  • --jit-stats and --mem-stats now print however the program ends. They only ever printed after a normal finish, so a script that ended through sys.emit or sys.fail — the documented shape for a tool that reports JSON — printed no statistics at all, and there was no way to tell "nothing happened" from "the counter never ran". Faults and resource limits had the same gap. All of them now emit.

  • str.split and str.lines use about a quarter less memory on large inputs. Splitting a 55 MB string into five million pieces peaked at 971 MB and now peaks at 729 MB; the memory asked of the runtime dropped by 36%. str.lines — read a file, iterate its lines — is identical, and is the one most likely to meet a big input. Two causes, neither necessary: the result array grew by repeated doubling instead of being sized once, and the text was copied in full before the first piece was produced. Both are no slower and the results are unchanged.

  • The rest of the str functions stop copying their input too. Nearly every str.* function used to duplicate the whole string it was given before doing anything — including the ones that only look at it, like str.starts_with, str.find, str.contains and the str.is_* checks. For the common shape, lots of calls on short strings, that copy was most of the cost: a benchmark of 12.8 million predicate calls went from 0.56 s to 0.41 s, 27% faster. Results are unchanged everywhere.

  • Reading a large file no longer needs ceremony. fs.read and fs.read_text had an 8 MiB default cap, so a 30 MB export could not be read without first discovering the limit and passing { max_bytes = … }. The default cap is gone; a large file reads like any other. max_bytes keeps the direction that was actually useful — refusing an unexpectedly large file before it costs you the memory. The only limit left is structural: one value cannot exceed 256 MiB, and for a file bigger than that, fs.grep searches it without loading it.

  • A web server could only run trivial request handlers. Any handler that did more than about four thousand steps of work — which is to say any handler that did anything — dropped the connection and killed the server, reporting a nonsense error against a line of your script that was correct. Measured: a handler looping 3,900 times answered 200; the same handler looping 4,100 times closed the connection and the process exited. Fixed. If you tried http.serve and concluded it did not work, it didn't; it does now.

  • A served request now has a time limit. Granting --allow-serve arms a 30-second deadline on each request; a handler that runs past it gets a 503 and the server carries on. --serve-timeout=MS changes it, --serve-timeout=0 removes it. It is on by default because a server runs work chosen by someone else, repeatedly, with nothing else bounding it — and because the CPU budget above no longer does. It is not armed under --fast or scua test, where the clock fast-forwards.

  • A correct program is no longer stopped part-way through for taking too long. SCUA had a built-in CPU ceiling of 100 million steps per turn, and a perfectly ordinary counting loop hit it after a few seconds and was killed. Nothing you asked for and, in a survey of fourteen other languages, nothing any of them does. The rule now is one sentence: a budget you set kills; the built-in one does not.

    $ scua count.scua          # a 300-million-iteration loop
    300000000                  # …used to stop a third of the way through

    Every deliberate limit is untouched. --max-ops=N still kills, and still exits 6. So does a sandbox mod's budget, and so does an embedding host's op_budget — a runaway plugin is contained exactly as before. The difference is only that you are no longer treated as untrusted when running your own script.

    One consequence worth knowing: without --max-ops, a genuinely infinite loop now runs until you stop it, where before it died after a few seconds. If that matters for your case, set a budget.

  • Building a value that's too big is now an ordinary error you can catch. One value — one array, one table, one string, one bytes — has a ceiling of 256 MiB, about 16.7 million elements in a plain array. Going over it used to stop the program with runtime error: ObjectTooLarge: no line, nothing try/rescue or pcall could catch, and the same exit status as a script's own sys.fail. Now it names the limit and how far over you went, points at a way around it, and behaves like every other fault:

    cannot hold 5000000000 items: reserve(5000000000) is over the largest a single array may be (16777214 elements)

    The value you were building is untouched, because the size is checked before anything is allocated. Inside a partition it ends that turn instead of taking the whole process down with it — one actor that builds something too big no longer kills the server and every other actor with it. See Values that get too big.

  • Arrays reach twice the size they used to. A plain array grew by doubling and stopped one doubling short of what it was always allowed to hold, so the ceiling depended on how you built it: reserve and fill reached higher than the same array built with push. A push-built array now reaches 16,777,214 elements instead of 8,388,608. Nothing about your code changes.

  • Reading a large file uses noticeably less memory. A big block of file data used to be copied by the garbage collector every time it ran — including the collection that the read itself set off. Now it's handed over instead of copied. A 32 MB fs.read peaks at 67.7 MB instead of 99.6 MB. This applies to fs.read, which returns raw bytes; fs.read_text is unchanged for now.

  • A read limit that told you to crash the program. Reading a file bigger than the limit printed the file's actual size as the max_bytes to pass — and for a file over 256 MiB, passing that value was guaranteed to kill the run. It no longer suggests a value that cannot work, and max_bytes is now capped rather than taken literally, so { max_bytes = 4000000000 } keeps meaning "I don't mind how big this is".

  • max_bytes was being ignored inside a partition handler under --io=async. Both the option and its error message did nothing on that path; the old, circular message ("read raw bytes with fs.read" — from inside fs.read) came back with it. Fixed, and that message is gone everywhere.

  • durable.verify_roundtrip said a save was safe and then the save failed. It checked that the data could be encoded but not that the result could be held, so a value too large to save passed the check and then killed the program. It now checks both, and durable.encode refuses with a catchable error naming the size and the limit, so you can fall back instead of dying.

  • A sandboxed script's failures now say what happened. A host running untrusted code got the bare word error with a line number for most failures — including the message a script raised itself with error("…"). The real message now comes through.

  • New exit status 7, for the case where the runtime itself cannot continue. Previously that fell back to 1, which is a script's own "ran fine, found nothing" — so a CI step could not tell a dead interpreter from a clean negative result. See Exit status.

#0.20.0 — 2026-08-31

  • A tool can print an answer and warn about something. print, sys.write and all five log levels write to standard output, so a tool whose stdout a caller parses as JSON had no channel left for a warning — the only thing that reached stderr was sys.fail, which ends the program. The new --log-stderr flag sends log lines to standard error instead, leaving stdout carrying only what the script printed:

    $ scua --log-stderr tool.scua < in.json
    {"ok":true,"sum":5}

    It's a flag on the run rather than something a script chooses, because the program reading stdout is the one that needs it clean. Log statements are written the same way either way, and the default is unchanged — at a terminal, logs still appear alongside everything else. It combines with --log and --log-time.

  • Exit status 1 used to mean four different things. A syntax error, a missing capability, a crash and a script's own sys.fail("no match") all exited 1, so a caller couldn't tell "your program is broken" from "the thing you searched for isn't there". Each now has its own status:

    Status Meaning
    3 The script didn't compile
    4 The script wanted a capability the run wasn't granted
    5 The script crashed (an uncaught fault)
    6 --max-ops or --max-mem stopped the run

    0, 1 and 2 keep their meanings and stay yours to choose with sys.exit / sys.fail; scua now reserves 3–6 for its own outcomes. Naming a file scua can't read also moves from 1 to 2, where the rest of the bad-argument errors already were. scua test and scua fmt --check are unchanged (both still exit 1 when they find something). If you have a script or CI step matching on exit code 1 to detect a broken program, it needs updating — that case is now 2–6.

  • The agent-tool guide no longer promises something the runtime doesn't do. It said sys.emit and sys.fail discard buffered print output so the result channel carries only the result. In a normal run the earlier lines have already been streamed and can't be recalled, so they appear in front of the JSON. The guide now says what actually happens, and points at --log-stderr for the case it was trying to cover.

  • fs.rename(from, to) — replace a file without a window in which it is gone. fs.write empties its target before it writes: for a moment the old contents are gone and the new ones have not landed, and a crash or a Ctrl-C in that window leaves the file empty rather than stale. There was no way out of it. Now there is: write the new copy under a temp name and swap it in with fs.rename, one step that either happened or did not.

    fs.write("data.json.new", fresh)?
    fs.rename("data.json.new", "data.json")?

    An existing destination file is replaced — that is the point. Both paths are bounded by the granted root exactly like every other fs path: no absolute paths, no .., no symlink out, and the root itself is refused at either end. fs.write itself is unchanged and still truncates; see Replacing a file without losing it for why that is deliberate.

  • fs.grep no longer answers "no results" to a question it cannot ask. A pattern containing a newline matched nothing and reported success, because the search reads one line at a time and the newline never reaches the matcher. The text could be sitting in the file — str.find would find it — and the search would say it was not there. Such a pattern is now an Error that says exactly that, and points at reading the file and using str.find/regex.find to search across lines.

  • fs.grep says when it stopped early. Without a max, the search caps at 10 000 results; it used to return exactly 10 000 hits and call that success, which is indistinguishable from having found them all. Reaching the default cap is now an Error naming it and suggesting how to narrow the search. A max you set yourself still truncates quietly — you chose the number.

  • { max = 0 } in fs.grep means zero results. It used to be read as "no cap given" and fall through to the 10 000 default, so a budget that computed down to nothing came back with ten thousand hits. { max_per_file = 0 } had the same problem — it meant "no per-file cap", so asking for nothing from each file returned every line of it — and now also means zero. A negative count is refused rather than ignored.

  • A declared list or enum is now actually checked when loose data crosses into a record. A field declared { string } accepted anything at all: hand a single word to sys.parse_args where the tool wanted a list of files and the program got a five-character string back, which for f in args.files then walked one character at a time — no error, just a plausible-looking wrong value. A field declared as an enum was the same: any string, any number, even a variant of a different enum went straight in. Both are now rejected at the crossing, naming the field, what arrived and what was declared:

    {"error":{"message":"field 'files' of Args is string, expected { string }","field":"files","record":"Args","value":"a.txt"}}

    The same crossing gained three more checks that were missing with it: a list of scalars checks each element (and names the index of the first wrong one); a flags field must hold a set of that flags type; and a { f32 }-style numeric buffer field is converted to its declared element kind, so data arriving from JSON or a save file gets the packed storage the annotation promised instead of quietly staying boxed. A list of records was already checked, and its behaviour is unchanged — except that an empty string no longer satisfies it (zero elements meant zero checks).

    This applies everywhere loose data meets a record type: sys.parse_args, a typed let, a typed function parameter, a message arriving at a partition handler, and a loaded save.

    One consequence worth knowing when you write a tool: a command line carries text, so a { string } argument can only be supplied from a JSON body on stdin, and an enum argument can't be supplied by either route. Both used to look like they worked and didn't — a string in an enum field matched no variant, so a match over it silently fell through to the default. Declare a string and convert it in your own code.

  • A long-running task no longer starves the others. A task that looped for a long time used to run to completion before any other task got a turn — so a background job could freeze everything else until it finished. Tasks now take turns automatically: a long loop pauses every few thousand steps, lets its siblings run, and picks up where it left off. Nothing to enable and no change to your code.

    Two caveats while the rest lands: this applies to interpreted code, so a loop hot enough to be compiled still runs to completion; and it does not yet apply inside a partition handler.

    CPU budgets are unaffected: --max-ops=N still means "this turn may run at most N steps", however many times it paused, so a runaway loop is still stopped exactly as before.

  • An HTTP server can log. A print inside an http.serve handler never reached stdout — the request was served correctly and the line simply never appeared, for the life of the process. (It looked like a print quirk, because sys.write happened to work: it forces its own flush.) Output now appears as requests are handled.

  • A task blocked in net.accept no longer looks like it never started. Anything printed before the blocking accept — including the line before net.listen — was buffered out of sight. The same fix landed for http.serve earlier; this covers the rest of the class.

  • The scheduler's resumption ceiling says something when it fires. A task looping on plain yield tripped an internal safety valve that stopped the program early, printed nothing, and exited 0 — indistinguishable from finishing normally. It now reports what happened and why.

  • A finished background task no longer holds onto memory. Every task started with spawn stayed in the scheduler's queue after it completed, keeping its whole call frame alive — about 264 bytes for a trivial task, and more the more variables it used. A server spawning a task per request grew forever. Tasks inside a partition had the same problem, and there the dead tasks were also written into saved state. Both release now.

  • fs can delete and create directories. fs.remove(path) removes a file or an empty directory; fs.remove_all(path) removes a directory and everything in it; fs.mkdir(path) creates one (including missing parents, and it is Ok if the directory already exists). Deletion is bounded by the grant, not by care: absolute paths and .. are rejected, the path must resolve inside the granted root, the root itself is refused however you spell it, and a symlink is unlinked rather than followed. Grant --allow-fs at the narrowest directory that works.

  • A file you can write is now a file you can read back. fs.write has no size cap and fs.read had a fixed 8 MiB one, so a script could write a 9 MB file and then fail to read its own output — and the error told you to "read raw bytes with fs.read", which is the call that had just failed. fs.read and fs.read_text now take { max_bytes = N } to set the limit for one call, in either direction, and the error names the file's actual size, the limit, and the exact option to paste back.

  • A file of exactly 8 MiB reads. The cap rejected a file at the limit while every message said the limit was 8 MiB, so 8,388,608 bytes failed with "over 8 MiB" and 8,388,607 succeeded.

  • fs.grep searches a single file. fs.grep("needle", "notes.txt") — which the signature plainly invites — used to answer i/o error on a file that exists and is perfectly readable. It searches the file now. fs.list and fs.find still want a directory, but say so instead of blaming the disk.

  • A missing parent directory says so. fs.write("logs/today.txt", …) with no logs/ directory reported "the path resolves outside the granted root — a symlink pointing out of it, most likely". It now says the parent does not exist, and points at fs.mkdir.

  • Naming a variable rows, row, take or with_column no longer breaks the frame verb of the same name. Writing let rows = 0 at the top of a file made f.rows() fail with "no callable field and no such method or global function" — and only those four names did it, so let total = 0 sitting right next to it was perfectly fine. The four were the ones SCUA's own built-in helpers happen to call, which is not something you could have known or guessed from your own code. Any name behaves the same way now.

  • A whole number pushed into a list of decimals is no longer silently rounded. A list that had only ever held decimals switched to a compact numeric layout behind the scenes, and pushing a whole number into one converted it to a decimal instead of widening the list back out. Past about 9 quadrillion that conversion is lossy, so the number came back changed: 9007199254740993 read back as 9007199254740992.0, and the largest int as 9223372036854776000.0. type() reported float for a value you pushed as an int. Both the value and its type are now preserved exactly. Lists you declare as { f64 } are unaffected — they convert whole numbers to decimals on purpose, and still do.

  • A server no longer fails its first request. Every http.serve server answered its very first request with a 500 — whatever the path, and however trivial the handler — and every request after it correctly. Since the first request after start is the one a health check makes, a load balancer probes with, and a smoke test sends, this read as flakiness rather than as a bug; and the reason was only visible under --serve-debug, so in production it was a bare 500 with nothing to search for.

  • A task that printed and then started a server no longer looks like it never ran. http.serve never returns, and printed output is normally flushed between tasks, so anything a spawned task printed before calling it was buffered forever — even a print on the line before the server started. It is flushed now. Note that running http.serve inside spawn still stops every other task: the accept loop blocks the whole program, which the how-to now says plainly.

  • --jit-stats no longer turns the JIT back on. Running scua --jit=off --jit-stats app.scua silently re-enabled the JIT — the flag you reach for to check whether the JIT is off was the flag that turned it on. On a 60-million-iteration loop that was 0.38s instead of 20.03s, and it depended on the order you typed the flags in: --jit-stats --jit=off was correctly off. If you have ever bisected a JIT problem and found it disappeared when you added --jit-stats, this was why. The stats now report mode=off compiled=0, which is what you were asking for.

  • SCUA can run commands. exec starts other programs — git, a build, a test suite, or whatever an agent just decided to run — and gives you the output, the exit status, and a handle you can watch and cancel.

    import exec
    match exec.run_any(["git", "log", "--oneline", "-n", "5"])
      Ok(r)    -> print(bytes.to_string(r.stdout))
      Error(e) -> print(`could not run git: {e.message}`)
    end

    Run it with --allow-exec-any. --allow-all does not grant this, and it is the only capability it skips. Every other grant narrows what a script can reach; a command you run inherits your operating-system user instead, so a script holding exec and no fs can still run cat. Turning it on is delegation, and it should take a decision rather than a convenient flag.

    There is no shell: argv is an array that goes to the OS unparsed, so a value that lands in an argument stays an argument and shell injection is not reachable rather than filtered. Spawn ["sh", "-c", …] yourself when you want pipes and globs.

    A live process can be streamed as it runs (exec.read, with empty bytes meaning the output ended, as in net.read), waited on, asked whether it has finished, and cancelled — and cancelling signals the whole process group, so a command that started its own children takes them with it.

    Every call has a deadline that cannot be switched off, because a command burns wall-clock rather than interpreter steps and --max-ops cannot see it. Captured output is bounded, truncation sets truncated, and when it fires you keep both ends of the output: a build that fails after a megabyte of warnings puts the reason on the last line, and keeping only the first 256 KiB would hand you the warnings and throw away the error.

    One trap worth knowing before you meet it: the environment is not inherited, so a child sees only what you pass in opts.env. The program name is still found using your PATH, so ["cargo", "build"] runs fine with no environment — but ["sh", "-c", "cargo build"] does not, since the shell looks up cargo in the child's empty PATH. To give a child an environment, hold one: --allow-exec-any --allow-env=PATH,HOME, and pass env = { PATH = env.get("PATH") or "" }.

    See Run commands and examples/exec.scua.

  • You can declare in advance which commands a program may run. Where exec.run_any runs anything, exec.run runs only what the operator declared in the capability file:

    exec = [["zig", "build", "test"], ["git", "log", "--oneline", "-n", "{int}"]]

    The call is the same line either way — exec.run(["zig","build","test"]) — so hardening a working program means pasting the commands you already run into the file and dropping the _any, rather than rewriting every call site. Your argv must match a declaration exactly: same length, literals identical, and a hole ({string}, {int}) fills exactly one argument that is never re-parsed. A hole before a literal -- refuses anything starting with -, so a search term cannot turn into a flag; after a -- a leading dash is just a filename. Anything else is Error({ kind = "not_declared" }), whose message names the closest declaration.

    Mistakes in the file are refused at startup rather than surfacing later as a puzzling failure: a hole where the program should be, a name like {count} that is not a real hole, or an empty template. So is exec = true — there is no "all commands" here; that is exec_any, and you have to name it.

  • When a script runs out of CPU budget, the error now tells you what happened and what to do. It used to say "execution budget exceeded (possible infinite loop)" — which named no limit, no value and no remedy, and accused correct code of being broken. A finite loop that simply did a lot of work was told it was probably infinite. It now reads:

    this turn used its whole CPU budget of 100000000 steps — the built-in default. If the work is
    genuine, raise it with `--max-ops=N` (e.g. `--max-ops=1g`) or remove the budget with `--max-ops=none`

    And it points at the right line. The old message blamed the statement after the loop — code that had never run — so the obvious fix was to edit a line that was not the problem.

  • --max-ops=none removes the CPU budget. There was previously no way to say "no limit" from the command line at all; the only route was to pass an arbitrarily large number and hope. Relatedly, --max-ops=0 is now refused with an explanation instead of being obeyed — it used to set a budget of zero and stop the script on its first step, which is not what anyone means by it.

  • exec works on Windows. Same surface, same guarantees: cancelling takes the whole process tree with it (via a job object, which a child cannot escape — and which the OS tears down even if SCUA dies first), a killed command still reports 137, and output streams the same way. One honest caveat: Windows takes a command string rather than an argv array, so your arguments are quoted for the child using the standard rules — right for almost every program, and wrong for the few that parse their own command line. exec is now absent only where there is no process model at all (wasm).

  • A command no longer freezes everything else while it runs. Under --io=async, exec yields the way http and fs already do, so a command issued from an actor handler or an http.serve handler runs alongside everything else instead of holding the runtime for the child's lifetime. Four actors each running a one-second command take about a second, not four.

  • A capability typo now names every capability there is. --allow-file refused an unknown key with a list that had never included sandbox, so anyone who mistyped it was told sandbox was not a capability at all.

#Fixed

  • A British date reads as British. @{d:date.long} under en_GB gave June 3, 2024, and date.short gave 6/3/2024 — which a reader in the UK takes as 6 March. The date order now follows the region, not just the language, so en_GB, en_AU, en_NZ, en_IE, en_IN and en_ZA are day-first. en_US and a bare en are unchanged. Every non-English locale was already correct, which is why this looked like a working feature.

  • The 12-hour format tokens are reserved instead of quietly wrong. time.format("h:mm") rendered 02:05 and 14:05 identically, because the AM/PM marker (A/a) it needs isn't implemented yet — so a 12-hour format had no way to say which one it meant. h/hh now refuse the way MMM and ddd already do, and the message points at HH. If you were using them, you were getting an ambiguous string; the refusal is telling you about a bug you already had.

#Added

  • money.amount(m) and money.code(m) — read a money value's two parts back out. Until now a money value could be built and operated on but not inspected: money.format renders a symbol, and $ means USD, CAD and AUD alike, so the currency was effectively unreadable.

    money.of(money.amount(m), money.code(m)) gives back m exactly, at the original scale — including for zero-decimal currencies like JPY and three-decimal ones like KWD. Reach for these rather than parsing the formatted string.

  • f.to_csv() — the frame as RFC 4180 CSV. Core could already read CSV and could only write the box-drawing table meant for a human, which is the wrong way round for a data table. Quoting is handled for you: a field containing a comma, a quote, CR or LF is quoted, an inner quote is doubled, and a nil cell is an empty field. csv(f.to_csv()) gives back an equal frame.

    It's in the language rather than left to each project because joining with commas works perfectly until one field contains a comma — and then writes a file that isn't obviously broken, just silently wrong by one column from that row on.

  • csv() never silently changes what a cell says. It used to read 007 as the number 7 and "01" as 1 — leading zeros gone at read time, with nothing downstream able to recover them, because a later cast to string runs after the damage. Zip codes, product codes, phone numbers, zero-padded order ids.

    A cell is now typed only when the typed value renders back to exactly the text that was there. 42 is still an int and 1.50 still an exact decimal; 007, +7, -0 and 1_000 stay strings. Nothing else moves — 1e3, true and 0x10 were already strings, and money is unaffected.

    Note this was never about quoting: 007 unquoted and "01" quoted lost their zeros identically.

  • csv() treats quoting as authoritative. Two RFC 4180 deviations, both silent. A newline inside a quoted field used to end the record, so the field was truncated and the row count came out wrong. And a quoted field was trimmed anyway, so " padded " came back as padded — quoting is precisely how you insist a space is part of the value. Both found by round-tripping to_csv output back through it.

  • A type error naming an anonymous table says which one. fn f(t: {string: any}) given {} used to report is table, expected table — the same word on both sides, with no way to tell what would satisfy it. It now says is table [K]: V, expected table {string}, which shows the real problem: {string: any} declares a field named string. The map form is {[string]: any}, and it works.

  • A duplicate column name in a frame is refused, instead of reading back two different ways. A frame could end up with two columns of the same name — from csv("a,a\n1,2"), or from a join whose _right suffix collided with a column the left frame already had. column("a") and row(0).a then returned different data from the same frame, with no error on either route.

    csv now refuses a duplicate header and join refuses a colliding suffix, both naming the column and what to do. There is no correct reading of such a frame, so nothing is lost by refusing to build one.

  • money.format follows the locale. A Euro amount now reads 1.234.567,89 € under de_DE and 1 234 567,89 € under fr_FR, instead of US conventions everywhere. Separator, decimal point and symbol placement all follow the run locale; per-currency minor units are unchanged, so JPY still renders with no decimals.

    Under the neutral default (und) and en_US, output is byte-identical to before — so if you have never set a locale, nothing has changed. If you have, the old output was using the wrong conventions for your users, which is why there is no compatibility switch.

  • Month and weekday names, in the language you asked for. time.format gained MMM/MMMM, ddd/dddd and A/a, so t.format("D MMM YYYY") gives 3 Jun 2024 — and with the AM/PM marker in place, h/hh work again. The render layer gained matching components: @{d:month.short}, @{d:weekday}, @{d:day}, @{d:year}, beside the whole-date formatters it already had. One rule names them all: a formatter names a field, and .short is the only modifier.

    Both come off one table, so t.format("MMMM") and @{d:month} can't disagree about what June is called. Thirteen languages ship; sys.locales() lists them, and anything else falls back to English.

    What SCUA carries is now stated in both directions in the reference — names and date field order are in; collation, number and currency formats, timezone display names and transliteration are not, now or later.

  • math.erf(x), math.erfc(x) and math.lgamma(x). The special functions statistics needs and math didn't have — normal-distribution probabilities and the log-gamma that keeps factorials representable past 172. Accurate to the last bit or two against the reference implementations.

    Reach for math.erfc(x) rather than 1 - math.erf(x) whenever you want a tail: erf(8) rounds to exactly 1.0, so the subtraction gives 0 where the real answer is about 1.1e-29.

  • hash.sha384(data) and hash.sha512(data), beside the existing hash.sha256. Both take text or raw bytes and return the digest as bytes (48 and 64 bytes). They're the sizes specs ask for by name — JWT's HS384/HS512 among them — and they run at native speed, which a digest written in SCUA cannot: fine for a token, hopeless for anything iterated.

#Fixed

  • scua test runs every file you name, not just the last one. scua test a_test.scua b_test.scua used to run only b_test.scua and report 1 file — which is exactly what a correct single-file run prints, so nothing told you the first file had been dropped. A hook or CI step doing scua test $CHANGED_FILES was silently testing a subset chosen by argument order.

    You can now name any mix of files and directories, repeated as often as you like, and the summary counts what actually ran.

  • An fs path refusal names the rule you broke. It used to list all three at once — "invalid path (absolute, .., or a symlink escaping the granted root are not allowed)" — leaving you to work out which applied. Now fs.read("/tmp/x") says the path is absolute and what to do about it, .. says it cannot climb out of the root, and a symlink pointing outside says so specifically. All of them mention the granted root, which is how you tell a boundary refusal from a missing file.

  • A missing import is reported against the thing that's missing. str.slice("abc", 1, 2) without import str used to say "'slice' is a builtin call form, not a value" — a complaint about the one part of the line that was fine, whose own advice then contradicted itself. It now says undefined name 'str', which is the actual problem and points at the fix.

    The giveaway was that str.trim(...) reported this correctly all along. Whether you got a useful message depended on whether the method name happened to also be a builtin.

  • scua test runs a package that has dependencies. In a project with a lock and a vendored deps/, the test runner reported cannot resolve module 'x' for an import that worked perfectly well when you ran the same file directly. Running a file consulted the lock; running the tests did not. Both now use the same resolution, so if your program can import it, your tests can too.

    If you have been copying sources into one flat directory so your tests could find them, you can stop.

  • An option before a subcommand works. scua --mod-path deps/x test used to report test: cannot read file, because anything before the subcommand stopped it being recognised as one. The subcommand is now found wherever you write it, and gets the options from both sides.

  • scua test no longer ignores the options you give it — including the one that silently changed which tests ran. Every option was discarded. An option that takes a separate value was worse: the value was taken as the directory to test, and it beat a file you named explicitly. So

    $ scua test my_test.scua --mod-path deps/other
    1 passed, 0 failed

    ran the tests in deps/other and reported success while my_test.scua never ran at all — a green result, exit code 0, for code that was never tested. scua test now takes the first path you give it, understands --mod-path DIR, and refuses anything else with a usage message, which is what scua fmt, schema, pack and init always did.

  • scua fmt refuses an unknown option instead of treating it as a filename. scua fmt --check x.scua was fine, but any other option became the file: scua fmt --wrong x.scua reported --wrong: cannot read file.

  • A compile error inside a module you imported now names that module's file. It used to be reported against the file doing the importing, carrying the imported file's line number. A ten-line library imported by a two-line script gave you app.scua:10 — a line app.scua has not got. On a real project it is worse, because the line usually does exist: you get sent to code that is fine, in the wrong file, and the message is about something you cannot see there.

    The line was always right about the module. Only the filename was wrong, and it now names the module — as a full path when the module sits beside a script you ran from elsewhere. scua test, the debugger and the editor extension all pick up the same correction; the editor now leaves a module's error to the tab that module is open in, rather than underlining a spot in the file you are looking at.

  • Naming an enum or flags type where a value goes says so, instead of "undefined name". Only a variant is a value, so Colour on its own is not one — but calling it undefined sends you hunting a typo in a name declared three lines up. The message now says the name is a type and shows the value form (Colour.Red).

    Inside a module it also names the route that works. Writing return { Colour = Colour } to share an enum is the mistake this most often is, and it isn't needed: import carries a module's enums across on its own, so a consumer already writes lib.Colour.Red without the enum appearing in the export record at all.

#0.19.1 — 2026-08-29

#Fixed

  • Looking up a missing number key in a map works again. m[999] on a plain map that doesn't have that key should give you nil. In 0.19.0 it reported an error about records having named fields, which is a message about a different kind of value entirely, and on some builds it could crash. Any map keyed by numbers was affected: a sparse grid, an index by id, a hash chain. String keys, arrays and records were not.

    Found within hours of release by a package author whose compression library uses exactly that pattern. Fixed, with tests covering the missing case in both directions.

#0.19.0 — 2026-08-29

#Changed

  • base64.decode and hex.decode now return bytes instead of a string. Decoded base64 and hex are binary — a signature, a key, a digest — and a SCUA string is always valid text. Returning a string meant these two could hand you one that wasn't, and then every string function misread it quietly: len on a 32-byte decoded value answered 25, counting characters that were never there.

    If you were using the decoded value as text, wrap it: bytes.to_string(base64.decode(s)?). That is one extra step and it is the step that was missing — it checks, so input that isn't valid text is refused where it happens, instead of turning into a string that later misbehaves. If you were feeding the result to something binary — a checksum, a signature comparison, a key — it now just works, with no conversion at all.

    bytes.from_hex already worked this way, and hex.decode disagreed with it about the same operation. They now agree.

  • A whole float now prints with its .0. print(1.0) says 1.0, not 1 — so you can tell a float from an int by looking at it, which you could not before. It matters most where a value leaves the program: json.encode writes 1.0, so decoding a document and re-encoding it now gives back what you started with, where a list of round numbers used to come back as JSON integers. toml.encode already did this; scua schema now does too.

    The tools that exist to tell you what a value is now do. A failing assert_eq(2.0, 3) says "expected 3, got 2.0" where it used to say "got 2"; the debugger's variable view distinguishes a float from an int; and print([1.0, 2, 3.5]) shows [1.0, 2.0, 3.5] — that middle 2 really is 2.0, because an array literal mixing ints and floats is a float array. The old output hid both the type and the promotion in exactly the places you look when something is confusing you.

    This changes printed output, so a test asserting on "1" for a float will now see "1.0". Integers are untouched (print(2 ** 10) is still 1024), and so are the numbers inside a vec, mat, quat or color — every component of those is a float by construction, so there is no int for one to be confused with. Pull a component out into a value of its own and it prints as the float it is.

  • An unrecognised string escape is now a compile error. A backslash before a letter with no escape of its own used to drop the backslash and keep the letter, so "\e[1m" compiled to the four characters e[1m and a terminal library wrote visible garbage where it meant to set bold — a wrong string from a line that reads as obviously correct. The escapes that exist are \n \t \r \e \0 \\, \u{HEX}, and the escaped delimiters \" \' \` \{ \}; anything else is refused where you write it.

    Two are new. \e is the ESC character every ANSI terminal sequence starts with, so "\e[1m" now means what it looks like. \u{…} writes any character by its Unicode codepoint — "caf\u{e9}" is café, "\u{1F600}" is 😀 — in both string forms, and it always adds exactly one character however many bytes that takes. Code that reached for \u{…} before was silently getting the literal text u{e9}, which is easy to miss when both sides of a comparison are written the same way.

  • A builtin used as a value is an error rather than nil. let f = print bound nil, silently, and the failure surfaced wherever f was eventually called. Builtins are call forms, not values, so this now says so where you write it and names the wrapper that gets past it: fn(…) return print(…) end. Module functions such as sys.write and str.slice are values and are unaffected.

  • An unknown short option is reported instead of ignored. sys.parse_args silently dropped a token like -v, so a habit-typed short flag gave you the default back with no indication it went nowhere. Named arguments are long-form; a negative number is still a value, and positionals are still ignored.

  • A reserved word can now be a table key, in quotes. { "not" = 1 } works, which matters because JSON Schema has a field called not and real payloads carry end, for and in. Bare is still refused — { not = 1 } can't be told apart from the keyword — so the rule is unchanged in spirit and has one fewer hole in it: exactly one of the two spellings works for every word, and the error says which. A word that's only sometimes a keyword (type, state, ask) is an ordinary name and goes the other way: bare, with quotes refused as redundant.

#Added

  • wait works at the top level, and top-level code shares the clock with your background tasks. wait(2s) in the main body used to fail with "wait outside a coroutine" — the top level was the one place you could not pause. It now works, and a task you spawn runs while the main body is sleeping rather than waiting for it to finish. --fast fast-forwards it like any other wait.

  • A coroutine can resume another coroutine. Previously any nested resume failed with "cannot resume from within a running coroutine", which also meant a resume inside a message handler was refused. Both work now. Resuming a coroutine from inside itself is still an error, and nesting is capped at 64 deep.

  • str.find and str.rfind take a start offset. str.find(s, needle, from) finds the first match at or after character from; str.rfind(s, needle, from) finds the last one starting at or before it. Scanning a large document token by token no longer means re-slicing the tail each time, which got slower the further in you went. Existing two-argument calls are unchanged.

  • Checksums and base64 accept raw bytes, and base64 speaks the URL-safe dialect. hash.crc32 and hash.fnv1a now take bytes as well as text, as hash.sha256 already did — so the formats that mandate CRC-32 (gzip, zip, PNG), where the data is compressed bytes and never text, are reachable. base64.encode takes bytes too, and a trailing options record picks the dialect: { url_safe = true } swaps +/ for -_ and { pad = false } drops the = padding — JWT, JWS, JWK, WebAuthn and OAuth PKCE all want both. base64.decode needs no option: it now accepts either alphabet, padded or unpadded.

  • return f() now hands back everything f returned. Wrapping a multi-value function needs nothing special any more:

    fn div_mod(a, b) return a // b, a % b end
    fn wrapped(a, b) return div_mod(a, b) end
    let q, r = wrapped(20, 6)          -- 3, 2 — used to be 3, nil

    It used to forward only the first value, silently, so the obvious way to write a wrapper was the one way that lost data. Forwarding is transitive, so a chain of wrappers works too. Everything else is unchanged: a call still gives just its first value anywhere other than the tail of a binding, an assignment, or a return.

  • sys.parse_args now handles an ordinary command line, not just an agent tool. Declare a record and you get positional arguments (tidy in.txt, which used to report the field as missing), short flags derived from the field names (-v for verbose, which used to be ignored), and a --help written from the declaration — names, types, defaults and all — that exits 0. An optional second argument sets the usage line: sys.parse_args(Args, { usage = "tidy <file>" }).

    Nothing about the agent-tool path changes: a JSON body on stdin still binds, still wins over the command line, and a field it filled is never re-filled from a positional. Unknown options, missing required fields, failed where clauses and one argument too many all still exit 2 without running your code.

  • int.parse(s) — a whole number or a reason why not. tonumber is built for data you wrote yourself: it trims whitespace, reads 0x, falls back to a float, and says nil to everything it can't manage, which leaves you unable to tell a typo from a number too large to hold. int.parse is the version for a string that arrived from somewhere else. It takes a sign, decimal digits and _ separators and nothing else, and answers Ok(n) or Error(message):

    import int
    match int.parse(field)
      Ok(n) -> use(n)
      Error(why) -> reject(why)
    end

    A number past the end of the range is an Error — it never wraps round to a negative value and never quietly becomes a float, which is what tonumber("9223372036854775808") does today. Both ends parse exactly, -9223372036854775808 included. Each refusal names its own fix, so " 42" tells you whitespace isn't trimmed and points at str.trim, and "1.0" tells you that's a float. decimal.parse and money.parse have had this shape all along; the int type was the gap.

  • is(a, b) — are these the same object? == compares contents, so two separately built tables with matching fields count as equal and there was no way to ask the other question. is answers it, which turns cycle detection, identity maps and "have I already seen this one?" from something you had to discover by experiment into one obvious call. For values that have no identity — numbers, strings, bytes, keys, vectors and colours — it compares the value, so it never depends on how the runtime happened to allocate.

#Fixed

  • push into a map now tells you, instead of quietly doing nothing. push is for arrays. Given a map — let a = {} rather than let a = [] — it used to accept the value, discard it, and leave len(a) reporting that the value was there, so a if len(rows) > 0 guard passed and the loop after it did nothing. Pushing text into the same map produced a message about a {f32} or {flagset} array, types the program never mentioned. Now it says what is wrong and what to write instead: `push expects an array, but got a table — a table takes ``t[key] = value```. Pushing into a real typed array still reports the type you declared.

  • map_all and wait_all over a large list are no longer slow enough to look like a hang. Fanning out over a few thousand items inside a partition handler got dramatically worse as the list grew: 50,000 items took over seven seconds, and 200,000 never finished. The cost grew with the square of the list, so it was invisible in small examples and brutal at real sizes. It is now proportional to the list: 50,000 items take 0.02 s, and 800,000 finish in a quarter of a second. Results still come back in the order you passed them, one arm failing still doesn't disturb the others, and the concurrency limit still queues rather than dropping work.

  • A sandbox's fuel limit is now enforced. sandbox.run and sandbox.actor take a fuel option bounding how much work untrusted code may do before it fails with out_of_fuel. Setting it had no effect: the value was assigned to the running budget and then immediately overwritten by the per-turn refill, so every sandbox ran under the built-in default of 100,000,000 steps — roughly four seconds of CPU — however small a limit you asked for. Sandboxed actors never carried the limit at all. Both paths now honour it, and the default is the documented 200,000 steps.

    This tightens a real bound, so untrusted code that used to finish may now stop with out_of_fuel. Raise fuel if a mod legitimately needs more.

    A mod that runs out of fuel stops the mod, not your program: the ask waiting on it gets back Error(the sandbox ran out of fuel), the next mod you load runs normally, and your host carries on — the same thing that already happened when a mod failed some other way, such as dividing by zero.

  • nan prints as nan everywhere. On x86-64 Linux and Windows, print(0.0 / 0.0) wrote -nan where the same program on an ARM machine wrote nan, because the two architectures disagree about the sign bit of a not-a-number. A NaN's sign carries no meaning, so it is no longer printed and your program's output reads the same on every machine. The infinities keep their sign, which does mean something.

  • Saving a partition that holds an open connection now refuses instead of writing a broken file. A capability — an open connection, listener, socket, shared table or HTTP stream — names a live resource that cannot survive being written to disk. Saving one used to succeed and produce a file whose restored handle would, once the program opened its next resource, quietly refer to that one instead. Persisting now fails with a clear error while the connection is still reachable, which is what sending one to another partition has always done.

  • A multi-value call dispatches the same way a single-value one does. let a = day.day() worked while let a, b = day.day() failed with "fields only exist on tables and records, not on datetime values" — whenever a local shared its name with the method being called. That is the same defect 0.17 fixed for the single-value spelling, still open in the multi-value one.

  • A declared { f32 } is a real packed buffer however the array was built. The element type used to be applied only when the right-hand side was an array literal. An array arriving from fill, from range, from a function declared -> { f32 }, or assigned into a record field declared { f32 } kept whatever storage it was built with, so the binding held an ordinary array behind a declared buffer type: a later push would put a string into a declared { f32 } without complaint, and a host reading the buffer for a zero-copy upload silently got no buffer. The type now applies where the value arrives, and an array whose elements don't fit it is refused there.

  • --help and --version reach your script. The runner answered both itself wherever they appeared, including after the -- separator its own usage text promises will pass everything through — so the two flags every command-line tool is expected to answer were the two a SCUA tool could not see.

  • group_sum, join and pivot are no longer quadratic. All three looked a key up by scanning, so each one slowed by 4× every time the data doubled: grouping 100,000 rows into 100,000 groups took 13.4 seconds, where a dozen lines of ordinary SCUA doing the same thing with a table took 0.02. They now answer in the same time as that hand-written table, with identical results.

  • fill builds the same array a push loop does. fill(n, 0.0) reserved boxed storage and then immediately abandoned it, so it peaked at roughly twice the memory of the identical array built by pushing in a loop.

  • ask is usable as an ordinary name, as reference/keywords.md has always said it was. let ask = 5 was accepted and then the use of the name failed, with the error pointing at whatever token followed it — landing on a correct-looking line some distance from the cause.

  • scua test takes a file, so you can run the one test file you are working on: scua test tests/parser_test.scua. It also follows symlinks when discovering files, where a directory of linked tests previously reported that it contained none.

  • An integer index into a record is an error, not nil. A record's fields are named and its layout is fixed, so p[0] can never resolve — it used to answer nil, and that nil then flowed onward as an ordinary value.

  • Four diagnostics that pointed at the wrong thing. A reserved word as a table key ({ not = 1 }, { "not" = 1 }) said the quotes were redundant, which sent you to the spelling that had already failed; it now names the reserved word and the route that works. A literal { in a backtick string reported the comma several characters past it and never mentioned braces; it now names the brace and the escape. json.encode and toml.encode refusing a deeply nested value blamed a cycle they could not have detected; they now say what they measured. Assigning a float to an int binding now names the remedy — seed it 0.0, or annotate it float.

#0.18.0 — 2026-08-26

#Changed

  • Enum and flags values now know what they are. print(Colour.Green) says Colour.Green instead of 1, and a flags mask says Perm.Read|Perm.Exec instead of 5 — in logs, in tostring, in string interpolation, inside printed arrays and tables, and in error messages. A mask also stays a mask: combining, testing and removing flags all give you back the flags type, where before it turned into a plain integer the first time you used it.

    The number is still there whenever you use a variant as a position — tiles[dir] indexes, dir + 1 counts, explicit discriminants like Monster = 52 and Read = 1 << 0 are unchanged, and .to_int() hands you the integer for a wire or C boundary. The rule is: an array index is a position; a table key is a value.

    Three things changed behaviour, so check code that relies on them:

    • Colour.Green == 1 is now false. Comparing a variant to a number is a compile error in typed code, telling you to write .to_int(). Comparing two different enums is false everywhere now, including in untyped code where the compiler could not previously see it.
    • t[Colour.Green] and t[1] are now different table keys, as are variants of two different enums that share a discriminant.
    • Printed output changed, so any test asserting on "1" will now see "Colour.Green".

    Ordering two different enums is a fault rather than a silent answer — Colour.Red < Size.Small had no meaning and used to return true.

  • Enum values survive an edit to their own declaration in a save. A durable save now records an enum value by name, so reordering, renumbering or reformatting an enum or flags declaration no longer changes what existing saves mean — they load correctly, where before an edit that looked like tidying quietly rewrote every save on disk. Renaming or removing a variant is refused with a message that names it ("this save holds Colour.Green, and Colour no longer has a variant of that name") rather than reporting the file as corrupt. Cluster values work the same way, which matters most during a rolling deploy, when two versions of your code are running at once. json and toml still carry the plain number, since neither format has an enum.

    This changes the save format, so saves written by an earlier build do not load. Appending new variants has always been safe and still is — it is reordering and renaming that used to be dangerous.

  • Enum.from_int(n) now checks its argument. A number that names no variant faults where it used to produce a value that looked like a variant and matched nothing. Use Enum.try_from_int(n) when a number arriving from outside may legitimately be unknown — it answers nil and lets you decide.

#Added

  • Arrays of enum or flags values are packed, so a tile grid or a per-entity state array costs the same as it did when a variant was a plain integer (measured: 200,000 values, 5.62 MB — the same as an array of ints).

  • Enum.variants(), listing every variant in declaration order, and e.variant_name() / e.enum_name(), which give a variant's own name and its enum's name as strings. flags types have all three too.

  • Saves are about three times smaller, and the save format changed to do it. A typed, versioned durable save of a five-field record used to cost about 88 bytes; it now costs about 28 — smaller than the same record written as JSON, and smaller than a Python pickle of it, while still preserving the types neither of those can. Two things account for it: numbers, counts and lengths are stored in as few bytes as their value needs (a 1 used to cost exactly what 20000 cost), and a field name is now written once per save rather than once per record (a schema with long field names used to more than double the file). Both saving and loading also got faster.

    This is a format change, and old saves do not load. It is a clean break rather than a compatibility layer, taken now because the format is pre-release: durable.decode on a file written by an earlier build returns Error("not a durable blob …"). durable.digest values change for every value, so anything signed against an old digest needs re-signing.

    Loading is also stricter, in ways no save this or any earlier version wrote: a file with trailing bytes, a repeated field in one table, a padded number, text that is not valid UTF-8, a regular expression that does not compile, or a value outside what the language can build — a decimal with 200 decimal places, a NaN — is now reported as corrupt rather than silently accepted. NaN is refused when saving too, including inside a vector or colour: it is not equal to itself, so a save carrying one could be neither compared nor verified.

  • More kinds of value can be saved. money, frame data tables, vectors, matrices, quaternions, colours, the geometry types (rect, aabox, sphere, capsule, ray, plane), localized strings, and compiled regexes (which save as their pattern text) all have a durable form now. The one that mattered most: a frame with a money column — the schema the data-table guide is built around — could not be saved at all before, and failed with a message naming neither frames nor money. See Save it and load it back.

#Fixed

  • Sending a deeply nested message no longer kills the process. tell and ask copy their arguments into the receiving partition, and that copy had no depth limit — so a value nested a few thousand levels deep (which is easy to build in a loop, and easy for generated code to produce) ran the native stack out and the process died with no error message and no output, losing anything printed before it. A message may now be at most 256 levels deep, the same limit a durable save already used, and going past it is an ordinary catchable fault that names the limit. The same applies when a partition is created — spawning copies the program's module-level values, so the message says which of the two ran out.

  • A data table no longer shares arrays with the code that built it, which was giving wrong answers. frame({ x = c }) used to keep your array rather than a copy of it, and f.column(name) handed that same array back — so an ordinary edit after the fact silently changed what the frame reported. let c = [1,2,3], then frame({ x = c }), then c.push(4) produced a frame that said it had 3 rows while total("x") answered 10. Writing into a column you read out (f.column("z")[0] = 100) changed the frame's data, and f.column_names().push(…) made it claim a column it did not have. A frame now owns its columns: building one copies what you pass in, and column/column_names give you copies. Numeric columns keep their packed storage, so this costs nothing per element to read.

    It does cost memory while a frame is being built — briefly, the original array and the frame's copy both exist — and if you keep the source arrays alive alongside the frame you are now genuinely holding two copies. Published measurements: peak memory for a 4-column table went from 141 to 220 bytes per row.

  • A function with more locals than the compiler can address now says so, instead of crashing. Past 255 local variables in one function you would get thread NNNN panic: integer overflow and a stack trace from inside the compiler. It now reports which binding did not fit, and what to do: "too many local variables in one function — the limit is 255, and 'x255' has nowhere to go. Split this into smaller functions, or group the values into a table." Loops and match claim several registers at once, so they could crash a little under the limit; those are covered too. Most likely to matter for generated code.

#Added

  • You can run low-trust code in a sandbox. import sandbox compiles a source string into a fresh partition that holds nothing — no files, no network, no environment, no clock, and none of your program's capabilities however many it was granted. Use sandbox.run(source, input) for a one-shot answer, or sandbox.actor(source, state) for code you call repeatedly (a mod, an entity behaviour, a rule that keeps score), which hands back an ordinary actor you tell and ask. Creating a sandbox needs the new sandbox capability (--allow-sandbox); the sandboxed code never receives it, so it cannot create sandboxes of its own. There is no setting that relaxes the isolation — the tier is decided by which function you called, so there is nothing to misconfigure. What crosses the boundary is data only: numbers, strings, arrays, records, tagged values. A function cannot cross in either direction, because inside a sandbox a function is an index into its code and would mean something else entirely in yours; nor can an actor reference, which would let the code inside send messages with your permissions. Work, memory, source size and function count are all bounded, with defaults sized for something called every frame rather than a long-running script, and the work limit cannot be caught from inside. Failures come back as a record — kind, line, message, and whatever name you gave it — because "the code is wrong", "it's too slow" and "it tried to hold too much" want three different responses. Release one with sandbox.stop. See Run code in a sandbox and examples/sandbox.scua.

  • Function parameters can carry default values. fn greet(name, greeting = "Hello") — write = value after a parameter and the call may leave that argument out. The rule is one sentence: a default fills the argument when it is missing or nil, so a parameter with a default is never nil inside the body, and a value that might not be there selects the default without an if at the call site (connect(host, cfg @ "port")). Only nil triggers it — false and 0 are values and pass straight through. The default is an ordinary expression, evaluated on each call that omits the argument, so fn f(xs = []) gives you a fresh array every time rather than one shared list; and it isn't evaluated at all when you do supply the argument. Defaults run left to right in the function's own frame, so a later one can use an earlier parameter (fn slice_from(s, start, stop = len(s))). They work on named functions, inline function values and methods, and a defaulted function fits a narrower function type, so a callback still gets its defaults. Two things to know: every parameter after a defaulted one must have a default too (SCUA has no named arguments, so a default in front of a required parameter could never be reached), and this differs from a record field default, which fills a missing field and keeps an explicit nil. If a parameter must be able to receive nil, don't give it a default. See Functions and closures and examples/default-params.scua.

#Fixed

  • An import can no longer reach outside the folder it is searched in. import "../other/thing" used to walk out of the importing file's directory and load — and run — a .scua file from anywhere on disk, with no fs capability, because module resolution is treated as something the person running the script asked for. That is fine when you wrote the script and not fine when somebody else did, which matters if you are ever running scripts you did not write. A module spec is now one or more name segments separated by /, and .., a leading /, and spaces are refused outright rather than tidied up. Nothing about the normal cases changes: a sibling module and a nested sub/helper resolve exactly as before. To use modules from another directory, add it with --mod-path DIR — which is what that flag has always been for — or depend on it by name with scua-pkg. The refusal says so, instead of leaving you hunting for a missing file.

  • A saved session now survives editing your code. This one is for embedders using session hibernation (scua_persist_actor / scua_load_actor). A saved session used to store each function by its position in the compiled program, so adding or removing a function — anywhere in the script — shifted those positions and the session's stored handler resolved to whichever function had moved into its slot. That function ran in the handler's place and its return value became the session's new state, with both the load and the following poll returning success and nothing printed to say so.

    A session now records what each function is rather than where it sat, so adding, removing, renaming and reordering functions all leave saved sessions loadable, and a bugfix deploy binds them to the new code. Where a save genuinely cannot be honoured, the load refuses and names the function — `restock` is not in this program — it was renamed or removed since the save was written — instead of reporting the file as corrupt.

    One rule is worth knowing, because it is the one asymmetry. Editing the body of a named function is fine. Editing the body of an anonymous one (let f = fn(x) … end) invalidates saved references to it. An anonymous function has no name to be recognised by — it is identified by its position among its siblings — so a save cannot prove which of two lambdas it meant, and guessing is how you end up running the wrong one. If a function is stored in session state and you expect to edit it, give it a name.

    A session that was paused inside a function you then edited has that paused turn dropped, and the load reports how many it dropped; the session itself lives. A paused turn can never resume under a changed body — it would continue from a position in code that no longer exists — so the choice is between losing the turn and losing the session, and the turn is the smaller loss. Hosts that cannot accept a dropped turn can ask for the load to be refused instead.

    Session blobs written by earlier versions are refused, because they predate the record. Nothing changes for scripts that don't hibernate.

  • A capability file that says tty = false is obeyed again, including under --allow-all. Writing false in an --allow-file is how you say "everything except this one thing" — scua --allow-all --allow-file=no-net.toml app.scua runs a trusted script with the network shut off. Every capability honoured that except tty, the grant sys.readline and sys.readpassword need: tty = false was obeyed on its own, but the moment --allow-all appeared on the same command line the script could read the terminal anyway, and nothing in the output said so. "Everything except the terminal" was the one policy you could not write. This was a defect in 0.17.0, the release the grant shipped in. Nothing else moves: a file that doesn't mention tty still gets it from --allow-all, and --allow-tty on the command line still outranks whatever the file says.

#0.17.0 — 2026-08-21

#Changed

  • A module you import is now type-checked, so adding a dependency can surface its type errors in your build. The checker used to run on the file you ran and nothing else: every imported module — and therefore every published package — compiled with the type system effectively switched off. Its annotations, where refinements and arity were advisory, so a call to f(1) against fn f(a, b) inside a library bound nil and carried on, while the identical line in the file you ran was a hard error. That inconsistency was reported four separate times as an arity bug; arity was never special, the checker simply was not there. It is now, with the module's errors reported against the module's own file and line rather than yours. If a module is yours, fix what it reports. If it is a dependency, you cannot patch it in place — editing a file under deps/ breaks the hash check its lock entry holds it to — so pin the toolchain version you were on and file the bug with the file and line the error names. A module that opts out with a leading --!dynamic stays opted out.
  • A runtime fault names the file it actually happened in. A fault raised inside an imported module used to be reported against the file you ran, which sent you looking in the wrong place. Every line of a backtrace now names its own file too — at describe (evolve.scua:10) where it used to say at describe (line 10).

#Added

  • You can ask the person at the keyboard a question: sys.readline() and sys.readpassword(). Until now a script could read a payload piped into it, but there was no way to prompt someone and read their answer — the thing every other language has. sys.readpassword reads without echoing, for a password prompt. Both need the new --allow-tty grant: reading a terminal is where a human types secrets, so it is a capability like fs or net, not something any script gets for free, and an actor never inherits it. Both return nil at the end of input, so guard for it — that is what lets the same script work when a person types, when input is piped in, and when there is no terminal at all.

  • Unique ids: import ids. ids.uuid4(), ids.uuid7(), ids.ulid() and ids.parse(s). No capability grant needed. The bits come from the host's random source rather than from rand — which matters, because rand is seeded and replays the same stream forever, so an id built on it would repeat in every process that started from the same seed and nothing would tell you. Prefer uuid7() or ulid() for database keys and log lines: both put the timestamp first, so ids sort by creation time as plain text, and ids minted within one turn come out in issue order however many you mint. The timestamp is the turn's clock — the same value now() returns — so it fast-forwards under --fast and starts at zero under scua test, like every other clock reading. ids.parse(s) returns Ok(id) with the id in canonical form (lowercase for a UUID, uppercase for a ULID), so storing what it hands back keeps one spelling of an id in your data instead of two. These are not secrets — a time-ordered id carries a readable creation time, so use crypto.random_bytes(32) for a session token or a reset link — and not replayable: a re-run produces different ids; use a seeded counter if you need ids a replay reproduces.

  • crypto.random_bytes(n). 1 to 1024 fresh unpredictable bytes from the host. This is the seed crypto.keypair has always asked for and that the language previously gave you no way to obtain, and it is the right shape for a token, a nonce or a salt. It fails loudly rather than quietly returning predictable bytes if the machine has no entropy source.

  • A module can export a type. An enum (or flags) declared in a module is now nameable from the files that import it, as alias.Enum.Variant — in a match pattern and as an expression that constructs the variant. Exhaustiveness works across the boundary, so dropping an arm on a library's enum is the compile error it always should have been. Before this, a library's type was unusable as a type: consumers had to redeclare it verbatim, and two packages hit the wall independently and gave up, handing back stringly-typed tables and losing the check at exactly the boundary where it is worth the most. type Local = lib.Signal gives it a shorter local name — an abbreviation, not a second type, so the two spellings mix freely and compare equal. Existing verbatim redeclarations keep working (that is why they worked); you can now delete them. If a redeclared copy has drifted from the original, the compiler says so instead of letting two same-named enums match each other's patterns.

  • You can remove a key from a table: delete(t, k). Assigning nil never did — it set the value and left the key, so len still counted it, for … in still yielded it, and json.encode still wrote "k":null. That made a cache, an index or a set impossible to shrink, with no workaround: rebuilding the table needs iteration, and iteration couldn't tell a live key from a dead one. delete returns true if the key was there (not the removed value — read t[k] first if you want that), and a missing key is a no-op returning false. Also spellable t.delete(k). The two operations now mean different things and it is worth learning which is which: assigning nil sets the value; delete removes the key. Assignment is unchanged, so nothing you have written behaves differently. A record's fields are fixed, so delete on one is an error rather than a way to punch a hole in a typed value; on an array it tells you to use remove(array, index). Two things to know: don't delete from a table you are iterating (collect the keys first — a delete moves entries around and the loop can skip one), and a delete changes iteration order. That second one exposes rather than breaks the old behaviour: for k, v in t was only ever insertion-ordered for a small string-keyed table you had only added to, and stopped being so past sixteen keys anyway. This is also the fix for the json.encode shape that gets you a 400: drop the key with delete and the field is simply absent, instead of present as null.

  • sys.write(s) writes without a newline. print was the only way to produce output, so a carriage-return progress bar, a spinner, or any display that updates in place could not be written at all. sys.write puts s on the same stream print uses, with no trailing newline, and it appears immediately — so sys.write("\rworking 40%") in a loop rewrites one line. It shares one buffer with print, so the two interleave exactly in the order you wrote them. The bytes go wherever stdout goes, verbatim: in a pipe or a log file a \r display is every frame on one line rather than an animation, and there is deliberately no way to ask whether stdout is a terminal (that would make a program's output depend on how it was launched). A tool that should only animate for a human takes a flag. It cannot be recalled once written, so don't mix it with sys.emit/sys.fail, which promise a stdout carrying only their result. Unlike print, sys.write is a value: let out = sys.write works, so a function can take an output sink.

  • The call-depth limit is a value you can handle. Recursing too deeply raises a normal fault — stack overflow: call depth exceeded 1000 frames — which try/rescue and pcall catch, and which an uncaught run reports with a source line and a backtrace like every other fault. Previously it aborted the process outright: no line, no backtrace, no recovery, and inside an actor it took down every other partition in the process with it. This is what a library that walks somebody else's data needed. A document forty levels deep was fine and one a few hundred deep was fatal, and there was no way to defend against it from inside the language — so tree walks had to be hand-rewritten as explicit-stack loops purely to survive input the author didn't control. Now the boundary reports it:

    fn parse_checked(doc)
      try
        return Ok(walk(doc))
      rescue err
        return Error(`document nests too deeply: {err}`)
      end
    end

    The budget (--max-ops) and the memory cap (--max-mem) stay uncatchable, deliberately: a limit is catchable when unwinding to the handler undoes it, and unwinding pops call frames but gives back no budget and frees no memory. See Errors and faults. For embedders, the return code is unchanged (SCUA_ERR_OTHER), but what you get in errbuf is: it used to be the bare word StackOverflow with no line, and is now the located fault message, like any other script fault.

  • The depth ceiling is four times higher, and it is now a single number. 1000 frames, the same on every path — it used to be 250 or 378 for the same program depending on which internal call path the call took, which is not something anyone chose.

  • .env files. --env-file=.env loads one and grants env for exactly the names in it:

    $ scua --env-file=.env app.scua

    Naming the file is the grant — you don't also need --allow-env, and you shouldn't have to write the file's key list twice. Note what that buys you: with --env-file alone your script sees the file's names and nothing else, so a stray AWS_SECRET_ACCESS_KEY in your shell can't reach it. Add --allow-env=NAME,... if you want particular real variables too.

    A real environment variable wins over the file, which is what makes .env a defaults file — and --env-file is repeatable, applied in order, with nothing overwriting what is already set. The format is the usual one, including export prefixes, both quote styles, and multi-line quoted values.

    Two things that catch people, and both are deliberate: a Windows path needs single quotes ("C:\temp" contains \t, so it reads as a tab), and ${VAR} is not interpolated — it stays literal, because a value that could name another variable would be a way around the allowlist you just set.

  • dotenv.parse(s) reads the same format as ordinary data, with no capability at all — for when you want a .env file's contents rather than its authority. It shares one implementation with --env-file, so the two can never disagree about what a file means.

  • Capability grants can come from a file, so a sandboxed run is one flag instead of six. Write the policy once:

    # permissions.toml
    [capabilities]
    fs = "./data"
    net = ["api.internal", "cdn.example"]
    env = ["PORT", "HOME"]
    serve = "127.0.0.1:8080"
    $ scua --allow-file=permissions.toml app.scua

    The keys are the flag names, so there is nothing new to learn — true is the bare flag, a string or list is the constrained form, and false or [] refuses it outright. A refusal sticks, so scua --allow-all --allow-file=no-net.toml app.scua means exactly what it reads as. An unknown key is an error rather than a silent no-op.

    Only a file you name applies. Capabilities come from what you type — a scua.toml beside your code grants nothing, so a script cannot arrange to be trusted more on its next run.

  • --allow-all grants every capability in its broadest form, for a script you already trust. A narrower flag still wins, so --allow-all --allow-net=api.internal means "everything, but net only reaches api.internal" — the order you write them in does not matter.

  • toml.parse reads all of TOML 1.0, and toml.encode writes it. Arrays, floats, inline tables, [[arrays of tables]], dates, literal and multi-line strings, and the 0x/0o/0b integer bases all parse to their own types — an array is an array you can index and iterate, and a date is a datetime you can do arithmetic on, not a string. Previously each of these was an Error, so any config file using one could not be read at all.

    match toml.parse(text)
      Ok(cfg) -> print((cfg @ "worker")[1] @ "name")   -- second [[worker]] section
      Error(why) -> print(why)
    end

    toml.encode(table) is the other direction, with keys sorted so the output is stable in version control. Exact decimal and money values are written as strings — TOML's only number types are 64-bit int and float, and either would drop the exactness (json.encode already does this) — and a value with no TOML form at all, like bytes, is a fault rather than a silent approximation. Two deliberate limits. A bare TOML local time (07:32:00, a time of day with no date) reads as a string, because SCUA has no time-of-day type and giving it an invented date would be worse. And a date's spelling does not survive a read-modify-write — a datetime is an instant plus an offset and holds no "written date-only" bit, so 2026-08-19 read in and written back out is 2026-08-19T00:00:00Z. Same instant, longer spelling.

    A date that does not exist is now an error rather than a nearby date (1979-99-99 used to read as 1987-06-07), and a bad document tells you the line it went wrong on.

#Fixed

  • Encoding a value that contains itself no longer kills the interpreter. json.encode (and toml.encode) on a cyclic value — a parent pointer, a doubly-linked list, a cache that references its owner — used to crash the whole process with no message, no line number, and any print output before the call lost. It is now an ordinary fault that try/rescue and pcall catch, and the message says what is almost certainly wrong: "value nests deeper than 256 levels (does a table or array contain itself?)". Legitimately deep data still encodes — the 256-level budget is the same one durable.encode has always applied, and real documents run tens of levels, not hundreds. json.decode had the mirror-image problem — a crafted string of thousands of nested [s crashed the rebuild — and now returns an Error value past the same depth, like any other malformed input.

  • A year past the end of the calendar is now refused instead of silently becoming a different year. A datetime counts milliseconds in a 64-bit integer, so it reaches about 292 million years either side of the epoch and then stops — but nothing said so. time.of(300000000, 1, 1).year() came back as -284554050: a perfectly valid-looking date, hundreds of millions of years from the one you asked for, with no error and nothing in the output to suggest anything had gone wrong. (In an unoptimized build the same call aborted the process outright, which at least was loud, but could not be caught.) Every way of building or moving a datetime now checks the range and faults with the limit in the message — time.of, time.at, time.add, time.diff, and the normalizing carry (time.of(2026, 1, 1e13)) — so pcall/try can intercept it like any other fault, identically in every build. time.parse returns an Error value rather than faulting, as it already did for malformed input, and now distinguishes the two: 300000000-01-01 is out of range, not malformed. The usable range is unchanged: years -292275054 to 292278993 all round-trip, and the documented spelling of every ordinary date is the same as before. A saved value or a cluster message carrying an impossible instant is rejected as corrupt.

    The limit is enforced on the date you get back, not just on the arithmetic. time.of(292278993, 12, 32) and time.add on the last representable instant used to land a day past the end of the calendar and hand back a datetime that printed fine but that time.of and time.parse both refused — a value that could not be read back from its own output. Both now fault with the same limit message.

  • Dates before year 1 were a day out. The calendar's leap-year rule was applied to an already-adjusted era on the negative side, so every year below 1 was treated as though it kept 29 February. Anything from the pre-Christian era came back one day late: time.of(0, 1, 1) read as 31 December of year -1, and 29 February appeared in years that never had it. Proleptic Gregorian dates now match the standard rule (divisible by 4, except centuries unless divisible by 400) on both sides of year 0.

  • time.parse rejected the dates SCUA itself prints for years before 1. A pre-year-1 datetime formats with a leading minus, and the parser stopped at that minus and called its own output malformed — so time.parse("{dt}") failed for any date BCE, and a date written out and read back was lost. It now reads the sign, and a well-formed negative year past the floor of the calendar is reported as out of range rather than malformed, matching the positive end.

  • Running a script could hang at startup, if whatever launched it left an unused pipe on stdin. SCUA read all of stdin at launch for every program, which means waiting until the writer closes — so a script started by a supervisor, a CI step, an editor task, or any parent that wires up stdin and never writes to it would compile and then sit there forever, before running a line. A long-running scua serve.scua was the worst case: it looked like the server had wedged on startup. Stdin is now read the first time the program actually asks for it (sys.stdin, sys.args_table, or sys.parse_args), which is the only time waiting for the writer is the point. Nothing changes for a tool that does read stdin: scua tool.scua < payload.json and producer | scua tool.scua still get the whole payload, and reading it twice still gives the same bytes.

    One consequence worth knowing if you script around SCUA. A script that ignores stdin no longer drains the pipe, so a producer writing more than the pipe buffer now gets SIGPIPE and exits 141 where it previously exited 0. This is ordinary Unix behaviour — producer | head -1 does the same — but under set -o pipefail it can turn a green CI step red. It only affects a pipeline that was already feeding a program which ignored the data.

  • import("name") works inside a module, and loads that module's own imports. Two faults, one shape. A module whose body used the expression form of import ended up sharing an identity with the module it pulled in, so reaching into the first gave you the second — usually surfacing as "attempt to call a non-function" on a member that plainly exists. And a module loaded with import("name") got none of the modules it imported, so every one of its import bindings was empty and the first call through one failed with "fields only exist on tables and records, not on nil values". The declaration form (import name) was never affected by either. Modules still load once each and are shared, however many importers reach them and by whichever form.

  • A host that refuses JIT memory now falls back to the interpreter instead of crashing. If the operating system declines the executable mapping the JIT needs — a hardened macOS process without the JIT entitlement, a sandbox, a locked-down enterprise policy — SCUA is meant to quietly run the interpreter instead. That is what a release build did. A Debug or ReleaseSafe build, which is what you get when you embed SCUA and build your host without optimisations, panicked outright with reached unreachable code. The refusal is now handled the same way in every build mode.

  • Three TOML values used to parse to the wrong thing rather than being refused. x = """hi""" read as ""hi"", x = "say \"hi\"" kept its backslashes instead of unescaping them, and a [[section]] header became an ordinary table whose name was literally [section]. If you worked around any of these, the workaround is no longer needed — and if you had one and did not know it, this is why your values looked odd.

  • A list in scua.toml can be a list. [tooling.declarations] had to be written as arbitrarily labelled keys, because the manifest could not hold an array — and the files then loaded in an unpredictable order. Write it as declarations = ["a.scua", "b.scua"] under [tooling] and the order is the order you wrote. The labelled-key form still works, so nothing needs changing.

  • The JIT could compute with a stale variable in a hot loop that calls a function twice. A loop body that read an outer variable and then called functions two or more times could, once the loop ran hot enough to reach the optimising tier, keep using a leftover internal value in place of the variable — silently producing wrong numbers while the variable itself still read correctly. The interpreter and the baseline tier were never affected. If a long-running loop with repeated calls has been giving you answers that disagree with --jit=off, this was why.

  • The JIT works in the released macOS builds. It never has. The signed binaries were built with the hardened runtime and no entitlements, which is exactly the configuration macOS uses to forbid the executable memory a JIT needs — so every macOS release ran interpreter-only, --jit-stats reported compiled=0, and nothing said why. Unsigned development builds JIT fine, which is why it survived testing for so long. If you have measured SCUA's performance on macOS from a downloaded release, those were interpreter numbers.

  • A module's own function no longer changes what a different module computes. A top-level fn whose name matched a built-in (build, add, total, key and 90-odd others) replaced that built-in for the whole program, so importing an unrelated module could silently change the answer a library returned — with no error and nothing the library's author could do about it. Declaring such a name now shadows it for that module only.

  • A local named after a frame column no longer silently ignores the column. let amount = 100 next to an amount column turned f.filter(amount > 50) into 100 > 50, a constant true that passed every row and returned the wrong total. It is now refused, naming both meanings, rather than guessing.

  • sort_by sorts columns containing nil, datetimes or booleans. A nil cell used to disorder the values around it ([3, nil, 1, 2] came back as 1, 3, nil, 2) and a datetime column came back in its original order as though it had sorted. Nils now sort last, datetimes chronologically, and booleans false-before-true.

  • Ok(x) == Ok(x) is true. Results and enum variants carrying payloads compared by identity while arrays and tables compared structurally, so assert_eq(parse(text), Ok(expected)) — the most natural test to write — always failed and said nothing about why.

  • A parameter named after a method no longer breaks the call. fn f(day) return day.day() end failed while an identical fn g(x) return x.day() end worked, the same expression compiling or not depending purely on what the parameter was called.

  • A failing run now prints what it printed. When a run hit the reduction budget, the memory cap, or any other hard stop, everything the script had already printed was thrown away — so a script that printed eight hundred lines and then blew its budget showed you none of them, and you had no idea how far it got. Output is flushed before the run stops, on every path.

  • A closure that outlived a caught fault could read a wrong value. If a function stored a closure somewhere (a global, a table, a callback list) and then faulted, and something caught the fault, the closure kept pointing at a stack slot that the next call reused. Reading it gave a plausible wrong number — no error, no warning. It now captures its value when the fault unwinds, the same as when the function returns normally.

#0.16.0 — 2026-08-19

#Changed

  • Durable cluster snapshots have a new format, and older nodes will not read it. A snapshot written by 0.16 records the point past which stale writes are refused; that information had nowhere to live before. A 0.16 node still reads a snapshot written by 0.15. A 0.15 node handed a 0.16 snapshot starts empty rather than loading it and silently dropping the protection. If you run a mixed cluster, upgrade the whole cluster, or expect a node that is rolled back to refill from its peers.
  • The package manager needs to catch up before scua add, vendor and friends work on 0.16. scua-pkg refuses any toolchain newer than the one it knows about, so those commands stay refused until a scua-pkg release supports 0.16. If you depend on them today, stay on 0.15 until that lands. Nothing else in the language is affected, and running a project that already has its deps/ works.

#Added

  • cluster.stats reports rejected_skew — writes discarded because the sending node's clock was too far ahead of yours. It was previously counted nowhere, so a node with a bad clock could have every write it sent dropped, indefinitely, with nothing to see. If this number is climbing, check clocks before anything else.

#Added

  • Datetimes order with <, <=, > and >=. Only == worked before, so every comparison had to detour through .to_ms() — including the hot path of a scheduler deciding which jobs are due, which is exactly where a milliseconds-vs-datetime mix-up costs you. Ordering is by the instant, so it is correct across offsets. Where two values are the same instant read at different offsets — which == already treated as unequal, since a datetime carries the offset it was read at — the offset breaks the tie, so exactly one of <, ==, > is always true. Comparing a datetime to a plain number now says so rather than raising a bare type error.

#Fixed

  • The constant limit that could refuse a valid file is gone. A field name had to live in the first 256 constants of its function, because the instruction encodes it in a byte, and exceeding that was a compile error. It now falls back to naming the key in a register instead — the same thing Lua does — so the encoding detail stops being a language limit. One of the first published packages was sitting at 236 of 256 and would have stopped building on its next few entries.
  • A file full of assertions compiles. The compiler's constant pool counted every mention of a literal rather than every distinct value, so writing the same string a few hundred times filled it and the next field access failed with a bare TooManyConstants — no file, no line, no budget. Test files hit it hardest, because a test file is mostly the same handful of literals repeated; one file of around 29 assertions was enough. Identical constants now share a slot, which raised the practical ceiling by roughly six times in our measurements. The limit that remains is real (a field name has to live in the first 256 constants of its function, because the instruction encodes it in a byte) and now says so: which field, which line, the budget, and how far over you were.
  • A cluster could stop reclaiming deleted keys entirely, and two replicas could stay permanently out of step. Three faults stacked. A node that accepted a connection would hand over its whole store before it knew who it was talking to, then clean up a deleted key it had just sent, on the grounds that nobody it knew about still held one. The node on the other end then had that deletion refused when it tried to hand it back, because the protection against stale writes did not distinguish a delete from a value, and a delete cannot resurrect anything. With the two stores disagreeing and neither able to accept the other's copy, the repair path that exists for this stayed silent, because a node with nothing left to send sent nothing at all rather than announcing what it had. Cleanup now waits for a connection to identify itself, deletes pass the fence, and a node with an empty store still announces it.
  • A node listing its own address as a seed could dial itself forever. The obvious cluster configuration lists every node's address, including the one you are configuring, and one timing produced a redial cycle that repeated for as long as the timing held. Your own address is now discarded before anything is dialled.
  • A cluster could stop collecting deleted keys, forever. A node that had been fenced off — refused for sending a value older than a delete everyone else had already cleaned up — kept the whole cluster's cleanup pinned behind it. One late node meant nobody could ever reclaim the space. Fenced nodes are now skipped when working out what is safe to collect, and a node rejoins the count as soon as it agrees again.
  • A restarted node no longer accepts a deleted key back. The protection that refuses a stale write for a key you have already cleaned up was not written to disk, so restarting a node reopened exactly the window it exists to close: a replica that had been offline could hand back a value you deleted, and it would come back. This is what the new snapshot format above carries.
  • Deleting a key now actually gets saved. Cleaning up a deleted key changed the store without marking it as needing a save, so a node whose last action was a delete could keep writing out its previous state indefinitely — and reload the deleted key on restart.
  • A cluster node could crash or mix up peers under load. Handling one peer's message could disconnect a different peer, after which the node kept writing into memory belonging to the peer that had gone. It surfaced as a crash, or as a frame delivered to the wrong node.
  • A node that can never be reached is no longer counted as healthy forever. A member with no connection was marked as probed without a probe ever being sent, so it stayed "alive" indefinitely.
  • Rejoining a cluster after a failed start no longer crashes. If binding the port or reaching a seed failed, retrying could free the same address twice.
  • A node's suspicion counter no longer overflows, and a corrupt snapshot no longer leaves a half-loaded store behind.
  • sys.exit inside spawn stops that task instead of restarting it. It aborted the task and then ran it again from the top, over and over, producing millions of repeats of whatever it printed — and, worse, freezing every other task in the program behind it, because the scheduler never got a chance to move time forward. It now stops the task, as documented, and the rest of your program carries on. Note that the exit code is still discarded when you call it from inside spawn; if you need a run to fail, return the outcome and exit from the top level.

#0.15.0 — 2026-08-18

#Added

  • scua main.scua runs a project with dependencies. Once scua vendor has written deps/, the ordinary command works — no --mod-path, no wrapper command. SCUA finds your project by looking for scua.toml beside your file and upward, stopping at the top of your repository so a stray manifest somewhere above can never capture your script, and resolves imports from the lock.

  • Dependencies are verified before they run. Every dependency file is hashed against the record that shipped with it, and that record against your lock. If anything under deps/ has been edited since it was fetched, the program refuses to start and names the file. On by default, about a millisecond.

  • --mod-override name=DIR, for testing a local copy of a dependency. It replaces exactly one module and reports what it replaced. Pointing --mod-path at something the lock already provides is refused rather than silently preferred, so you always know which copy you got.

  • Table keys that aren't names. A key is a name: bare when it's an identifier, quoted when it isn't — so a header map, JSON with hyphens or dots, or data exported from another tool is now one expression instead of four statements:

    let headers = { "content-type" = "application/json", "x-api-key" = key, accept = "application/json" }

    Both forms compile to the same code, so there's exactly one way to write any given key: quoting one that's already a name ({ "accept" = … }) is an error telling you to drop the quotes. The two spellings you might arrive with from elsewhere — Lua's { ["k"] = v } and JSON's { "k": v } — each get an error pointing at the form SCUA uses. A key computed at runtime is still an assignment (h[expr] = v), because it isn't a literal.

  • Annotating a table with non-name keys, via the open marker the record form already had: let h: { accept: string, [string]: string } = { accept = "…", "x-api-key" = key }. The named fields are checked as usual; keys beyond them are allowed.

  • http requests take headers, any method, and a response you can inspect. http.request(url, opts) joins http.get/http.post, all three taking { headers, method, body, max_bytes }. Responses now carry headers alongside status and body, so you can read retry-after or x-request-id instead of guessing. This is what makes an authenticated API client possible — every commercial LLM API authenticates by header, so previously only unauthenticated endpoints were reachable.

  • Errors you can branch on. http failures are now Error({ kind, message }), where kind is a stable token (dns, connect, tls, tls_untrusted, timeout, too_large, bad_url, bad_header, bad_method, not_allowed, unsupported, interrupted). A retry policy can retry a DNS blip and give up on a bad certificate; before, every failure was the same sentence.

  • json.decode accepts bytes as well as text, so an http response body decodes directly.

  • Read a response as it arrives. http.open stops at the response head and returns a stream; http.read(stream, max) yields bytes as they come, with empty bytes meaning the body ended cleanly; http.close(stream) releases it — which is also how you cancel, since dropping the connection is what makes a server stop generating. A failed read closes the stream for you. This is what Server-Sent Events and NDJSON need; framing stays in your code, because providers disagree about how a stream ends. Under --io=async, both opening and reading yield the runtime while they wait, so other actors keep running for the life of the stream instead of queueing behind it.

  • bytes.find and bytes.concat, the two primitives incremental framing was missing. bytes could slice but not search or join, so buffering a stream across chunk boundaries meant converting to an array and scanning byte by byte.

  • Requests have a deadline. timeout_ms bounds the whole exchange — connect, TLS handshake, send, and read — defaulting to 120 seconds, and reporting Error({ kind = "timeout" }) when it expires. Previously a server that accepted a connection and then went quiet would hang the script forever. scua --http-timeout=MS sets a bound for the whole run; a per-call timeout_ms may tighten it but never loosen it, so you can bound a script you didn't write without editing it.

#Changed

  • A response body is now bytes, not string. The old string was never checked for valid text, so a binary download produced a "string" that wasn't one. Use bytes.to_string(resp.body) for text, or pass the bytes straight to json.decode.
  • A 3xx redirect is now Ok(resp), not Error. The request succeeded; the server said "look elsewhere". Read resp.headers["location"] and re-request it if you want to follow — which means every hop is checked against your --allow-net allowlist. If you have code treating Ok as "it worked", check the status.
  • A request with a body and no content-type is sent as application/json. Set the header yourself for anything else (a form post, say).
  • A misspelled option is now a loud error. http.get(url, { header = … }) used to send the request without your headers — unauthenticated, and a 401 comes back as Ok. It now stops with unknown option 'header' — did you mean 'headers'?.
  • Unknown HTTP methods are refused rather than quietly sent as a GET.

#Added

  • A function written inside a handler can use the partition's state fields. Previously any nested function — including a wait_all or map_all arm — got undefined name for a field the handler itself could read on the line above, with nothing to say the name was a state field:
    partition Poller
      state urls = []
      state hits = 0
      on Poll()
        let pages = map_all(urls, fn(u)
          hits = hits + 1        -- writes the partition's real state
          return http.get(u)
        end)
        print(`{hits} fetched`)
      end
    end
    Reads and writes both go to the live state, so a write is visible to the handler when the arm finishes, and to the next message after that. Names resolve the way you'd expect: a local shadows a state field, and a state field shadows a global of the same name. A function that captures state still can't leave the partition — sending one in a message is refused, as it always was.

#Fixed

  • A cluster node could freeze. When two nodes connected to each other at the same moment and one connection was collapsed as a duplicate, the surviving node could read an already-drained socket and block there permanently — it stopped ticking and never recovered. It surfaced as a mesh going quiet under load for no visible reason.
  • len is documented correctly. Editor hover text described it as counting bytes in a string; it counts characters, as the strings guide has always said — len("café") is 4, not 5. Behaviour is unchanged; only the documentation was wrong.
  • A wait_all or map_all arm can now use a variable from the handler around it. Closing over an enclosing local — the most natural way to write an arm — did not work: the arm read a value belonging to itself rather than the one it captured, and if its own body had fewer variables than the position it was reading, the run died outright. The same fault hit a coroutine(…) resumed while the function that created it was still running.
    on Go()
      let base = 10
      let total = 0
      let rs = wait_all([
        fn() total = total + base  return "a" end,
        fn() total = total + 1     return "b" end,
      ])
      print(total)   -- 11; previously a wrong number or a hard stop
    end
    Sharing now works in both directions, as it already did for ordinary nested functions: the arms see the handler's variables, and the handler sees what the arms wrote. Two shapes were unaffected and still behave as before — capturing a global, and a spawned task (its captures are settled when the function that made them returns, which is before the task runs). Unrelated but adjacent: a state field still can't be named inside a nested function; pass it in as an argument.
  • Saved partitions from older versions are rejected rather than misread. The fix above changed the size of a stored value, so a blob written by an earlier release now fails to load with a clear version error instead of being read at the wrong offsets. Re-save any blob you want to keep.
  • A shared function now behaves the same inside a partition handler as outside it. A function that read a field or called a module function, used at the top level and from a handler, could misbehave inside the handler: a module arrived as an opaque value, so str.upper(s) failed with "fields only exist on tables and records" — and, worse, a plain field read could silently return a different field's value when the handler's table happened to list its fields in another order. Nothing warned you; you just got the wrong number. Both are fixed, and either symptom needed the same three things to line up (the access inside a shared function, that function run at the top level first, then run again inside a handler), which is why it went unnoticed. Handler code gives up an internal caching optimisation to get there, so a field-heavy loop inside a handler is slower than it was — it is also, now, correct.
  • A record's field types are now checked when you write through them. h["accept"] = 42 on a record whose accept is a string used to compile and run; so did h.accept = 42, which only failed later wherever the field was next used. Both are caught at the assignment now, and reading h["accept"] carries the field's declared type instead of any. Records declared open ([string]: string) still accept keys beyond the declared ones, and a key computed at runtime is still unchecked — the checker only knows the constant ones.
  • The HTTP client no longer re-reads the system certificate store on every HTTPS request, which cost about 3 ms and 1.3 MB per call. Connections are reused too.
  • An open record no longer faults when you use it. record H { accept: string, [string]: string } declared that extra keys are allowed, and the type checker agreed — but adding one still failed at runtime with cannot add a field to a sealed record. Open records now compile as open.
  • Windows builds — full parity. SCUA now ships prebuilt for Windows (x86_64 and arm64) alongside macOS and Linux: scua.exe, the language server, the debug adapter, and the libscua / scua.h embedding SDK. Download the .zip, extract, and put bin\ on your PATH (or run bin\scua.exe directly). Everything works — the language, the filesystem capability (--allow-fs), and the full networking suite: --allow-net, --allow-serve, --allow-udp, and --allow-shared. Embedders linking the static library on Windows add ws2_32 (the Windows counterpart of Linux's -lm). The JIT is dormant on Windows, so scripts run on the interpreter tier — the same as under CPU emulation on other platforms — correctness is unaffected.

#0.14.0 — 2026-08-11

#Added

  • Your own host modules can park — and hibernate mid-call. A native registered with scua_register_module can call scua_return_pending under the async I/O platform: the script's call (say model.ask(question)) parks exactly like http.get does, the platform's submit callback fires with your module's labels, and you complete it by token — or freeze the actor mid-call and wake it later with the answer. When parking isn't available the call returns 0 and your native takes its blocking path. This also makes registered host modules callable from actor handlers at all (previously spawning an actor with host natives registered failed with "value cannot be sent between partitions"); natives are rebound from your live registrations at spawn and rehydration, so re-register them after a load, like grants and the platform.

  • Session hibernation for embedders. A host can now freeze a session actor to a blob — even one parked mid-call on http.get or another host capability — drop the partition entirely (zero resident memory), and later rehydrate it in a fresh partition: scua_persist_actor / scua_load_actor, plus scua_actor_tell_text to deliver the next message. For the mid-call case, scua_io_parks enumerates the waiting call after a reload and scua_io_wake_* (same result shapes as scua_io_complete_*) hands it the answer the host holds, so the script resumes in its Ok(...) arm as if it was never frozen — wake before the first poll, or the call settles as Error("interrupted") per the snapshot reload rule. Scripts need no changes. See the new Hibernate a session guide, the runnable examples/embed_hibernate.c (zig build embed-hibernate), and the cost-curve benchmark (zig build bench-hibernate). Actor blobs from scripts that declare record types are schema-stamped and refused by an incompatible program instead of being misread.

  • Entity-style code — records in a list — now runs at full JIT speed. Loops like es[i].x = es[i].x + 1 over an array of records (the natural way to write game entities, simulation state, or rows of data) previously never reached the optimizing tier and could run several times slower than the same logic on parallel plain arrays. They now compile on both architectures: the new recarray benchmark runs ~3.4× faster on x86-64 and ~2.7× on ARM64, matching LuaJIT on the same workload. You no longer need to restructure record code into parallel arrays for speed.

  • Plain arrays of numbers are now stored unboxed, automatically. An array that has only ever held integers (or only floats) keeps its elements in a packed 8-byte layout — half the memory, and substantially faster hot loops (int-array workloads run up to ~2× faster; float-array loops additionally vectorize where the hardware supports it). You don't need to do anything: the array notices on its first push, and if you later mix in a value of another type it seamlessly converts back to the general layout, once. { f64 }-style typed buffers are unchanged and remain the way to guarantee a packed layout. Saved partitions round-trip packed arrays exactly, including full 64-bit integer precision.

  • scua init — scaffold a new project. scua init [name] writes a small, runnable starter project: an entry main.scua that reads sys.args() and prints a greeting, the greeting split into a greet module, a main_test.scua, a scua.toml with debug/release build profiles, a README, and a .gitignore. With a name it fills a new directory; with none it uses the current one; either way it refuses to overwrite anything. Two focused variants:

    • scua init compact [name] — the bare-bones cut: just a single runnable main.scua.
    • scua init zed-debug / scua init vscode-debug — add a debugger configuration to an existing project (.zed/debug.json / .vscode/launch.json). If the file already exists they insert the SCUA configuration into it, leaving your other configurations — and any comments — untouched.

    See the CLI reference.

  • Clearer fn and return hints in the editor. Hovering or completing fn/return in a SCUA-aware editor (Zed, VS Code) now spells out parameters and the three return forms — no value → nil, one value → -> T, and several → -> (T, U), unpacked at the call with let a, b = f(…).

#Changed

  • The interpreter got a lot faster across the board — on our benchmark suite it now beats the Lua 5.4 interpreter on the overall score of both tiers (micro and realistic). No flags, nothing to opt into: plain scua yourscript.scua is roughly twice as fast as before on call-heavy code (recursion, callbacks, small helper functions), element-typed { f64 } buffer loops are more than twice as fast, and array pushes, field access, division/modulo, and float-constant math all got substantial boosts. This matters most where the JIT can never help: platforms that forbid runtime code generation (game consoles, iOS) run the interpreter only, and it is now genuinely fast. Scripts behave exactly as before — same results, same errors, same limits.
  • Linearly recursive functions got ~40% faster under the JIT (ARM64), ~33% on x86-64. A function whose body makes one recursive call to itself — accumulators (return n + f(n - 1)), list walks, countdowns — now compiles its descent as a loop instead of a chain of calls, on both architectures. Nothing to opt into; same results, same errors, same limits. (Branching recursion like fib keeps its existing fast path.)
  • The JIT got ~30% faster on recursive code (ARM64). Self-recursive functions — parsers, tree walkers, divide-and-conquer algorithms, the classic fib shape — now inline two levels of their own recursion in compiled code instead of one, running roughly a third faster under the default --jit=on. Same results, same errors, same limits.

#Fixed

  • The debugger resolves module imports. Debugging a multi-file program — for example a scua init project whose main.scua imports greet.scua — previously failed with "no module resolver is configured". The debug adapter now resolves imports relative to the program's directory, exactly like running it from the CLI.
  • Step into functions from other modules. The debugger's call stack now reports the file each function was compiled from, so stepping into an imported module's function opens that module's source at the right line (previously every frame was attributed to the entry file).

#0.13.0 — 2026-07-13

#Added

  • The JIT now runs on x86-64 too. Hot loops compile to native code on x86-64 (macOS and Linux), not just ARM64. Nothing else changes: it's off by default, you opt in with --jit=on, and the output is identical to the interpreter. On a platform without JIT support the flag is still accepted and ignored, so it's safe to leave in a launch command everywhere. See Make scripts fast.
  • Datagrams — the udp capability. udp.connect(host, port) opens a socket locked to one peer (the DNS/QUIC/game-client shape); udp.bind(addr, port) opens one that hears from anyone. Send with udp.send / udp.send_to, receive with udp.recv, answer whoever reached you with udp.reply. Read the warnings, because UDP is not like anything else here: it does not deliver your packet, and it is not encrypted. A lost datagram and a slow peer are indistinguishable, so the timeout and the retry are yours — that's the trade you're buying, and it's why games and DNS want it (a late packet is worse than a lost one). Datagrams are their own grant: --allow-udp=1.1.1.1:53 — never implied by --allow-net, because a datagram can hurt a third party. The grant names addresses (ADDR[:PORT], CIDR[:PORT]), not just "yes": an unscoped UDP send is "may packet anything on the internet", which isn't a capability. Names are resolved then checked, so a name resolving outside the allowlist is refused. udp.bind additionally needs --allow-serve — hearing from strangers is listen authority on top of datagram authority, so granting one never smuggles in the other. Two guardrails you will meet: a peer may be sent at most 3× the bytes it has sent you (RFC 9000's anti-amplification rule — without it a forged sender address turns your server into a DDoS reflector), and payloads are capped at 1200 bytes (QUIC's portable-safe floor; a fragmented datagram is all-or-nothing, so fragmenting multiplies your loss). Both are loud Errors, never silent. In a receive loop use udp.recv_batch(sock, max) rather than udp.recv: it waits for one datagram and then takes everything else already queued (never waiting for the batch to fill — that would trade away the latency you chose UDP for). It matters more than it sounds. Under --io=async each udp.recv costs a thread handoff, capping a one-at-a-time loop near 3,500 datagrams/s — under what a 64-player 60 Hz game server needs — while recv_batch clears 13,600/s. One exchange → recv; a stream → recv_batch. With --io=async, a receive inside an actor handler parks instead of blocking the worker, so other partitions keep running while you wait for a packet. The script is unchanged — no async, no await; only the platform differs. See Send datagrams with UDP, examples/udp_echo.scua, and examples/dns_query.scua (a real DNS query, by hand).
  • Run an HTTP server — the serve capability. http.serve(handler, { bind = "127.0.0.1:8080" }) serves requests; the host owns accept/parsing/framing and your script owns the handler (fn(req) -> resp). req is { method, path, headers, body } (header names lowercased, body bytes); return { status = 200, headers = {}, body = b"ok" }. Listening is its own grant — scua --allow-serve=127.0.0.1:8080 — deliberately separate from --allow-net (calling out never grants listening); without it http.serve doesn't exist. A handler fault or a returned Error becomes a 500 (its text withheld unless you pass --serve-debug). Raw TCP too: net.listen(addr, port) / net.accept(l) under the same grant. See Serve HTTP requests and examples/serve.scua.
  • Run many things at once — wait_all / map_all. wait_all([fn() … end, …]) runs every zero-arg arm and waits for all of them, returning their outcomes in order; one arm failing never cancels the others (a faulting arm's slot is an Error you can test with e.fault). map_all(xs, f, { concurrency = 8 }) is the parallel map. No async/await, no promises — and no ? on the call (the failures live in the slots). See Run many things at once and examples/wait_all.scua.
  • b"…" byte-string literals. Write bytes inline — b"ok", b"", b"a\nb" — the same escapes as regular strings. Handy for body = b"ok" and anywhere a bytes value is wanted (a bare b or bird is still an ordinary name).
  • Non-blocking I/O — --io=async. By default SCUA I/O is blocking. scua --io=async … runs fs/http calls on an offload pool so that under wait_all/map_all the waits actually overlap. Results are identical either way — it's an optimization, not a requirement. If a partition is snapshotted or migrated mid-request, that call returns Error(interrupted) and is never silently re-issued (no double-sent POST); reconcile against the durable side rather than blind-retry. See Write handlers that survive snapshots.
  • Frame column search. Grep a data table the same way you grep files/strings:
    • f.filter(col.contains("lit")) — fixed-string substring on a string column
    • f.filter(col.matches(re)) — compiled regex over a string column
    • f.search(needle[, { columns, ignore_case, regex }]) — multi-column shortcut (fixed-string or regex; a compiled re is always regex) See Data tables and examples/frames.scua.
  • Agent-tool I/O (sys + CLI helpers). One-shot tools for agents and harnesses:
    • sys.stdin() — whole launch-time stdin as one UTF-8 string
    • sys.args_table() / sys.parse_args(RecordType) — named CLI flags + stdin JSON into a typed record (with where refinements); bad usage exits 2
    • sys.emit / sys.fail / sys.exit — one JSON result on stdout, diagnostics on stderr, chosen exit status (decimal/money encode as lossless strings)
    • scua schema <file> --type T — JSON Schema draft 2020-12 for a record/enum
    • scua pack — generated teach-a-model in-context pack (compact/full) See Write tools for AI agents and examples/agent_tool.scua.
  • A regex module — ReDoS-safe regular expressions. A linear-time, no-backtracking engine (RE2 / Rust / ripgrep dialect) that can never hang on a pathological pattern, so it's safe to run on user- or model-supplied patterns. regex.compile(pattern) returns Ok(re) or a clear Error (backreferences, lookaround, and unsupported constructs fail loudly at compile — never a silent mis-match, matching ripgrep's default engine). Then regex.is_match, regex.find, regex.find_all, regex.captures (with named groups), and regex.replace ($1/${name} substitution). Offsets are 1-based codepoints and the engine is codepoint-native. No capability needed. \w \s \d are ASCII in v1 (\p{L} and multiline are planned). See Match text with regular expressions.
  • fs.grep with regex — pass a compiled regex value as the pattern, or a string with { regex = true }, to search files with a regular expression instead of a fixed string (a bad pattern is an Error, not a crash). { only_matching = true } emits each matched substring (with a stop field) rather than the whole line — the regex equivalent of grep -o.
  • str.matches / str.find_re / str.replace_re — run a compiled regex over an in-memory string: a boolean test, the leftmost match record, and capture-aware replace ($1/${name}), the same engine as regex.* and fs.grep with the string first.
  • fs.grep token-economy output shapes — ask for the cheapest sufficient answer instead of paying for whole lines you'll only post-process. { files_with_matches = true } returns just the matching paths (stopping at each file's first hit); { count_only = true } returns a per-file count (or, with { matches = true }, the number of individual matches); { invert = true } selects the lines that do not match; { max_per_file = N } caps matches per file. These make fs.grep far cheaper to hand to an AI agent, which greps constantly and budgets tokens on the result shape. Fixed-string only for now — regex is the next step.
  • fs.grep context lines — { context = N } (or { before = N } / { after = N }) attaches the N lines around each match as before and after string lists on the hit, the way grep -C/-B/-A do. When two matches are close their windows merge, so no line is ever shown twice. fs.grep_text renders the context indented under each match.
  • fs.grep_text(...) — the same search rendered as one compact path:line: text string, ready to paste into a prompt or a log without walking a list of records.

#Fixed

  • .. on bytes now concatenates bytes instead of silently producing garbage. b"abc" .. b"abc" used to render each operand as its debug text and join those, giving you the 26-character string "bytes(616263)bytes(616263)" rather than 6 bytes — so building a payload the obvious way (a loop, or an HTTP body, or a datagram) quietly put a hex dump on the wire. bytes .. bytes now yields bytes. And mixing is a loud error, never a coercion: cannot concatenate `bytes` with a string — `..` never coerces between bytes and text, naming bytes.from_string / bytes.to_string as the fix. Text concatenation is unchanged.
  • http.serve and net.read no longer stall against clients that keep the connection open. A socket read waited for a full internal buffer instead of returning the bytes that had arrived, so a keep-alive client (curl, a browser, another service) that sent a request and held the connection open never got its response — while a client that closed its write side (piping into nc) worked, which is how it hid. Reads now return as soon as bytes arrive.

#Improved

  • Calling a serve verb without the grant now names the fix. http.serve / net.listen / net.accept in a run without --allow-serve used to fail at runtime with a generic "attempt to call a non-function"; it is now a compile-time error that says what to grant: `http.serve` requires the `serve` capability … run with `--allow-serve=ADDR:PORT`.
  • map_all's concurrency is host-capped. Asking for more than the per-partition in-flight-I/O ceiling (64) now clamps to it — the extra work queues and still all completes, it just never holds more than 64 operations in flight at once.

#0.12.0 — 2026-07-06

#Added

  • An optional JIT — hot loops compile to native code. SCUA now has a just-in-time compiler. It's off by default (the interpreter runs everything); turn it on with --jit=on and hot loops are compiled to native machine code as they run. The output is identical to the interpreter, down to the byte, so it's safe to toggle. Game math (vectors, quaternions, matrices) and string-keyed tables benefit most — on the benchmark suite the JIT lands ahead of LuaJIT overall (the vector entity loop runs several times faster). It's ARM64-native (Apple Silicon and ARM Linux) and a harmless no-op elsewhere. See Make scripts fast; --jit-stats prints what it did. The interpreter stays the default and the whole thing compiles out of a client build for platforms that forbid runtime code generation.
  • reserve(array, n) — pre-size an array in one step. When you know how big an array will get, xs.reserve(n) allocates the capacity up front so the fill loop never re-allocates on the way up. Length is unchanged; shrinking is a no-op; returns the array so it chains. The win grows with the buffer — a large element-typed buffer ({ f64 }, { f32 }, …) goes straight to its final home.

#Improved

  • Big element-typed buffers no longer weigh 4–5× their size at runtime. Large { f64 }/ { f32 }/{ vecN } buffers now live in their own memory blocks that the garbage collector moves by reference instead of copying, and the runtime returns freed memory to the OS instead of holding peak usage forever. An 8-million-element { f64 } buffer now costs almost exactly its own 64 MB — previously ~300 MB — and programs whose memory use spikes and then drops give the memory back. Buffers over 256 MB can now exist at runtime (they can't be saved with persist yet — saving one reports an error).
  • Exact money — a first-class value type that carries its currency. Write a money literal as an amount and an ISO 4217 code: 19.99 USD, 500 JPY, -42.50 EUR. The amount is exact (it reuses the decimal engine — never a float), and the currency travels with the value through arithmetic, printing, and storage. Arithmetic is dimensional and safe by construction: money ± money (same currency), money ×/÷ a number (quantity or rate), and money ÷ money → a plain ratio; adding a bare number to money, multiplying two amounts, or mixing currencies is a compile-time error with a clear message (currency matching is checked at run time). Build one dynamically with money.of(amount, code); format, split penny-exact, round to the currency's places, and convert at an explicit rate with money.format, money.split, money.round, and money.convert. See examples/money.scua.
  • Exact-double numeric buffers ({ f64 }). The element-typed array family now includes f64: a packed, unboxed buffer of exact doubles. Where { f32 } is single-precision (and rounds a value with more precision than a 32-bit float holds), { f64 } stores each number exactly at 8 bytes — and still halves the memory of the boxed default. Use it when precision matters (a high-precision simulation, an exact coordinate); { f32 } stays the choice for GPU vertex data. See the collections guide and examples/typed-arrays.scua.
  • Rendering and localization (render). Bake a config table against caller-supplied data: render(source, locale, external) resolves @{path} references and returns a fresh payload. The config names abstract slots (self/... for itself, external/... for your data) and never the shape of who renders it. Directive blocks build config beyond strings — @ref (pull a subtree), @raw (emit literally), @when (include a subtree only when a slot is truthy; gated features just disappear), @match (pick a variant), and @each (a bounded map over a list). Localization lives next to your data: a localized({ en_US = "...", es_ES = "..." }) value collapses to the active locale (BCP-47 tags normalize, missing tags fall back to the primary subtag); @{date:date.long} renders a date in the locale (1 de febrero de 2023); @msg evaluates ICU MessageFormat for plurals and gender ({count, plural, one {# message} other {# messages}}); and @strip prunes authoring scaffolding from the output. render_tracked additionally returns the read-set with per-input provenance, for precise cache invalidation. render is pure, total, and runs under hard limits, so even a config you didn't write can't hang or exhaust memory. See the rendering and localization guide and examples/render.scua + examples/localization.scua.
  • Dates and times (the time module + a datetime type). Turn the millisecond clock into civil dates you can read, format, and parse. Build one with time.now(), time.at(ms), or time.of(2026, 6, 29, 14, 0); read its fields as methods — dt.year(), dt.weekday(), dt.parts(); render with dt.format("YYYY-MM-DD HH:mm") or dt.iso(); and read strings back with time.parse("2026-06-29T12:00:00Z") → Ok(datetime)/Error. A datetime carries the UTC offset it's read at, prints as ISO-8601, and survives a durable save/load. Durations stay plain milliseconds: time.add(dt, 90min), time.diff(a, b), time.humanize(ms) ("1h 30m"), and time.parse_duration("2h5m"). It's a fixed-offset Gregorian calendar — no time-zone database, DST, or leap seconds (yet). In dt.format() the month/weekday name tokens (MMMM/dddd) are still reserved; for a localized month name use the render layer's @fmt (see the rendering bullet above). See the dates and times guide and examples/time.scua.
  • System locale & timezone. sys.locale() and sys.timezone() report the locale tag and UTC offset the program runs under — no capability grant needed. They're neutral by default ("und" and UTC), so runs are reproducible; set them at launch with --locale=de-DE --tz=+02:00, or read the real device with --locale=host --tz=host. The timezone feeds the date layer (time.now()/time.of default to it), which makes it easy to pin a fixed environment in tests. See Set the locale and timezone and examples/locale.scua.
  • Data tables (frame). A small columnar data table for totalling, sorting, and grouping data — spreadsheet-style answers in a few lines. Build one from a table of columns: frame({ region = ["EU", "US"], amount = [10.50d, 20.00d] }), then query it with chained operations: f.rows(), f.column(name), f.head(n), f.pick(names), f.sort_by(name[, descending]), f.total(name), f.mean(name), f.group_sum(key, value), and — filtering rows and adding computed columns — f.filter(predicate) and f.with(name, expr), where column names are written bare (f.filter(region == "EU" and amount > 100.00d)), or a fn(row) … end for arbitrary per-row logic; combining and reshaping — f.join(other, key[, "inner"|"left"]), f.pivot(index, columns, values), and f.unpivot(ids, values) (melt: stack wide columns into a long name/value pair). A frame prints as an aligned table. The headline is exact money: a decimal column totals, groups, and computes exactly (no float drift). You can also parse CSV text straight into a frame with csv(text) — cells are typed per value, so money cells become exact decimals. See the data tables guide and examples/frames.scua.
    • f.to_table() renders a frame as a DuckDB-style box-drawing table, returned as a string for printing or copying into a report/chat/comment (numeric columns right-aligned; every row included, unlike the capped print preview).
    • Column names and types are checked at compile time. When a frame is built from a frame({ … }) literal, a mistyped column — in total/mean/column/sort_by/pick/group_sum/join/pivot, or written bare in a filter/with predicate — is reported before the program runs, with a "did you mean" suggestion. A reducer that sums or averages (total/mean/group_sum/pivot) also checks the column is numeric, so summing a text column is caught up front. The schema follows the chain. Frames whose columns aren't known up front (a frame argument, or one from csv/join/pivot/a host) are checked at runtime instead.
    • Host embedders can populate a frame from C/Zig: build each column with the existing array APIs and assemble it with the new scua_new_frame(...), using scua_decimal(coeff, scale) for exact-money columns (no float round-trip). It's the Arrow-shaped copy-in, so a third party can back a frame with SQLite/Parquet/DuckDB without SCUA depending on Arrow or a database. Data that already comes as an Arrow C Data Interface batch can skip the per-column glue and pass the ArrowSchema/ArrowArray pair straight to scua_frame_from_arrow(...) (int/float/utf8/decimal128 columns; nulls become nil). See Drive a script from your engine.
  • Built-in geometry shapes. Six new value types — rect, aabox, sphere, capsule, ray, and plane — alongside vec/color. Build them with flat constructors (sphere(center, radius), aabox(min, max), ray(origin, dir), …) and operate on them by calling the operation on the shape: s.volume(), box.contains(p), box.closest_point(p), s.area(), s.center(), s.size(), s.radius(), cap.length(), plane.distance(p), r.point_at(t), and more. intersect(a, b) tests whether two shapes overlap or hit (aabox×aabox, sphere×sphere, rect×rect, ray×sphere, ray×plane, ray×aabox, either order), and shape + vec translates a shape. See the math guide and examples/geometry.scua.

#Changed

  • Operations now read best on the receiver. Across the math types, the operation goes on the value — write v.length(), c.to_hex(), s.volume(). The same name now works across types without clashing (a bytes value also answers b.to_hex() and b.slice(start, end)). The older bare-function forms (length(v), to_hex(c)) still work, so existing code keeps running, but the receiver form is the one to learn and what the docs now use.
  • Matrix and quaternion constructors read under the type. Write mat4.translate(v), mat4.rotate(q), mat4.scale(v), mat4.id(), quat.axis_angle(axis, angle), quat.id() (and the mat2/mat3 equivalents) — the type name groups its constructors instead of an underscore prefix. The flat names (mat4_translate, quat_axis_angle, …) still work.

#0.11.0 — 2026-06-28

#Added

  • A color value type. Colors are now first-class, alongside vec/mat/quat. A color is four float components (r/g/b/a) stored in linear space, so blending and arithmetic are correct by default. Build them with rgb/rgba (linear), srgb/srgba and hsv/hsva (decoded to linear), color_hex("#rrggbb"), or a #rrggbb / #rrggbbaa / #rgb / #rgba hex literal. About 148 CSS named colors are available as color_<name>() (e.g. color_cornflowerblue()), plus color_transparent(). Operate on them with lerp, lighten, darken, with_alpha, inverted, and luminance, and read them back with to_hex / to_srgb; the .r/.g/.b/.a swizzles and the + / * scalar operators work as for vectors. Because storage is linear, lerp(#000000, #ffffff, 0.5) is a light grey (#bcbcbc), the math-correct midpoint — not the naive #808080. A color is its own type and never silently mixes with a vec4. See the math guide and examples/colors.scua.

#0.10.0 — 2026-06-28

#Changed

  • enum, flags, and gate now use braces. Their bodies are a comma-separated { … } list instead of an end-delimited one, matching record — enum Dir { North, East, South, West }, flags Layer { Ground, Player, Wall }, gate Owner { Audience.Server, Audience.Client }. Discriminants, payloads, and comments are unchanged (enum Key { Escape = 27, Space, Enter }, enum Shape { Circle(float), Rect(int, int) }). contract is not affected — it still ends with end. This is a breaking change: existing enum/flags/gate declarations written in the old … end form must be rewritten with braces.

#0.9.1 — 2026-06-28

#Added

  • Host-provided modules can be imported from any file. When a host embeds SCUA and registers a native module (e.g. a gfx or rl graphics binding), script code in an imported module can now bring it in with import gfx — the same way it imports the standard library — instead of only the entry script being able to see it. So host-API-using code can live across modules, not just in the entry file.

#Fixed

  • The always-available builtins now work inside an imported module. floor, min, max, abs, clamp, round, ceil, sort (and tostring, type, assert, format, key, builder, and {x:.2f}-style number formatting) used to fail with "attempt to call a non-function" when called from a function defined in an imported module — they only worked in the entry script. They are now genuinely ambient everywhere, so shared logic that does any math (suit = floor(id / 13), clamps, easing) can live in a module like any other code.

#0.9.0 — 2026-06-28

#Added

  • Audience gates: project state for who's allowed to see it. Declare audiences as an enum, group them into named gates, and mark a record field gated SomeGate. project(value, RecordType, audience) returns a copy containing only the fields that audience may see — ungated fields always, a gated field only when the audience is in its gate. This is the safe way to send a cut-down view of authoritative server state to a client without leaking server-only fields (a bonus timer, an upcoming drop), instead of hand-stripping fields and hoping you didn't miss one. Fields are open by default, so you only annotate the secrets. Projection recurses into nested records and { Record } collections (a secret nested anywhere is dropped) and returns a deep copy that shares nothing mutable with the source; the audience must be an enum value. (A static "what does this audience receive" leak-report is still planned.)
  • Verify and protect your saves. durable.verify_roundtrip(v) confirms a value survives save→load with nothing lost (a QA/CI guard, exact thanks to byte-determinism). durable.digest(v) and hash.sha256(data) give a stable content hash. A new crypto module signs and verifies those digests with Ed25519 (crypto.keypair(seed), crypto.sign(msg, secret), crypto.verify(msg, sig, public)) — so a server can sign a save's digest and a client can verify it but not forge it (cheat-resistant saves with almost no backend). Keys are bytes the host supplies; never ship a secret key in a client. See Verifying a save.
  • Save and load your own data with the durable module. durable.encode(value, schema_version?) turns a value into a typed, versioned binary blob, and durable.decode(blob) turns it back — preserving int/float/decimal/big/string/bytes/key (unlike JSON), with a layout decoupled from memory so old saves keep loading as your records change. Decoding into a typed record migrates and deeply validates it; a corrupt blob is a value-level Error you can match. durable.version(blob) reads the stamped schema version. The functions are pure (no capability); write the bytes to disk with fs. See Evolve your saved data safely.

#Changed

  • A migrate hook's result is now type-checked. Previously a record with a migrate hook had a weaker load boundary than one without — a wrong-typed value could be laundered through the hook into a typed field (a float into an int). The boundary now type-checks the hook's output as strictly as a direct typed assignment, so migration can't admit a mistyped field.
  • The typed boundary now checks nested data, not just the top level. When loose/any data is assigned into a record type, the field-type checks, migrate hooks, and where refinements now run on nested records and the elements of a typed { Record } collection, all the way down — not only the outermost record. So a corrupt or stale value buried inside a loaded tree (a bad wins on a player inside a season, an un-migrated nested record) is caught where it lives instead of silently sailing through. This includes self-recursive types — a linked list or tree (Node { value: int, next: Node? }) is now checked, migrated, and refined at every level, to the data's true depth, not just near the top. Fields typed any and scalar collections like { int } are still trusted (no declared shape to check). A cyclic value (one that points back at itself), or one nested deeper than a few hundred levels, faults at the boundary rather than loading — a cycle can't be persisted anyway.
  • Clearer error when a record type is used as a value. Writing Player{ … } (the struct-literal syntax from other languages) or using a record type name on its own no longer reports a bare undefined name. The compiler now explains that a record type isn't a value and shows the construction form: let x: Player = { … }.

#Fixed

  • Reassigning a variable to a record/array that refers to itself no longer corrupts it. Inside a function, node = { v = i, next = node } (the natural way to build a linked list or tree by accumulating state) used to make the new value point at itself instead of the previous one. It now reads the old value correctly, so building hierarchical structures in a loop — and persisting them — works.
  • A partition that sends a message to itself no longer crashes. A tell/ask from a partition to itself copies the message within one heap; for a message carrying a string, bytes, key, vector, or path that could corrupt memory and crash. Self-messaging is now safe.
  • Deeply nested expressions report an error instead of crashing. Pathologically nested input — thousands of nested parentheses, a very long chain of unary not/-, or a deep ** chain — used to overflow the stack and crash the compiler. It now stops with a located expression nested too deeply error. This matters for any tool that compiles untrusted SCUA source.

#0.8.1 — 2026-06-24

#Added

  • Editor: hover and go-to-definition on type names. Hovering a declared type in a script — a handle, record, enum, flags, or type alias (including host-API types from a --!declare file) — now shows what it is, and go-to-definition jumps to its declaration, across files. Previously only functions and constants did.

#Fixed

  • Editor: host-API hover/jump now works from subdirectories. Hovering or jumping to a declared gfx.* member from a script in a subdirectory now finds the project-root scua.toml (the earlier fix covered error diagnostics but not the hover/jump index).

#0.8.0 — 2026-06-24

#Added

  • Opaque handle types for host APIs. A host's declaration file can now name an opaque resource with handle Name (e.g. handle Texture), so a script's editor type-checks resource arguments and returns by name — passing a Sound where a Texture is wanted, or reading a field off a handle, is flagged, mirroring what the runtime already enforces. The handle keyword is only meaningful in a --!declare file and reserves nothing in ordinary scripts. See Embed SCUA in a host program.

#Fixed

  • Editor: PascalCase host-function names highlight correctly. A declared fn LoadTexture(...) (the common native-binding casing) is now parsed/highlighted as a function, not mis-flagged.

#0.7.0 — 2026-06-24

#Fixed

  • Editor: host-API calls no longer show a spurious "undefined name" error. With a host's --!declare file loaded, rl.DrawTriangle (and any declared namespace) hovered and type-checked but still drew a red squiggle from a second diagnostic pass; that pass now honors the declarations.
  • Editor: scua.toml is found from subdirectories. The language server now walks up from the open file to the nearest scua.toml (like other editors/servers), so one manifest at the project root covers scripts in src/, examples/, etc. — you no longer need a scua.toml beside every script.

#Added

  • Vec values cross the embedding boundary. A host native can now read a vec2/vec3/vec4 passed as an argument and return one, so a binding's f(vec2) -> vec2 functions take and give first-class vectors (with swizzles and vec math) instead of flattened number pairs or {x, y} records. See Drive a script from your engine. (For binding authors: scua_to_vec / scua_vec / scua_return_vec.)

#0.6.0 — 2026-06-24

#Added

  • Host constants. An embedding host can now expose named scalar values (a binding's key codes, blend modes, flags) as plain fields of a module — a script reads gfx.KEY_SPACE with no call, the same as a module function. See Embed SCUA in a host program. (For binding authors: scua_register_consts + a const member in the generated --!declare file.)
  • Re-entrant callbacks. A host can call a script function from inside another native that the script invoked mid-frame (a log handler, an input or audio hook) — scua_pcall now nests safely instead of corrupting the in-flight turn. See Drive a script from your engine.
  • Owner-thread safety. A partition is now bound to one thread; touching it from another (e.g. an audio library's callback thread) is rejected (SCUA_ERR_THREAD) rather than racing the collector. Marshal off-thread work to the owner thread.

#Changed

  • Saving refuses while a host resource is reachable. If a script is holding an opaque host-resource handle (a texture, sound, font, … from an embedding host) that would be reached by a save, scua_persist now fails instead of writing a handle that dangles after reload. Release such resources before saving; this keeps "the partition is the save file" honest.

#Fixed

  • The math type names quat and mat2/mat3/mat4 are now accepted in type annotations (like vec2/ vec3/vec4 already were), so a function or binding signature can name them without a spurious "unknown type" error.

#0.5.0 — 2026-06-24

#Added

  • Element-typed numeric buffers. Annotate an array with a numeric element type — { f32 }, { i32 }, { u8 }/{ u16 }/{ u32 }, or a packed { vec2 }/{ vec3 }/{ vec4 } — and it stores its elements unboxed and contiguous, like a C float*, while still indexing, pushing, slicing, and iterating like any array. A value that doesn't fit the element type is a clean, located fault instead of a silent wrap. This is how a script builds vertex/point/pixel data that a host engine uploads with no copying. See Collections and Drive a script from your engine.
  • Embedding API for typed buffers. Hosts can inspect a buffer's element kind/size/count, build one from a contiguous block, and pin its packed bytes for a GPU upload (scua_buffer_info, scua_new_buffer, and typed-buffer support in scua_pin_bytes/scua_arg_bytes).

#Fixed

  • Two vector/matrix/quaternion values with equal components now compare equal (vec2(3, 4) == vec2(3, 4) is true); previously they only compared equal if they were the same object.

#0.4.0 — 2026-06-23

#Added

  • Editor support for a host's API. A host that registers native functions (e.g. an engine binding) can ship a declarations file so your editor gives you hover docs, signature help, and type-checking for those functions — the same experience you get for the language's own built-ins. The file is read by the tooling only; it never runs. See Drive a script from your engine.

#0.3.2 — 2026-06-23

#Fixed

  • A native registered under a namespace (e.g. rl.GetMousePosition()) now returns all of its values to a let x, y = … binding, not just the first.

#0.3.0 — 2026-06-23

#Added

  • Host embedding API (drive scripts from C). SCUA embeds in your own program like Lua: link one static library and one C header and own your frame loop. The host can call into a loaded script (update(dt)/draw()), hold script values across frames as durable handles, exchange structured values and multiple return values, pass opaque native resources (textures, sounds) that a script holds without a raw pointer ever touching managed memory, and pin a script-built byte buffer for a zero-copy upload. See Embed SCUA in a host program and Drive a script from your engine.

#0.2.0 — 2026-06-23

#Changed

  • Editor tooling (syntax highlighting and grammar) brought in lockstep with the language, including the interface, select, and wait_for constructs.

#0.1.0 — 2026-06-21

First public release — the prebuilt scua runtime, the libscua embedding SDK, the full manual, runnable examples, and editor tooling (LSP, debugger, Zed/VS Code). The language at launch already included:

  • A Lua-feel core with optional types. Dynamic by default; add type annotations only where correctness matters, and they're checked at the boundaries. Functions, closures, multiple return values, if/match as expressions, : ranges.
  • Structured data with persistence for free. Arrays, tables, records, and first-class paths into nested data; save a unit of state to a flat blob and load it back with no serialization code, including schema migration as your records change.
  • Pattern matching, enums, flags, and contracts for expressing and validating shapes and invariants.
  • An error model built on Ok/Error results, the ? propagation operator, try/rescue, and pcall.
  • Partitions and actors for isolated, message-passing state (state/on/ask/tell), in-partition concurrency (spawn, wait, durations), and selective receive (select/wait_for) — with live state that can be saved and migrated between processes.
  • Strings done right: UTF-8 text with interpolation and format specs, a distinct bytes type for binary data, interned key identities, and an efficient string builder.
  • Numerics for real work: exact decimal money, huge-magnitude big numbers for idle games, and a games math library (vec/mat/quat).
  • Capabilities, granted by the host: files (fs), networking (net), environment variables (env), command-line arguments, and TOML/INI config — all default-deny until explicitly granted.
  • Modules and import, a standard library (including JSON and content search), logging with compile-time stripping, and conditional compilation.
  • Tooling: a formatter (scua fmt), a test runner (scua test), a terminal debugger (scua debug), and named build profiles via scua.toml.