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.
#Unreleased
Log lines now go to standard error, not standard output.
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. The old--log-stderrflag is gone, since it describes the default;--log-stdoutputs log lines back on standard output if you want a single merged stream. Runningscua tool.scua 2>&1also merges them, 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.