staal

Staal runtime semantics (v0.1)

This document defines what a Staal program does when it runs. The reference interpreter (src/interp/) implements it directly; any future backend (native code via Cranelift) must produce the same results, model requests, actions and log for every program. Where this document and an implementation disagree, the implementation is wrong.

A program is only run after it passes every static check (E001-E008, E1xx). The runtime relies on those checks and also re-checks the properties that matter for safety, so a bug in one layer is caught by the other.

1. Running a program

A run is described by a run config (JSON):

{
  "entry": "handle_refund",
  "inputs": { "message": "I was charged twice", "order": { ... } },
  "grants": { "pay": { "cap": "payments.refund", "max": { "amount": "500.00", "currency": "USD" } } },
  "model":  { "provider": "script", "responses": [ ... ] },
  "secrets": { "key": { "env": "API_KEY" } },
  "adapters": { "simulated": { "fail": [ ... ] } },
  "net": { "timeout_ms": 30000 },
  "limits": { "max_steps": 1000000, "max_depth": 256, "default_max_tokens": 1024 }
}

The result is either the entry function’s return value or a failure (section 8), plus the execution log (section 10).

Without a config file

staalc run, resume, profile and debug take a config file with --config, or make one from the command line:

staalc run prog.staal [--entry <function>] [--input <name>=<value>]...

The entry is --entry, or main. Each --input gives one parameter: a Text parameter takes the value exactly as written, any other parameter the value as JSON (--input count=3, --input 'tags=["a", "b"]'), checked as inputs are. A parameter with a default may be left out. The config made is {"entry": ..., "inputs": {...}}, with no grants, secrets or model, and is treated exactly as a file holding it would be: resume must be given the same entry and inputs (the journal records the config’s hash). An entry function with a capability or Secret parameter is refused before anything runs: only a config file can grant those. --config with --entry or --input is refused.

staalc config prog.staal [--entry <function>] prints a config to fill in for that function: every input without a default, as a placeholder of its type (empty text, 0, false, {"amount": "0.00", "currency": "USD"}, a record’s fields, an enum’s first variant, a list of one, none, an empty set or map); a grant for every capability with the limits the code declares (for a library capability, what it needs, its Secrets as environment variables named after the capability, such as TERMII_SMS_KEY; for a C function, an empty library and sha256); every Secret parameter as an environment variable of its name in capitals; and, if the function can reach a model call (through what it calls, function values it names, or library capabilities it is given), Claude, with its key from ANTHROPIC_API_KEY. The placeholders are values of their types; what they should be is for the operator to write.

staalc schema prog.staal [--entry <function>] prints what a caller outside the program sees of that function, for describing it as a tool (to an MCP client or an agent framework): input, a JSON Schema (2020-12) of the inputs it takes, where a parameter with a default is not required and each declared record or enum is a $defs entry referred to by $ref, so recursive types are described exactly; output, a JSON Schema of the result as a run writes it; and grants and secrets, the parameters only an operator gives, which are never inputs; and reads_console, whether the function can reach read_line(), which a host whose standard input is not a console must refuse. Money is described with its currency’s decimal places. The runtime’s own check of inputs (section 3) still decides: the one thing it refuses that the schema accepts is a money amount too large to hold exactly.

2. Values

Constants. const NAME: T = value declares a constant at the top of a file (the type may be left out). Its value may use literals, other constants, and arithmetic, comparison, and/or/not, records, lists, field reads, indexing and the built-ins other than header and fill_secret; anything else is E101, as is a cycle between constants. The compiler computes every constant with the same operations the engines use, and a failure (overflow, division by zero, an index out of range) is E101. Before checking, every use of a constant is replaced by its value’s expression, so at run time a constant is exactly the literal it stands for: trusted, revealing nothing, and the same on both engines. pub const exports it to other modules, which use it as module.NAME or import it by name (E107 without pub). A local variable, parameter, loop variable or capability need cannot have the name of a constant in scope (E101).

Type Runtime value
Text a UTF-8 string
Int a signed 64-bit integer
Bool true or false
Money an exact amount in minor units (e.g. cents) and a currency code
declared record type its fields, in declaration order
declared enum type one of its variants, and that variant’s fields in declaration order
List<T, N> at most N values of type T, in order
Optional<T> a value of type T (some(x)), or nothing (none)
(A, B, ...) a tuple: two or more values, of these types, in this order
Map<K, V, N> at most N values of type V, each under a different key of type K, in key order
Set<T, N> at most N different values of type T, in order
fn(A, B) -> R a function value: a function, and the values it captured
Proposed<T> raw model output (JSON), not yet checked against T
Receipt proof of one completed action: id, capability, method
Outcome ok, error, and (hidden) what succeeded
Done a list of evidence values
cap ... a handle to a capability held by the broker (section 6)
Unit the result of a function that returns nothing

Text literals are written in double quotes on one line. A backslash starts an escape: \" a quote, \\ a backslash, \n a new line, \t a tab, \r a carriage return, and \u{...} the Unicode character with that hexadecimal code (1 to 6 digits, e.g. \u{e9} for é; surrogates are refused). Any other escape is an error (E900), never a backslash kept as text. Braces are ordinary characters. len counts characters, not bytes.

