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, andscua_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_clockandscua_advance_clockput the script on the host's clock, sowait(500ms)spans frames and pausing the game pauses the script.scua_set_fault_hookreports each fault a partition or task survives, once, andscua_post_textlets 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.cshows the whole loop. - An embedded script's partitions can run on several
cores.
scua_set_workers(p, 4)before the load, and eachscua_run_turnsruns the script's partitions on the host's thread and three workers. Each host function says how it may be called:scua_register_module_modetakes main-thread (the default, still run only on the host's thread), parallel-read, deferred or any-thread. Deferred calls are applied byscua_apply_deferredin 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_VERSIONis 3.examples/embed_multicore.cis a frame with six agents on four threads. - A project lists the commands it may run in
scua.toml, and--allow-execgrants them. Put argv templates under[exec] commands = [...]and launch with--allow-exec:exec.runandexec.spawnrun those commands and no others. The list travels with the program, so a launcher can show it, and only the project's ownscua.tomlis 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-fsor the per-user folder reaches the file holding it, the launch stops withE-EXEC-POLICYand suggests a folder of the program's own, such as--allow-fs=./data. A policy file'sexeclist follows the same rule. - An unknown option is called one.
scua --sandbox agent.scuasaysunknown option `--sandbox`. Did you mean `--allow-sandbox`?and exits2. durable.format(blob)anddurable.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, anddurable.decodenames an empty, foreign or newer blob instead of one message for all three.actors.stats().errors: how many turns ended with anErrorthat noaskwas waiting for, such as a?in a told handler.parseand the typed decoders take their options from a top-levelconst. Writeconst STRICT = { unknown = "refuse" }once and passparse(v, T, STRICT). The options still have to be a table literal the compiler can read, so aletor 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 withpretty.- A field's
wheretakes a reason:title: string where title.len() >= 1 else "a title is required". The reason leads the fault's message, as a record-levelwhere … elsealready did, andscua schemapublishes it as the field's description. scua schemamaps morewhererules. A comparison with a constant (days > 0,0 <= n,score < 1.5) becomesminimum,exclusiveMinimum,maximumorexclusiveMaximum; a length comparison (s.len() >= 1,len(tags) < 5) becomesminLength/maxLength, orminItems/maxItemson a list; and anandof mapped rules maps both. Before, only ranges and membership were mapped.--max-total-ops=Nbounds a whole run. It counts the same steps as--max-ops, but over every turn of the main script and its partitions, and awaitdoes not start it again. A tool that loops with a sleep between rounds, which--max-opsnever stopped, now stops after N steps in all, with exit status 6.--max-opsis unchanged: it still bounds a single turn.sys.script_path()andsys.executable(): the absolute paths of the running script and of thescuabinary, 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 byfs.closeand 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-sharedlets 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()andtime.mono_ms(): clocks that move while a turn runs.now()stays put within a turn and await(d)moves it by exactlyd, 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 andtime.wall()the current instant, both read on every call. Under--fastandscua testthey 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-anycoversexec.runandexec.spawntoo. 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 onlyexec.runand has no grant is now pointed at--allow-execand the[exec]list.- A
scua.tomlinside 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=IDsays when nothing checks the id. With no[app] idinscua.toml, a mistyped id would get a new, empty folder; the run now warns once and suggests the line to add.E-USER-LOCKEDnames 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.servenow 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.readlineorsys.readpasswordaddstty;http.serve,http.stats,net.listen,net.accept,net.local_portandudp.bindaddserve;exec.run_anyandexec.spawn_anyaddexec_any. Passing a module itself around as a value adds every grant its functions need. Before, a package that served HTTP listed onlyhttp, 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
waitoryieldin a partition handler faults at the call. The rule is unchanged (a handler suspends throughask, a fulltell,wait_fororselect), but the fault now happens where thewaitis written, with its line. So apcallortryaround 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-LOCKEDexits 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.lockholds only the code that waits. In anhttp.servehandler, 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.lockreports a failed lock call asio, keepingunsupportedfor 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'sErrorsays 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) andbad_path(the path is a folder, or cannot be a file).iois kept for the rest, so a program retries only what might pass. Locking a folder now says to lock a file inside it, such asdata/.lock.fs.write'sErroris{ kind, message }too, with the same kinds.existsmeans 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_pathandiomean what they mean forfs.lock. Showe.messagewhere 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-fsnames 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/genis 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_pcallreturnedSCUA_ERR_OTHERwith 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
spawnat the script's top level, or inside a function the host called withscua_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
execrefusal keeps what it says. Anot_declaredmessage 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-filenames a missing file as missing, and a folder as a folder, instead of "cannot read this file".scua --helpnow lists every key the file can carry,execanduseramong them.- The no-per-user-folder hint points at your
[app] idwhen the run can writescua.tomland 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 withtype R = { id: string }checked nothing at a typedlet, a parameter or a return, so a value withid = 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.
-> Notereturning{ id = id }with an untypedidholding1built aNotewhosestringfield held an int; it is now refused, naming the field. Literals and values of a known type cost nothing extra. scua testcharges a told handler's failure to the test that told it. Atellreturns before its handler runs, so a failingassert_eqin the handler was reported as a file-level ERROR while the test said PASS, and a handler that ended with anError(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,askit 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 })andjson.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 atypealias (goal: int = DEFAULT_GOAL,dir: PayDirection = PayDirection.Both) was an undefined name when another file built the record:let q: quest.Quest = {}, orparse(v, Config)for a config holding one. Aconstholding a table is still the declaring module's own; build such a default there. actors.settle(timeout)returns at its timeout underscua testand--fast. With a partition that waits on a timer forever, it never returned.pcallof a built-in or module function gives its result and catches its fault.pcall(sys.platform)gaveOkof the wholesystable,pcall(g)forlet g = sys.platformgaveOk(nil), and a fault inside the function, such assys.readline's over-long line, went straight past thepcall. Wrapping it in a function (pcall(fn() return sys.platform() end)) always worked.sys.readlinekeeps 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
--mountand no--allow-fsnames the file'sres: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.namefrom a module with noreturnis 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-fsflags are refused; the second used to replace the first silently.
- Reading a host file in a run that has
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.yousuggesteddev.dev.you; it now suggestsdev.you.mytool. Reading a host path in a run whose only folder isuser:saidwrite user:/etc/hosts, a spellinguser:never takes; it now names the file'suser:path and says what--allow-fswould 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.scuawithimport 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=DIRnow works with a space too, is listed inscua --help, and the message says it is unchecked. - A callable field named
keysorvaluescan be called as a method.ctx.keys("note:")on a value whose type wasn't known at compile time failed with "undefined name 'keys'", althoughctx.getand 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-servealone, a program that usesuser:paths in any of its files is told--allow-user=ID(with the id from[app] idwhen 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.lockcalls on a lock file that didn't exist yet, made by several programs starting together, failed with "no such file or directory" (so didfs.writeandfs.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 nomono_msthere", 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 schemareports a syntax error with its line, in the words a run uses, and checks the project'sscua-versionfirst, 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 adatetimesays how to move or measure one:time.add(dt, ms),time.diff(later, earlier)ordt.to_ms(). It used to be a bare "type error". - A
matchused as a value with ado … endarm 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.repasks "did you meanstr.repeat?".
- Inside a sandbox, a standard-library function the sandbox isn't
given now says so:
sys.executable()'s description says it resolves symlinks, so a launch line built from it names one installed version; writescuato follow upgrades.hex.encodetakes bytes as well as text, asbase64.encodedoes, 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 aT, all the way down. It replacesas(value, T).Tis any type an annotation takes:parse(v, Save),parse(v, { Item }),parse(v, Pt?),parse(v, lib.Pt). It builds new records, fills defaults, runsmigratehooks and everywhererule at every level, including map values,{ Enum }elements and collections of collections, and never changes the value it is given. Aconstof 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)andini.parse(text, T)giveOk(T), checked and converted asparsewould, or anErrorholding the fault record, bad bytes included, so oneError(e) -> log(e.message)arm handles a corrupt file and a save that doesn't fit. The checker knows whatjson.decode(text, Save)?gives. Without the type, nothing changes. - Encoders take a type.
durable.encode(value, version, T)andjson.encode(value, T)check the value againstTall the way down before encoding:Ok(bytes)(or text), or anErrornaming the path to the bad value, and nothing encoded. A value with no saved form, such as a function left in a field typedany, is anErrortoo: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 haspath(raw.ledger[1].id) andat(ledger[1].id, the path from inside the value); a typed decoder's bad bytes carrylineandcolumn. - The deep mode.
--check=deep(orSCUA_CHECK=deep) makes every annotation check all the way down, asparsedoes, 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 --fixrenames every built-inas(…)in a file toparse(…).- A
decimalormoneyfield reads its JSON text."1.5"and"19.99 USD", asjson.encodewrites them, are accepted by a record field of that type, sojson.decode(json.encode(v), T)round-trips. - Typed message handlers and constructors.
on Buy(item: Item)andpartition Shop(c: Config)check their arguments on entry, as a function's typed parameters do, and the checker checks atell's, anask's and a construction's arguments against them. - A per-user folder for a program's saves and settings:
user:paths.--allow-user=IDgrants the folder the platform keeps for the program:%APPDATA%\IDon Windows,~/Library/Application Support/IDon macOS, and$XDG_DATA_HOME/ID(by default~/.local/share/ID) on Linux.--user-dir=PATHgrants a folder you name, such assavebeside the game while you develop it. The program writesfs.write("user:saves/slot1.json", …), and everyfsfunction takesuser:paths, beside the folder--allow-fsgrants, 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 ascon. A literal that breaks the rule is a compile error, and a built one anError.fs.findlists a name on disk that breaks it withbad_name = true, and every function that writes refuses it. Auser:literal with an upper-case letter gets a warning, since a Mac treatsSettings.tomlandsettings.tomlas one file. fs.find's records are now documented in full:path,name,rel(the path below the directory searched),is_dirandbad_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] idinscua.toml; a launch that names another stops withE-USER-MISMATCH. scua user path IDprints where the folder is, and creates nothing.fs.mounts().useris{ id, path, created }, ornilwithout the folder.- A program that uses
user:paths still runs without the folder: a note on stderr says so before it starts, and auser:call it reaches stops the run with status 4, naming the flag to add. - A capability file can say
user = "ID"oruser-dir = "PATH", honoured only from outside every folder the run can write.--allow-allnever 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-fsfolder (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-usermarker holding the id (hidden on Windows), so scua never writes into another program's folder: an existing folder without one stops the run withE-USER-FOREIGN.scua user adopt IDshows such a folder and what is in it, and--yesmarks it as the id's; a shipped launcher passes--user-adopt, for the folder Steam Cloud downloads on a second machine. Nofscall, 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-dircreates only its last folder, so an unplugged drive isE-USER-NOPARENTrather than an empty folder on the system disk, and a link to a missing folder isE-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 testgives 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 nowimport fswhether or not the project has ares/folder; host files stay out of reach.- Under WebAssembly,
--user-dir=/savesnames 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.tomlwith its[app] idamong its own files is checked against that id like a program in its project: another--allow-userid stops withE-USER-MISMATCH, naming the bundle. Only[app] idis read from it.
- A name a program creates in
- For embedders:
scua_grant_user,scua_revoke_user,scua_app_idandscua_user_folder. A host grantsuser: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 perE-USER-…code, the code and reason inerrbuf. The newSCUA_ERR_USER_LOCKED(21) is a folder another run holds;SCUA_USER_NOWAITreturns it at once instead of waiting about 5 seconds, andSCUA_USER_ADOPTmarks an existing platform folder.scua_use_project,scua_eval_fileandscua_load_script_fileread 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 resolvesuser:paths itself declares it withscua_io_platform_caps. Without it auser:read or write runs inline rather than parking, so a reactor written beforeuser:existed never writes a file nameduser:….scua_io_platform_capsnow reads only the bitsscua.hdefines (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
ifwhose 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 inr = (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 asroundin one andceilin 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-checkchecks a server in CI without starting it.scua --allow-serve=ADDR:PORT --serve-check app.scuaruns the program up to its firsthttp.serve, applies every check the server makes before it starts (a handler or a function incontextthat reaches alet, modulestatewritten 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 exits0. A refusal prints the message and exits with the status it has without the flag. A program that never reacheshttp.serveexits with the new status8, 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 twoset-cookielines, 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,stateor payload now checks the value's kind and, for a record, the kind of each of its own fields and its ownwhere. 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
letused to be checked at every level; now only the top. Decode into the type (json.decode(text, Save)?) orparseit where it arrives. The checker warns (shallow-check) at a typedletor return of ananyvalue into a record with nested parts. as(value, T)is nowparse(value, T).asstill works, with a warning; the next release refuses it.scua fmt --fix file.scuarenames 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
moneyfield 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 needsparseto fill defaults. Untyped code that added keys to such a literal now faults. sys.parse_argsbuilds throughparse, 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.
- Data loaded with a decode followed by a typed
- 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 endcompiles for thenilits 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 wasnilthat now gets a record, or a function compiled for numbers now called withnil) is compiled again from the values it gets now, and--jit-statscounts the loops this happens to asloop-retires. - A response header whose value is not a string is a
500.
http.serveused to leave such a header out of the response and answer as if nothing were wrong, soheaders = { "x-count" = 5 }sent nox-countat 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 withtostringbefore you set it. A header key that is not a string, such ash[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
fspath 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. Everyfscall answers such a path with anErrorsaying so, and creates nothing. fs.mounts().stateis"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 comparedstatewith"res only"should compare with"res".- A capability file
scuarefuses 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 toscua, and 1 is the status a script uses for "ran and answered no". --blob-keysrefuses 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
fspath that starts with the granted folder's own name says so. With--allow-fs=saves, paths are relative tosaves, sofs.write("saves/slot1.json", …)looks forsaves/saves/. The error used to say the directorysavesdoes not exist; it now says paths are relative to the granted folder and suggestsslot1.json.
#Fixed
scua testfails a test whose partition faulted. A handler the test drove that faulted, such as anassert_eqinside anonhandler, 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.decoderefuses a number it cannot hold exactly. An integer pastint's range (12345678901234567890) became a float without a word, so a client's request id came back changed; now it is anErrorthat names it. A number past a float's range (1e400) became Infinity, whichjson.encodethen refused; now it is anErrortoo.execfinds a program on yourPATH. The manual said it did, but a program installed only outside/usr/binand/bin(such as/opt/homebrew/bin/npx) was not found unless the child was given aPATH. A bare program name is now looked up on thePATHscua was started with.http.servesends nocontent-lengthon a 204. A204 No Contentor304 Not Modifiedresponse carries neither a body norcontent-length, as HTTP requires.- Two manual corrections.
sys.readlinepauses 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 schemamaps 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_allof 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, orfalsein 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.serveno longer sends a body after aHEADresponse. It wrote the handler's body after the headers, which a client keeping the connection open read as the start of the next response. AHEADnow gets the headers aGETwould, with the body's length ascontent-length, and no body.scua serveanswers aHEADwith the file's real length, where it used to say 0.fsreads and writes work again in the WebAssembly build. Under a WASI host such as wasmtime, everyfs.read_text,fs.writeand other call on a file returnedpermission denied, whilefs.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
fsfolder 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 nofsgrant. 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
whileloop, set fields of a record passed to them or append to an array. A bit writer such as a JPEG encoder'sput_bits(w, code, size), called inside a loop or inside anifthere, now runs as part of the loop's native code instead of making a call each time. A shortwhilewritten in the loop itself, such aswhile 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 shortwhileinside 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
nilto one, includingnilfor 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-statslists 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
constorletread in the loop,min,maxandabs,floor,roundandceilor a shift inside anif,~, aniltest 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,
niltests and calls inside anif, arrays read from a record's field, and awhileinside anifthat 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
forinside 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-statsalso countsentry-fails=, the times a compiled loop was skipped because the values it was compiled for had changed. --jit-statsnames 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.fromis"start","current"or"end", and the new position comes back, sofs.seek(f, 0, { from = "current" })says where a handle is. Packedres: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 withfs.seekit 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 anError.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 withoutbytes.slicecopies.bytes.deflatercompresses data that arrives in pieces. Feed it withbytes.deflate_push(d, data)(abytesvalue or a{ u8 }, or a range of one) and end it withbytes.deflate_finish(d); the result is exactly whatbytes.deflategives 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 ofbytes.inflate. It writes zlib (the default, as PNG needs), gzip or raw streams atlevel0 to 9, takes abytesvalue or a{ u8 }array (or afrom..torange 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.inflatereads 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 passsize, so code that works on the pixels needs nobytes.to_arraycopy. 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 anError. gzip input uses the length recorded in the file the same way.
#Changed
bytes.inflatedescribes 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,
letor field declaredP?used to let any table through, so a field with a default that the table left out read asnil. It now gets the same check asPwhenever the value isn'tnil. 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 withfield '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 says1 worker (8 with SCUA_SCHEDULER=threaded)where it used to say8 workers. Under--output=jsontheservingevent also carries"scheduler".
#Fixed
fs.writeandfs.write_chunkno 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.inflatechecks 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 anError: 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 isError({ kind = "timeout" }), and the stream is closed, as after any failed read.actors.dropandsandbox.stopsay when a partition is busy. Inside a handler they do not wait for another partition's turn, and a partition in one answeredOk(false), as if it had already stopped, while it kept running. It is nowError({ kind = "busy" }), asactors.snapshotalready was, with a message saying how to retry. The manual now coversactors.snapshot'swaitanddropoptions.bytes.deflateholds 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.inflaterefuses an option it doesn't know, and amax_bytesthat 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=Nprints 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 everyNrequests, 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
contextthat reads a main-scriptletis refused before the server starts. Withcontext = { get = fn() return app end }andlet app = …, the server used to start and then answer every request that called it with a500.http.servenow 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 aconst. This holds for a top-levelfnpassed as a value and for a function nested anywhere insidecontext, 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 thatexclusivewas there to protect. It now faults before writing anything, withfs.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 coversfs.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_alland a frame'ssearch, alongside thehttpandnetcalls that already had it.crypto.aes_ctrandcrypto.pbkdf2return it as anErrorof kindmisuse, because nocryptocall faults.exec.spawnandexec.spawn_anynow refusestdin, which onlyexec.runandexec.run_anyuse. - A fault about a very large value is reported as that
fault.
assert_eq,assert_neor amatchwith no arm, given a value close to the 256 MiB per-value limit, used to stop the whole run withruntime error: ObjectTooLargeorOutOfMemory(exit 7). The fault is now raised as usual (exit 5, andpcallcatches it), and a value shown in a fault message is cut after 64 KiB with a count of what was left out, such asaaaa… (268369864 more bytes). - A long
bytesvalue prints its first 16 bytes and a count.print,tostring, interpolation and fault messages showed every byte of abytesvalue 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_hexstill 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.renameand the otherfscalls answeredError(<path>: i/o error). They now saythe name is too long, andfs.renamenames the end that is too long. - Size-limit messages quote the limit of the kind of value
they are about. A
bytesvalue can hold 268,435,432 bytes and a string 268,435,424, because a string's header is 8 bytes bigger.fs.readquoted the string limit for abytesread, and a..that grew too large quoted 268,435,455, the size of a whole value with its header. Each message now saysone bytes valueorone text valueand quotes that kind's limit.
#New, for evaluation:
res: and pack files
A program's own data has its own path prefix,
res:. Thefsverbs read it from a stack of read-only layers:- at the bottom, the project's
res/folder (or--res=PATH); - above it, each
--mount=PACKor--mount=DIR, a later one winning; - on top, each pack the program mounts with
fs.mountbefore 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.- at the bottom, the project's
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.mountsandfs.where;- ranged reads,
fs.read(path, { offset, len }); relon everyfs.findrecord;scua res build | list | check;scua serve site.pak;--show-res.
An application bundle can carry its data.
scuanow reads a.scuappthat holds the app'sres: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 listandscua res checktake a.scuapptoo. A bundle with assets runs without--allow-fsand reads only its own files. scua-pkg 0.3.25 writes these bundles withbundle --app.res/read as a host path is pointed out. While the project has ares/folder:- a literal
res/…path in a read is a compile warning that names theres: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.- a literal
fs.openstreams a packed file, stored or deflated, withfs.read_lineandfs.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 returnsError("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ßeandSTRASSE), or are one name written two ways (é, andewith a separate accent).scua res buildwarns about a name that is not in NFC, and--nfcwrites every name composed. A miss names the other spelling when the pack has one.A host gives a script its data.
scua_mountadds a pack file or a folder as ares:layer, andscua_mount_memorya pack in the host's memory, with areleasecallback that runs exactly once.scua_res_layerslists the layers andscua_res_resetdrops them.scua_eval_fileandscua_load_script_fileadd the script's projectres/folder, as thescuacommand 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.snapshotrecords theres:layers, andactors.reviverefuses a save whose layers are not all mounted, withError({ 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 throughscua_load_reportversion 3,scua_actor_statsversion 2 andscua_set_res_revive. A save from before this release records nothing and revives as before.scua testreadsres:. Tests read the project'sres/folder with no flag, andscua testtakes--mountand--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_textand 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-filefile, read as--mountreads them. A--mounton the command line replaces the list.--blob-keysrefuses a keys file ares:layer can reach: one inside a mounted folder, or one that is itself a mounted pack.execrefuses acwdthat is ares:path.Faster CRC-32.
hash.crc32, gzip, and the check on every wholeres: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.listandfs.findon host paths now answer in natural order, asres:listings do: runs of digits compare as numbers, soe1m2comes beforee1m10, andfs.findlists 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 ares:path into a host path. With a resource view but no--allow-fs,import fscompiles, 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 fornpartitions. It startsnpartitions by callingmake(for examplefn() return Db(url) end) and returns one send-only address for all of them. Anasksent to it goes to a free member, sonasks 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 eachask's own timeout still applies. A member that faults is replaced by callingmakeagain, and theaskit was answering getsError({ kind = "fault" }). See A pool of partitions.http.serve(handler, { health = "/healthz" })answers a health check without running your handler. It answers200while the server is serving and503once a stop has begun;health_sheds = truealso answers503just 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),.gzfiles with{ format = "gzip" }and zip entries with{ format = "raw" }. It returnsOk(bytes), or anErrorthat 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_argstakes a list of positionals. Afiles: { string }field collects every positional left, soconvert *.pngworks; 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, andscua schemapublishesadditionalProperties: falsefor that record. See Read command-line arguments.fs.write(path, data, { exclusive = true }),fs.open(path, { mode = "create" })andfs.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 }andfs.syncwait for the data to reach the disk. See Replacing a file without losing it.
#Breaking: typed boundaries
- A typed parameter, return or
letrefuses a record value that is missing a field with a default.fn take(r: P)called with{ a = 5 }, wherePdeclaresb: int = 2, used to run withr.bnil and fail later with a baretype error. A literal like that is now a compile error, and loose data such as decoded JSON is refused at the boundary, both sayingfield 'b' of P is missing; use as(value, P) to fill its default. Convert withas(value, P), which fills defaults, wherever the data may lack them. A record literal written directly into a typedletorreturnstill 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 aconst, and anything else goes incontext; - a module's
statestarts as declared on every request; - state that lasts between requests lives in a partition passed in
context; --max-membounds each request: one over the limit gets a500on its own. See Each request runs on its own and Keep state in a server.
- a handler can't reach the main script's
- A handler can wait.
ask … timeout,wait_for,select,http.get,fs.readandnet.readwait for that request alone while the server answers others. They used to give a500.
#Breaking: typed buffers and enums in JSON
json.encodeandtoml.encodewrite 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. Aflagsset 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 }holding300, or a plain[300, 2], andp[0]was300. 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. Passingnilto one that isn't declared optional ({ u8 }?) faults too. - A typed annotation no longer changes an array someone else
holds.
let p: { u8 } = dinside a function, or a record field declared{ f32 }, used to convertditself, so a caller's{ u16 }turned into a{ u8 }and then refusedpush(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. Storing1e40used 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)), onpush,a[i] = v,insert, a literal and a typed annotation alike.infandnanwritten as such still store, and ordinary rounding (0.1reads back as0.10000000149011612) is unchanged. The lanes ofvec2,vec3andvec4are not affected: like float arithmetic, vector math can still overflow to infinity.
#Changed
- Compiled loops are much faster. A counted
forloop 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 asfor i in 0:n do s = s + i % 7 endruns in less than half the time, and anhttp.servehandler that spends its time in a loop like that answers about 1.8 times as many requests.--max-opsstill stops a program at exactly the same step and line. whileloops and counted loops run faster under the JIT. On x86, awhile i < nloop that sumsi % 7and stepsiby one now takes less than half the time it did, close to the same loop written withfor. Aforloop that adds up its index takes about 30% less time, and a loop that does several%by a constant and keeps totals withif(theordersbenchmark) about 15% less.--max-opsstill 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.encodeorbytes.from_string, totella partition, to start aforloop, 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 astostringandabsabout 10% faster.--max-opsstill 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-statscounts 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.renameno longer replaces a read-only file, and a file it replaces keeps its permissions. Renaming over a0444file used to succeed and leave a0644one. It is now anError;fs.removethe 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.joinandbytes.from_arrayhold their result once while they build it, where they used to hold it twice: a 16 MB join peaks about 16 MB lower.fserrors name the path:Error(notes/todo.txt: no such file or directory). A path under a directory that does not exist no longer suggestsfs.mkdirwhen you were only reading it.- A run stopped by
--max-opsinside 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
bytesvalue past 268,435,432 bytes names that limit. - The
--max-memguide explains peak memory: the process can reach about twice the live data the cap measures. See--max-mem. - A missing field with a
wherenames itself. Withrecord R2 { n: int where n in 0:10 = 3 }, callingfn f(o: R2)asf({})faulted with a baretype error. A typed parameter checks the value it's given and doesn't fill defaults, sonwas missing; the fault now says so (field 'n' of R2 is missing, so its `where` constraint can't hold …) and suggestsas(value, R2), which fills them. http.servereads each request's headers in a single pass, about 5% more requests a second on small requests.http.serveuses about 10% less CPU per request. It copies and allocates less for each request and response,json.encodeandbytes.from_stringcopy 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, wherehelperhas awhileloop, the default JIT could lose the value ofn - 1oncerecwas optimized, so the recursion never reachedn < 1and stopped withstack overflow: call depth exceeded 4000 frames. It now gives the same answer as--jit=off. - A hot loop that reads a
livestring, list or table no longer risks a crash or a wrong value. Under the default JIT, a loop such asfor i in 0:n do let s = TUNING.name … t.v … endcould keep using wheretwas before the read made the partition's memory grow, and so read memory that had moved. Loops that read only number or booleanlivekeys 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 thebindtext 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 namedstd. It is the built-in now, and where your file doesn't declare its ownlenit has the same identity aslen(x).live.publish(…)andlive.version(…)were recorded as using a package namedlive.- A partition's constructor parameters were recorded as packages named
after themselves, and
partition P(a)had the same identity aspartition P(a, b). - A module's
state x = 1had the same identity aslet x = 1. - An import was identified by the name you gave it, not the module it
loads: with
import json as min one file andimport toml as min another,fn f(s) return m.parse(s) endhad 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 ubesidefn util) can no longer be hashed; rename one of the two. - A top-level
let a, len = 1, 2was ignored: a function callinglenwas identified as calling the built-in, although the file'slenhides it. Each name of such aletis 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
constis the same function again. In 0.34.0,const F = fmadeF == ffalse, 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 aconstor 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 aconstagain. 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]andt[F]are one entry afterconst 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
statetable 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
constafter 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 alet, 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, thenconst 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 writeserror(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
stateline, or any function they call that reads or writes a top-levelletis a compile error. The error names the variable, the path to it and a fix: make itconst, pass it in as a constructor argument, or keep it instate. A call through a function value the compiler can't follow stops the program with the same message. An embedding'sworldis 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
letthrough 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. Useconst, orstatefor a value each partition keeps for itself.stateat the main script's top level is refused. - A module's top level can't fill or change one of its own
statevalues. 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 thestatefrom it:state INDEX = build_index(). A function the top level calls that writes thestatestops 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 Startis 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 usedStartas an ordinary message name needs another name.wait_fortakes 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, nohost, noids.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'slib.sizes[0] = 99change 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 instateand 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
constis frozen, sochart.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:]. constfreezes 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 byxs.push(1)is an error now. Uselet, or build the value in aletand writeconst 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
constname 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, andstdis now a reserved word, sostd(xs)for a standard deviation is writtenxs.std()orstd.std(xs). Using a hidden built-in's name above itsletorconstis an error. - The named colours moved into a module.
import colorand thencolor.red(),color.navy()and the rest;color_red()is a compile error that namescolor.redand the import.color_hex,rgb,srgbandhsvare unchanged. See Colors. - (Narrow)
live NAME = …at the main script's top level is now a declaration of alivevalue. - 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 itsfnworks.
#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_FORMATfrom 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 anidfield. 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 tostringorfn takeused 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 returnsError({ 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-alldoes not include it. actors.freezeis renamedactors.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 }), withkindsaying what happened:gone,fault,no_reply,interrupted,timeout,limitormisuseamong them, andalready_liveorstale_generationfrom a revive. When a handler's own reply is anErrorwhosekindis one of these, the asker sees it asupstream_<kind>, soe.kind == "gone"always means the runtime found the partition gone. A handler that faults while answering anasknow answersError({ kind = "fault", message = … })instead ofError(message). - An
askto a partition that was dropped or hibernated getsError({ kind = "gone" })at once, instead of waiting out its timeout. Anaska partition was waiting on when it was snapshotted comes back asError({ 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 withstale_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.dropandsandbox.stopon one giveError({ 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_persistsigns a session save with HMAC-SHA256, as actor snapshots are signed. Load it with the newscua_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 passSCUA_BLOB_ACCEPT_UNKEYED.scua_loadrefuses a signed save. Unkeyed saves are unchanged.scua blob-info --keys FILEchecks a session save too. - Partitions are named by
scua_actor_id(slot, generation and runtime epoch) instead of a bareuint32_t, andSCUA_ABI_VERSIONis 2. Every entry point that took or returned an actor id (scua_persist_actor,scua_load_actor,scua_actor_tell_text, thescua_io_park*andscua_io_wake_*calls) uses it, andscua_actor_listlists 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 isSCUA_ERR_RANGE. Rebuild your host against the new header. - New result codes:
SCUA_ERR_ALREADY_LIVE(16) andSCUA_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_pushandscua_table_setreturnSCUA_ERR_TYPEon a frozen value.- A partition needs the
sandboxgrant 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 alet.- Module
state: a module can keep a value per partition withstate name = …, saved with the partition. A library function that uses module state can sit in aconst. See State in a module. - Constructor arguments:
partition Shop(catalog, owner)keeps what it needs instatelines (state items = catalog) and is created withShop(PRICES, "Mara"). See Constructor arguments. on Start()runs once in a new partition before its first message. Seeon 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.encodewrites 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) andscua_get_actor_statsreturn the runtime's counts: partitions running, slots, the ready queue, dead letters, suppressed log lines, and revives refused by reason.askinsidemap_allandwait_allarms sends every request at once and collects the answers in order. A partition may have up to 64 asks waiting; one past that getsError({ kind = "limit" })at once. See Asking several partitions at once.- Fenced revives:
actors.revive(blob, { generation = g })andscua_load_actor_fencedtake 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. livevalues 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_hpcopies 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 returnsOk(version)or anError(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
liveversions they read. A revive into a process that is behind is made and reported (live_behindin the load report and inactors.stats());actors.revive(blob, { refuse_live_behind = true })refuses it withError({ kind = "live_behind" }). - Embedding
livevalues:scua_live_publishpublishes from JSON,scua_live_set_startmakes partitions wait for, or refuse to start without, a first publish (SCUA_ERR_UNPUBLISHED, 19), a publish past the retained versions isSCUA_ERR_LIMIT(18), andscua_get_actor_statsaddslive_versions,live_behindandlive_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 astateline 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 withwhererules. --mem-statsreports the longest collection (gc-pause-max-us), and the background collections of idle partitions (trims,trim-pause-max-us).- Signed saves.
--blob-keys=FILEsigns every actor snapshot with your deployment's key (HMAC-SHA256) and revives only snapshots that verify; the others are refused withintegrity. Every key in the file verifies and the one markedsignsigns, so keys rotate without stranding saves.--accept-unkeyed-blobsalso 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), andloads_by_key,loads_other_keysandloads_unkeyedin 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 inconstants_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 insections_discarded) once the partition has run since the revive. scua blob-infoshows a save's identity (as its locator), whether it was signed, and the parts it is made of;--keys FILEchecks 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
importof 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 andfloorcost 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, thenscua vendorto bring it intodeps/. Ifdeps/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.palettethenp.push(…), or a function parameter) names that variable and offers a copy that runs, such asp = p[0:]. It never tells you to "uselet" when you already have one.let lib = import("lib")andconst lib = import("lib")get the same "thelibmodule exports" message asimport lib. deleteon 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-levelletreported in one compile, asimport mdoes. - 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 runscua upgrade @scope/name, rather than suggesting an edit its hash check would refuse. - Advice that compiles. Assigning to a module's
constfrom one of its functions says to usestate;color.hex(…)sayscolor_hexis still a built-in; a literalletchanged 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.failreason, so the first line of stderr is always about the program. Each shows once, with one line number;scua testprints 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-gcis about 34 KB lower. { kind, message }is the shape of the actor runtime's errors and of errors that cross anaskor a sandbox boundary;pcallandtryelsewhere still give the fault's message as a string.- Fixed:
--mem-statsprintederror("boom")as a bareerror; the fault now comes first, with its message. - Fixed:
scua check x.scuasaid "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-statsreportsshared-compiled=. - A message costs the same however many idle partitions
exist. Only partitions with something to do are visited: an
askbeside 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 … inloops 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
constkeeps 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
statenamed like its module'sstategets 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 await_allarm's error could read memory the heap had just moved away from; they no longer do. SCUA_ACTOR_ORDER=shuffle:SEEDruns 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
filterandwith, 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 yearnext to ayearcolumn used to read the column without a word, whilefn total2next to atotal2column was already refused. Both are refused now, as the guide says. To update: writerow.yearfor the column, or rename your function. - Breaking:
--max-mem(and an embedder'smem_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=1Mran 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 withmemory limit exceeded. To update: a program that creates actors under a tight cap may need to raise it;--mem-statsshows the total asall-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 atelloraskto a sandboxed actor and in itsreply. Code inside a sandbox also can't start actors, withactor(…)or with apartitionof its own;spawnstill 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-opscounts creating an actor as one step, like the call it reads as. It counted nothing.sys.exit,sys.emitandsys.failin a partition's handler end that handler's turn and keep the partition's state.sys.emitwrites its result.sys.fail(reason, code?), andsys.exitwith 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. Anaskwhose handler ends withsys.exitorsys.emitbefore replying gets anErrorinstead 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'sreturn Error(e)still leaves the exit status alone. See Partitions and the actor model.sys.fail,sys.emitandsys.exitin a spawned task stop that task and nothing else, and otherwise do what they do in a handler:sys.emitwrites its JSON, andsys.failis reported on stderr straight away and fails the run. In a task they used to vanish:sys.failprinted nothing and the run exited 0. Seesys.fail,sys.emitandsys.exitin 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 apushyou never wrote:pushand literals both say "add", and stores andinsertsay "store" and "insert". - Actors can use the
clustermodule with the run's secret. They used an empty secret, so with--allow-cluster=SECRETan actor'scluster.openstopped the main program opening the same table.
#Fixed
- A
whileloop no longer leaves a stale value in a variable it only assigns. A variable assigned inside awhileloop but not read until after it could keep an earlier, wrong value once the loop ran long enough to be optimized:x = i * 2in the loop, thenprint(x)after it, printed 8190 instead of 199998. It is now the same with the optimizer on or off.forloops were not affected. insertinto a typed array refuses a value that doesn't fit, aspushdoes. 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(…),matcharm, 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
OutOfMemorywell 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
scuacommand. - A table column named
printorworldcan be used by its bare name insidefilterandwith. - Defining your own
pushfunction no longer makesfilterreturn no rows. clusterwrites from several actors at once under--workerscould be lost, or crash the run.- Programs with many background tasks that
waitno longer stop after about ten million wake-ups with "a task is looping on plainyield". The check now fires only on a task that keeps yielding without ever waiting (includingwait(0)loops), and the error points at that task'syieldline 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,decimalandbigincluded) 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=alwayschecks 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 thenp.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-statsreportssink-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(…)andError(…)are wrappers, and the checker now follows them: fromOk,Errorandpcall, 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 typedlet, 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, ormatch 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'saddandsys.emitused to writeOk(…)orError(…)into the text and carry on, so a failure could end up stored in a file or sent as an answer.print, log lines andtostringstill show a Result as it is. To update:`stored: {r?}`, ormatchit first.Breaking: a function that can return an
Errormust returnOkorErroron every path. That is a function that uses?, or one with areturnthat gives a Result. In it:- a plain
return 5is a compile error: writereturn Ok(5); return f()?is one too, because the?takes the value out of a Result you could pass on as it is: writereturn f();- when the function doesn't use
?, it must not be able to run off its end orreturnwith no value, because that givesnil: end it withreturn Ok(nil); - a
-> int(or any type butResultorany) 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 anError, ornilwhen all is well" helper: it givesOk(nil)instead, and each caller'sif bad != nil then return bad endbecomeshelper(…)?. Change both, or neither: with only the helper changed, every!= niltest treats success as a failure. Comparing a Result withnilis now a compile error for that reason; the message namesmatches Errorandmatches Ok. So is a test'sassert_eq(helper(…), nil)(writeassert_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,nilor anError, 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 inOkoffers it last.- a plain
Breaking: a
matchwithOk(…)orError(…)arms must cover both. An arm with awhenguard, or with a literal inside (Error("timeout")), covers only what it says. When the checker knows theOkholds a bool, as it does forfs.paths_aliasand for a function of your own whose everyOkholds a comparison (return Ok(x > 5)),Ok(true)andOk(false)together cover it. A bareOk ->arm matched every value, anErrortoo, and is now refused. To update: addError(e) -> …(orOk(v) -> …), or a_ -> …arm; writeOk(_) ->for anyOk.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 byz = sharedin anOk(shared)arm, or byz = bytes.from_array(…), orlet data = json.decode(s)followed bydata = {}, 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 itany:let wrapped: any = load().assert(r)on a Result is refused likeif r. - In a module (a file you
import),?and areturnof 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:
?andreturn Error(e)at the top level of the script you run stop it likesys.fail(e):{"error": …}on stderr, exit status 1, and whatever was printed stays printed. A top-levelreturn Error(e)used to exit 0, silently. Underscua 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 hostError({ kind = "failed", message, value, line }); it used to say onlyfaultedand "failed to start", which lost the reason. To update: a script or CI job that relied on exit status 0 after a top-levelreturn Error(e)should check for exit status 1, ormatchthe 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: onnil, on anError, and on anOk(nil). It used to replace onlynil, so anErrorand anOk(…)came back unchanged. To update: if you relied onr ?? dkeeping a Result, writerormatchit.Breaking: a function that uses
?and runs off its end givesOk(nil). So does a barereturnin it. It used to givenil, which is neitherOknorError, so a caller'smatchon it found no arm. A function declared-> anyis left as it was. To update: a caller that looked fornilmatchesOk(_)instead; to keepnil, declare the function-> any.Breaking:
_binds nothing._ = f()andlet _ = 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-> anyif 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,addandsys.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_csvand a frame'sto_table. WhereOk(…)orError(…)used to be written into the text and the program carried on, you now getthis 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 andtostringstill show a Result in full, andjson.encodestill writes it as{"kind": …, "value": …}. To update: take the value out with?ormatchbefore it becomes text, or writetostring(r)where showingOk(…)orError(…)is the point. A test of the success path finds nearly every such place, because anOkis refused as well as anError.Breaking: a module whose top level gives an
Okor anErrormakes theimportfault, 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 withmatch, 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?orreturn Ok(…)has itsOk's value taken as the new state, and anErrorkeeps the old state, as anonhandler's does. A plain function that returns anOkas 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:
misuseandlimiterrors from another partition or a sandbox arrive asupstream_misuseandupstream_limit, astimeoutalready arrived asupstream_timeout, so a caller can tell its own mistake from one on the far side of anaskor a sandbox. To update: a caller that checks anaskreply or a sandbox's answer forkind == "misuse"or"limit"reads"upstream_misuse"or"upstream_limit".Breaking: assigning to a field of an imported module (
lib.check = …) is refused in a--!dynamicfile 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.failwrites anOkin its reason without its contents.sys.fail({ last = r })withranOkwrites{"error":{"last":{"kind":"Ok"}}}: what fails a script goes to stderr and on to log services, and anOkmay hold a decrypted secret. AnErrorkeeps its value.--debug-values,scua testandscua debugshow the contents. To update: nothing, unless something reads the value back out of the failure; pass--debug-valuesthere, or put the value itself in the reason.
#Breaking: crypto
Breaking: every crypto call returns
OkorError, 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 anErroryou handle, andcrypto.verifyanswersfalse. Eight calls that returned their value directly now returnOk(value). To update, take the value out with?, which works in a function and at the top level of a script (where anErrorstops it with exit 1), ormatchthe 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 returnsOkorError: declare it-> Resultand 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_ctrandpbkdf2) now also return anErrorwhere they used to stop on a bad key, an unknown algorithm or curve, or an argument that is not bytes. See the crypto reference andexamples/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
Errorholds{ kind, message }.kindsays 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"forpbkdf2'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, andmisusearriving through anaskisupstream_misuse. Underscua testandscua debug, amisusealso 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_ctrandpbkdf2used to give a string. To update: where you show or log the reason, write{e.message}instead of{e}; branch one.kind.Breaking: a function can't mix its own Errors with crypto's passed on by
?. A function that returnsError({ kind = "bad_key", … })on one path andcrypto.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:matchthe crypto call and return your own Error:Error(e) -> return Error({ kind = "bad_key", message = e.message }).Breaking:
hash.equalisfalsewhen either side is empty, or is not bytes or a string. An empty signature is never a valid one, and a missing header isnil, so a webhook check can no longer accept an emptyX-Signature:against a secret that failed to load, and it never stops the program.hash.equal(b"", b"")used to betrue. 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_evalandscua_eval_fileused to returnSCUA_OKforsys.fail,sys.exit(n)withnnot 0, and a top-levelreturn Error, andsys.failwrote its body to the host process's stderr. Now they returnSCUA_ERR_FAILEDwith the reason (orexit N) inerrbuf,__returnholds a top-levelError's value, and nothing goes to the host's stderr.sys.exit(0)is stillSCUA_OK.SCUA_ABI_VERSIONis now1. To update: treatSCUA_ERR_FAILEDas the script reporting a failure, and read the reason fromerrbuf. 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=1to that run (or setSCUA_WORKERS=1).scua testandscua debugalready run on one worker.
#New and changed
Your partitions run on several cores by default.
scuanow 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 setSCUA_WORKERS=1), whichscua testandscua debugalways 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.readandfs.read_textread 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 MBfs.readtakes 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,askand 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'sfor k, v in tvisits the keys in the same order as the sender's.fs.readandfs.read_textread files that do not report a size, such as those under/procand/syson 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 equalledColour.Green. And a list of numbers such as[1, 2, 3]accepts apush("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(…)orhash.equal(…), and a bareaskstatement. - A Result stored in a named table field, or given to
json.encode. A field meant to hold one takes it through a name declaredany:let outcome: any = run(job). - A function using
?that returnsOk(value)on one path and can also run off its end. if bad != nil then return bad endafterlet bad = check(x)?, wherecheckgivesOk(nil): the guard can never return.
To drop a Result on purpose, write
let _ = f(); the warning goes.exec.close_stdin,exec.killandhttp.servenow say in their signatures that they can return anError(they always could), so a bare call to one is warned about too.- A call whose Result nothing looks at
(
scuapoints 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 inOk(…), 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 ashash.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 bytesreplaces… is a value, expected bytes, so the message says what to do.The checker now checks the code inside partition handlers. An
onoraskhandler'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:?andreturn Error(e)end the turn, areturnis the reply, and the end of a handler needs noreturn. 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-warningskeeps 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--!strictfile 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 warningsscua testandscua debuggive while a program runs, and your editor keeps showing every warning. It asks for the opposite of--deny-warningsand--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 = falseunder[run]in itsscua.toml, or under[run.<profile>]for one profile, andscua,scua testandscua debugkeep compile warnings off stderr there, with the same exceptions as--no-warnings. The command line wins without an error:--warningsshows them again, and--deny-warningsor--dep-warningsshow and count them as usual, so a quiet project's CI can still refuse warnings. A script with noscua.tomlis unchanged. The manifest now has its own reference page, scua.toml.scua testprints each warning once. A call such astonumber([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") andprice.amount()its exact amount as a decimal, on any money value, including one read from a CSV column, with noimport. They aremoney.codeandmoney.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.codeis still an error, and it now says to writeprice.code(). See Handle money andexamples/money.scua.A field on other plain values says what to do instead.
d.yearon 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 testand 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=offor--workers=1to get around a hang like this, you can take it out.
#0.31.0 — 2026-10-02
Breaking:
crypto.aes_ctrreturnsOk(bytes)orError(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 anErroryou 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)?), ormatchthe result. Seeexamples/counter-mode.scua.Breaking: an
askthat times out now givesError({ kind = "timeout", message = "ask timeout" }), and only a timeout can. It used to beError("ask timeout"), which a handler could also return, by mistake or by passing on the result of its ownask, 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 anErrorwhose payload sayskind = "timeout"arrives askind = "upstream_timeout", soe.kind == "timeout"always means thisask'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 reade.kind(ore.message) instead. See The timeout form.Breaking, rarely: an
actor(fn, state)function that returns anErrorkeeps its old state. TheErrorused to become the actor's new state. Now the turn ends there, the state stays as it was, and theErroris written to stderr (see "A handler that ends its turn with anError" below). To update: if theErrorwas meant to be the new state, wrap it:return { last = Error(e) }.Breaking: a save frozen while an actor held an
Erroras its state may refuse to load. A save written by an earlier build while an actor held such anErroras 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.freezenow refuses to write a save like that, withError({ kind = "bad_state", … }), rather than handing back bytes no revive could load.Breaking: fault messages name an
Okwithout 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, andOk(…)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. AnErrorkeeps its message (got Error(bad input)), and other values are shown as before.print,..and templates are unchanged, andscua test, the debugger and--debug-valuesshow the full value. To update: a log filter that read a value out of a fault message should look forgot an Ok. Underscua test,scua debugand the debugger, a failingassert_eqstill shows both values; anywhere else it saysexpected an Ok, got an Ok, so pass--debug-valuesto see them.Breaking, rarely: calling a function a built-in module does not have is now a compile error.
import strthenstr.len("abc")used to compile and then stop at run time with "a binding namedlenreplaces 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: "strhas no functionlen—lenis 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 testsys.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, wherepinsis a module whosecheck_pinalways returnsOkorError, is now a compile error, as the same line already was whencheck_pinwas 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 withpins.check_pin("0000") matches Ok, ormatchthe result.Breaking, rarely:
json.decodesays where the input went wrong. ItsErrorused to be the bare textinvalid JSON. It now gives the line and column, as ininvalid 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 beginsinvalid JSON. To update: code that compared theErrorto the exact textinvalid JSONshould teststr.starts_with(e, "invalid JSON").Breaking, rarely:
--max-opscounts 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 testandscua debuglist 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-warningsmakes 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 andscuaexits with status3before 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-warningsfails a test file that warns.--!strictgives one file that rule. A compile warning in a file whose first line is--!strictstops 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-warningsor the package's own--!strict.--dep-warningsshows them, and--dep-warnings --deny-warningscounts them, for a deploy gate that refuses a dependency that warns.--debug-valuesshows full values in fault messages. When you need to see what anOkheld to understand a fault and can't run the debugger, pass--debug-values:match: no arm matched (got Ok(hunter2)), theassert_eqandassert_netexts, a condition given a Result, a typed parameter's faultvalue, and the line for a handler turn that ended with anErrorall show the value in full. It works from the command line only (never fromscua.toml, a profile, an environment variable or a bundle), and it says so on stderr before anything runs.scua testand the debugger show full values without it.A handler that ends its turn with an
Errorkeeps the actor's state, and the actor goes on working.?in a partition'sonhandler, or in the function anactor(fn, state)or asandbox.actorruns, used to store theErroras 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 anonhandler's answer, so theErroris written to stderr when it happens, the way a 500 fromhttp.serveis, 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-outreturn Error(e)in anonhandler does the same; it used to be dropped without a word. In anaskhandler theErroris 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 anError" in the partitions guide. Anactor(fn, state)function that returned anErroras 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 runninghttp.servecarry 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.servehandler that faults, or answers 500 by returning anError, 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 anErrorcan'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, andcrypto.aes_key_wrap/unwrap(A192KW).crypto.aes_cbc_hmac_encrypt/decrypttake 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-opsbudget. It now counts one step per HMAC, and a budget still stops a huge iteration count, before any work is done. It returnsOk(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 anErroryou 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. Seeexamples/password-key.scua.crypto.aes_gcm_decryptandcrypto.aes_cbc_hmac_decryptreturn anErrorfor 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'siv, 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]inscua.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 throughscua-pkg0.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 testand an application bundle all check it and exit with status3; an embedding host'sscua_use_projectreturnsE-SCUA-TOO-OLD. A script with noscua.tomlis never checked. See Requiring a minimum scua.scua tool.scuappruns an application bundle. A bundle is a whole tool in one file, written byscua-pkg bundle --app main.scua: its own.scuafiles, the packages its lock resolved, the lock, and a digest for each.scuanow runs one directly, so a machine running your tool needs onlyscua, notscua-pkgand not adeps/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 anE-APP-FORMATorE-APP-CORRUPTcode 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,--profileandscua debugare refused with a bundle.scuareads bundle format 3, which needs ascua-pkgnewer than 0.3.19: a bundle built with scua-pkg 0.3.19 (format 2) is refused, and must be rebuilt withscua-pkg bundle --app. Tryscua --allow-fs=. examples/tally.scuapp README.md, and see Ship a tool as one file.Embedders:
scua_eval_fileandscua_load_script_filegiven an application bundle now returnSCUA_ERR_FORMATwith a message saying what it is, instead of a syntax error about byte0x02.http.serveacceptsOk(response)as the response. A handler that uses?can end withreturn Ok({ status = 200, body = … }), and a proxy can return whathttp.requestgave it. It used to answer those with a 500. A returnedErroris 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
tonumbergiven a table, array, record or bool, which always givesnil:scua: app.scua:12: warning: `tonumber` is given a table …. A warning prints on stderr and the program still runs (andscua teststill passes); editors show it as a warning.tonumberitself is unchanged and never stops a running program. Underscua test,scua debugand 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.droporsandbox.stopused 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-statsnow counts only running actors inpartitionsandall-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 filteror any other name the standard prelude also defines stopped with "withis 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 fmtformats a file that useson,ask,select,migrate,contractorpartitionas an ordinary name. A line such ason["a"] = trueorask.x = 1, or a statement that sends withask p.V() timeout 1sand ignores the reply, was taken for the start of a handler, soscua fmtrefused 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 thencompiles in an ordinary function. The compiler assumed everyaskrepliesOkorErrorand refused it, though the same line ran inside a handler. A reply is the handler's own value, so atrue/falsereply is a normal condition.Fixed: an
ask,wait_fororselectinside amap_allorwait_allarm now gives its line. In a partition's handler, such an arm's slot held anErrorwithline = 0. The arm is still refused, since an arm may only pause forwaitor I/O, but theErrornow carries the line of theaskand says what to do instead: ask from the handler itself, before or after themap_all/wait_all. Itskindis nowuser, as for anaskmade outside a handler.Fixed:
scua testalways prints theFAILline 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
nilto a binding that started with a value now says how to allow it. A binding takes the type of its first value, solet n = 5thenn = nilis refused. The error now adds the annotation that allows it: "annotate itint?", or, for a table written as a literal, "annotate itany, or declare a record for its shape and annotate itName?".Enum.from_int(n)on a number that names no variant now points attry_from_int. The fault reads "from_int: 7 names no variant ofColor— 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 namedb,tell,askorhandleused as an ordinary name, a capitalised table key or parameter name, or a float written with only an exponent (5e-324).Zed highlights a
matchwhose arms mix calls andreturn. Amatchwith a first arm likeOk(conn) -> serve(conn)and a laterError(e) -> return elost its highlighting from thereturnon. 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 onesnil, as it always has;as(value, Record)leaves behind keys the record doesn't declare, on purpose, so newer data still loads; andjson.decoderefuses a repeated key because two readers can disagree on what it means.The reference text for
sys.failandsys.emitsays what they do with earlier output. It said earlierprintoutput 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
1and3cover.1is any failure your script chose to report withsys.failorsys.exit(1), a clean "not found" being the usual one, andsys.fail's{"error": …}says which.3means nothing ran: the script didn't compile, a compile warning stopped it under--deny-warningsor--!strict, or the project needs a newerscua.
#0.30.0 — 2026-09-28
Breaking: a condition refuses a Result or a flags set.
if verify(token) then, whereverifyreturnsOkorError, 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 withmatch, or test withr matches Ok/r matches Error. When the checker can see that a function always returnsOkorError, the error comes before the program runs. A flags set is refused the same way (if perms & Perm.Writewas always true, even for an empty set): writePerm.Write in perms, orperms & (Perm.Read | Perm.Write) != Perm.nonefor "any of these". This coversif,elseif,while, if-expressions,not, the left side ofand/or,matchguards,whererefinements, contract clauses,assert, render's@whenand debugger breakpoint conditions (a refused breakpoint condition doesn't pause and reports "condition error" in the debug console). Everything else, including0,"", empty collections and enum variants, behaves as before. For a default when a value is missing,??is the operator to use. Embedders:scua_to_boolandscua_arg_boolkeep reporting plain truthiness.Breaking:
OkandErrorare reserved. A contract, enum variant, flags member, record, type alias, interface, partition handler or message namedOkorErroris now a compile error, such as "Erroris reserved for results — rename this record". Rename it (ParseError,Accepted).Breaking: boolean options must be
trueorfalse. An option a built-in reads as a switch used to accept any value, sojson.encode(x, { pretty = "false" })pretty-printed,sort_by(f, "n", "true")sorted ascending, andbase64.encode(s, { url_safe = 1 })used the standard alphabet. This covers:json.encode'spretty;- the switches of
fs.findandfs.grep(recursive,gitignore,ignore_case,word,regex,only_matching,files_with_matches,count_only,matches,invert); sort_by'sdescending;base64.encode'surl_safeandpad;crypto.aes_ctr'slittle_endian.
Anything else is now an error that names the option: "option
prettyofjson.encodemust 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
fnwith the same name used to replace the first without a word, so withfn describe(l: Length)andfn 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 "describeis 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)besidefn area(r: Rect)works as before.Breaking: a declared return type is now checked while the program runs.
fn f(v) -> boolused to be checked only where the checker could follow the value, so something that reached thereturnthrough 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 satisfiesfloatornumber,T?acceptsnil, and each value of a-> (int, string)return is checked. Falling off the end of a function declared-> intreturnsniland now stops there too; add the missingreturn, 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_moduleis read-only to scripts. It is now protected like a built-in module, sogfx.draw_rectangle = nilstops 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 typedlet, parameter, return or field now only checks a value that already exists: it no longer fills a missing defaulted field or runs amigratehook, and the value keeps whatever type it had. Loading old or decoded data is spelledlet hero = as(save, Player), which always gives you a newPlayer: fields copied, missing defaults filled,migraterun for old data, at every depth, then checked. A literal where the type is written (let p: Point = { x = 1, y = 2 }, areturnfrom-> Point, aPointfield) still builds a record, as before. Fix: where you loaded saves or decoded JSON through a typedlet, addas(...); without it, a defaulted field the data lacks readsnil.Breaking: a literal passed to a typed parameter is checked, not built. With
fn heal(p: Player), the callheal({ name = "Ed", hp = 10 })used to fillPlayer's defaults; now the literal is only checked, so a defaulted field it leaves out readsnilinsidehealand the value has noPlayermethods. 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
seento 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, andjson.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
recordinside 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
Playerchanged 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 awhererule, does refuse it; renaming a record or moving it to another module does too. For a partition's ownstate, 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 errorkindfor these is nowidentity_refused, notschema_mismatch: code that branches one.kind == "schema_mismatch"afteractors.revivemust acceptidentity_refusedtoo.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 isnewer_format.actors.generationstill 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.
- On the old release, add an
ask Export() return { … the state fields … } endhandler 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,askit for its state, and store the answer withdurable.encode. - Keep the old binary and the old blobs until step 3 has worked.
- After upgrading, create a fresh actor and hand it the decoded state,
converted with
as:tell fresh.Import(as(durable.decode(bytes)?, ShopState)).
- On the old release, add an
Breaking: a saved session records its built-in functions by name. A saved session (
scua_persistandscua_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 kepthex.encoderesumed 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_verifyneeds a realtrue.net.dial_tlsandnet.start_tlsread it as "anything butnilorfalse", so passing anError, a string or a number asked to skip certificate checks. Anything other thantrueorfalseis 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_persistcomes back as the same record type, with its methods, whenscua_load_scriptloads 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_scriptnow returnsSCUA_ERR_FORMATand 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 withas.Breaking: records keep their type in a shared cluster table. A record stored with
cluster.setis 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.getstops with an error naming the record and both field lists, andcluster.watch,cluster.subtreeandcluster.historygiveError({ 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=autopicks 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_WORKERSsets the same thing from the environment (a number orauto), for a run you cannot add a flag to; the flag wins when both are given. The default is still one core.x matches Cis alwaystrueorfalse. It used to hand back its last clause's value as it was, so withcontract Held(x) x end,5 matches Heldgave5andnil matches Heldgavenil, 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 ofmatchesas anything but a yes or no should use the clause's expression directly.x matches Okandx matches Errortest a Result. They give a plain boolean,falsefor anything that isn't a Result, soif http.get(url) matches Ok thenreads the way it means. Any other name aftermatchesis a contract, as before.Under
--io=async,net.readandnet.writelet 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 orwait_allarm 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 withnet.set_timeoutis 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 anhttp.servehandler. Seeexamples/net_overlap.scua.Under
--io=async,net.dialandnet.dial_tlspark 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 returnsError("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 withspawnpark on I/O too. Until now only a partition's handler could: threespawned tasks that each made a 0.3 s request took 0.9 s with or without the flag, and a top-levelwait_allran its arms one after another. Both now overlap (about 0.3 s).fs,http,execandnetcalls 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 withwait_alland print them in one place, or run it without the flag.Three new limits for parked
netcalls, 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 anErrorthat names its flag. A parked write's copy of its data counts toward--max-mem.--mem-statsprints a[net]line with the high-water mark of each.You can see what a program is waiting on. Under
--io=async,netcalls still waiting when the program exits, faults or is stopped with Ctrl-C orSIGTERMare named on stderr, likescua: 1 net call still waiting at exit (net.read 127.0.0.1:5432 …). A call that has waited 30 seconds says so atinfolevel (on by default), and--log=debugshows each park, resume and queue.A write that stops partway now blocks the writes behind it. Under
--io=async, when anet.writetimes out after sending part of its data, or its task is stopped while it waits, every later write on that connection returnsError("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=asyncwith a livehttp.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-statsand--jit-statsnow 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.acceptparks only on a platform that declares the newnet_streamcapability. 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 othernetcall. 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.callpasses NaN and infinity tof64andf32parameters. They were refused with "expected f64, got float", so a C function that takes infinity, such assqlite3_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_lineends a line at a lone\rtoo, the same breaksstr.linessplits on. A file that uses\ralone for line endings (old Mac files, some exporters) used to come back as one long line, with the\rs inside it.\nand\r\nread 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. Usehash.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)showedEvent.Quit(nil). It now showsEvent.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 theimportline. 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 madeparse_argsreport a supplied option as missing ("value is missing required field 'path'"), while the same line without: Argsworked. 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 "
gis nil". When a top-level binding has replaced a built-in, it says so: a file withlet len = 0got the bare message from everylen(x)in it, and now gets "lenis int here — a binding namedlenreplaces the built-in, so rename it".scua testsays how to test code that needs a capability. A test that reached a module importingfs,netorhttpwas told to "grant it with--allow-fs", a flagscua testrefuses. 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-opsyou 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, andtrycannot 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.verifyandcrypto.keypairtake 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.verifyanswersfalse, never an error, for a malformed key or signature, andcrypto.check_keysays why a key would be refused.crypto.aes_gcm_encryptandcrypto.aes_gcm_decryptencrypt and authenticate data, andcrypto.aes_ctrcovers formats that use counter mode, such as encrypted zip files. For encrypted JWTs there are alsocrypto.rsa_encrypt/crypto.rsa_decrypt(RSA-OAEP),crypto.aes_key_wrap/crypto.aes_key_unwrapandcrypto.aes_cbc_hmac_encrypt/crypto.aes_cbc_hmac_decryptandcrypto.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/filtercallbacks 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 laterscua_eval/scua_eval_filecalls 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 wasscua_add_module_pathon itsdeps/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.acceptorudp.recvwaits 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 returnsError("the listener is closed")orError("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 (orexec.wait) is waiting:exec.closestill stops the process at once, and the waiting read ends as end-of-file.Fixed: a
waitinsidewait_allnever finishing while another arm waited on I/O. In a partition handler under--io=async, await_allwith one arm onnet.acceptand another that calledwait(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 = nilwas 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.revivereturns anErrornaming 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 anErrorsaying 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()?wherefreturnednil, 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_argsno 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 areturn, it overwrote the expression's other parts:`hi {sys.parse_args(Args).name}`printedworldworld, andf(x, sys.parse_args(Args).name)stopped with "attempt to call a non-function". Binding the result to aletfirst always worked, and still does.An enum field reads back from JSON.
json.encodewrites a payload-free enum as its number,scua schemapublished 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 (1or"Replayed"), and aflagsfield its bitmask or a list of member names, wherever data becomes a record: decoded JSON,sys.parse_args(--mode Fastor--mode 0) andas(value, R). A number or name the enum doesn't have is refused with the ones it does. A function parameter or a typedletstill needs a real variant, as before; convert withfrom_int.scua schemapublishes 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
listwas reported at a line of your file that doesn't exist. Callinglist.filter(5, …)reportedyourfile.scua:20, line 20 of the built-inlistmodule, even in a two-line file. It is now reported at your own call, and the backtrace showsat 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 anask, nested anywhere inside another value, forwarded between actors, or kept in an actor's state acrossactors.freezeandactors.reviveis still that record, and its methods work. Before, it arrived as a plain table, and an overloaded method such asareaon it failed with "areaexpects 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: "
areahas 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 bytesactors.freezereturned, 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 toPlayerrefuse? From a script,actors.blob_version(blob)reads the format alone.Embedding: every load says what it did.
scua_last_load_reportfills ascua_load_reportfor the lastscua_load_actor, orscua_load_scriptover saved data: functions matched exactly and rebound onto edited code, paused turns dropped, record types rebound, newstatefields filled. The same line goes to your log sinks atinfo, andactors.revivesends it there too.scua_actor_format_versionandscua_actor_blob_format_versionname both sides of an actor format refusal.Fixed:
rand.intover 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.seedtreats a seed as a whole number. A float that holds a whole number now seeds exactly like it, sorand.seed(2026.0)andrand.seed(4052 / 2)give the same numbers asrand.seed(2026), and-0.0is0. 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 like1.0gets a different sequence than before. A fraction, NaN or infinity is now an error instead of quietly picking a sequence.Breaking: the
strcase and letter helpers understand every language.str.upper,str.lower,str.titleandstr.capitalizeconvert accented, Greek, Cyrillic and other letters (str.lower("ÉCOLE")is"école"), andstr.is_alpha,str.is_alnumandstr.is_spaceanswertruefor 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_digitstill accepts only0–9, the digitsint.parsereads. Check protocol words with the newstr.ascii_lower/str.ascii_upper. A few non-ASCII letters now fold onto ASCII ones (İtoi, the Kelvin sign tok,ıtoI,ſtoS), andstr.is_spaceaccepts Unicode spaces. So code that matches header names, HTML tags or keywords throughstr.loweraccepts 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, fromtonumber, passed as a callback) reachedxas 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 afloatornumberparameter,int?still acceptsnil, 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 withtry/rescueorpcall, the fault is a record, like awherefault: reade.message(ande.valuefor what arrived), noteas 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 silentlynil. It is now checked like any other call into a module, so a call that leaves out trailing arguments, such asweb.text("ok")forfn 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
filterandwith. It used to mean the variable and silently ignore the column. The error names both ways to write it:row.regionfor the column, or a renamed variable or afn(r) … endpredicate for the variable. For a table built withframe({ … })you get this when the program compiles; for one read from a file, when thefilterruns.Breaking: declaring the same
statename twice in one partition is now an error. It used to compile, and the second declaration silently replaced the first. You now getpartition 'P' declaresstate atwiceat 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 awhererule 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_tlsandnet.start_tlsnow 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, or0for none, as withhttp. Reading and writing still wait as long as it takes, because some connections are meant to sit quiet, butnet.set_timeout(conn, 5s)now limits each read and write on a connection, andnilremoves the limit. Running out of time is an ordinaryErrornaming the call and the limit, such asnet.read: timed out after 5s, and a read that times out leaves the connection open. Dialling a name that doesn't exist now sayscould not resolve the hostinstead ofcould not connect, and a misspelled option tonet.dial_tlsornet.start_tlsis now an error instead of being ignored.Re-raising a caught error keeps where it happened.
try … rescue e … error(e) endused to report the re-raise line and start the backtrace there, losing the real cause. Now an error raised again from itsrescueblock, 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
bytesvalue can go inside{…}in a backtick string.`got {b}`shows it the wayprintdoes (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 \dthey 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_identifiersaid yes to reserved words.str.is_identifier("end")wastrue, so code that quoted only "non-identifier" names wrote outend = …, which doesn't parse. Reserved words now answerfalse.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 calledsys.exittook 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]andstr.findwith 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 aforloop may differ from before. That order was never promised (see Collections).Fixed: a column named after a built-in was ignored inside
filterandwith. With a column calledyear,count,total,productor 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, likeabs(delta), is still the function.rowinsidefilterandwithmeans 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
filterandwith, and the predicate failed with a bare "type error".An error raised inside
filter,withor 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
statefields are faster. A partition with more than 16statefields 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
statearrays), 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_subandmath.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 returnOk(result), or anErrorwhen 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.expm1andmath.nextafter.log1p(x)andexpm1(x)computelog(1 + x)andexp(x) - 1without losing a tinyx's digits.nextafter(x, toward)gives the next float afterx.math.float_epsilon, andmath.epsilonexplained.math.epsilonis1e-9, a tolerance for comparing results, not the gap between floats. That gap (about2.2e-16) is the newmath.float_epsilon.Slicing and searching a large
bytesvalue is fast.bytes.slice,bytes.slice_exactandbytes.findused 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 alsobytes.u8(b, off), which reads one byte likeb[off], alongsidebytes.u16leand the other fixed-width readers.str.linessplits text that uses a lone\rfor line endings. Old Mac files and some exporters end lines with\ralone, andstr.linesreturned such text as a single line. A line now ends at\n,\r\nor\r.hash.sha1andhash.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.sha1is there for protocols that still require SHA-1. Don't use it to check that content is unchanged; usehash.sha256.A library's record types can be given a local name and carried by its enums.
type Leaf = lib.Leafnow works (it used to fail with an error that listedLeafamong the types it said weren't there), and an enum case carrying one of the library's records, likelib.Shape.Box({ w = 2, h = 3 }), can be built from any file.Fixed:
http.serveaccepted 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
spawninside 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
spawnwas 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.
spawna 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 .. piecelost 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 theforloop fix below, it reported nothing and printed a wrong answer. It is correct now.Fixed: a very long
forloop 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 affectedforloops, only when compiled (which is the default), and it reported nothing: the program finished and printed a wrong number. Awhileloop was never affected. Both are correct now, and agree with the uncompiled answer exactly.Fixed: two tasks started together no longer starve each other.
spawntwice 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 mailboxesmay 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 actorsgives 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. endfreezeis a snapshot, not a teardown — which is whydropis there. Freeze on its own gives you a backup; it isdropthat 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.revivebrings 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 andexamples/hibernation.scua.Neither verb needs a capability. Getting the bytes in or out of the process still does — write them with
fs, send them withnet.A misspelled message name is now an error instead of silence.
tell box.Adld(1)against a partition that handlesAddused to compile, run, do nothing and exit 0 — a partition answers the messages it knows and ignored the rest. Withaskit was worse than nothing: no reply ever came, so you got a timeout, which reads as "that partition is slow" rather than "you wroteTotl".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_foror aselectarm counts as handled, so parking onwait_for(Resume)and being sentResumeis fine.tellto a stopped actor no longer corrupts memory. Stopping an actor withsandbox.stopand then sending it a message could crash the program withArenaTooLarge, 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.askwith no timeout waits five seconds, and the guide now says so. It previously claimed such anaskwaits forever, so anyone who believed it wrote a wrapper they did not need.A server no longer stops the rest of your program.
http.serveused to hold the whole process for as long as it ran: a task you hadspawned never got another turn, awaitin 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 aprintinside 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.servecall — 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.acceptstill 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 withask; one that tries gets a 500 saying so rather than a mysterious answer. It canspawn, 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
askone:askwaits for a reply and a handler does not wait. --fastand a running server are now refused together.--fastskips through waits to make a run deterministic, and a network socket is the one thing it cannot skip.
- A handler runs to completion. It cannot
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-timeoutnow 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
tellused 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_forandselectnow refuse outside an actor handler instead of quietly answeringnil. All three wait for something only an actor handler can be woken for, and used to hand backnilwhen called from a task or the top level — solet answer = ask thing.Question()setanswerto 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 readingnilas 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.
waitworks at the top level of ascua testfile, 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 withscua <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.htmlwhen there is one and a directory listing otherwise, sets the content type for around thirty common extensions, and hides dotfiles (--allreveals them). It listens on your own machine only;--host 0.0.0.0widens 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 3000picks the directory and the port; a bare number is read as a port, soscua serve 3000works 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
HEADreports a content length of 0 rather than the real size. It serves files only — for a server that runs your own code,http.serveis 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 toudp.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, whilefillna("amount", 0.1)is refused, because writing a rough value into an exact column would quietly stop it being exact. A filled0prints as0.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 equalstotal(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 answersnilrather 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_bytakes 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 as135.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.00The values themselves are unchanged — this is display only.
f.mean("amount")still gives you135.95833333333334, andto_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.5prints0.5, not0.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 fieldsnil, awhererefinement on the record never fired, and a literal with fields the record does not have was accepted. The same record bound bylet t: TextItem = { … }, or passed to a parameter typedTextItem, 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
lettyped with anenumis checked.fn f(p: Direction)accepted any value at all —f(42)ran — and so didlet p: Direction = 42. The same declaration as a record field has always been checked. Both now refuse withparameter '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
totalandmeana column andgroup_sumby 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, wheremeananswers 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_sumstill 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 themaxof a money column is money and themodeof a text column is text. Amedianis exact for a decimal column: the midpoint of20.00and35.00is27.50. distinct(),drop(names),rename(map),tail(n), andsort_bynow takes an array of columns for a multi-key sort. Mistyped column names and non-numeric reducers are still caught at compile time, astotalalways was.
math.fmodnow means what it means everywhere else, which changes its answer for negative numbers. It was a floored remainder —math.fmod(-7, 3)gave2— while C, Python and JavaScript all give-1for that. It is now truncated, matching them. The floored answer is what%gives (-7 % 3is2, unchanged), so both conventions are now reachable; before,fmodwas a second spelling of%under a name promising the other one. If you usedmath.fmodwith 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)is10. Bases 2 and 10 are exact, somath.log(1000, 10)is3and not2.9999999999999996(which is what computing it by hand aslog(x)/log(b)gives, and what Python returns).math.exp2(log2's inverse, which was missing whilelog2was there) andmath.clamp01(the 0…1 clamp interpolation code reaches for).list.min,list.maxandlist.meanjoinlist.sum— the obvious next calls after summing a list, which had to be hand-rolled. An empty list givesnilrather than inventing a value.scua --versionnow says when a build is not built for speed. A release prints justscua 0.26.0, as before. A build compiled from source without-Doptimize=ReleaseFastadds 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 == nanis 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 reportn == nfalse andn != nfalse in the same line.scua testruns interpreter-only, so a test asserting NaN behaviour asserted something the runtime did not do — and a package that refuses NaN values withif value != value(correct at run time) saw that branch never taken under test. Ordering (<,<=,>,>=) against NaN was already correct and is unchanged, andis(n, n)is still true:isasks 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 — solet a = 1; let b = 2sent you to look at your numbers, and so didprint(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 @ 2crashed the interpreter;t @ swheresis a plain string variable quietly returnednil, 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 withpath"server/host"— a plain string in a variable was never a path, it just failed silently. If you relied on thatnil, usepath"…". - A TLS failure against an IP address explains
itself. Dialling an IP whose certificate was issued for that IP
was refused with "check the
servernameoption", which was no help because theservernamewas 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.watchinside 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 withObjectTooLarge. 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 ittellthe 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 anErrorfails.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 withreturned 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.opaqueand distinctffi.pointertarget 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 asopen(path);optsarrives asnil, which is what the type already allowed. Everything after an omittable parameter is omittable too, as with defaults; a required parameter after aT?one keeps both required. - A call written
f(x)?is now checked likef(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 norT?, it is now refused at the call. - Tagged enums have a JSON spelling. A variant that
carries data encodes as
{ "kind": "Text", "value": "hi" }(valuean 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, somatchworks on what a JSON caller sent. A wrongkindor a wrongly sizedvalueis refused at the boundary, by field.scua schemapublishes such a field as aoneOfover the variants, each fixed on itskind, where it used to publish an opaque description.Ok(…)andError(…)encode the same way. Payload-free enums still cross as their number. scua schemadefines every record it refers to. A record two or more levels below the type you asked for was referenced with$refbut 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 } endused 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 typedletfirst. Now the literal builds the record theletwould 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'sframe, on a value that is not the library's record, calls the built-inframe; 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 }makesdb.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 typedPlayerwhosemanahas a default is now accepted the way a typedletalways accepted it; so is returning such a literal from a function declared-> Player, or assigning it to aPlayervariable. 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
enumorflagsdeclared in a module is now a distinct type from a same-named one declared anywhere else. Two libraries can each declareenum Outcomeand you can import both; you can declare your ownKindbeside 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.durableand cluster data written by the previous release still loads when the enum name is unambiguous in the reading program. - Every
bytesfunction 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 noimport 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 itswhererefinements 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/midnames a module by its path and binds its last segment (mid, or what you write afteras). A module's imports now look beside the module first:lib/mid.scuadoingimport helperfindslib/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 dynamicimport("…"). - 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. Animportalias that is also a top-levelfnorlet, 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 differentflagstypes with one name, visible in one file, are reported exactly as two different enums already were. - Two libraries can each declare a
record Itemwith adescribemethod. 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 animportinside a function or block (it is a top-level statement). - Embedding:
scua_add_module_path. The host-side twin of--mod-path: animportin a script run throughscua_eval_fileis tried against the entry file's own directory, then each directory the host added. A package'sexamples/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
cstrargument the callee keeps after the call —sqlite3_bind_textwithSQLITE_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 atstep) and takesffi.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 asFFI callback faulted: <its message>instead of an owner-thread violation it did not commit. - A release symbol that returns
intcan say so,{symbol = "sqlite3_close", result = "i32"}, and a non-zero result faults the explicitffi.closewith 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
A package importing
ffi,execorsandboxnow 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(…)andpoints.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 readingp[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 byg.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 newreclitbenchmark 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 .. nwithpa 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. Thewordcountbenchmark 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 abuilderfor 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. Thewordcountbenchmark 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 whichfor k, v in tablevisits 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
nilfor 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. Thewordcountbenchmark 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 thewhile cur != niltest 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-statsreportsopt-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.scuacompiles 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=offruns 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=onon a build or a platform without one used to be accepted in silence — every counter in--jit-statsread 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=truecan 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,warnandsevereare 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.printandsys.writeare 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 withscua: --log-stderr: cannot read fileand 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-stdoutto keep the old behaviour while you migrate.scua tool.scua 2>&1merges 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.emitwrites the result and nothing else reaches standard output —print,sys.writeand 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 ofsys.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 orsys.fail(which exits), so progress bars, spinners and plain status lines had nowhere to go that wasn't the output itself.printoutput 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_argsno 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_argsnow 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.--helpand 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 fmtmis-indenting everything after a table key or record field named like a keyword. A key such ason = 2,then = 3orend = 4on its own line (the vocabulary of a state-machine table, say), a fielddo: intin a record, or a member access likespec.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 afnused as a type (f: fn(int) -> int).scua fmt --checkthen 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.--checkreports 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 fmtalways wrote; the manual'sscua fmtsection now says so, and the examples and the guide's snippets were reformatted to match it, soscua fmt --checkpasses 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 endwithhrecursing 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)orobj.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-statsreports this ascallee-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 anifinside anif; it now compiles them, includingelsearms 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 endcould read the inner value on the path whereawas false. A recursive call inside anifwhose result was a float could fault with "type error" under the JIT. And a function that called another function inside anif, where that call had to fall back to the interpreter, could resume at the wrong instruction. All three reproduce on 0.18.0.x ?? defaulton 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
ifruns 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!= nilmeasures −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,
scuanow tells you so and exits instead of dying on an illegal instruction, and the package shipsscua-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.0andstate * 1664525used 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
ifno longer stops the JIT compiling the whole function. Code likeif p.w == nil then return dot(p, p) endused 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!= nil1.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 != nilis as cheap asif 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!= nilmeasures −35% in the interpreter and matches the== nilversion. (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
niltoo, 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 == nilon 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 anif x == niltest 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.concattakes exactly two arguments, so the only option wasconcatin 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.joindoes 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 bebytesor strings.import bytes let parts = [b"GET ", b"/index.html", b" HTTP/1.1"] print(len(bytes.join(parts)))$ scua join.scua 24reserveon 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 allocatednboxed 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 callingreserveat 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
bytesvalue 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.readon a connection upgraded withnet.start_tlsblocked 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 = nilnow works the way it reads. Writinglet x = niland assigning a real value later used to fail — the binding took the typenil, which nothing butnilcan ever be assigned to. It now takesany, so the placeholder idiom works, and it works the same in every context.const X = nilkeeps 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).removeexisted without it.indexmay equallen(array), which appends. Returns the array.sortsays what it could not order. Sorting an array of tables used to report a baretype 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.sliceclamps 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_exactfails instead, joiningbytes.u32beand the other fixed-width readers, which already do.bytes.sliceis unchanged.http.openstreams 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'scontent-encoding.New: encrypted raw connections, so
netcan reach a managed database.net.start_tls(conn)encrypts a connection you already opened, andnet.dial_tls(host, port)connects and encrypts in one step. Both hand back the same kind of connection, sonet.read,net.writeandnet.closecarry 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.dialfollowed bynet.start_tls. Redis and anything HTTPS-shaped expect encryption from the first byte, so those usenet.dial_tls. Usingnet.dial_tlson 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/rescueandpcallturn 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-memnow 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-memand open many files or connections at once, either raise it or close handles as you finish with them —fs.closeandnet.closerelease 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") plusfs.write_chunk(f, data).fs.writetakes 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 andfs.read_chunk(f, max)pulls raw bytes, andfs.close(f)releases it. Until nowfs.read/fs.read_textwere 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 andexamples/streaming_files.scua.The two readers signal the end of the file differently, and it matters.
fs.read_chunkis finished when it returns empty bytes.fs.read_lineis finished when it returnsnil— 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_texthad 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_RESOURCEcode and ascua_blocking_resource()call that names the specific handle. ⚠️ This error was previously reported asSCUA_ERR_OTHER; a host checking for that value needs updating.New:
fs.lstat(path). The same record asfs.stat, plusis_symlink, and it does not follow a final symlink.fs.statfollows 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 withfs.lstatfirst. 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 wherefromwas, 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.charsandstr.pad_start/str.pad_endgot 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-statsand--mem-statsnow print however the program ends. They only ever printed after a normal finish, so a script that ended throughsys.emitorsys.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.splitandstr.linesuse 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
strfunctions stop copying their input too. Nearly everystr.*function used to duplicate the whole string it was given before doing anything — including the ones that only look at it, likestr.starts_with,str.find,str.containsand thestr.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.readandfs.read_texthad 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_byteskeeps 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.grepsearches 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 triedhttp.serveand concluded it did not work, it didn't; it does now.A served request now has a time limit. Granting
--allow-servearms a 30-second deadline on each request; a handler that runs past it gets a503and the server carries on.--serve-timeout=MSchanges it,--serve-timeout=0removes 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--fastorscua 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 throughEvery deliberate limit is untouched.
--max-ops=Nstill kills, and still exits6. So does asandboxmod's budget, and so does an embedding host'sop_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 withruntime error: ObjectTooLarge: no line, nothingtry/rescueorpcallcould catch, and the same exit status as a script's ownsys.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:
reserveandfillreached higher than the same array built withpush. Apush-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.readpeaks at 67.7 MB instead of 99.6 MB. This applies tofs.read, which returns raw bytes;fs.read_textis 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_bytesto 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, andmax_bytesis now capped rather than taken literally, so{ max_bytes = 4000000000 }keeps meaning "I don't mind how big this is".max_byteswas 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 withfs.read" — from insidefs.read) came back with it. Fixed, and that message is gone everywhere.durable.verify_roundtripsaid 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, anddurable.encoderefuses 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
errorwith a line number for most failures — including the message a script raised itself witherror("…"). The real message now comes through.New exit status
7, for the case where the runtime itself cannot continue. Previously that fell back to1, 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.writeand 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 wassys.fail, which ends the program. The new--log-stderrflag 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
--logand--log-time.Exit status
1used to mean four different things. A syntax error, a missing capability, a crash and a script's ownsys.fail("no match")all exited1, 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 3The script didn't compile 4The script wanted a capability the run wasn't granted 5The script crashed (an uncaught fault) 6--max-opsor--max-memstopped the run0,1and2keep their meanings and stay yours to choose withsys.exit/sys.fail;scuanow reserves3–6for its own outcomes. Naming a filescuacan't read also moves from1to2, where the rest of the bad-argument errors already were.scua testandscua fmt --checkare unchanged (both still exit1when they find something). If you have a script or CI step matching on exit code1to detect a broken program, it needs updating — that case is now2–6.The agent-tool guide no longer promises something the runtime doesn't do. It said
sys.emitandsys.faildiscard bufferedprintoutput 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-stderrfor the case it was trying to cover.fs.rename(from, to)— replace a file without a window in which it is gone.fs.writeempties 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 withfs.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
fspath: no absolute paths, no.., no symlink out, and the root itself is refused at either end.fs.writeitself is unchanged and still truncates; see Replacing a file without losing it for why that is deliberate.fs.grepno 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.findwould find it — and the search would say it was not there. Such a pattern is now anErrorthat says exactly that, and points at reading the file and usingstr.find/regex.findto search across lines.fs.grepsays when it stopped early. Without amax, 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 anErrornaming it and suggesting how to narrow the search. Amaxyou set yourself still truncates quietly — you chose the number.{ max = 0 }infs.grepmeans 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 tosys.parse_argswhere the tool wanted a list of files and the program got a five-character string back, whichfor f in args.filesthen walked one character at a time — no error, just a plausible-looking wrong value. A field declared as anenumwas 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
flagsfield must hold a set of thatflagstype; 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 typedlet, a typed function parameter, a message arriving at apartitionhandler, 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 amatchover it silently fell through to the default. Declare astringand 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
partitionhandler.CPU budgets are unaffected:
--max-ops=Nstill 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
printinside anhttp.servehandler never reached stdout — the request was served correctly and the line simply never appeared, for the life of the process. (It looked like aprintquirk, becausesys.writehappened to work: it forces its own flush.) Output now appears as requests are handled.A task blocked in
net.acceptno longer looks like it never started. Anything printed before the blocking accept — including the line beforenet.listen— was buffered out of sight. The same fix landed forhttp.serveearlier; this covers the rest of the class.The scheduler's resumption ceiling says something when it fires. A task looping on plain
yieldtripped 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
spawnstayed 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 apartitionhad the same problem, and there the dead tasks were also written into saved state. Both release now.fscan 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 isOkif 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-fsat the narrowest directory that works.A file you can write is now a file you can read back.
fs.writehas no size cap andfs.readhad 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 withfs.read", which is the call that had just failed.fs.readandfs.read_textnow 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.grepsearches a single file.fs.grep("needle", "notes.txt")— which the signature plainly invites — used to answeri/o erroron a file that exists and is perfectly readable. It searches the file now.fs.listandfs.findstill want a directory, but say so instead of blaming the disk.A missing parent directory says so.
fs.write("logs/today.txt", …)with nologs/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 atfs.mkdir.Naming a variable
rows,row,takeorwith_columnno longer breaks the frame verb of the same name. Writinglet rows = 0at the top of a file madef.rows()fail with "no callable field and no such method or global function" — and only those four names did it, solet total = 0sitting 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:
9007199254740993read back as9007199254740992.0, and the largestintas9223372036854776000.0.type()reportedfloatfor a value you pushed as anint. 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.serveserver 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.servenever returns, and printed output is normally flushed between tasks, so anything a spawned task printed before calling it was buffered forever — even aprinton the line before the server started. It is flushed now. Note that runninghttp.serveinsidespawnstill stops every other task: the accept loop blocks the whole program, which the how-to now says plainly.--jit-statsno longer turns the JIT back on. Runningscua --jit=off --jit-stats app.scuasilently 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=offwas 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 reportmode=off compiled=0, which is what you were asking for.SCUA can run commands.
execstarts 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}`) endRun it with
--allow-exec-any.--allow-alldoes 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 holdingexecand nofscan still runcat. Turning it on is delegation, and it should take a decision rather than a convenient flag.There is no shell:
argvis 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 innet.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-opscannot see it. Captured output is bounded, truncation setstruncated, 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 yourPATH, so["cargo", "build"]runs fine with no environment — but["sh", "-c", "cargo build"]does not, since the shell looks upcargoin the child's emptyPATH. To give a child an environment, hold one:--allow-exec-any --allow-env=PATH,HOME, and passenv = { 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_anyruns anything,exec.runruns 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 isError({ 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 isexec = true— there is no "all commands" here; that isexec_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=noneremoves 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=0is 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.execworks 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.execis 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,execyields the wayhttpandfsalready do, so a command issued from an actor handler or anhttp.servehandler 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-filerefused an unknown key with a list that had never includedsandbox, so anyone who mistyped it was told sandbox was not a capability at all.
#Fixed
A British date reads as British.
@{d:date.long}underen_GBgaveJune 3, 2024, anddate.shortgave6/3/2024— which a reader in the UK takes as 6 March. The date order now follows the region, not just the language, soen_GB,en_AU,en_NZ,en_IE,en_INanden_ZAare day-first.en_USand a bareenare 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/hhnow refuse the wayMMManddddalready do, and the message points atHH. 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)andmoney.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.formatrenders a symbol, and$means USD, CAD and AUD alike, so the currency was effectively unreadable.money.of(money.amount(m), money.code(m))gives backmexactly, 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 read007as the number 7 and"01"as 1 — leading zeros gone at read time, with nothing downstream able to recover them, because a latercastto 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.
42is still an int and1.50still an exact decimal;007,+7,-0and1_000stay strings. Nothing else moves —1e3,trueand0x10were already strings, and money is unaffected.Note this was never about quoting:
007unquoted 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 aspadded— quoting is precisely how you insist a space is part of the value. Both found by round-trippingto_csvoutput back through it.A type error naming an anonymous table says which one.
fn f(t: {string: any})given{}used to reportis table, expected table— the same word on both sides, with no way to tell what would satisfy it. It now saysis table [K]: V, expected table {string}, which shows the real problem:{string: any}declares a field namedstring. 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 ajoinwhose_rightsuffix collided with a column the left frame already had.column("a")androw(0).athen returned different data from the same frame, with no error on either route.csvnow refuses a duplicate header andjoinrefuses 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.formatfollows the locale. A Euro amount now reads1.234.567,89 €underde_DEand1 234 567,89 €underfr_FR, instead of US conventions everywhere. Separator, decimal point and symbol placement all follow the run locale; per-currency minor units are unchanged, soJPYstill renders with no decimals.Under the neutral default (
und) anden_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.formatgainedMMM/MMMM,ddd/ddddandA/a, sot.format("D MMM YYYY")gives3 Jun 2024— and with the AM/PM marker in place,h/hhwork 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.shortis 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)andmath.lgamma(x). The special functions statistics needs andmathdidn'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 than1 - math.erf(x)whenever you want a tail:erf(8)rounds to exactly1.0, so the subtraction gives0where the real answer is about1.1e-29.hash.sha384(data)andhash.sha512(data), beside the existinghash.sha256. Both take text or rawbytesand return the digest asbytes(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 testruns every file you name, not just the last one.scua test a_test.scua b_test.scuaused to run onlyb_test.scuaand report1 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 doingscua test $CHANGED_FILESwas 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
fspath 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. Nowfs.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
importis reported against the thing that's missing.str.slice("abc", 1, 2)withoutimport strused 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 saysundefined 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 testruns a package that has dependencies. In a project with a lock and a vendoreddeps/, the test runner reportedcannot 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 testused to reporttest: 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 testno 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 failedran the tests in
deps/otherand reported success whilemy_test.scuanever ran at all — a green result, exit code 0, for code that was never tested.scua testnow takes the first path you give it, understands--mod-path DIR, and refuses anything else with a usage message, which is whatscua fmt,schema,packandinitalways did.scua fmtrefuses an unknown option instead of treating it as a filename.scua fmt --check x.scuawas fine, but any other option became the file:scua fmt --wrong x.scuareported--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 lineapp.scuahas 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
enumorflagstype where a value goes says so, instead of "undefined name". Only a variant is a value, soColouron 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:importcarries a module's enums across on its own, so a consumer already writeslib.Colour.Redwithout 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 younil. 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.decodeandhex.decodenow returnbytesinstead 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:lenon 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_hexalready worked this way, andhex.decodedisagreed with it about the same operation. They now agree.A whole float now prints with its
.0.print(1.0)says1.0, not1— 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.encodewrites1.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.encodealready did this;scua schemanow 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; andprint([1.0, 2, 3.5])shows[1.0, 2.0, 3.5]— that middle2really is2.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 still1024), and so are the numbers inside avec,mat,quatorcolor— 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 characterse[1mand 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.
\eis 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}"iscafé,"\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 textu{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 = printbound nil, silently, and the failure surfaced whereverfwas 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 assys.writeandstr.sliceare values and are unaffected.An unknown short option is reported instead of ignored.
sys.parse_argssilently 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 callednotand real payloads carryend,forandin. 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
waitworks 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 youspawnruns while the main body is sleeping rather than waiting for it to finish.--fastfast-forwards it like any other wait.A coroutine can
resumeanother coroutine. Previously any nested resume failed with "cannot resume from within a running coroutine", which also meant aresumeinside 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.findandstr.rfindtake a start offset.str.find(s, needle, from)finds the first match at or after characterfrom;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.crc32andhash.fnv1anow takebytesas well as text, ashash.sha256already did — so the formats that mandate CRC-32 (gzip, zip, PNG), where the data is compressed bytes and never text, are reachable.base64.encodetakes 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.decodeneeds no option: it now accepts either alphabet, padded or unpadded.return f()now hands back everythingfreturned. 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, nilIt 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_argsnow 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 (-vforverbose, which used to be ignored), and a--helpwritten 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
whereclauses and one argument too many all still exit 2 without running your code.int.parse(s)— a whole number or a reason why not.tonumberis built for data you wrote yourself: it trims whitespace, reads0x, falls back to a float, and saysnilto everything it can't manage, which leaves you unable to tell a typo from a number too large to hold.int.parseis the version for a string that arrived from somewhere else. It takes a sign, decimal digits and_separators and nothing else, and answersOk(n)orError(message):import int match int.parse(field) Ok(n) -> use(n) Error(why) -> reject(why) endA 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 whattonumber("9223372036854775808")does today. Both ends parse exactly,-9223372036854775808included. Each refusal names its own fix, so" 42"tells you whitespace isn't trimmed and points atstr.trim, and"1.0"tells you that's a float.decimal.parseandmoney.parsehave 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.isanswers 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
pushinto a map now tells you, instead of quietly doing nothing.pushis for arrays. Given a map —let a = {}rather thanlet a = []— it used to accept the value, discard it, and leavelen(a)reporting that the value was there, so aif len(rows) > 0guard 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_allandwait_allover 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 theconcurrencylimit still queues rather than dropping work.A sandbox's
fuellimit is now enforced.sandbox.runandsandbox.actortake afueloption bounding how much work untrusted code may do before it fails without_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. Raisefuelif a mod legitimately needs more.A mod that runs out of fuel stops the mod, not your program: the
askwaiting on it gets backError(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.nanprints asnaneverywhere. On x86-64 Linux and Windows,print(0.0 / 0.0)wrote-nanwhere the same program on an ARM machine wrotenan, 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 whilelet 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 fromfill, fromrange, 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 laterpushwould 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.--helpand--versionreach 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,joinandpivotare 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.fillbuilds the same array apushloop 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.askis usable as an ordinary name, asreference/keywords.mdhas always said it was.let ask = 5was 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 testtakes 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, sop[0]can never resolve — it used to answernil, 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.encodeandtoml.encoderefusing 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 it0.0, or annotate itfloat.
#0.18.0 — 2026-08-26
#Changed
Enum and flags values now know what they are.
print(Colour.Green)saysColour.Greeninstead of1, and aflagsmask saysPerm.Read|Perm.Execinstead of5— in logs, intostring, 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 + 1counts, explicit discriminants likeMonster = 52andRead = 1 << 0are 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 == 1is 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]andt[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.Smallhad no meaning and used to returntrue.Enum values survive an edit to their own declaration in a save. A
durablesave now records an enum value by name, so reordering, renumbering or reformatting anenumorflagsdeclaration 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 holdsColour.Green, andColourno 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.jsonandtomlstill 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. UseEnum.try_from_int(n)when a number arriving from outside may legitimately be unknown — it answersniland 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, ande.variant_name()/e.enum_name(), which give a variant's own name and its enum's name as strings.flagstypes have all three too.Saves are about three times smaller, and the save format changed to do it. A typed, versioned
durablesave 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 Pythonpickleof 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 (a1used to cost exactly what20000cost), 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.decodeon a file written by an earlier build returnsError("not a durable blob …").durable.digestvalues 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.NaNis 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,framedata 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: aframewith 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.
tellandaskcopy 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 adurablesave 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, andf.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], thenframe({ x = c }), thenc.push(4)produced a frame that said it had 3 rows whiletotal("x")answered 10. Writing into a column you read out (f.column("z")[0] = 100) changed the frame's data, andf.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, andcolumn/column_namesgive 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 overflowand 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 andmatchclaim 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 sandboxcompiles 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. Usesandbox.run(source, input)for a one-shot answer, orsandbox.actor(source, state)for code you call repeatedly (a mod, an entity behaviour, a rule that keeps score), which hands back an ordinary actor youtellandask. Creating a sandbox needs the newsandboxcapability (--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 whatevernameyou gave it — because "the code is wrong", "it's too slow" and "it tried to hold too much" want three different responses. Release one withsandbox.stop. See Run code in a sandbox andexamples/sandbox.scua.Function parameters can carry default values.
fn greet(name, greeting = "Hello")— write= valueafter a parameter and the call may leave that argument out. The rule is one sentence: a default fills the argument when it is missing ornil, so a parameter with a default is nevernilinside the body, and a value that might not be there selects the default without anifat the call site (connect(host, cfg @ "port")). Onlyniltriggers it —falseand0are values and pass straight through. The default is an ordinary expression, evaluated on each call that omits the argument, sofn 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 explicitnil. If a parameter must be able to receivenil, don't give it a default. See Functions and closures andexamples/default-params.scua.
#Fixed
An
importcan 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.scuafile from anywhere on disk, with nofscapability, 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 morenamesegments 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 nestedsub/helperresolve 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 withscua-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 = falseis obeyed again, including under--allow-all. Writingfalsein an--allow-fileis how you say "everything except this one thing" —scua --allow-all --allow-file=no-net.toml app.scuaruns a trusted script with the network shut off. Every capability honoured that excepttty, the grantsys.readlineandsys.readpasswordneed:tty = falsewas obeyed on its own, but the moment--allow-allappeared 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 mentionttystill gets it from--allow-all, and--allow-ttyon 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,
whererefinements and arity were advisory, so a call tof(1)againstfn f(a, b)inside a library boundniland 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 underdeps/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--!dynamicstays 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 sayat describe (line 10).
#Added
You can ask the person at the keyboard a question:
sys.readline()andsys.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.readpasswordreads without echoing, for a password prompt. Both need the new--allow-ttygrant: reading a terminal is where a human types secrets, so it is a capability likefsornet, not something any script gets for free, and an actor never inherits it. Both returnnilat 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()andids.parse(s). No capability grant needed. The bits come from the host's random source rather than fromrand— which matters, becauserandis 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. Preferuuid7()orulid()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 valuenow()returns — so it fast-forwards under--fastand starts at zero underscua test, like every other clock reading.ids.parse(s)returnsOk(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 usecrypto.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 seedcrypto.keypairhas 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(orflags) declared in a module is now nameable from the files that import it, asalias.Enum.Variant— in amatchpattern 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.Signalgives 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). Assigningnilnever did — it set the value and left the key, solenstill counted it,for … instill yielded it, andjson.encodestill 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.deletereturns true if the key was there (not the removed value — readt[k]first if you want that), and a missing key is a no-op returningfalse. Also spellablet.delete(k). The two operations now mean different things and it is worth learning which is which: assigning nil sets the value;deleteremoves the key. Assignment is unchanged, so nothing you have written behaves differently. A record's fields are fixed, sodeleteon one is an error rather than a way to punch a hole in a typed value; on an array it tells you to useremove(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 twas 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 thejson.encodeshape that gets you a 400: drop the key withdeleteand the field is simply absent, instead of present asnull.sys.write(s)writes without a newline.printwas 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.writeputsson the same streamprintuses, with no trailing newline, and it appears immediately — sosys.write("\rworking 40%")in a loop rewrites one line. It shares one buffer withprint, 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\rdisplay 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 withsys.emit/sys.fail, which promise a stdout carrying only their result. Unlikeprint,sys.writeis a value:let out = sys.writeworks, 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— whichtry/rescueandpcallcatch, 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 endThe 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 inerrbufis: it used to be the bare wordStackOverflowwith 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.
.envfiles.--env-file=.envloads one and grantsenvfor exactly the names in it:$ scua --env-file=.env app.scuaNaming 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-filealone your script sees the file's names and nothing else, so a strayAWS_SECRET_ACCESS_KEYin 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
.enva defaults file — and--env-fileis repeatable, applied in order, with nothing overwriting what is already set. The format is the usual one, includingexportprefixes, 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.envfile'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.scuaThe keys are the flag names, so there is nothing new to learn —
trueis the bare flag, a string or list is the constrained form, andfalseor[]refuses it outright. A refusal sticks, soscua --allow-all --allow-file=no-net.toml app.scuameans 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.tomlbeside your code grants nothing, so a script cannot arrange to be trusted more on its next run.--allow-allgrants every capability in its broadest form, for a script you already trust. A narrower flag still wins, so--allow-all --allow-net=api.internalmeans "everything, but net only reaches api.internal" — the order you write them in does not matter.toml.parsereads all of TOML 1.0, andtoml.encodewrites it. Arrays, floats, inline tables,[[arrays of tables]], dates, literal and multi-line strings, and the0x/0o/0binteger bases all parse to their own types — an array is an array you can index and iterate, and a date is adatetimeyou can do arithmetic on, not a string. Previously each of these was anError, 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) endtoml.encode(table)is the other direction, with keys sorted so the output is stable in version control. Exactdecimalandmoneyvalues are written as strings — TOML's only number types are 64-bit int and float, and either would drop the exactness (json.encodealready does this) — and a value with no TOML form at all, likebytes, 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 — adatetimeis an instant plus an offset and holds no "written date-only" bit, so2026-08-19read in and written back out is2026-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-99used 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(andtoml.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 anyprintoutput before the call lost. It is now an ordinary fault thattry/rescueandpcallcatch, 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 onedurable.encodehas always applied, and real documents run tens of levels, not hundreds.json.decodehad the mirror-image problem — a crafted string of thousands of nested[s crashed the rebuild — and now returns anErrorvalue 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
datetimecounts 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)) — sopcall/trycan intercept it like any other fault, identically in every build.time.parsereturns anErrorvalue rather than faulting, as it already did for malformed input, and now distinguishes the two:300000000-01-01is 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)andtime.addon the last representable instant used to land a day past the end of the calendar and hand back a datetime that printed fine but thattime.ofandtime.parseboth 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.parserejected 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 — sotime.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.scuawas 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, orsys.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.jsonandproducer | scua tool.scuastill 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
SIGPIPEand exits 141 where it previously exited 0. This is ordinary Unix behaviour —producer | head -1does the same — but underset -o pipefailit 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 ofimportended 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 withimport("name")got none of the modules it imported, so every one of itsimportbindings 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.tomlcan 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 asdeclarations = ["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-statsreportedcompiled=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
fnwhose name matched a built-in (build,add,total,keyand 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 = 100next to anamountcolumn turnedf.filter(amount > 50)into100 > 50, a constant true that passed every row and returned the wrong total. It is now refused, naming both meanings, rather than guessing.sort_bysorts columns containing nil, datetimes or booleans. A nil cell used to disorder the values around it ([3, nil, 1, 2]came back as1, 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, soassert_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() endfailed while an identicalfn g(x) return x.day() endworked, 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,vendorand friends work on 0.16.scua-pkgrefuses any toolchain newer than the one it knows about, so those commands stay refused until ascua-pkgrelease 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 itsdeps/works.
#Added
cluster.statsreportsrejected_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.exitinsidespawnstops 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 insidespawn; 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.scuaruns a project with dependencies. Oncescua vendorhas writtendeps/, the ordinary command works — no--mod-path, no wrapper command. SCUA finds your project by looking forscua.tomlbeside 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-pathat 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
recordform 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.httprequests take headers, any method, and a response you can inspect.http.request(url, opts)joinshttp.get/http.post, all three taking{ headers, method, body, max_bytes }. Responses now carryheadersalongsidestatusandbody, so you can readretry-afterorx-request-idinstead 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.
httpfailures are nowError({ kind, message }), wherekindis 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.decodeaccepts bytes as well as text, so anhttpresponse body decodes directly.Read a response as it arrives.
http.openstops 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.findandbytes.concat, the two primitives incremental framing was missing.bytescould 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_msbounds the whole exchange — connect, TLS handshake, send, and read — defaulting to 120 seconds, and reportingError({ kind = "timeout" })when it expires. Previously a server that accepted a connection and then went quiet would hang the script forever.scua --http-timeout=MSsets a bound for the whole run; a per-calltimeout_msmay tighten it but never loosen it, so you can bound a script you didn't write without editing it.
#Changed
- A response
bodyis nowbytes, notstring. The old string was never checked for valid text, so a binary download produced a "string" that wasn't one. Usebytes.to_string(resp.body)for text, or pass the bytes straight tojson.decode. - A 3xx redirect is now
Ok(resp), notError. The request succeeded; the server said "look elsewhere". Readresp.headers["location"]and re-request it if you want to follow — which means every hop is checked against your--allow-netallowlist. If you have code treatingOkas "it worked", check the status. - A request with a body and no
content-typeis sent asapplication/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 asOk. It now stops withunknown 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
statefields. Previously any nested function — including await_allormap_allarm — gotundefined namefor a field the handler itself could read on the line above, with nothing to say the name was a state field:
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.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
#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.
lenis 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_allormap_allarm 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 acoroutine(…)resumed while the function that created it was still running.
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 aon 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 endspawned task (its captures are settled when the function that made them returns, which is before the task runs). Unrelated but adjacent: astatefield 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"] = 42on a record whoseacceptis a string used to compile and run; so didh.accept = 42, which only failed later wherever the field was next used. Both are caught at the assignment now, and readingh["accept"]carries the field's declared type instead ofany. 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
recordno 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 withcannot 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 thelibscua/scua.hembedding SDK. Download the.zip, extract, and putbin\on yourPATH(or runbin\scua.exedirectly). 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 addws2_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_modulecan callscua_return_pendingunder the async I/O platform: the script's call (saymodel.ask(question)) parks exactly likehttp.getdoes, 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.getor another host capability — drop the partition entirely (zero resident memory), and later rehydrate it in a fresh partition:scua_persist_actor/scua_load_actor, plusscua_actor_tell_textto deliver the next message. For the mid-call case,scua_io_parksenumerates the waiting call after a reload andscua_io_wake_*(same result shapes asscua_io_complete_*) hands it the answer the host holds, so the script resumes in itsOk(...)arm as if it was never frozen — wake before the first poll, or the call settles asError("interrupted")per the snapshot reload rule. Scripts need no changes. See the new Hibernate a session guide, the runnableexamples/embed_hibernate.c(zig build embed-hibernate), and the cost-curve benchmark (zig build bench-hibernate). Actor blobs from scripts that declarerecordtypes 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 + 1over 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 newrecarraybenchmark 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 entrymain.scuathat readssys.args()and prints a greeting, the greeting split into agreetmodule, amain_test.scua, ascua.tomlwithdebug/releasebuild profiles, a README, and a.gitignore. With anameit 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 runnablemain.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
fnandreturnhints in the editor. Hovering or completingfn/returnin 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 withlet 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.scuais 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 likefibkeeps 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
fibshape — 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 initproject whosemain.scuaimportsgreet.scua— previously failed with "no module resolver is configured". The debug adapter now resolvesimports 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
udpcapability.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 withudp.send/udp.send_to, receive withudp.recv, answer whoever reached you withudp.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.bindadditionally 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 loudErrors, never silent. In a receive loop useudp.recv_batch(sock, max)rather thanudp.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=asynceachudp.recvcosts 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 — whilerecv_batchclears 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 — noasync, noawait; only the platform differs. See Send datagrams with UDP,examples/udp_echo.scua, andexamples/dns_query.scua(a real DNS query, by hand). - Run an HTTP server — the
servecapability.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).reqis{ method, path, headers, body }(header names lowercased,bodybytes); 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 ithttp.servedoesn't exist. A handler fault or a returnedErrorbecomes 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 andexamples/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 anErroryou can test withe.fault).map_all(xs, f, { concurrency = 8 })is the parallel map. Noasync/await, no promises — and no?on the call (the failures live in the slots). See Run many things at once andexamples/wait_all.scua. b"…"byte-string literals. Write bytes inline —b"ok",b"",b"a\nb"— the same escapes as regular strings. Handy forbody = b"ok"and anywhere abytesvalue is wanted (a bareborbirdis still an ordinary name).- Non-blocking I/O —
--io=async. By default SCUA I/O is blocking.scua --io=async …runsfs/httpcalls on an offload pool so that underwait_all/map_allthe 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 returnsError(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 columnf.filter(col.matches(re))— compiledregexover a string columnf.search(needle[, { columns, ignore_case, regex }])— multi-column shortcut (fixed-string or regex; a compiledreis always regex) See Data tables andexamples/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 stringsys.args_table()/sys.parse_args(RecordType)— named CLI flags + stdin JSON into a typed record (withwhererefinements); bad usage exits 2sys.emit/sys.fail/sys.exit— one JSON result on stdout, diagnostics on stderr, chosen exit status (decimal/moneyencode as lossless strings)scua schema <file> --type T— JSON Schema draft 2020-12 for a record/enumscua pack— generated teach-a-model in-context pack (compact/full) See Write tools for AI agents andexamples/agent_tool.scua.
- A
regexmodule — 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)returnsOk(re)or a clearError(backreferences, lookaround, and unsupported constructs fail loudly at compile — never a silent mis-match, matching ripgrep's default engine). Thenregex.is_match,regex.find,regex.find_all,regex.captures(with named groups), andregex.replace($1/${name}substitution). Offsets are 1-based codepoints and the engine is codepoint-native. No capability needed.\w \s \dare ASCII in v1 (\p{L}and multiline are planned). See Match text with regular expressions. fs.grepwith regex — pass a compiledregexvalue 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 anError, not a crash).{ only_matching = true }emits each matched substring (with astopfield) rather than the whole line — the regex equivalent ofgrep -o.str.matches/str.find_re/str.replace_re— run a compiledregexover an in-memory string: a boolean test, the leftmost match record, and capture-aware replace ($1/${name}), the same engine asregex.*andfs.grepwith the string first.fs.greptoken-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 makefs.grepfar 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.grepcontext lines —{ context = N }(or{ before = N }/{ after = N }) attaches the N lines around each match asbeforeandafterstring lists on the hit, the waygrep -C/-B/-Ado. When two matches are close their windows merge, so no line is ever shown twice.fs.grep_textrenders the context indented under each match.fs.grep_text(...)— the same search rendered as one compactpath:line: textstring, ready to paste into a prompt or a log without walking a list of records.
#Fixed
..onbytesnow 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 .. bytesnow yieldsbytes. And mixing is a loud error, never a coercion:cannot concatenate `bytes` with a string — `..` never coerces between bytes and text, namingbytes.from_string/bytes.to_stringas the fix. Text concatenation is unchanged.http.serveandnet.readno 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 intonc) 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.acceptin a run without--allow-serveused 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'sconcurrencyis 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=onand 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-statsprints 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 withpersistyet — 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 thedecimalengine — 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), andmoney ÷ 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 withmoney.of(amount, code); format, split penny-exact, round to the currency's places, and convert at an explicit rate withmoney.format,money.split,money.round, andmoney.convert. Seeexamples/money.scua. - Exact-double numeric buffers
(
{ f64 }). The element-typed array family now includesf64: 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 andexamples/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: alocalized({ 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);@msgevaluates ICU MessageFormat for plurals and gender ({count, plural, one {# message} other {# messages}}); and@stripprunes authoring scaffolding from the output.render_trackedadditionally returns the read-set with per-input provenance, for precise cache invalidation.renderis 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 andexamples/render.scua+examples/localization.scua. - Dates and times (the
timemodule + adatetimetype). Turn the millisecond clock into civil dates you can read, format, and parse. Build one withtime.now(),time.at(ms), ortime.of(2026, 6, 29, 14, 0); read its fields as methods —dt.year(),dt.weekday(),dt.parts(); render withdt.format("YYYY-MM-DD HH:mm")ordt.iso(); and read strings back withtime.parse("2026-06-29T12:00:00Z")→Ok(datetime)/Error. Adatetimecarries 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"), andtime.parse_duration("2h5m"). It's a fixed-offset Gregorian calendar — no time-zone database, DST, or leap seconds (yet). Indt.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 andexamples/time.scua. - System locale & timezone.
sys.locale()andsys.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.ofdefault to it), which makes it easy to pin a fixed environment in tests. See Set the locale and timezone andexamples/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)andf.with(name, expr), where column names are written bare (f.filter(region == "EU" and amount > 100.00d)), or afn(row) … endfor arbitrary per-row logic; combining and reshaping —f.join(other, key[, "inner"|"left"]),f.pivot(index, columns, values), andf.unpivot(ids, values)(melt: stack wide columns into a longname/valuepair). A frame prints as an aligned table. The headline is exact money: adecimalcolumn totals, groups, and computes exactly (no float drift). You can also parse CSV text straight into a frame withcsv(text)— cells are typed per value, so money cells become exactdecimals. See the data tables guide andexamples/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 cappedprintpreview).- Column names and types are checked at compile time.
When a frame is built from a
frame({ … })literal, a mistyped column — intotal/mean/column/sort_by/pick/group_sum/join/pivot, or written bare in afilter/withpredicate — 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 (aframeargument, or one fromcsv/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(...), usingscua_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 theArrowSchema/ArrowArraypair straight toscua_frame_from_arrow(...)(int/float/utf8/decimal128 columns; nulls becomenil). See Drive a script from your engine.
- Built-in geometry shapes. Six new value types —
rect,aabox,sphere,capsule,ray, andplane— alongsidevec/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), andshape + vectranslates a shape. See the math guide andexamples/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 (abytesvalue also answersb.to_hex()andb.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 themat2/mat3equivalents) — 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
colorvalue type. Colors are now first-class, alongsidevec/mat/quat. Acoloris four float components (r/g/b/a) stored in linear space, so blending and arithmetic are correct by default. Build them withrgb/rgba(linear),srgb/srgbaandhsv/hsva(decoded to linear),color_hex("#rrggbb"), or a#rrggbb/#rrggbbaa/#rgb/#rgbahex literal. About 148 CSS named colors are available ascolor_<name>()(e.g.color_cornflowerblue()), pluscolor_transparent(). Operate on them withlerp,lighten,darken,with_alpha,inverted, andluminance, and read them back withto_hex/to_srgb; the.r/.g/.b/.aswizzles and the+/* scalaroperators 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. Acoloris its own type and never silently mixes with avec4. See the math guide andexamples/colors.scua.
#0.10.0 — 2026-06-28
#Changed
enum,flags, andgatenow use braces. Their bodies are a comma-separated{ … }list instead of anend-delimited one, matchingrecord—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) }).contractis not affected — it still ends withend. This is a breaking change: existingenum/flags/gatedeclarations written in the old… endform 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
gfxorrlgraphics binding), script code in an imported module can now bring it in withimport 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(andtostring,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 namedgates, and mark a record fieldgated SomeGate.project(value, RecordType, audience)returns a copy containing only the fields that audience may see — ungated fields always, agatedfield 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)andhash.sha256(data)give a stable content hash. A newcryptomodule 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
durablemodule.durable.encode(value, schema_version?)turns a value into a typed, versioned binary blob, anddurable.decode(blob)turns it back — preservingint/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-levelErroryou canmatch.durable.version(blob)reads the stamped schema version. The functions are pure (no capability); write the bytes to disk withfs. See Evolve your saved data safely.
#Changed
- A
migratehook's result is now type-checked. Previously a record with amigratehook had a weaker load boundary than one without — a wrong-typed value could be laundered through the hook into a typed field (afloatinto anint). 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/
anydata is assigned into a record type, the field-type checks,migratehooks, andwhererefinements 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 badwinson 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 typedanyand 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 bareundefined 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/askfrom 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 locatedexpression nested too deeplyerror. 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, ortypealias (including host-API types from a--!declarefile) — 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-rootscua.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 aSoundwhere aTextureis wanted, or reading a field off a handle, is flagged, mirroring what the runtime already enforces. Thehandlekeyword is only meaningful in a--!declarefile 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
--!declarefile 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.tomlis found from subdirectories. The language server now walks up from the open file to the nearestscua.toml(like other editors/servers), so one manifest at the project root covers scripts insrc/,examples/, etc. — you no longer need ascua.tomlbeside every script.
#Added
- Vec values cross the embedding boundary. A host
native can now read a
vec2/vec3/vec4passed as an argument and return one, so a binding'sf(vec2) -> vec2functions 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_SPACEwith no call, the same as a module function. See Embed SCUA in a host program. (For binding authors:scua_register_consts+ aconstmember in the generated--!declarefile.) - 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_pcallnow 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_persistnow 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
quatandmat2/mat3/mat4are now accepted in type annotations (likevec2/vec3/vec4already 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 Cfloat*, 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 inscua_pin_bytes/scua_arg_bytes).
#Fixed
- Two vector/matrix/quaternion values with equal components now
compare equal (
vec2(3, 4) == vec2(3, 4)istrue); 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 alet 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, andwait_forconstructs.
#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/matchas 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/Errorresults, the?propagation operator,try/rescue, andpcall. - 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
bytestype for binary data, internedkeyidentities, and an efficient stringbuilder. - Numerics for real work: exact
decimalmoney, 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 viascua.toml.