All notable changes to staalc are recorded here. Versions follow
Semantic Versioning; before 1.0, a minor version may change
the language or its file formats.
staalc schema <file> [--entry <fn>] prints what a caller outside a program sees of
a function, for describing it as a tool: a JSON Schema (2020-12) of its inputs, one
of its result, and the grants and secrets only an operator gives. Records and enums
are $defs referred to by $ref, so recursive types are exact, and money is
described with its currency’s decimal places. Checked against the runtime’s own
input rules on 39 values: they agree on all but a money amount too large to hold
exactly, which the runtime refuses and the schema cannot express.limits.deadline_ms: how long a run (or each resume of it) may keep starting
actions and model calls. Checked before each one starts, never during one, so it
never cuts an action in half; fatal (deadline), so try cannot catch it; and a
resumed run decides as its journal says it did. On both engines, concurrent tasks
included.usage: for each capability parameter of the entry function,
the actions attempted and the money charged through it, per currency, whatever its
limits, counting a resumed run’s replay. From the C interface, Python and Node.js.staalc schema reports reads_console: whether the function can reach
read_line(), through its calls and the library capabilities it is given.staalc mcp <manifest.json>: Staal programs served as tools to Model Context
Protocol clients over stdio, under 2025-11-25 (after initialize) and 2026-07-28
(per request), as docs/MCP.md describes. A tool call is an ordinary run: the
manifest gives the grants, secrets, model and timeouts, and the arguments are only
ever inputs. Across calls the server adds quotas (calls and money per window, each
run reserving its worst case until it settles), concurrency limits, deadlines and
once, which makes repeated calls of one operation one run. A ledger, fsync’d
before each step, has a recovery rule for every crash point; a run left in doubt
waits for an operator, who is told by a waiting record and an optional notify
command run with a cleared environment. staal_run_status reports any run;
staalc mcp status lists unfinished runs and quota use. Tested end to end, killed at
every journal and ledger write, and against the official Python (mcp 2.3.0) and
TypeScript (@modelcontextprotocol/client 2.3.1) SDK clients.docs/MCP.md: the design of staalc mcp, kept as its reference.CONTRIBUTING.md says what to expect from review (a first response within two
working days) and how to take a first issue; packages/README.md lists what every
package must meet.idempotency_key whose answer was lost was
reported to the program as a failed action, which try could catch, while the
service may have acted on it: the money moved and the run said it failed, and a
program that caught the failure and sent the request again did so as a new
operation, with a new key the service would not deduplicate. Now the run stops
(in_doubt) and resuming sends the request again with the same key, as RUNTIME.md
section 12 said it would. Found by the agent benchmark (bench/agent), where a lost
Stripe answer left a refund made and its email unsent. A GET without an answer is
still a failure the program can catch.bench/agent: the same support agent written in Python alone and in Python with
Staal, run against prompt injection, malformed model output, permission escalation,
failing services and crashes around every external call, and against realistic
later changes to each agent; with its code size and what each team maintains.@staal/node carries addons for Linux x86_64 and arm64 and both Macs.A quickstart in the README and on the site (examples/quickstart/), run as written
by the Python and Node tests.
payments package refunds through Paystack (payments.paystack.refund) and
Flutterwave (payments.flutterwave.refund), beside Stripe. Each refund is checked
against its transaction first (it exists, succeeded, and is in the refund’s
currency); Paystack is sent kobo, Flutterwave v3 naira, in whole units only. Tested
against a local server answering as each service documents; not yet against the
live services.node/, npm name @staal/node; npm refuses staal as too close to stall and steal): run, resume,
resolve and check over the C interface, returning Promises and running off the
event loop, with types for every result. Tested on both engines, through real
crashes at every journal write (a child process killed, the run resumed and, where
an action was left in doubt, resolved), installed from its packed file.
scripts/node_package.sh packs it.STAAL_RELEASE_DIR, AUDITWHEEL_PLAT in scripts/wheel.sh) and the npm
package (STAAL_ZIGBUILD=1). RELEASING.md says how, and how to publish to PyPI and
npm..github/workflows/publish.yml: publishing a release uploads its wheels to PyPI,
through trusted publishing (no token stored), and its npm package to npm.examples/policy/ holds the program and a Python app, tested
on both engines and through a resume (python/tests/test_policy.py).LICENSE-THIRD-PARTY.txt, the licences of every
crate the binaries contain (scripts/third_party_licenses.py), with LICENSE and
NOTICE.install.sh: installs the latest release for Linux x86_64 or macOS with one
command, checking the archive’s SHA-256, into ~/.staal and ~/.local/bin.db.postgres.query and db.postgres.execute. A grant names the
databases it reaches (server, database, user, password from the environment);
TLS is required and verified, except to a server on this machine. Queries run in a
read-only transaction and give exact JSON rows; statements commit durably, with
idempotency keys kept in the database when the grant asks. A statement whose
connection breaks during COMMIT is left in doubt, never guessed. Tested against a
real server, with crash tests at every point. MongoDB is deferred.staalc run prog.staal [--entry <function>] [--input <name>=<value>]... (main
by default; Text inputs as written, others as JSON), for run, resume, profile
and debug. Capabilities and Secrets still come only from a config file.staalc config prog.staal [--entry <function>] prints a run config to fill in:
placeholders of the right type for every input, grants with the limits the code
declares (and what library capabilities need), secrets, and a model when the
function can ask one."model" may be left out of a run config; a model call then fails, saying so,
before anything is sent or recorded.cargo fuzz on stable Rust).foreign ffi.<library> { fn ... } declares C functions with C types
(integers, bool, text, at most six arguments), each the capability
ffi.<library>.<function>. A grant names the library file and its SHA-256, checked
before the run starts; on Linux the library is loaded through the descriptor that
was hashed. Every run granted one is marked unchecked: an unchecked event in the
log, a warning from staalc run, "unchecked" in the C interface’s result, and a
section in staalc doc. Arguments are checked against the declaration before the
call; calls are journaled, replayed, made one at a time per library, and a call cut
short by a crash is made again only when the grant says "repeatable": true.{"git": ..., "version": "^1.2"} takes the newest
release tag of that repository within the range, and {"version": "~0.3"} the
newest version the project’s registry lists. Ranges are Semantic Versioning
(^, ~, =, comparisons, wildcards; pre-releases only when named). One version
per package fits every range asking for it; a locked one is kept while it fits, and
staalc update [--package <name>] moves it. A conflict is reported with who wants
what, never searched around. A moved release tag or registry entry, or a tag whose
manifest gives another version, stops the fetch with nothing changed. staal.lock
is version 2 (version 1 is still read), recording each package’s version.staalc publish adds a version
(a published version never changes); staalc yank stops one being chosen again.net.tcp.accept and net.tcp.reply: listening. accept waits for the next client
on an address only allows and gives its request as an Incoming; reply answers
that connection and closes it. Tasks made by parallel serve clients at once. The
request is untrusted. Both are made again after a crash and never twice; a request
accepted but not answered when a run stopped is lost with its connection, and the
resumed run’s reply fails saying so. A crash test checks it at every crash point.
Plain TCP, not encrypted.net.tls.exchange: the TCP exchange over TLS, its certificate always verified
against Mozilla’s roots and the operator’s extra ones and against the host. A failed
handshake is not sent; a reply the peer did not end with close_notify is refused
as possibly cut short.parallel(list, f, ...): concurrency. f, a declared function, runs for every item
at once on up to 16 threads; the results come back in the list’s order, and a
failure is the first by position, so the outcome never depends on scheduling. Tasks
share no data; a capability limited by uses or max is refused for them. Each
task’s operations are their own stream in the journal (version 2), so a resumed run
replays every task exactly: the crash tests kill concurrent actions and model calls
at every crash point and resume on either engine. staalc resolve --operation
names one of several actions in doubt. Budgets count every task (E004); logs are
the same whatever the scheduling. The debugger and the REPL run tasks in turn."when", the text of the request they answer.net.tcp.exchange: a TCP request and its reply, read to the peer’s close or to a
delimiter, to the addresses only allows (canonical host:port, host:* or *).
An exchange that may have reached the peer is never repeated: it is left for an
operator, who records the reply with the new staalc resolve --reply <text>. A
crash test checks it at every crash point. Plain TCP, not encrypted.db.sqlite.query and db.sqlite.execute: SQLite databases as runtime
capabilities. A query opens the database read-only and gives its rows as JSON for
parse; a statement runs in a durable transaction. SQL must be trusted (E006);
values from outside are bound parameters. Paths are checked as fs checks them.
Queries replay from the journal; a statement left in doubt stops for an operator,
or with "idempotency_key": true is resolved by a key kept in the database in the
same transaction, which the crash tests check at every crash point. Linux only.read_line(): console input, some of the next line or none at the end. It is
journaled (input): a resumed run replays the lines it recorded and reads only the
rest, which a crash test checks at every crash point. Its text is untrusted.scripts/package.sh (a release archive with its SHA-256),
scripts/wheel.sh (a Python wheel holding staalc and libstaalc, so pip
install needs no Rust) and scripts/wheel_test.sh (the wheel installed into a
clean environment and used with no Rust on PATH). A tag vX.Y.Z runs the release
workflow on Linux and both macOS kinds; CI builds and tests the wheel on every
push. staalc --version. docs/RELEASING.md says how to release; a test keeps
the version the same in staalc, the Python package, the VS Code extension and the
changelog. staal.Library() finds the library installed with the package.staalc debug: breakpoints by line (in the program or a module), step, next,
finish, and variables shown as JSON, on a run that is otherwise staalc run’s,
on the interpreter.staalc profile: per function, calls, steps and time (total and own), and the
model calls, tokens and actions it made, from a run that is otherwise staalc
run’s. The counts are the same on every run, and the steps on both engines.staalc repl: declarations, statements and expressions one at a time, each checked
and run as a whole program by the same checker and runtime as a file, on both
engines. Inputs that read the clock, draw a random number or call a model are not
kept, and nothing reaches the world.staalc doc [-o <out.md>], generated documentation: each declaration as written
(signatures without bodies, a capability’s needs and action signatures) with the
// block above it. Only a program that passes every check is documented.staalc test, for tests written in Staal: every test_ function of a test file
runs as its own run in both engines, which must agree on the outcome and the log.
tests/staal tests the standard library and the language this way, and cargo
test runs it in each engine.editors/vscode: a TextMate grammar and a client for
staalc lsp. Its tests run the grammar with VS Code’s tokenizer over every Staal
file in the repository and the server with VS Code’s protocol library; a cargo
test keeps its reserved words and built-ins identical to the compiler’s.staalc lsp, a language server: errors as you type, completion and formatting,
for any editor with an LSP client. Its errors come from the same function as
staalc check, and the tests require them to be identical for every program in
the repository.staalc fmt [--check] <file>..., the formatter. It changes only the whitespace
between tokens and keeps every comment and line break, so it cannot change what a
program means; it checks its own result before writing. The examples and packages
are formatted with it.docs/GRAMMAR.md: the complete syntax of Staal. A test keeps it exact: its
reserved words, contextual words and built-in functions are the compiler’s, and
every example in it parses.print, now and random_int: printing to standard output, the current time,
and uniform random Ints from the operating system’s random source. Each is an
external operation in the journal (print, clock, random), so a run resumed
after a crash at any point uses exactly the values it had already given the world,
and prints each line once; a crash test proves it at every crash point. They run on
the interpreter only, and a function value cannot use them.format_time,
parse_time (strict RFC 3339, an optional value), make_time, date_parts,
time_parts and weekday. Every day of the range is tested to round-trip.sha256 (lowercase hex), base64_encode
and base64_decode (standard alphabet, padded, strict), hex_encode and
hex_decode. The decoders give an optional value. Checked against the published
SHA-256 and RFC 4648 test vectors. A hash of blinded data still reveals it (E007).map, filter, count, any, all and fold: list functions that call a
function value on each item in order, on both engines; native code calls the
compiled function directly. any and all stop at the first item that decides.upper,
slice, index_of, split, join and to_int; abs, min, max and rem;
append, concat, reverse, sort and sum. Lists keep their maximum size:
append fails on a full list, split takes the most parts in the call, and
concat’s result has room for both. Positions count characters.fn(Int) -> Int: a named function used as a value,
or fn(x: Int) -> Int { return x + k } written in place, which copies the
variables it reads. They are passed, returned, held in variables and called as
f(x) or adder(3)(4), and generic functions take them. A function value only
computes (no action, model call, done, printing, clock or random number, through
any call), so no capability,
secret or proof ever enters one: reading a capability is E003. They are never
kept inside data, compared, encoded or sent. The compiler lifts each one written
in place into a function of its own; both engines run them, and native code
calls them natively.interface Priced { fn price(self) -> Money }, as bounds on type
parameters, <T: Priced + Named>. A type satisfies one by having its methods,
with the same parameters, result, fails and trust, which the caller could call
itself; calls through a bound resolve in each copy to the type’s own method.fn first<T>(xs: List<T, 10>) -> Optional<T>. Type
parameters are inferred from the arguments; the body is checked with each as an
unknown type, and each set of types used becomes a copy (first<Text>) that is
checked and run like any function, on both engines.fn Line.total(self) -> Money, declared beside a record or enum and
called as line.total() on any expression, with named arguments and defaults.
The compiler resolves each call by type and rewrites it into a plain call before
checking, so every rule applies unchanged. On a capability, x.m() is always an
action.fn f(...) -> T fails E with E a record or enum, fail(e) with a
value of E, and outcome.failure, an Optional<E>, after try f(...). A
declared failure that is not caught must be declared by the caller too, so
.failure always holds the declared type. Both engines carry them.Map<Text, Int, 100>, and sets, Set<Text, 100>, bounded like lists,
with Text or Int keys kept in order. ["a": 1] and [:] build maps; get,
put, remove, keys, values, entries, add, items and to_set work on
them, and len and contains take them. put and add fail past the maximum
size. In JSON a map is an object and a set an array of distinct items. A model is
never asked for one. for (k, v) in entries(m) unpacks any list of tuples.(Int, Text), built as (1, "a"), read as t.0 and unpacked with
let (n, s) = t. In JSON a tuple is an array of exactly its length. A model is
never asked for one. Blind paths name positions (@blind(pair.1)). Both engines
run them.fn f(x: Int, tries: Int = 3), and named arguments,
f(1, tries: 5). Defaults are constants; arguments are evaluated in the order
written and passed to the parameters they name, on both engines. An entry
parameter with a default may be left out of a run’s inputs.type Amount = Money: another name for any type, replaced by that
type before checking, so the two are interchangeable everywhere. Aliases of
records and enums build their records and variants. Cycles, clashing names and
aliases of unknown types are errors; pub type exports one.Optional<T>, with some(x) and none, matched with
some(x), none and _. none takes its type from where it is used; a T is
never made optional implicitly. In JSON none is null, and a model is asked
for one through an anyOf with null. Blinding reaches through them: whether
a blinded value holds anything decides nothing (E007). some and none are
reserved names. Both engines and staalc build executables run them.enum Payment { Pending, Paid { amount: Money, reference: Text } },
and match with field patterns, .. and _. A match must handle every
variant (new code E109). Enums are read from and written as JSON objects with
a kind, so a model’s answer is validated into a declared variant. Choosing an
arm reveals only the variant (E007), and bound fields keep the value’s trust
(E006). Both engines and staalc build executables run them, with the same
results, failures and step counts.const NAME: T = value, computed when the program is
checked and substituted where used; pub const exports one.continue in repeat, loop and for, on both engines. Skipping the rest
of an iteration on a blinded value counts as deciding what follows (E007).""" quotes, with the closing quotes’ indentation
removed."\(expr)", for Text, Int, Bool and Money; it is joining
text with +, so the trust and blinding rules apply unchanged. to_text(x)
does the conversion directly.\", \\, \n, \t, \r and \u{...}. An
unknown escape is an error (E900)."provider": "anthropic"), through Anthropic’s
Messages API with structured outputs. Tokens are counted before each call and
charged from the usage the API reports; rate limits and overloads are retried.provider failures, and
their tokens are charged.examples/claude/triage.staal, run against the live API: the injected request
for a larger refund stays data, and the token count matched the usage reported.extra_root_certificates for the Claude provider, as for net.http.Response (and the new Incoming) can no longer be proposed by a model or
parsed from text, as they could not be built in code (E101).f(1)(2) is one call of what f(1) returns, where before the (2) was read as
a separate statement.add, get, …) cannot be
called: add(x) is the built-in, so with such a variable in scope it is E101.pub capability and a mark other than @blind on a method’s self are syntax
errors (E901). Both were accepted and silently had no effect.split, sum,
min, max, map, any, all, count or any other of them must be renamed.net.tcp.exchange whose peer answered and closed at once was reported
as in doubt, because macOS refuses a new socket timeout once the peer has closed.
The read timeout is now set before sending and kept when an update is refused;
net.tcp.accept the same. Found by CI on its first macOS run, with three test
problems: a TLS test server that could reset the connection, a PostgreSQL test
server reachable on 127.0.0.1 only while macOS resolves localhost to ::1, and the
licences of two macOS-only crates (objc2), which release_test now checks for every
release platform.db or ffi, and a library capability declared under
db., capability families of the runtime; all are refused now, as net and fs
were.staalc resolve refuses an operation that resume makes again by itself (fs.read,
fs.write, db.sqlite.query, net.tcp.accept, net.tcp.reply). Before, it could
record a receipt for an fs.read or a db.sqlite.query, and the resumed run then
failed on a result it could not read.put and add were not counted as operations that can fail, so whether code
after a full map or set ran was not tracked as depending on their inputs (E007).The first version published in this repository. Earlier versions (0.1 to 0.21) were internal; their history is in the git log.
try, modules with pub
and imports, and reassignment checked for order.canonical targets and limits
(only, uses, each, max) enforced before a library’s code runs.net.http (HTTPS through rustls) and fs capabilities, with secrets taken only
from environment variables.staal.lock.staalc build links a program into a standalone executable (Linux).include/staal.h) and a Python wrapper over it.staalc, source
files end in .staal, and projects use staal.json and staal.lock.