Multi-line text is written between """ quotes. The opening quotes end their line; the closing quotes begin a line, after only spaces or tabs, and that indentation is removed from every line of the text (a non-blank line indented less is an error). Line breaks between the lines are kept, the one before the closing quotes is not, and \r\n line endings count as \n. Code may follow the closing quotes on their line. Escapes and interpolation work as in one-line text.

Interpolation. \(expr) inside text is replaced by the value of expr, which must be Text, Int, Bool or Money (12.50 NGN); the expression ends at the matching ) on the same line. "a \(x) b" is exactly "a " + to_text(x) + " b", so the result is trusted only if every part is, and blinded data stays blinded (E006, E007). to_text(x) is available directly. A Secret can never be interpolated, and a record or list needs json(x).

Enums. enum Payment { Pending, Paid { amount: Money, reference: Text } } declares a type whose value is exactly one of its variants, each with its own fields (possibly none). Payment.Pending and Payment.Paid { amount: a, reference: r } build one; every field must be given once (E101). A field of an enum value cannot be read with . (E104), since which fields exist depends on the variant; match (section 4) is the only way in. Two enum values are equal when they are the same variant with equal fields. Variant fields follow the rules for record fields: no Secret, Header, Receipt, Outcome, Done or capability, and no field called kind. pub enum exports one; another module writes module.Payment.Paid { ... }, or imports Payment by name. Both engines run enums and match (section 15).

Tuples. (a, b) builds a tuple of type (A, B); t.0, t.1, … read its elements (t.0.1 reads into a nested one), and let (x, y) = t unpacks it into two variables, optionally typed: let (x, y): (Int, Int) = t. Reading a position it does not have is E104, unpacking something that is not a tuple E101. Two tuples are equal when every element is. A tuple cannot hold a Secret, Header, Receipt, Outcome, Done or capability, and a model is never asked for one (E101), since an output schema cannot fix each position for every provider: use a record. @blind paths name positions as fields: @blind(1), @blind(range.0). Both engines run tuples.

Maps and sets. A map’s keys, and a set’s items, are Text or Int; they are kept in order (Ints by value, Texts by their bytes), so everything that lists them lists them the same way on every engine. Like a list, every map and set has a maximum size in its type. ["a": 1, "b": 2] builds a map; [:] and [] are an empty map and an empty list or set, which take their type from a typed let. The built-in functions are get(m, k) (an Optional value), put(m, k, v) (a map with that entry added or replaced), remove(m, k), keys(m), values(m), entries(m) (a list of (key, value) tuples), and for sets add(s, x), remove(s, x), items(s) and to_set(xs); len and contains take maps (by key) and sets too. None of them changes its argument: each returns a new value. put and add fail (kind value) when the new entry or item would exceed the maximum size, and a map literal fails when a computed key appears twice (a literal key given twice is E101). Two maps or sets are equal when they hold the same entries or items. A map or set cannot hold a Secret, Header, Receipt, Outcome, Done or capability, and a model is never asked for one (E101): use a list of records. for (k, v) in entries(m) unpacks each entry, and works for any list of tuples. Both engines run maps and sets.

Type aliases. type Amount = Money gives a type another name. An alias is exactly the type it names: before checking, every use is replaced by that type, so Amount and Money are interchangeable and every rule applies to them alike. An alias may name any type, including another alias, a list, an optional value, a record or an enum; through an alias of a record, Alias { ... } builds that record, and through an alias of an enum, Alias.Variant builds that variant. Aliases that name each other in a cycle are E101, as is an alias with the name of another type in its file or of a built-in type, and a type an alias names must exist even if the alias is never used (E102). pub type exports one, like a record.

Optional values. Optional<T> holds a T or nothing. some(x) makes one that holds x; none makes one that holds nothing, and takes its type from where it is used: a typed let, a parameter, a field, a return, a list beside a some(...). let x = none alone is E101, since nothing says what x may hold. A T is never turned into an Optional<T> silently: write some(x) (E101 otherwise). What an optional value holds is reached only through match with some(x), none or _ (a field read is E104). == and != compare two optional values, or one with none; two are equal when both hold nothing or both hold equal values. Optional<Optional<T>> is refused (E101), since some(none) and none would be the same JSON, and an optional value cannot hold a Secret, Header, Receipt, Outcome, Done or capability. Both engines run optional values.

No value can be created out of thin air: Receipt only by an action, Done only by done(...), Outcome only by try, capabilities only by grants.

Money

Money is exact; there is no floating point anywhere in the runtime.

Int literals

Integer literals must fit in a signed 64-bit integer; larger literals are rejected by the compiler (E101).

3. Inputs and validation

Values arriving from outside (run inputs, model output) are checked against a declared type by the same strict rules:

validate x applies these rules to the raw output in x. On success it yields a value of type T. On failure it is a failure of kind validation (section 8). Validation checks shape only; the result is still untrusted.

4. Statements and expressions

Evaluation is strictly left to right and statement by statement.

Arithmetic

Operands Operators Result
Int, Int + - * / Int; / rounds toward zero
Money, Money (same currency) + - Money
Money, Int (either order) * Money
Text, Text + Text (joined)
Int unary - Int

Anything else (Money / Int, Money + Int, …) is rejected by the compiler. Arithmetic never wraps, rounds money, or converts currencies. These are failures of kind value, which try around a call can catch:

Limits that keep a run finite

Two deployment limits apply to every run; exceeding either is a failure (step_limit, depth_limit):

A third is optional:

These limits are safeguards, like a timeout; they are not part of a program’s meaning, and whether they trigger can depend on data (see the timing note in the README).

5. Model calls

propose(T, instruct: I, data: [D...], tokens: N):

  1. Stateless. Every model call is a separate request with no conversation history, no shared session and nothing carried over from earlier calls. This is a hard requirement: the compiler checks each call’s inputs against that call’s rules only.
  2. Request. The request contains exactly: the instruction text I (or a fixed default when there is none), the data values D... encoded as JSON, and a JSON description of type T. Data is placed in a delimited section marked as data, never in the instruction position. Capabilities are never encoded (the compiler forbids it; the runtime refuses if one appears).
  3. Token limit. The runtime asks the provider to count the request’s input tokens. With tokens: N, if input >= N the call is refused (budget) without being sent; otherwise the output limit is set to N - input, so input + output can never exceed N. Without tokens:, the output limit is default_max_tokens.
  4. Budget. Before sending, every enclosing budget (section 7) must have at least one call left and, if it limits tokens, at least the call’s worst-case tokens left; otherwise the call is refused (budget) without being sent. After the reply, the actual input + output tokens are charged. A provider reply that exceeds the output limit is a failure (provider), and its tokens are still charged.
  5. Result. The reply’s raw output becomes a Proposed<T>. A definite provider error (a refused key, a malformed request, a refusal, output cut off at the token limit, a reply that is not of the required shape) is a failure of kind provider; when the model ran, its tokens are still charged.
  6. Unavailable. When the provider cannot be reached, or stays rate limited or overloaded after its retries, the run stops (in_doubt, exit code 3) without recording an answer. Resuming makes the call again; nothing needs resolving first, because a model call changes nothing in the world.

Providers

"script" replies from a fixed list, in order, for tests: {"provider": "script", "responses": [{"output": …, "output_tokens": n}, …]}. A response may instead be {"error": "…"} (a provider failure) or {"unavailable": "…"} (the run stops, as in step 6). Input tokens are counted as one per 4 bytes of the request’s JSON.

"anthropic" calls Claude through Anthropic’s Messages API:

"model": { "provider": "anthropic", "model": "claude-sonnet-5-5",
           "api_key": { "env": "ANTHROPIC_API_KEY" } }
Key Meaning Default
model the Claude model id, e.g. claude-sonnet-5-5 or claude-haiku-5-5 required
api_key {"env": NAME}: the environment variable holding the key required
base_url the API’s address; HTTPS, or plain HTTP to 127.0.0.1 or localhost only https://api.anthropic.com
timeout_ms the most one request may take 600000
connect_timeout_ms the most connecting may take 10000
retries extra attempts after a rate limit, an overload or a server error (0 to 10) 2
retry_delay_ms the first wait between attempts, doubling each time, unless the API sends retry-after (at most 60 s) 1000
proxy "none", or "environment" to use HTTPS_PROXY "none"
extra_root_certificates PEM files of root certificates to trust in addition to Mozilla’s, for a network whose proxy signs TLS with its own authority none

6. Capabilities and actions

The capability broker holds every capability. A program only ever holds a handle. A capability has a path (building.lock.open), a set of limits, what has been used of them so far, and optionally a parent.

Limits

Every limit is optional, and all limits declared on a capability apply together. They are written on the type, cap path(key: value, ...), or on the grant in the run config.

Limit Meaning Example
max the most money spent through the capability in total over the run, charged when an action succeeds cap payments.refund(max: 500 USD)
each the most money one action may spend cap payments.refund(each: 50 USD)
uses how many actions may be attempted in total cap building.lock.open(uses: 2)
only the targets actions may act on, as patterns cap twilio.message.send(only: ["+234*"])

Grants, narrowing and checking

7. Budgets

A function declaring budget(calls: C, tokens: T) opens a budget meter for each invocation. A model call is charged to every meter open on the call stack at that moment (the function’s own and its callers’). Meters close when the function returns or fails.

8. Failures

A failure has a kind and a message:

Kind Cause
action an adapter reported that an action failed
capability an action was refused by the broker (a limit, the currency, or an amount or target it cannot determine)
value arithmetic or indexing could not produce a value (section 4)
validation validate rejected model output
budget a model call would exceed a token limit or a budget
provider the model provider failed or misbehaved
step_limit, depth_limit a deployment limit was reached
deadline the run passed limits.deadline_ms before starting an action or model call (section 4)
internal a state the compiler should have ruled out (a bug in staalc), reported instead of crashing
journal, in_doubt durability failures (section 11)

A failure stops the current function and every caller up to the nearest enclosing try. try e evaluates e: on success it yields Outcome { ok: true, error: "" } holding the result; on failure it yields Outcome { ok: false, error: "<kind>: <message>" }. If no try catches it, the run ends with that failure.

Typed errors. fn charge(c: Charge) -> Money fails ChargeError declares the record or enum type its failures carry. In such a function fail(e) takes a value of that type (in any other function, Text); it is an action failure whose message is the value as JSON, and which carries the value. For r = try charge(c), r.failure is an Optional<ChargeError>: the value charge failed with, or none when it succeeded or failed some other way (an overflow, a refused action). A call to a function declaring fails E that is not under try lets its failure leave the caller unchanged, so the caller must declare the same fails E (E101 otherwise): what .failure holds is always of the type the callee declares. A capability’s action cannot declare fails. .failure, like the rest of an Outcome, is untrusted. Both engines raise and carry typed errors.

step_limit, depth_limit, deadline, internal, journal and in_doubt are fatal: try never catches them, and they always end the run. The deployment limits are safeguards, not part of the program’s meaning, so a program must not be able to observe them (a runaway loop or recursion must not end in a “successful” run). An internal failure is a bug in staalc, which no program may hide.

9. Evidence

done(evidence: [...]) yields a Done holding its evidence values. Each item must lead to at least one receipt (directly, through a successful outcome, or through a nested Done); the compiler guarantees this (E008) and the runtime checks it again, failing with internal if it ever does not hold. When the entry function returns a Done, its evidence (receipts, and the results held by successful outcomes, recursively through nested Done values) is written to the log as the run’s evidence.

10. Execution log

Every run produces an ordered log of events (JSON lines): run start, each model request exactly as sent and its reply and token counts, each validation result, each action requested, refused, performed or failed (with amounts), each budget refusal, each failure caught by try, and the run’s result and evidence. The log contains the data sent to models and so may contain sensitive data; it must never be given to a model as context.

11. Durability: the journal, crashes and resume

A run started with a journal survives being killed at any moment (power loss, out-of-memory kill, kill -9). Resuming it finishes the run so that:

How

Everything a run does is deterministic except what the outside world tells it: token counts, model replies, action results, the time and random numbers; and printing is a change to the world. Each of these is an external operation, numbered 1, 2, 3, … in the order the run reaches them. For each one the runtime appends, and flushes to disk (fsync) before continuing:

  1. an intent record: what is about to be asked of the world;
  2. after the world answers, a result record.

Resuming re-executes the program from the start. At each external operation:

Resume requires the identical program text and run config: the journal’s first record holds the SHA-256 of each, and any difference is refused before anything runs. For a program with modules, the program hash covers every file it loads (each file’s module path, length and text), so a change to any imported module is refused too. File names are not part of it: the same program resumed from another folder, or after its folder was moved, is the same program. A single-file program’s hash is the hash of its text. (Journals of multi-file programs written before v0.17 recorded file names, and cannot be resumed by v0.17.)

The crash window, and idempotency

A crash after an intent but before its result leaves that operation in doubt: it may or may not have happened.

The run id is 128 bits from the operating system’s random source, so keys never collide with another run’s. A predictable or reused key could make a service return a different run’s receipt.

Failure kinds

Kind Cause
journal the journal could not be written, or replay diverged from it
in_doubt an action that is not idempotent may or may not have happened; or a model call or token count did not complete (resuming makes it again)

Both are fatal, like step_limit: try never catches them.

The journal file

JSON lines, append-only. Records:

{"j":"start","version":2,"run_id":"…","program_sha256":"…","config_sha256":"…"}
{"j":"intent","n":1,"op":"count","key":"…","body":{…}}
{"j":"result","n":1,"ok":…}            or  {"j":"result","n":1,"err":"…"}
{"j":"retry","n":3}                    an operator authorized one more attempt at action 3
{"j":"attempt","n":3}                  that attempt is about to be made
{"j":"end","ok":true,"result":…}       or  {"j":"end","ok":false,"failure":"…","message":"…"}

op is count, model, action, print, clock, random or input. After an intent come only its result, or, for an action, retry/attempt pairs and then its result. A record of a concurrent task (section 18) also has "t", the task’s id: each task’s operations are numbered from 1 by themselves and must follow these rules among themselves, and the tasks’ records may interleave in any order. Version 1 journals, which have no tasks, are read as they are. A crash can tear only the last line; a torn last line is discarded on resume (it was never acknowledged). Any other malformed line is corruption, and resume refuses. Resuming a journal that already has an end record replays it without contacting the world and reports the same result. Only one process may use a journal at a time: an operating-system lock, released automatically if the process dies, enforces this.

The journal holds the data sent to models and to actions, and so may contain sensitive data. Like the log, it must never be given to a model as context.

Contracts for providers and adapters

12. The runtime’s own capabilities: net.http, net.tcp, fs and db

Most capabilities are performed by an adapter and return a Receipt. Three families belong to the runtime itself, which performs them, checks them, and records them in the journal like any other action. Capability paths starting with net., fs. or db. are reserved for them; any other path under those names is a compile error (E101).

Capability Action Result
net.http.get, net.http.delete c.get(url), c.get(url, headers) Response
net.http.post, net.http.put, net.http.patch c.post(url, body), c.post(url, body, headers) Response
net.tcp.exchange, net.tls.exchange c.exchange(address, data), c.exchange(address, data, until) Text
net.tcp.accept c.accept(address), c.accept(address, until) Incoming
net.tcp.reply c.reply(connection, data) Receipt
fs.read c.read(path) Text
fs.write c.write(path, content) Receipt
db.sqlite.query c.query(database, sql), c.query(database, sql, params) Text (JSON rows)
db.sqlite.execute c.execute(database, sql), c.execute(database, sql, params) Receipt
db.postgres.query c.query(database, sql), c.query(database, sql, params) Text (JSON rows)
db.postgres.execute c.execute(database, sql), c.execute(database, sql, params) Receipt

url, path, content, address, data, until, connection, database and sql are Text; params is a List<Text, N>. A body is Text or a Secret (below). headers is a List<Header, N>. uses and only apply as to any capability; max and each do not (no money the runtime can measure is spent), and are refused (E101, or a config error on a grant).

Built-in types and functions

Secrets

An entry parameter of type Secret is supplied only from the environment:

"secrets": { "key": { "env": "TERMII_API_KEY" } }

Giving it in inputs is a config error: secrets are never written into a run config. The variable must be set, at least 8 characters long, and printable ASCII without spaces, quotes or backslashes (so filling it into a JSON string or a header can never change their structure). The journal and the log record a secret by its variable name only. Every secret value is also removed from every response body, file read and log event (replaced by [secret removed]), so a service that echoes a key back cannot leak it into the program, the log or the journal.

Canonical targets

only compares text, so every target is first put into one canonical form, and a target that cannot be is refused (capability) before anything is sent.

Files

Every path is opened one directory at a time, each relative to the one before, and no symbolic link is ever followed, in any directory or as the file itself. A directory swapped for a link while a program runs is therefore either refused or irrelevant. Reads accept only regular files, opened without blocking (a FIFO cannot hang the run), refuse a file with more than one hard link, and stop at fs.max_read_bytes; the text must be UTF-8. Writes go to a new temporary file in the same directory, are flushed, and replace the target with one atomic rename, then the directory is flushed: a crash never leaves half a file, an existing file keeps its permissions, and another hard link to it keeps its own contents. A write returns a Receipt whose id is derived from the content’s SHA-256.

Linux and macOS only; on other systems every file action fails.

TCP

net.tcp.exchange connects to address (host:port), sends data, and gives the reply as text. Without until it closes its sending side after the data, which tells the peer the request is complete, and reads until the peer closes; with until (a delimiter such as "\r\n") it reads until the reply ends with it, for protocols that keep the connection open. net.tcp.exchange is plain TCP: nothing is encrypted. net.tls.exchange is the same exchange over TLS, its certificate always verified against Mozilla’s root certificates and those net.extra_root_certificates adds (never instead), and against the address’s host. A handshake that fails (an untrusted, expired or other host’s certificate) fails before any data is sent, so it is not sent. Without until, TLS is ended properly after the data (close_notify); a peer that closes the connection without ending TLS could have had its reply cut short by someone between, so that reply fails (action), though the exchange happened. Addresses are canonical (the host as the URL Standard writes it, lowercased and in ASCII, IPv6 in brackets; the port 1 to 65535), and an only pattern is an address, host:* (any port of one host) or *: a * never stands for part of a host. The time limit is net.timeout_ms for the whole exchange, the connect limit net.connect_timeout_ms, and a reply longer than net.max_response_bytes or not UTF-8 text fails (action) though the exchange happened.

An exchange that certainly sent nothing (no such host, connection refused, connect timeout) fails with a message beginning not sent:. Once data may have reached the peer, a broken connection or a timeout leaves it in doubt: it is never repeated, and resume stops until an operator records what the peer answered (staalc resolve --reply <text>), or --failed, or --retry. The journal records a hash of the data sent, never the data, and the reply itself, so a resumed run gets it without connecting. A crash test checks this at every crash point.

Listening

net.tcp.accept listens on address and gives the next client’s request as an Incoming { connection: Text, data: Text }; net.tcp.reply sends data on that connection and closes it, with the receipt reply:<connection>. A run starts listening on an address at its first accept there and stops when it ends; every accept on one address, in any task, takes clients from the same queue, so tasks made by parallel can serve several clients at once. The address and only are as for net.tcp.exchange; the host is where the run listens (127.0.0.1:8080 takes connections only from the same machine, 0.0.0.0:8080 from any). only limits net.tcp.accept; net.tcp.reply takes no only (E101, or a config error on a grant), since it answers only a connection the run accepted, and a connection that is not open (answered already, or never accepted by this process) fails (action). Waiting for a client has no time limit. Without until the request ends when the client closes its sending side; with until it ends when the request ends with it. Reading it must finish within net.timeout_ms and net.max_response_bytes, and it must be UTF-8 text, or the accept fails (action) and that client is dropped, so a program that must outlive one bad client catches it with try. Writing the reply has the same time limit. The request is untrusted (E006). An Incoming cannot be built in code, proposed by a model or parsed from text (E101). Nothing is encrypted.

The journal records each request and a hash of each reply, never the reply. Both are made again after a crash, and neither is ever made twice. A connection does not outlive the process that accepted it: when a run stops between a request’s accept and its reply, that client’s connection is lost, and the resumed run’s reply fails, saying so, rather than answer anything (a reply made just before the run stopped may have reached the client). An accept cut short takes the next client on resume. staalc resolve has nothing to record for either. A crash test checks this at every crash point.

Databases

db.sqlite.query and db.sqlite.execute act on SQLite database files. The database is a path, made canonical and checked against only as a file’s is, and checked as fs checks a path (each folder in turn, no link followed, a regular file with one name), with no link where SQLite keeps its journal (-journal, -wal, -shm); SQLite then opens it refusing any link in its path. SQLite opens files by path, so a process that can rename folders inside the granted ones could race that check: those folders should be writable only by whoever runs the program.

Linux only so far; elsewhere every database action fails.

PostgreSQL

db.postgres.query and db.postgres.execute act on PostgreSQL databases. A grant names the databases it reaches; the program’s database argument is one of those names, and any other is refused (capability). only does not apply (E101, or a config error on a grant).

"db": { "cap": "db.postgres.query",
        "databases": { "shop": { "host": "db.internal", "port": 5432, "dbname": "shop",
                                 "user": "reader", "password": { "env": "SHOP_DB_PASSWORD" } } } }

host, dbname and user are required; port is 5432 unless given; password is a Secret from the environment (kept out of every log, journal and error message, as any secret is), and may be left out for a server that asks for none. Every connection uses TLS, with the server’s certificate verified against Mozilla’s roots and those net.extra_root_certificates adds, and against host; "tls": "disable" is allowed only for a server on this machine (localhost, 127.0.0.1, ::1). The connect time limit is net.connect_timeout_ms, and a statement that runs longer than net.timeout_ms is cancelled by the server. Each action opens its own connection and closes it.

A query is made again after a crash, as SQLite’s is. A statement that fails before COMMIT, or that the server refuses at COMMIT, certainly changed nothing (action). One whose connection is lost while committing may or may not have been committed: it is in doubt and stops the run for an operator (staalc resolve), unless its grant says "idempotency_key": true. Then its key is written to _staal_keys in the same transaction, and the resumed run looks for it: a statement whose key is there is not run again. The tests run against a real PostgreSQL server: the crash tests kill a run at every crash point, with and without keys, and a proxy that cuts the connection during COMMIT checks that such a statement is left in doubt, not guessed.

HTTP requests

No redirect is followed (a redirect could lead outside only; the program gets the 3xx response). The runtime owns these headers and refuses them from programs: Host, Content-Length, Transfer-Encoding, Connection, Upgrade, TE, Trailer, Keep-Alive, Idempotency-Key and any Proxy-*; header names must be HTTP tokens and values contain no control characters. No proxy is used unless the run config asks for one.

"net": { "timeout_ms": 30000, "connect_timeout_ms": 10000,
         "max_response_bytes": 4194304, "proxy": "none" },
"fs":  { "max_read_bytes": 16777216 }

(These are the defaults. proxy is "none" or "environment", which reads the usual HTTPS_PROXY and NO_PROXY variables.)

Server certificates are verified against Mozilla’s root certificates, the list browsers use. An organisation whose services are signed by its own authority adds it with "net": { "extra_root_certificates": ["/etc/ourco/ca.pem"] }: the certificates in those PEM files are trusted in addition to Mozilla’s, never instead. A file with no certificate, or with a private key in it, is a config error. There is no way to turn verification off. A certificate that is expired, not yet valid, for another name, self-signed or from an authority not trusted is refused during the handshake, before any request data is sent.

What is known about a request, and crashes

What happened Example Result
The server answered any status a Response
Failed before anything was sent host not found, connection refused, connect timeout, a certificate refused, no common TLS version failure action, which try can catch
Failed after it may have reached the server timeout, broken connection, or any other TLS error while sending or waiting see below

A request that may have reached the server is treated exactly like a crash in the middle of an action. For get, which HTTP defines as safe, it is a failure action, and after a crash it is retried. For post, put, patch and delete the run stops in_doubt (the program cannot catch it, so it cannot retry and send twice), unless the grant says the service deduplicates:

"grants": { "pay": { "cap": "net.http.post", "idempotency_key": true } }

Then the runtime sends Idempotency-Key: <run id>-<operation> with the request, the same key on every attempt, and such a request is retried after a crash or a lost answer: a lost answer stops the run (in_doubt, which try cannot catch), and resuming sends the request again with the same key, with nothing to resolve first. The program is not told the request failed, because it could only repeat it as a new operation, with a new key the service would not deduplicate. Only the operator can say this, in the grant; a program cannot. If the service does not really deduplicate, or a retry comes after it has forgotten the key (Resend keeps keys for 24 hours), a request can be performed twice: this is the operator’s statement to make.

An HTTP request left in doubt is resolved with the response the server gave, which the operator finds in the service’s own records:

staalc resolve --journal run.journal --status 200 --body '{"id": "..."}'

(--failed and --retry work as for other actions; --performed is refused for HTTP requests, whose result is a response, not a receipt.) File reads and writes are always safe to retry: reading changes nothing, and writing the same content again leaves the same file. The journal records a write’s content by its SHA-256, which a resumed run must reproduce.

Journal files are created readable and writable by their owner only (0600), since they hold what the run sent and received.

13. Capabilities declared by libraries

A library can declare a capability of its own, built on the runtime’s: an SMS service, a payment provider, a ticketing system. A program then holds a capability for what it wants done (cap termii.sms.send(only: [...], uses: 10)), and never the HTTP capability, the API key or the URL behind it.

capability stripe.refunds {
    needs get: cap net.http.get
    needs post: cap net.http.post
    needs key: Secret
    needs base: Text

    canonical(id: Text) -> Text {
        return trim(id)
    }

    action create(payment_intent: Text @target, amount: Money @amount) -> Text {
        ...                       // requests through `get` and `post`
        return refunded.id        // the service's id, for the receipt
    }
}

fn refund(p: Text, a: Money, refunds: cap stripe.refunds.create(max: 200 USD)) -> Receipt {
    return refunds.create(p, a)
}

Declaring

capability PATH { ... } is a top-level declaration, in any file of the program. Its body holds, in this order:

Paths are global, not qualified by module: two declarations of one path are an error, and paths under net and fs belong to the runtime (E101).

An action’s own parameters are data only (no capabilities or secrets: those come through needs). At most one is marked @target (Text) and at most one @amount (Money). The compiler requires, for each action (E101):

fail(message) ends an action (or any function) with an action failure carrying the message, which a caller’s try can catch. Library code also has replace(t, from, to), lower(t), trim(t), form(record) (a record of Text, Int and Bool fields, form-encoded), minor_units(money) (an Int, cents for USD), currency(money) (the code, "USD") and url_segment(t) (the text percent-encoded as one URL path segment; fails for "", "." and "..", which a URL treats as no segment or as moving up a level).

Granting

The grant names the capability as usual and supplies each need under needs:

"refunds": { "cap": "stripe.refunds.create", "max": { "amount": "200.00", "currency": "USD" },
             "needs": {
                 "get":  { "cap": "net.http.get",  "only": ["https://api.stripe.com/v1/payment_intents/*"] },
                 "post": { "cap": "net.http.post", "only": ["https://api.stripe.com/v1/refunds"],
                           "idempotency_key": true },
                 "key":  { "env": "STRIPE_SECRET_KEY" },
                 "base": "https://api.stripe.com" } }

A need that is a capability is a grant like any other (same keys and checks), and the library’s declared limits narrow it when the run starts. A Secret need is {"env": VARIABLE}, with the rules of section 12. A Text need is a string. A missing need, an unknown one, a value of the wrong kind, and needs on a capability that is not a library’s are config errors. Each granted inner capability is logged (grant, with "for" naming the library capability).

A run cannot start inside a capability: an entry naming an action or canonical is refused.

Performing

c.create(p, a) on a library capability, in this order:

  1. The @target argument is put in canonical form by the library’s canonical.
  2. The broker checks the amount against max and each and the canonical target against only, through the whole chain of narrowed capabilities, and counts a use. If any check fails the action is refused and the library’s code never runs.
  3. The action runs with its arguments and the needs. Its requests are checked, journaled, and may be in doubt, exactly as if the program had made them.
  4. If it returns, it must have made at least one request that reached the world (was answered, or performed). Otherwise it fails with action: a receipt must stand for something that happened.
  5. The amount is charged and the program gets Receipt { id, capability, method }.

If the action fails after a request that may have changed something (any request but net.http.get or fs.read, unless it was certainly never sent), the amount may have been spent, so it is charged anyway and the log’s action_failed says "may_have_spent": true. A failure can therefore never be used to spend past a limit; the cost is that a refund refused by the service still counts against max.

uses on the library capability counts its actions; each inner capability has its own uses and only, set by the operator and narrowed by the library.

Crashes

The library action is not itself a journal entry; its requests are. A resumed run runs the action’s code again, and its requests replay from the journal in order, so the action ends exactly as it first did. This depends on the action computing the same requests from the same inputs and answers, which holds because library code has no clock, randomness or model calls. A request that is in doubt stops the run as in section 11, and is resolved the same way.

What the runtime does not check

The runtime checks the limits it is given, against the values the library defines. It cannot check that the library does what its name says. A library is code the operator chooses to trust with keys and network access, as with any library in any language:

Read a library before granting it keys. A library used as a package (section 14) is pinned to its exact text by hash, so it cannot change after it was reviewed.

14. Packages

A project is the folder holding the entry file. Without a staal.json there, a program has no packages and imports only its own files (section 11 and the README).

Manifests

The project’s staal.json names its dependencies in one of three ways:

{ "registry": "https://github.com/example/staal-registry",
  "dependencies": {
    "sms":   { "git": "https://github.com/laurells/staal-sms", "commit": "<40 lowercase hex digits>" },
    "email": { "git": "https://github.com/laurells/staal-email", "version": "^1.2" },
    "chat":  { "version": "~0.3.1" } } }

A location is https://, ssh://, user@host:path, file:// or an absolute path. Plain http:// and git:// are refused. A branch name, or a tag given as a commit, is refused: a dependency names one commit, or a range of versions that staalc fetch resolves to one commit and locks.

A version is Semantic Versioning 2.0.0 (MAJOR.MINOR.PATCH, optionally a pre-release such as -rc.1); build metadata (+...) is refused. A range is comparators separated by commas, all of which must hold: 1.2.3 or ^1.2.3 (at least 1.2.3, below 2.0.0; for 0.x, below the next minor version, and ^0.0.3 is exactly 0.0.3), ~1.2.3 (below 1.3.0), =1.2.3, >=1.2, >1.2, <2, <=1.4, and wildcards 1.*, 1.2.*, *. A pre-release version is chosen only by a range with a comparator naming a pre-release of the same MAJOR.MINOR.PATCH (^1.3.0-rc.1 allows 1.3.0-rc.2, never 1.4.0-beta). The registry is the project’s choice: a package’s manifest cannot name one, and a package’s own dependencies on the registry use the project’s.

A package is a git repository with a staal.json at its root:

{ "package": "sms", "version": "0.1.0",
  "authority": { "net.http.post": ["https://api.ng.termii.com/api/sms/send"] },
  "dependencies": { ... } }

package must be the name the depending project uses. version, when given, must be a version; a package fetched by version range must give it. authority is required ({} for none). Each key is a capability path; the value is "any", or a non-empty list of only patterns, checked like a grant’s (canonical, no * in a host). Package names are lowercase letters, digits and _, starting with a letter; net, fs, db and ffi, the runtime’s own capability families, are refused. Unknown keys are refused everywhere.

Fetching and the lockfile

staalc fetch [folder] first resolves the dependency graph: every package gets one commit that fits what everything depending on it asks for. A choice already in staal.lock is kept while it still fits; otherwise the newest fitting version is taken, skipping yanked ones. The resolver never tries older versions of the packages that ask: when no version fits every range, it says which packages want which ranges and which versions exist, and stops; an exact version ("=1.2.0") on one of them chooses another. staalc update [folder] forgets every locked choice, and --package <name> only that one, so they move to the newest versions that fit.

It reads each chosen commit from git’s objects (a bare clone, then the commit’s tree and blobs; never a checkout, so line-ending settings, attributes and filters cannot change a byte). Then it refuses, before changing anything:

Only then are packages written to .staal/packages/<name>/ and staal.lock replaced. The lock (version 2; version 1 is still read) records, per package, its location, commit, version, registry if it came from one, and the SHA-256 of every file. fetch prints each package with its version and authority, marked new, unchanged or was <old version and commit>, for review.

Registries

A registry is a git repository: staal-registry.json ({"staal_registry": 1}) at its root and packages/<name>.json for each package, listing every published version with the git location and commit it is, the SHA-256 of its files (of a line <file SHA-256> <path> per file, in path order), and whether it is yanked. staalc fetch reads the registry’s default branch. Since each version names a commit and a digest of its files, the registry cannot be made to serve other files for a version without its own history changing, and a locked version whose entry changed stops the fetch.

staalc publish --registry <folder> --git <location> --commit <id> adds a version to the registry whose working tree is <folder>, as a commit there: it reads the commit from git as fetch does, takes the name and version from its staal.json (a version is required), records the digest, and refuses a version already published, since a published version never changes. A new, empty repository becomes a registry at its first publish. staalc yank --registry <folder> --package <name> --version <v> [--undo] marks a version yanked: no project chooses it again, but a project that locked it keeps it. Both leave the commit for the operator to push. Whoever can push to the registry can publish under any name: a public registry needs every change reviewed.

Loading

Before a program is checked or run, the lock must match staal.json: every dependency, and every package’s own dependencies, locked at what it asks for (that commit; or, for a range, a version within it from that location or the project’s registry), and nothing else; and each package’s staal.json must give the version the lock records. Every package folder must hold exactly the files the lock lists, each with its hash, and only plain files (a link, even to identical content, is refused). Files are read once, verified, and the verified text is what the compiler reads. Any mismatch stops before anything is checked (exit 2), naming the file.

Imports

In the program, import sms.termii means module termii of package sms when sms is a dependency; the program must not also have a file or folder named sms. The program cannot import a package it does not depend on itself, even one another package uses. Inside a package, an import whose first name is one of that package’s dependencies means that package; any other import is the package’s own module (import util in sms is sms/util.staal, never the program’s util). Qualified names carry the package (sms.termii.send), so nothing in two packages, or in a package and the program, can be confused.

Authority (E108)

Every capability type written in a package’s code (parameters, return types, let annotations, and a declared capability’s needs) must name a path the manifest lists. If the manifest lists patterns for it, the type must carry an only whose every pattern is within them. Since a capability passed into a slot with only is narrowed to it (section 6), the package can never act beyond its manifest, whatever the caller grants. A capability a package declares must start with the package’s name. The program’s own code is not limited by a manifest: the operator’s grants limit it.

Journals

The program hash a journal records (section 11) covers every package module’s text and each package’s location and commit, so a run cannot resume on different package code.

What is not checked

staalc check and run trust staal.lock: whoever can edit both the lock and .staal/packages/ decides the code, as with any language’s lockfile. staalc fetch compares both with git. Git’s commit ids are SHA-1; the SHA-256 file hashes in the lock are what a later fetch is compared with. Manifests describe authority; they do not prove a package does what its name says.

15. Engines and native code

A run uses one of two engines, chosen for the run (--engine native on the command line, engine in the C interface), never in the run config.

A function qualifies when its parameters and result are Int, Bool, Text, Money, records whose fields are all of these, enums whose variants’ fields are all of these, optional values, tuples, maps or sets of these, or lists of these, and its body uses only let, set, if, match, repeat, loop, for, break, continue, return with a value, fail, literals (an integer must fit an Int; a list must not be empty), arithmetic, unary -, comparisons, and, or, not, field reads, record and variant literals, some(...), none, tuples, map literals, empty typed literals, indexing, the built-in functions other than header, fill_secret, print, now and random_int (the standard library’s included: map and the others that take a function value call it as native code), calls to other qualifying functions, function values of qualifying functions (whose types are fn(...) of the types above), and calls of function values. A function that calls one that does not qualify, or makes one that does not qualify into a value, does not qualify. A function value given to native code from the interpreter whose function (or that of any function value it captured) does not qualify makes that call run on the interpreter, so native code only ever calls compiled functions. Native code is entered only from the interpreter, when it calls a compiled function; model calls, actions, try, parse, validate, HTTP, files, budgets, printing, the clock, random numbers and the journal therefore never run in native code.

Both engines give the same results, failures (kind and message), logs and journals. Native code:

Because the engine is not in the config, a journal does not record it: a run that crashed on one engine resumes on the other.

The interpreter stays the reference. The tests run every runtime case on both engines and compare results, logs and journals, and compare generated programs on both (README).

16. The C interface

libstaalc (built as a C dynamic library alongside the Rust library) exports the functions declared in include/staal.h. Each takes UTF-8 strings, borrowed for the call, and returns a JSON object as a string the caller frees with staal_free:

Function Does status
staal_check(program_path) checks a program with its modules and packages ok, invalid, error
staal_run(program_path, config_json, journal_path, engine) runs it, with a new journal if journal_path is not NULL done, failed, stopped, invalid, error
staal_resume(program_path, config_json, journal_path, engine) resumes a journaled run as staal_run
staal_resolve(journal_path, resolution_json) resolves an action in doubt: {"performed": "<receipt>"}, {"status": 200, "body": "..."}, {"failed": "<reason>"} or {"retry": true} resolved, error
staal_version() the version, a static string not to be freed  

done carries result; failed and stopped carry failure (kind, message); all three carry log. stopped means resolve (if an action is in doubt) and resume, as exit code 3 does on the command line. invalid carries errors, each with code, file, line, col and message. error means nothing ran: a NULL or non-UTF-8 argument, an unreadable file, invalid JSON, a bad config, journal or engine, or a panic inside the library, which is caught and never unwinds into the caller. Runs share no state and may be made from several threads. Never use one journal for two runs at once: the journal’s lock is tested between processes (section 11), not between threads of one process.

17. Executables

staalc build <file.staal> -o <executable> [--runtime <libstaalc.a>] makes a standalone executable (Linux only, for now; elsewhere it refuses):

  1. The program is checked as by staalc check, packages and authority included. A program with errors is not built (exit 1).
  2. Every function that qualifies for native code (section 15) is compiled into an object file. Code is generated for the baseline of the target processor family, not for features of the build machine, so the executable runs on any machine of that family.
  3. The object file also holds the program’s source files (the entry file, every module and every package module, with how each import was resolved), the SHA-256 of the program’s fingerprint (section 11), the names of the compiled functions, and the constants and record layouts their code uses.
  4. It is linked with the runtime, libstaalc.a (found next to staalc, or given with --runtime), using cc.

The executable takes run, resume and resolve as staalc does, without the program file, with the same options (except --engine) and exit codes. When it starts, it loads and checks its program again from the sources it carries (package authority is not checked again: it needs the manifests, and was checked when it was built), and refuses to run (exit 2) unless the program’s fingerprint matches the one recorded and its native functions are exactly the compiled ones. Functions without native code run on the interpreter.

Its journals are bound to the program’s fingerprint, as staalc’s are, and not to how it is run: a run started by the executable can be resumed by staalc resume with the same program, and the other way round.

18. Concurrency: parallel

parallel(list, f, a, b) calls the declared function f as f(item, a, b) once for each item of list, all at once, and gives the results as a list in the list’s order (List<R, N> for a list of at most N items and f giving R; nothing, when f gives nothing). f is a function’s name, m.f from a module, not a function value: a function value only computes, while a task may call models and act through the capabilities it is given. What follows f is names and literals, evaluated once, so nothing is done once per task but the call itself.

19. C libraries: cap ffi.<library>.<function>

A program can call a function of a C library, only through a capability the operator grants, and every run granted one is marked unchecked: C code can do anything the process can, so the broker cannot check what it does.

Declaring

foreign ffi.zlib {
    fn crc32(crc: u64, data: text, length: u32) -> u64
    fn zlibVersion() -> text
}

fn checksum(c: cap ffi.zlib.crc32, data: Text) -> Int {
    return c.crc32(0, data, len(data))
}

Each function of foreign ffi.<library> is the capability ffi.<library>.<function>, called as c.<function>(...). The library’s name is lowercase letters, digits and _; a library is declared once in a program, each function once, with at most six parameters. A cap ffi.* names a declared function (E101 otherwise). The C types and the Staal values they take and give:

C type Staal Passed Returned
i8, i16, i32, i64 Int must fit the type, or the call is refused sign-extended
u8, u16, u32 Int must fit the type zero-extended
u64 Int 0 to 2^63-1 must be at most 2^63-1, or the call fails
bool Bool 0 or 1 any nonzero low byte is true
text Text a NUL-terminated copy, valid during the call only; text with a NUL is refused a NUL-terminated string, copied (at most 1 MiB, UTF-8) and never freed: for strings the library keeps, such as a version
void Unit (not a parameter) the default without ->

There are no floating-point numbers, pointers, buffers, structs, callbacks or variadic functions. With only these types every argument travels in an integer register, extended to 64 bits as its type requires, which is what the C calling conventions of x86_64 and aarch64 on Linux and macOS expect; on any other machine a grant is refused. Nothing checks the declaration against the library: a wrong declaration is wrong code, which is what makes the run unchecked.

Limits on the capability: uses only. max and each (nothing measurable is spent) and only (there is no target) are refused (E101, or a config error on a grant). What a C function returns is untrusted (E006). An Outcome of try around one holds no receipt (E008). A package may declare only foreign ffi.<its name>, and must list each function it uses as "ffi.<name>.<function>": "any" in its authority (E108). Library capabilities cannot be named ffi.*.

Granting

"crc": { "cap": "ffi.zlib.crc32",
         "library": "/usr/lib/x86_64-linux-gnu/libz.so.1.3",
         "sha256": "<64 lowercase hex digits>",
         "repeatable": true }

library is the file’s absolute path and sha256 its SHA-256, both required. When the run starts, before anything else, the file is opened, refused if it is not a regular file or if any user may write to it, read, hashed, and refused unless the hash matches; then it is loaded and every granted function looked up. Any failure is a config error (exit 2): nothing runs. On Linux the library is loaded through the descriptor that was hashed (/proc/self/fd), so what is loaded is what was hashed even if the path is changed meanwhile; on macOS it is loaded by path after hashing, and a file replaced in that moment would not be noticed. Only that file is pinned: libraries it depends on are found by the system’s loader as usual, and a library that finds others relative to its own location may not find them. Loading runs the library’s initialisers. A library is never unloaded.

Unchecked

For each ffi.* grant, the run’s log holds, before anything the program does, an event {"event": "unchecked", "capability", "library", "sha256", "why"}. staalc run and resume print this run is unchecked: it loaded C code for ... to standard error; the C interface’s result carries "unchecked": [capabilities]; and RunResult::unchecked() lists them. staalc doc shows foreign declarations under “C libraries (unchecked)”.

Calls and the journal

The arguments are checked against the declaration before anything is recorded. A call is then an action: authorized (uses), journaled with a hash of its arguments and of the library, made, and its value recorded, so a resumed run replays it without calling C. Calls into one library are made one at a time, whichever tasks make them, since whether its functions are safe to call from several threads is not known.

A call cut short by a crash (including a crash inside the C function) is in doubt. It is made again on resume only when the grant says "repeatable": true (the function changes nothing, or changing it twice is harmless). Otherwise resume stops, and staalc resolve accepts only --failed <reason> or --retry: the value a C function returned was seen by nobody outside the process, so it cannot be recorded for it.