ral(1) — language specification
1. About this specification
This document defines the observable behaviour of ral programs and the ral
shell. It is a reference. The tutorial teaches the language step by step.
The specification covers three surfaces:
- the core language, which every ral host must provide;
- the standard surface provided by the
ralprogram; - features that depend on a host or an operating system.
A host can add builtins. Such a builtin is part of that host, not part of the portable core. A platform note changes only the rule that it names. All other rules still apply.
This document specifies results that a program can observe. It does not specify Rust modules, internal process layouts, or implementation algorithms. The code is the authority for the current implementation.
1.1. Normative terms
The words must and must not state requirements. May states a permitted choice. “Is an error” means that ral must reject the program or operation. A rule states the rejection phase when that phase is observable.
Examples show the rules but do not replace them. The formal definition is authoritative for grammar and types. The plain-language sections are authoritative for observable runtime behaviour.
1.2. Reading paths
Read the core model first. Then read the sections on values, bindings, commands, pipelines, and failure to write ordinary programs. Read the later sections when you need modules, concurrency, capabilities, the interactive shell, or platform integration. The final formal section is for implementers and readers who want the type and evaluation rules.
2. The core model
ral keeps data separate from work. This distinction explains most of the language.
- A value is data. Strings, numbers, lists, records, and blocks are values.
- A command is work. It can return a value, write bytes, change permitted shell state, or fail.
ral checks how values and commands fit together before it starts the program. Every type is inferred; the language has no syntax for writing one.
2.1. Values do not run by themselves
A quoted string, a number, or a collection is already a value:
'hello'
42
[host: 'example.net', port: 443]
let runs its right-hand side and binds the result:
let answer = 42
let host = hostname
The first line binds the integer 42. The second line runs hostname and
binds its text result. Bindings are immutable. A later let can shadow an
earlier binding for the rest of its scope.
Use $ when you want the value bound to a name:
echo $host
Without $, a word in argument position is text. For example, echo host
passes the string "host" to echo.
2.2. Blocks make work into values
A block stores a command without running it:
let now = { date +%s }
Use ! to run stored work where a value is needed:
let timestamp = !$now
let inline = !{date +%s}
!$now runs a block held in a name. A command's value is its output, so let
binds what date writes, whether the command is written inline, as in
!{date +%s}, or reached through a name. | from-line and its siblings choose
another decoding.
A block can take parameters:
let greet = { |name| "hello, $name" }
let message = greet 'world'
In command position, ral recognizes the bound name greet and applies the
block. In other positions, use $greet to pass the block itself as data.
Blocks remember the bindings that were visible when they were created.
2.3. Commands have two results
A command can produce two independent things:
- A return value for ral.
- A stream of bytes for a terminal, file, or pipeline.
An external command usually writes bytes. A command's value is its output:
where a value is wanted, as on the right-hand side of let, ral captures the
command and decodes its output as UTF-8 text:
let branch = git branch --show-current
If the bytes are not UTF-8, decode them explicitly or keep them as Bytes.
A command that returns another value binds that value directly.
The | connects a stage's standard output to the next stage's standard input.
It carries bytes and nothing else: every stage but the last feeds the next by
writing, and its own value is not used. The final stage's result is the
pipeline's own and may be any value.
2.4. Pipelines carry bytes
A pipeline uses an operating-system byte channel at every |:
printf 'one\ntwo\n' | wc -l
Stage position is the whole of the connection, and neither side is obliged to
use it: a stage may write nothing, and a stage may never read. A value never
travels through |. Compose values by ordinary application, such as
length [1, 2, 3], or by binding a result and applying a function to it. Each
non-final stage must be a command that writes, so [1, 2, 3] | length is a
type error: a value in stage position writes nothing to the pipe. Codecs such
as from-json and to-json cross explicitly between bytes and values. A
decoder ends the byte pipeline: from-json returns a value and writes nothing,
so it may be the final stage, as in cat data.json | from-json, but never a
non-final one.
2.5. Failure is not false
A command either succeeds or fails. An unhandled failure stops the remaining
commands. ? runs a fallback only after failure:
cat 'optional.conf' ? echo 'default'
A Boolean is ordinary data. Returning false is still a successful command,
so it does not select the right side of ?. Use if to branch on a Boolean,
and use try when code must inspect or recover from a failure.
2.6. A session remembers; a block is contained
At the session level, completed changes remain available to the next run. This includes bindings and the shell's current directory. Changes made before a top-level failure remain too.
A block is a boundary for bindings: a let made inside it does not escape
when it returns, and neither does anything else lexical — the block's value,
output, failure, and recorded observations are what crosses. A directory
change, a module load, or any other unbracketed shell-state write made inside
a block persists past it exactly as it would outside one; a block is not a
boundary for the shell's state.
within and grant are the scoped forms: within temporarily changes the
directory, environment, or command handlers for its body's whole extent, and
grant temporarily reduces authority the same way. Code called inside either
scope sees that scope, even if the code was defined elsewhere, and the change
is undone when the scope ends — under within [dir: …], together with any
cd made inside it.
2.7. One complete example
let salutation = 'hello'
let greet = { |name| "$salutation, $name" }
let message = greet 'world'
echo $message | tr a-z A-Z
This program does the following:
- It binds a string value to
salutation. - It binds a parameterized block to
greet. The block rememberssalutation. - It applies
greetand binds the returned string tomessage. - It reads
$message, writes it as bytes withecho, and streams those bytes through the external commandtr.
The output is:
HELLO, WORLD
The following sections define each of these rules precisely.
3. Source text and program structure
A ral program is a sequence of statements. A script can use a newline or a
semicolon (;) to separate statements.
echo 'first'
echo 'second'; echo 'third'
An empty program does nothing and succeeds. Blank lines and extra semicolons between statements have no effect.
3.1. Source text
Source text must be valid UTF-8. When ral loads a script, a module, a -c
program, or a program read from standard input, it converts Windows line
endings (CRLF) and old-style CR line endings to LF. Source therefore has
the same meaning on every platform, however it was supplied.
Spaces and tabs separate words. They do not separate statements. A newline usually ends the current statement. A semicolon always ends it.
There is no general backslash continuation. A backslash outside a double-quoted string is an ordinary character. Double-quoted strings have their own escape and line rules; see Strings.
3.2. Comments
# starts a comment when it occurs where a new word could start. The comment
ends at the next newline or at the end of the source.
# A complete comment line.
echo 'ready' # A comment after a command.
curl http://example.test/page#part
The last # is part of the URL. It does not start a comment because it occurs
inside a word.
A run of # characters followed immediately by ' starts a raw string, not
a comment. For example, #'can't'# is one string. See Strings for the full
rule.
ral has no block comments. A comment does not cancel an open delimiter. In an
interactive session, ral asks for more input when a comment follows an open
{ or [. In a complete script, an unclosed delimiter is an error.
A script can start with a conventional interpreter line:
#!/usr/bin/env ral
ral reads this line as a comment.
3.3. Newlines and continuation
A newline can continue a statement only in the cases in this section. A semicolon cannot continue a statement in any of these cases.
Pipelines
| can occur before or after a newline. Blank lines and comment lines are
also allowed at that point.
generate
| transform |
consume
These three lines form one pipeline. The following text is an error because a semicolon ends the first statement:
generate; | consume
Failure chains
? can occur before or after a newline, exactly as | can. Blank lines and
comment lines are also allowed at that point.
read-primary
? read-backup
Bindings and control forms
The right-hand side of a let binding can start on a later line when the
newline follows =.
let report =
build-report
The condition and body of if and elsif can start on later lines. elsif
and else can also start on the line after the preceding branch. A case's
scrutinee and its arm list can be separated by newlines, and so can its arms.
if
$ready
{ deploy }
else
{ explain-delay }
case
$result
[`ok: { |value| use $value }, `err: { |error| fail $error }]
A newline after return ends that statement. It does not attach the value on
the next line.
return
42 # a new statement
Command arguments do not continue onto the next line merely because the next line is indented. Bind a long argument list to a value and spread it when you need to split such a command across source lines.
3.4. Braces, brackets, and parentheses
Braces ({ ... }) contain a block. Newlines inside a block still separate
statements.
Brackets ([ ... ]) contain a collection, pattern, index, or expression
block. Newlines are whitespace while the bracket is the innermost open
delimiter. Collection entries still need commas; a newline does not replace a
comma.
let ports = [
8080,
8081,
]
let prompt = [render: {
let mark = '>'
return $mark
}]
The block in the second example is inside a map, but its own braces are the
innermost delimiters. Its newline therefore separates the let and return
statements.
Parentheses have three uses: () alone is the unit literal (§4.1), they
group expressions inside $[...], and they delimit names in $(name). They
are not a general command-grouping form.
Postfix indexing requires no space before [. Thus $item[key] is one
indexed value, while $item [key] is two values in a command. A ! reaches
over a dereference's keys, so !$item[key] forces the field; any other
primary is forced first and indexed after, so !{cmd}[key] selects from the
forced result.
ral reports mismatched or unclosed delimiters. It also rejects source with more than 64 nested forms. This limit prevents an implementation stack overflow.
3.5. Words and punctuation
A bare word is source text that needs no quotes. Most visible characters can occur in a bare word. Spaces, tabs, line endings, and these characters end a bare word or start another language form:
| { } [ ] $ ^ ! < > " ' ` ( ) ;
Some characters depend on their position:
#starts a comment only at the start of a word. It remains literal inside a word.~at the start of a word, standing alone or followed by a path separator (/, or\on Windows; see below), abbreviates the current user's home directory, whichhomereturns (§10.2):~/srcis"!{home}/src". Anywhere else~is an ordinary character, so neitherfoo~barnor~bob/xnames a home directory.,separates items while[...]is the innermost form. Outside brackets, it can be part of a word, as in--features=a,b.:is punctuation before whitespace, a newline,], or the end of input. It remains part of forms such aslocalhost:5432.?and&are punctuation where a new word can start. They can occur inside a longer bare word.?is the failure chain; a lone&is always refused (§11.1), and so are the shell spellings&&and||, whose jobs here belong to;and?.- Inside
$[...], and only there,<,>,<=,>=,!=,&&, and||are operators rather than redirects and punctuation, and the word characters+,-,*,/,%, and=end a word, so$[1+1]is a sum and needs no spaces (§17.1). A numeral is still read whole, exponent sign included:$[1.5e+3]is a number. Outside$[...]all of these are word characters or shell punctuation:1+1is a word anda >= bwrites to a file named=. ...is the spread marker where a new word can start. It can occur inside a longer word.
Quote a word when there is any doubt. Quoting prevents punctuation in its contents from changing the program structure.
Whitespace separates words, and nothing joins them. Two words written with
nothing between them, such as --prefix=$d or 'a'"b", are an error, not
two arguments and not one: write --prefix= $d for two, or "--prefix=$d"
for one. Only the grammar's own attachments touch: an index after a $name
or a forced block ($h[k], !{f}[k]), a spread before its list (...$xs),
and a redirect before its target (>file). Except for redirects, these
attachments must touch: ^ls, ...$xs, !$x, $h[k]. A space after ^,
..., or ! is an error. The rule holds in every position, not only in a
command's arguments: if $c{a}, try{a}{b}, and [a'b'] are errors like
--prefix=$d.
Backslash escapes nothing in a bare word. For example, C:\Users\name is one
bare word. A dot is also ordinary, so .env and archive.tar are bare words.
A bare word containing a path separator is a path, and as a command head it
runs that path directly (§6.2). / is a separator on every platform; \ is
one on Windows only. So on Windows C:\Users\name and .\build are paths,
while on Linux and macOS a\b is an ordinary word, which as a command head is
a bare name searched on PATH.
3.6. Names and reserved words
A binding name starts with an ASCII letter or _. Later characters can be
ASCII letters, digits, _, or -. Names are case-sensitive.
These words cannot be binding names or block parameters:
if elsif else let return case
within grant try guard audit spawn gather
true false
This restriction is contextual. For example, a quoted occurrence is always
string data, and ^name uses an external command name rather than a binding.
3.7. Statements and stages
A statement is either a let binding or a command chain. A command chain can
contain pipelines joined by ?. A pipeline contains one or more stages joined
by |.
let is a statement form. It cannot occur after | or ?.
let result = produce | consume
produce | let result = consume # error
produce ? let result = fallback # error
The complete formal grammar is in §17.
4. Values and data
A value is data. ral does not split it into words or read it again as source text. For example, a string that contains spaces remains one value and one command argument.
ral has these kinds of value:
| Kind | Meaning |
|---|---|
Unit |
One value that carries no information |
Bool |
true or false |
Int |
A signed 64-bit integer |
Float |
A finite 64-bit floating-point number |
String |
Unicode text |
Bytes |
An arbitrary finite sequence of bytes |
List |
An ordered sequence of values |
| record or map | Values stored under string keys |
| variant | A tag with an optional payload |
| block or function | A suspended command |
| handle | A reference to concurrent work |
Blocks, functions, and handles are values, but later sections define how to run or inspect them.
4.1. Scalar literals
The literals true and false produce Bool values. () is the unit
literal, producing the one Unit value. It is punctuation rather than a
word, exactly like [] and [:]; the bare word unit is ordinary text. A
word shaped like a numeral produces a number:
numeral ::= integer | float
integer ::= sign? digit+
float ::= sign? ( digit+ "." digit* | digit* "." digit+ ) exponent?
exponent ::= ("e" | "E") sign? digit+
sign ::= "+" | "-"
digit ::= "0" … "9"
An integer produces an Int, a float produces a Float. A float must carry
a decimal point, and digits on one side of it are enough, so .5 and 2. are
both floats. An exponent alone is not: 1e6 has no decimal point, so it is a
word. There are no digit separators and no other base — 1_000 and 0x10 are
words. Leading zeros and a leading + are allowed and carry no meaning.
A numeral outside the range of its kind is a word instead. An Int must fit a
signed 64-bit integer, so 9223372036854775808 is text. Every Float is
finite, so a spelling that would overflow to infinity, such as 1.8e308, is
text too. The float conversion likewise rejects NaN and positive or negative
infinity, and floating-point arithmetic fails if its result would be
non-finite.
return ()
return true
return 42
return -7
return 3.14
return 1.0e6
An unquoted word that is not a numeral is a String. Thus return hello
returns the text hello.
Numerals denote numbers in every position
This reading does not depend on where the word stands. A numeral is its number
as a command argument, as a bound value, as a redirect target — everywhere a
word may appear. 007 is the number 7 and 1.50 is the number 1.5, wherever
they are written.
Each number in turn has exactly one printed spelling: the shortest decimal that
reads back as the same number. A Float keeps its decimal point, so 3.0
prints as 3.0 rather than 3, and it takes exponent form where plain digits
would run away, so the number ten to the three hundredth prints as 1.0e300
rather than as 301 characters. A
canonical numeral therefore crosses the shell unchanged, and a non-canonical one
is normalized on the way out:
| Written | Printed |
|---|---|
007 |
7 |
+5 |
5 |
-0 |
0 |
.5 |
0.5 |
1.50 |
1.5 |
3.0 |
3.0 |
1.50e300 |
1.5e300 |
Every printed number is itself a numeral of the grammar above, and reading it
back gives the number it came from. Printing and reading therefore compose to
the identity on numbers, and a printed float never degrades into text. The
grammar is unchanged by this: 1e6 still has no decimal point and is still a
word — it is the printer that never produces such a spelling.
Quote a word to keep its bytes: '007' is three characters and '1.50' is
four. Wherever ral renders a String back as source text — a completion, a
quoted argument — it quotes a numeral-shaped one for the same reason. The edge
worth knowing is a version-like token that is also a numeral: 3.10 is the
number 3.1 and prints as 3.1; write '3.10' when the digits name something
rather than count it.
4.2. Strings
A String contains valid UTF-8 text. length counts Unicode characters, not
UTF-8 bytes.
Single quotes make literal text. They do not process escapes or interpolation:
return 'literal $name and \n'
Add matching # fences when the text contains a single quote. Add more #
characters when the text contains a possible closing fence:
return #'it's literal'#
return ##'body with '# inside'##
Literal strings may span lines. Their contents are exact after source-text
normalization. A line ending written as CRLF or a lone CR in loaded source
therefore appears as LF in the value.
Double quotes process escapes and interpolation:
return "hello $name"
return "field: $record[key]"
return "host: "
return "sum: "
$(name) marks the exact end of a name when the following text could be part
of it. A splice is $name, $(name), $[...], !{...}, or !$name, and
means what the same text means outside the string. A $ or ! that opens
none of these is text.
$(name) takes nothing after it, inside a string or out: "$(red)[$host]"
is a colour followed by a bracketed host, and $(red)[x] outside a string
is two words that touch (§3.5). Every other splice is indexed by the
[key] groups written immediately after it, exactly as outside a string:
"$h[k]", "!{f}[k]", and "$[$xs][0]" select from the value.
A double-quoted string that begins with ~ followed by / or by the closing
quote begins with the home directory, exactly as a bare word does (§3.5):
"~/x" and "!{home}/x" mean the same. A ~ anywhere else in the string, and
any ~ in a literal string, is text; "\~/x" begins with a literal ~.
The supported escapes are \n, \r, \t, \\, \0, \e, \",
\$, \!, \~, \xNN, \u{X..}, and backslash followed by a line ending.
\xNN accepts one ASCII byte from 00 to 7F. \u{X..} accepts one valid
Unicode scalar value written with one to six hexadecimal digits. An unknown
or malformed escape is an error.
Interpolation accepts Bool, Int, Float, and String, each in its normal
text form. It rejects (), bytes, collections, variants, blocks, functions,
and handles. () is nothing to print, so it is not a word either: write the
text '()' if that is meant. Use an explicit conversion or select a field
instead.
4.3. Bytes
Bytes can contain any byte, including NUL and invalid UTF-8. There is no byte
literal. Codecs and input operations create byte values. to-bytes writes a
Bytes value through unchanged; ints-to-bytes builds bytes from a list of
integers from 0 through 255, which is how a program spells out bytes without a
literal for them. Section 7 defines byte pipes and the codecs that cross
between bytes and values.
length counts bytes. Equality compares bytes exactly.
Displaying bytes decodes them with replacement characters for invalid UTF-8.
This display is only for inspection; it can lose information. from-string
performs a strict UTF-8 decode and fails on invalid input.
4.4. Lists
A list is ordered. Its elements must have compatible types. Use commas between elements. A trailing comma is allowed.
let names = ['Ada', 'Grace', 'Edsger']
let empty = []
... inserts the elements of another list at that position. It creates a new
list and does not change the source list.
let middle = [2, 3]
return [1, ...$middle, 4]
Spreading a value that is not a list is an error. A literal's parts are evaluated left to right, and a failing spread fails where it is written.
4.5. Records and maps
Records and maps use the same runtime value: a string-keyed collection. They differ in what the checker knows.
- A record has known fields. Its fields may hold different kinds of value.
- A map has keys computed at runtime. Its values must have compatible types.
let server = [host: 'db.example', port: 5432]
let key = 'primary'
let ports = [$key: 5432]
let empty_map = [:]
A key is a label or data, and its form says which. A label is a bare name or
a 'single-quoted' word. A data key is a string: "double-quoted", whether
or not it interpolates, or a value such as $key. Any computed key that is
not a String is an error. Keys are unique in the resulting value and iterate
in sorted order. A tag is not a key, in a literal or in a pattern (§4.6).
[:] is the empty map, and the only literal that opens with :. Otherwise a
literal's keys decide. A literal every one of whose keys is a label is a
record; a literal with any data key is a map, because a keyset that is data
cannot carry a type per key; a literal with no entry at all is a list. So
["a": 1, "b": 2] is a map on keys written out, where [a: 1, b: 2] is a
record.
There is no empty-record literal: [] is the empty list, not [a: 1] with
its fields removed. A contract file (an rc file, a plugin manifest, a
capability profile; §12.12, §15.3, §15.5) that has nothing to set returns
(), which it reads as the empty keyset; one that returns a record is
ascribed its declared keyset before the file runs at all, and one that returns
a map or [] is refused there, with the spelling to use.
let record = [host: 'db', port: 5432]
let by_data = ["host": 'db', "port": 'https']
let computed = [$key: 5432]
In a map literal every explicit entry wins over every spread entry, regardless of position, and if spread entries conflict with each other the first spread entry wins. Writing the same static key twice in one literal is an error, so within a literal the earlier entry always wins; a duplicate arising from computed keys, which the checker cannot see, produces a warning and the last entry wins.
A record literal with a spread is an update of that one base: [...$r, k: v].
The ... comes first and there is only one; a spread after a field, and a
second spread, are parse errors. The fields written after it replace fields
the base already has, each at whatever type it is written, and the rest of the
base keeps its place in the result. A spread never adds a field: naming one
the base lacks is refused, with the three ways to carry a new field: give the
record the field from the start, nest the record ([cfg: $cfg, total: 3]), or
make $cfg a map ([...$cfg, "total": 3]). Nothing is appended after an unknown
remainder, so defaults are not merged over an unknown record; absence over an
unknown record travels as a variant.
Spreading a value that is not a record or map is an error. Spreading does not change the source value. A literal's parts are evaluated left to right, and a failing spread fails where it is written.
4.6. Variants
A variant records one named outcome. It can carry no payload or one value:
let absent = `none
let answer = `ok 42
let problem = `error [message: 'not found']
The tag takes the next value atom as its payload. So two tags in a row nest:
`a `b is one value, `a carrying `b. To pass two tags as two
arguments, bind the first: let a = `a and then f $a `b. A statement
boundary, comma, closing bracket, pipe, ?, or redirect leaves it without a
payload.
Section 8 defines case, which chooses one arm by tag.
A tag says which of several alternatives, and a key says which part of one
value, so the two alphabets belong to different type formers and never meet. A
tag keys nothing: [`dev: 8080] is a parse error, in a literal and in a
pattern alike. The one place a tag stands before a : is a case arm (§8.3),
which is syntax rather than a record.
4.7. Indexing
Indexing is postfix syntax. The [ must immediately follow the value being
indexed.
$items[0]
$server[host]
$config[database][port]
Lists use zero-based, non-negative Int indices. Records and maps use String
keys. A key written out selects a record field, or reads that key when the
value is a map, and then has the map's element type; which it is follows from
the value's type, never from statement order, and a block's parameter that
nothing else fixes is read as a record. An integer literal ($xs[0]) indexes
a list. Any other key ($m[$k]) is computed: an Int indexes a list and a
String a map, and the container type, the key type and the element type are
fixed once for the whole program, so a helper that indexes with a computed key
is accepted at the one container type its program uses and refused when
nothing fixes which. The prelude's get $m key default supplies a fallback.
env returns a map of strings, so !{env}[HOME] is a String.
A missing key, an out-of-range list index, a key of the wrong kind, or an
attempt to index another value kind is an error.
4.8. Equality and ordering
== and != inside $[...] compare data structurally.
The two operands have one type, and it is data (§17.3): no block, function
or handle at any depth.
- Unit, booleans, strings, and bytes compare by value.
- Numbers compare numerically.
- Lists compare their elements in order.
- Records and maps compare their key-value pairs without regard to insertion order.
- Variants compare their tag and payload.
Blocks, functions, and handles have no equality operation. Comparing one is a
static error, including when it is nested in a collection, and so is comparing
values of two different types: $[42 == hello] is refused, and $[3 == 3.0]
too, since the checker keeps Int and Float apart.
Ordering is defined only for numbers and strings, and both operands have one
type: <, >, <=, >=, lt and gt refuse a list, a record, a Bool, and
a number against text. Integer-to-integer ordering keeps the full 64-bit integer
precision. String ordering is lexicographic. + - * / take two numbers of one
type (Int or Float) and % two Ints; "a" + "b" is refused, and joining
text is interpolation.
4.9. Display
ral has a text conversion for command output and a value renderer for interactive hosts. They serve different purposes.
Text conversion writes strings without quotes and every other scalar value in
its usual form. It shows lists, records, maps, and variants with brackets and
tags. It shows bytes as lossy UTF-8, and a function, a block, or a handle in an
abbreviated form such as <|x| block>. str and to-string expose it: it is
total on data, every value having a text conversion. This form is readable but is
not always valid ral source and is not a serialization format.
Only text, numbers and true/false go into an interpolated string
("n: $n"), and () is nothing to print: neither a word nor an interpolant.
A list, a record or a map is rendered with !{to-json $x}, a list of text joined
with !{intercalate ', ' $xs}, and the text () is written '()'.
The interactive value renderer quotes strings with a safe hash fence, writes
() for Unit, and lays out collections to fit the available width. Maps
display in sorted key order. A host may shorten deeply nested collections or a
long string stored directly under a map key. It must mark any omitted content.
Lists and top-level strings are not shortened by the standard ral renderer.
5. Bindings, patterns, blocks, and functions
let gives a name to the result of a command or to a value.
let branch = git branch --show-current
let port = 8080
let greeting = 'hello'
The first binding runs git and binds its result. The other two bind values
without running an external command. Use $name to read a binding outside
command-head position.
echo "$greeting from port $port"
A bare name in command-head position follows the lookup rules in Commands
and name lookup. This is why let branch = git ... runs git, while
let greeting = 'hello' binds a quoted value.
5.1. Immutable bindings and scope
A binding cannot be changed. A later let with the same name creates a new
binding that hides the old one in the current scope.
let colour = 'blue'
let colour = 'green'
echo $colour # green
Bindings use lexical scope. Code uses the bindings that were visible where that code was defined.
let rate = 10
let add-rate = { |n| return $[$n + $rate] }
let rate = 20
add-rate 5 # 15
The function keeps the first rate. A binding at its call site cannot change
that captured value.
Every name is known before the program runs. Reading $name where no binding
or builtin of that name is in scope is a static error, not a failure at run
time, and ral suggests the nearest names that are in scope. Environment
variables are not names: read them as !{env}[NAME].
echo $colur # error: undefined variable: $colur
# help: did you mean `$colour`?
A - inside a name continues it, so "$os-gnu" reads $os-gnu; "$(os)-gnu" ends it.
(A trailing - does not: "$os-$arch" is $os, a -, and $arch.)
echo "$os-gnu" # error: undefined variable: $os-gnu
# help: `$os` is defined, and `-` continues a name: write `$(os)` to end it there
A binding shadows a command of the same name, at every scope, whatever is on
the current PATH: a program means the same on every host. ^name reaches the
external command.
let dd = { echo mine }
dd # mine
^dd --version # the binary on PATH
Prelude functions and builtins can be hidden the same way, although ral warns when a binding hides a prelude name.
A let is polymorphic: the type of a bound function generalises over every
type variable that the rest of the program does not also mention, so let first
= { |xs| return $xs[0] } can be applied to lists of any element type. The
exception is a weak type variable — the type of a value whose shape the
program did not decide, such as decoded data, or of a computed index. A weak
variable has one type for the whole program, every use of it must agree, and a
type that shows one prints as _α.
5.2. Patterns
The left-hand side of let is a pattern. Function parameters use the same
patterns.
| Pattern | Meaning |
|---|---|
name |
Bind the complete value. |
_ |
Ignore the value. |
[first, second] |
Bind a list of exactly that length. |
[first, ...rest] |
Bind the first item and bind the remaining list to rest. |
[host: h, port: p] |
Read named fields from a map or record. |
Patterns can be nested.
let [first, ...rest] = [10, 20, 30]
let [name: user, address: [city: city]] = $account
One pattern cannot bind the same name twice. ral reports this as a parse error, including when the repeated name is nested or used as a list tail.
A list pattern without ...rest must match the complete list. It is an error
if the list has too few or too many items. A map pattern requires every
field it names. Extra map fields are allowed.
A pattern either binds every name or binds none of them. If any nested part
does not match, ral reports an error and leaves the scope unchanged. try can
catch this runtime error.
Patterns are structural. They cannot test for a literal value. Use if for a
Boolean decision and case for a variant.
5.3. Plain blocks
A plain block stores commands as a value. Creating it does not run its body.
let announce = { echo 'ready' }
Force a block with ! to run it:
!$announce
!{echo 'run this anonymous block'}
! means force. It is not Boolean negation. Use not for Boolean negation
inside $[...].
A bound plain block is also forced when its bare name is the command head. These two statements are equivalent:
announce
!$announce
A block captures lexical bindings when the block is created. Its body runs in
a fresh local scope. Bindings created by the body do not escape. Shell state
is not local: a cd or other unbracketed shell-state write persists (§2.6).
The block's output, result, failure, and audit records remain observable.
let outside = 'outer'
let result = !{
let outside = 'inner'
let local = 42
return $outside
}
echo $result # inner
echo $outside # outer
echo $local # error: no such binding
An empty block returns (). A non-empty block returns the result of its
last statement. Earlier statement results are discarded.
5.4. Functions
A parameterised block is a function. Put one or more space-separated patterns
between the two | characters.
let add = { |x y| return $[$x + $y] }
let describe = { |[name: name, port: port]| echo "$name:$port" }
Do not put commas between parameters. { || ... } is invalid; use a plain
block { ... } when there are no parameters.
Each parameter is a separate pattern. A later parameter can therefore use the same name as an earlier parameter. The later binding hides the earlier one in the remaining function body.
let keep-second = { |item item| return $item }
keep-second 'first' 'second' # second
Call a function by putting it in command-head position:
add 2 3 # 5
describe [name: 'web', port: 443]
Use $function when passing a function as data.
let increment = { |n| return $[$n + 1] }
map $increment [1, 2, 3]
Without $, increment in argument position is the string "increment".
Each argument is applied from left to right. If a call supplies too few arguments, it returns a new function that waits for the remaining arguments.
let add = { |x y| return $[$x + $y] }
let add-five = add 5
add-five 3 # 8
If arguments remain after the function returns, ral applies them to the returned value. This is valid when that value is another function. Otherwise, ral reports a type error or a runtime error.
A function call gets a fresh local binding scope. Its parameters and local
let bindings do not escape. The function still acts in the caller's shell:
for example, a successful cd in a function changes the caller's current
directory, exactly as it does in a forced plain block.
5.5. return
return value produces value as the successful result of the current
statement. return with no value produces (). It accepts at most one
value.
Despite its name, return is not an early-exit statement. It does not skip
later statements in a block, function, or file.
let example = {
return 1
return 2
}
!$example # 2
Use if, case, or a failure to choose which computation runs. ral has no
non-local early return.
5.6. Top-level runs and local bodies
A top-level run is one complete script, one -c program, one submitted REPL
entry, or one exarch tool call. When the host keeps the session open,
successful top-level bindings are available to the next top-level run.
If a top-level run fails, effects completed before the failure still remain,
and so do the bindings it established: a let that landed before the failing
statement stays bound in the session. A binding after the failing statement is
never evaluated.
let before = 1
failing-command
let after = 2
After this failed top-level run, before remains bound in the session and
after does not exist.
Forced plain blocks are local bodies. The bodies used by within, grant,
try, guard, audit, spawn, and gather follow the same local rule. Their
bindings do not become top-level session bindings.
5.7. Recursion and forward references
A simple named block binding can refer to itself.
let countdown = { |n|
if $[$n == 0] { return () }
else { countdown $[$n - 1] }
}
Named blocks in the same statement list can also refer to each other in any source order. This supports mutual recursion and helper functions written after their callers.
let even = { |n|
if $[$n == 0] { return true }
else { odd $[$n - 1] }
}
let odd = { |n|
if $[$n == 0] { return false }
else { even $[$n - 1] }
}
Forward references apply only to simple named bindings whose right-hand side is a plain or parameterised block. A value must still be bound before an earlier statement can read it.
When a name is defined more than once, a reference inside such a block uses the nearest preceding definition. If there is no preceding block definition, it can use the first later block definition in the same statement list. This keeps ordinary shadowing while allowing forward block references.
Tail-recursive calls reuse the current evaluator frame. Non-tail recursion is limited by the configured recursion limit and fails with a clear error if it exceeds that limit.
The formal account of function application, recursion, and inferred types is in §17.
6. Commands and name resolution
A command starts with a head. The head decides whether ral applies a value, invokes a handler, or starts a program. Arguments do not use command lookup.
6.1. Command and value contexts
A statement and the right-hand side of let are command contexts:
hostname
let host = hostname
Both lines run hostname. The second line binds its result.
Arguments, return operands, collection elements, and interpolations are value
contexts:
echo hostname
return hostname
let tools = [hostname, uname]
Here, hostname and uname are strings. Use $ to read a binding:
echo $host
return $host
A command written as a collection entry, [n: length $xs], is refused, and
the diagnostic offers its spelling as a value, [n: !{length $xs}].
A value written alone in command context returns itself. This lets a literal or collection be the result of a block:
{ 'ready' }
{ [host: 'example.net', port: 443] }
6.2. Head forms
The spelling of the head selects one of four paths.
| Form | Examples | Meaning |
|---|---|---|
| Bare head | git status, map $f $xs |
Use the lookup order in §6.3. |
| Path head | ./build, /usr/bin/env, ~/bin/tool, .\build (Windows) |
Run that path directly. |
| Explicit value head | $f 3, !$factory 3 |
Apply the value. Never search for a program. |
| Caret head | ^git status |
Skip value bindings, then continue through handlers and program lookup. |
A head is a path when it contains a path separator: /, or on Windows also
\ (§3.5). A path head does not consult bindings, handlers, bundled commands,
or PATH.
ral expands ~ when it resolves the command. A relative path uses ral's current
logical directory.
A literal word, a bare ~, and a $[...] block are value heads wherever they
stand: let h = ~ binds the home directory, and 42 foo or ~ foo is a type
error rather than a missing command.
An explicit value head stays in the value world. If ral cannot apply it, ral reports an error. It does not reinterpret the value as a command name.
A bare $f with no arguments is only a value:
let copy = $f # store f
let result = f 3 # apply f
let same = $f 3 # apply f explicitly
^name is valid only at the start of a command. Its operand must be a plain
name, not a path. The caret skips user bindings, prelude functions, builtins,
and the entire handler stack — named handlers, base handlers, and catch-all
handlers alike — and executes the external command of that name, subject to
active capabilities. It is, in this respect, the same as a path head such as
./tool or /usr/bin/tool.
For example, ^echo is the operating system's echo, not ral's — a handler
for echo, and ral's own base echo handler, are both skipped.
The bare names within, grant, try, guard, audit, spawn, and
gather are reserved control forms. A caret form such as ^try uses caret lookup instead.
6.3. Bare-head lookup
ral resolves an ordinary bare head in this order. The first match wins.
- A value binding. ral searches the current lexical scopes, session
bindings, and prelude. Builtins live here as values. A matching value must
be one ral can apply — a function, a block, or a builtin. Otherwise, lookup
fails without falling through:
let date = 5; date +%sis a static error that saysdateis bound to anInt, and^dateruns the command. - A named user handler. This includes persistent handlers installed by
aliasand scoped handlers installed bywithin [handlers: …]. The innermost handler for the name wins. - A base handler. ral's own commands that take an argument list live
here.
echois the standard example, and a host may install more, such asdetach. A base handler has no arity: it takes the whole argument list, aList String. - A catch-all handler.
within [handler: …]handles the call only if no binding, named handler, or base handler claimed it. - A bundled or external command. A bundled command wins when the current
build supplies that bare name. Otherwise, ral searches the effective
PATH.
Prelude functions behave like user functions: use their bare name as a head,
and $name when passing one as a value.
Builtins are functions held as values: each takes the arguments its entry
declares, and supports partial application and higher-order use. A base handler is not a
value. It is reached in command position only, so $echo is an error.
A named handler may share a name with a binding or builtin. The ordinary bare
name still selects the binding. ^name is the external command name: it
skips bindings, natives, and every handler, base frames included — ^echo is
the operating system's echo, not ral's.
6.4. Handlers and aliases
A named handler or alias takes one parameter: the command's argument list. ral
renders the arguments after the head and passes them as one List String:
within [handlers: [deploy: { |args| audit-deploy $args }]] {
deploy 'prod' '--wait'
}
A catch-all handler takes the dispatched name and the argument list:
within [handler: { |name args| log-call $name $args }] {
unknown-command 'arg'
}
An arm receives strings because a command receives strings. A handler stands in
for a command, and it can only do that if it consumes what a call to that
command would send; an arm that expected typed arguments would not be
interchangeable with the command it replaces. So an arm cannot do arithmetic on
$args[0]: it holds that argument's rendering, '1' rather than 1. Use int
or float when the arm wants a number.
ral checks handler arity when the handler is installed. It also checks that the
arm stands in for the head it names (§9.4). A handler for echo, for example,
stands in for a command, so its value is its output.
Handlers are self-masking. While a selected handler runs, its own frame is temporarily absent. A call to the same name from its body reaches an outer handler, a base handler, a catch-all handler, or an external command:
within [handlers: [git: { |args|
warn 'running git'
git ...$args
}]] {
git status
}
Any named handler takes priority over any catch-all handler, even when the catch-all is in a more deeply nested scope. A base handler also takes priority over every catch-all handler.
6.5. Application and arity
ral passes arguments in two ways, and they share nothing. A value — a block, a lambda, or a builtin — is applied: it takes one argument at a time, at the arity its definition declares. A handler, a base handler, or an external or bundled command instead receives an argument list, and has no arity at all; see Arguments and spreading. This section is about application.
Parameterized blocks and builtins are curried:
let add = { |a b| $[$a + $b] }
let add-two = add 2
let five = add-two 3
Too few arguments return a partial application. The exact number runs the body. If arguments remain after the call returns, ral tries to apply them to the returned value. It reports an arity error when that value cannot be applied.
ral checks known arity and argument-type errors before execution. An
application's arity is always known, so the arguments written at a call site are
exactly the arguments that arrive. Nothing in that position stands for an
unknown number of them, because ... forms an argument list and a value takes
none.
6.6. Arguments and spreading
Command arguments are values. Ordinary and quoted words produce strings.
Numeric and Boolean literals keep their types. $name supplies a bound value,
and !{…} supplies a forced block's result. What becomes of them next depends
on which of the two mechanisms of Application and arity the head uses.
An argument list is the flat sequence of arguments that a base handler, a
handler or alias arm, or a bundled or external command receives. Its type is
List String.
A base handler and a handler or alias arm receive it rendered. ral converts
every argument with the text conversion of Display, the same conversion str
performs and echo writes. That conversion is total, so no argument is refused
there, and nothing crosses an argument list as an unrendered ral value:
echo hello [a: 1] # hello [a: 1]
let f = { |x| $x }
echo $f # <|x| block>
A bundled or external command receives operating-system arguments instead, and those are bytes. That is a second boundary, and a narrower one; its rule is below.
...value spreads a list into an argument list:
let flags = ['--all', '--long']
ls ...$flags '/tmp'
The spread value must be a list. Its elements are inserted in order at that position, and they are rendered like any other argument.
A value receives no argument list. It takes its arguments by application, one at a time, at the arity its definition declares, so a spread among them has nothing to spread into. ral reports a spread in the argument position of a value — a block, a builtin, or anything reached through a binding — before execution:
let numbers = [1, 2]
let add = { |a b| $[$a + $b] }
add ...$numbers # error: a value takes no argument list
add 1 2 # 3
echo ...$numbers # 1 2
The same ... marker also builds a list ([1, ...$middle, 4]) and matches the
tail of one ([first, ...rest]). Those are the forms of Lists and Patterns:
they construct and destructure a list, and neither forms an argument list.
Operating-system arguments must become text, and there the conversion is partial
rather than total. ral formats strings, integers, floats, Booleans, Unit, and
tagged values. Unit becomes the argument (). ral rejects bytes, lists, maps,
records, blocks, functions, builtins, and handles. Spread a list, and
send bytes through a byte pipe or decode them first.
ral reports that rejection before execution when the argument's type states the shape, so nothing is started:
let r = [a: 1]
cat $r # error: cannot pass [a: Integer] to external command 'cat'
Where the type does not state it — a parameter, or a value decoded at run time — the shape is known only to the run, and ral reports the same rejection at the call instead. A spread's elements are always reported at the call, because how many arguments a spread contributes is known only then, and an empty spread contributes none.
Arguments are never split, globbed, or parsed again. A string containing spaces remains one argument.
6.7. External commands
A bare external name uses the effective PATH. A within [env: [PATH: …]]
scope changes that search for its body. ral resolves the command once, then
starts that program with the current logical directory, environment, redirects,
and capability restrictions.
When the build supplies a bundled command with the requested bare name, the
bundled command wins over a same-named file on PATH. It still runs as a child
command with the same byte-pipe, failure, audit, and sandbox behavior as an
external program. Use a path head to select a particular system binary.
A missing bare command fails with status 127. A file found on PATH but not
executable fails with status 126, as does a path naming a directory; the
diagnostic says which, and a path head offers itself as text (#'…'#). A
command that never became a process is not advised to attempt. A non-zero
process result is a failure, except in a non-final byte-pipe stage that was
still running when the stage reading it ended.
6.8. Returned values and emitted bytes
A command can return a ral value and emit bytes independently.
- In statement position, bytes go to the current output unless redirected. The returned value is discarded.
- A command's value is its output: a
letwhose right-hand side is a command, whether written inline or reached through a block or function, binds what it writes. The capture is decoded as UTF-8 and loses one trailing newline. - In a pipeline, a stage's bytes go to the next stage's standard input. Every stage but the last feeds the next by writing, and its own value is unused; the pipeline's value is the final stage's.
- Value-style composition is ordinary application or bind. The
|operator does not apply a returned value as another stage's argument. - A redirect changes where emitted bytes go. It does not make those bytes the returned value.
let branch = git branch --show-current
let saved = echo 'hello' > greeting.txt
branch receives the captured branch name. The redirect sends echo's bytes
to greeting.txt, so saved is "".
Captured text is data. ral does not re-lex it, split it, or expand wildcards in
it. Use a decoder for structured bytes, and use from-bytes for data that is
not UTF-8.
7. Pipelines and input/output
A pipeline connects computations with |. Every interior connection is an
operating-system byte pipe, and stage position alone decides the wiring: the
left stage's standard output becomes the right stage's standard input. The
pipeline's own value is the final stage's, and may be an ordinary ral value.
ral never inserts a codec and never turns a returned value into pipeline
traffic.
7.1. Byte edges and value composition
The following is a byte pipeline. Its stages may be builtins, external commands, or ral code, but each interior handoff is bytes:
to-json $record | jq '.name' | from-line
An external command cannot receive a structured value by accident. The encoder takes its value as an ordinary argument, so the pipeline begins with bytes; the decoder ends it.
Neither side of a | promises traffic. A stage that writes nothing leaves the
next stage an ordinary end of input, and a stage that never reads leaves the
offered bytes unread; once that non-reader ends, ral ends the blocked producer
itself (§7.6), so nothing hangs.
Each stage must be a command ready to run. A stage still waiting for an argument is a type error, reported before anything starts, and the diagnostic says to apply it rather than pipe into it:
cat notes.txt | fold-lines $step # error
cat notes.txt | fold-lines $step 0 # runs
(pipe). A stage feeds the next by writing, so every stage but the last is
a command: an external, a builtin that writes, or ral code that writes. A
stage of any producer kind is accepted, but a decoder such as from-json
returns a value and writes nothing, so it ends the byte pipeline, and a value
is no stage. The pipeline's value is its final stage's. The checker refuses a
stage that cannot feed the next before anything starts:
echo !{length $xs} | cat # runs: the count is written, then piped
from-json | cat # error: a decoder ends the byte pipeline; nothing reaches `cat`
[1, 2, 3] | cat # error: a value in stage position writes nothing to the pipe
A block literal is a value, so a block literal as the final stage returns a
thunk and runs nothing. cat f | { from-line } is accepted and does nothing;
cat f | !{ from-line } runs the block as a stage.
In a multi-stage pipeline, a ral-written stage runs on its own thread and an
external command runs as its own process; every | uses an operating-system
byte pipe regardless. State changes made by a stage are private to it: its
working directory, environment changes, aliases, loaded modules, and bindings
do not alter the parent, since a stage thread evaluates over a cloned shell
that is simply dropped at the stage's end. Only pipe contents, the final
result, and recorded observations cross the boundary.
Value-style composition stays in the ordinary evaluator. There is no second kind of pipe; use application or bind:
let items = range 0 5
let squares = map { |n| return $[$n * $n] } $items
7.2. Output and results
Every computation produces two independent things:
- the bytes it writes to standard output, which go wherever position and redirects send them: into the next stage inside a pipeline, and to the surrounding stream otherwise;
- the value it returns to the evaluator.
Neither implies the other. A command's value is its output: where a value is wanted the bytes it writes are captured and decoded, and a computation that returns a value writes nothing of it. Only the final stage's value can become a pipeline's: every earlier stage feeds the next by writing (§7.1).
In statement position, byte output goes to standard output unless a redirect sends it elsewhere.
What let binds is decided by type (§6.8). A command that writes — an
external, a builtin that writes, or a handler arm standing in for one — has type
Command; where a value is demanded of it, namely a let's whole right-hand
side, an argument to a function that demands a value, or an arm joined with a
value arm, the checker wraps it in a capture frame, with three rules:
- On push, the standard-output sink becomes a fresh buffer and the previous sink is held.
- On return, the sink is restored and the buffer is yielded as
Bytes. The body's value is ignored. A buffer that grows past the cap is an error. - On halt, the sink is restored, what the body wrote is flushed to it, and the halt propagates.
The bytes are then decoded:
- decoding is strict UTF-8;
- one final
LF, or one finalCRLF, is removed; - no other bytes are changed.
Invalid UTF-8 is an error. Use from-bytes when the byte payload is binary.
A command that writes nothing captures "". Bytes written by a statement that
is not the tail of the captured computation go to the surrounding stream and
take no part in the binding.
When the final stage returns a value, the value simply comes back from that
stage's own thread: a ral-written final stage runs machine::evaluate over its
own closure and returns the result directly, no serialisation and no separate
channel involved.
let greeting = echo hello # "hello"
let data = command | from-bytes # Bytes
Capture is of the whole computation: everything the block writes is its value.
let answer = !{ echo first; echo second }
# prints nothing; answer is "first\nsecond"
A redirect of standard output discharges the demand: let saved = to-json 1 > f
binds ().
Captures nest. Output goes to the nearest enclosing sink, which inside a
capture is that capture's buffer. A let inside a captured block captures its
own command: let x = !{ let y = echo a; echo b } prints nothing and binds b.
If a captured computation fails, the bytes it wrote are flushed to the sink the
capture replaced rather than silently lost.
An in-memory capture keeps at most 16 MiB. A capture that reaches the cap fails rather than bind a truncated value — the bytes captured are the value, so a prefix would be the wrong one. The failure is ordinary: the buffered bytes are flushed by the third rule above, and try catches the error. Redirect output to a file when it may be larger.
7.3. Explicit codecs
Codecs make every conversion between values and bytes visible in the source. A decoder reads its byte input and returns a value:
| Decoder | Result | Rules |
|---|---|---|
from-bytes |
Bytes |
Preserves every byte. |
from-string |
String |
Requires valid UTF-8. |
from-line |
String |
Requires valid UTF-8 and removes one final LF or CRLF. |
from-lines |
[String] |
Splits by the line rule below and replaces invalid UTF-8 in each line with the replacement character. |
from-json |
a ral value, checked | Requires valid UTF-8 and JSON. JSON null becomes Unit, a whole number an Int, a number with a fraction a Float. |
from-jsonl |
a list of ral values, checked | Splits by the line rule below and decodes each line as from-json does. A blank line, holding only spaces, tabs, and carriage returns, holds no value. An error names its line. |
from-json-at tokens |
a ral value, checked | Decodes as from-json does, then reads the value at the RFC 6901 reference tokens tokens, a list of String: an object member by exact key, an array element by canonical decimal. A token that does not resolve is an error naming the pointer prefix and the object's keys or the array's length. |
from-csv |
a list of maps from header to text | Uses the header row as map keys. Fields are strings; duplicate headers are rejected. |
Decoders are command forms. They read the preceding byte pipe, or standard input when there is no preceding stage; only from-json-at takes an argument.
Checked boundaries
The shape of a decoded value is the file's, not the program's, so a decoder's result is a boundary: the checker gives it one type for the whole unit, _α, which every use of the value fixes (§5.1), and the decoder admits the value against that type before the program sees it. let n = $[$doc[size] + 1] over {"size": "12kB"} fails at the decode, naming where:
from-json: the value at `/size` is text (`'12kB'`), but this script uses it as an Int — line 3
A mismatch names an RFC 6901 pointer into the value, what was found, what the script uses it as, and the line of the use. An open record (the fields the script reads) admits extra fields; a closed record literal refuses them. Two decodes the program compares with < agree on numbers or on text, and are refused at the second decode otherwise. A JSON number keeps its kind: 3 is an Int and 2.5 a Float, so a position used as a Float refuses 3, and float at the use accepts both. A decoder never converts a value to fit its type.
The same discipline admits what use (§10.3), an awaited worker (§11.4), and the exarch host's answers (exarch-agents, exarch-pins, exarch-transcript) let in.
A decoder is a legal pipeline tail:
let document = cat data.json | from-json
length $document
The decoder consumes bytes and returns a value as the pipeline's result.
A decoder writes nothing, so it cannot feed a further stage:
cat data.json | from-json | wc -l is a type error, since a stage feeds the
next by writing and from-json returns a value. Bind the decoded value and use
ordinary application, or encode it again explicitly before another byte
pipeline.
An encoder takes its value as an ordinary argument and writes bytes:
| Encoder | Accepted value | Output |
|---|---|---|
to-bytes |
Bytes |
Those bytes, unchanged. |
ints-to-bytes |
a list of integers from 0 through 255 | The bytes they number. |
to-string |
any value | Its ral textual form. |
to-line |
any value | Its textual form followed by a newline. |
to-lines |
a list of values | Each element's textual form followed by a newline. |
to-json |
a JSON-representable value | UTF-8 JSON. Bytes become an array of integers; variants become tagged objects. |
to-jsonl |
a list of JSON-representable values | Each element as to-json encodes it, followed by a newline. |
to-csv |
a list of maps of text | UTF-8 CSV; columns are the first row's keys in sorted order. |
to-json rejects values with no faithful JSON representation, including blocks, lambdas, and handles. to-jsonl rejects a list holding one, names its index, and writes nothing. To decode a value already held in memory, encode it into a byte pipe first:
to-string $json_text | from-json
Lines
One rule says where a line ends:
- A line terminator is
LForCRLF. TheCRof aCRLFbelongs to the terminator, and only that oneCRdoes. - A lone
CRis ordinary text, never a terminator, whether mid-line or at the end of input. - The final line need not be terminated.
- Each line strips its own terminator, so mixed endings are fine.
| Input, as a ral string | Lines |
|---|---|
"" |
[] |
"a" |
["a"] |
"a\n" |
["a"] |
"\n" |
[""] |
"a\n\n" |
["a", ""] |
"\n\na" |
["", "", "a"] |
"a\r\nb\nc\r\n" |
["a", "b", "c"] |
"a\r\n\r\nb" |
["a", "", "b"] |
"a\rb\n" |
["a\rb"] |
"a\r" |
["a\r"] |
"a\r\r\n" |
["a\r"] |
Every line reader splits by this rule — from-lines, from-jsonl,
fold-lines, and the prelude's map-lines, filter-lines, each-line, and
line-count — as does lines on a string. Capture (§7.2), from-line, and
ask do not split: they remove at most one terminator from the end of what
they read, so "a\r\n\r\n" becomes "a\r\n" and "a\r" stays "a\r".
ral writes LF alone: to-line, to-lines, to-jsonl, and echo never
write CRLF. to-lines terminates every element, so to-lines [] writes
nothing, to-lines [""] writes one LF, and from-lines inverts to-lines
on every list of strings in which no element contains LF or ends with CR.
7.4. Redirects
Redirects attach input or output to one command or compound command:
| Form | Meaning |
|---|---|
> path |
Replace a file atomically when possible. |
>~ path |
Truncate the file and stream output into it. |
>> path |
Append output. |
2> path |
Stream standard error into a truncated file. |
2>> path |
Append standard error to a file. |
2>~ path |
Truncate the file and stream standard error into it. |
2>&1 |
Send standard error wherever standard output goes. |
< path |
Read standard input from a file. |
<< value |
Read standard input from a string value. |
Any stage takes trailing redirects, if and case included: if $ok { a } { b } > log. A command's redirects may also stand between its arguments: cmd a > f b.
These nine spellings are the whole redirect vocabulary: < path, << value, > path, >> path, >~ path, 2> path, 2>> path, 2>~ path and 2>&1. The digit 2 names standard error and is the only fd prefix; 1> is refused with advice to put a space before the > to pass 1 as an argument (echo 1>file would otherwise write an empty file). A redirect list binds streams: each of standard input, output, and error is bound at most once, and a second binding is a parse error with a caret on the second redirect. cmd > a > b, cmd 2> e 2>&1, cmd 2>&1 2> e, and cmd < a << b are all refused with "standard output is redirected twice; which one do you mean?" or its input and error counterpart. Position carries no meaning: 2>&1 sends standard error wherever standard output ends up, so cmd 2>&1 > f and cmd > f 2>&1 are one command. Redirect targets are evaluated before their files are opened, and the files open in one fixed order: standard input, output, error. Relative paths use the scoped logical working directory.
ral has no numbered descriptors beyond these three streams. A redirect naming any other file descriptor — 3> path, 4< path, >&5 — is rejected outright, with a diagnostic naming standard input, output, and error as the only three that exist; there is no general facility for managing a spawned process's own inherited handles.
Descriptor duplication runs in that one direction only. 1>&2, and its short spelling >&2, are rejected: their only honest use is the shell idiom for writing a diagnostic, and a diagnostic is what warn is for. The message names warn, and names 2>&1 too, since a program holding the exchange backwards means that one.
A redirect on a stage overrides the wire the pipeline would otherwise use. In cmd > file | next, cmd writes to file and next sees end of input.
Across a | the same collision is refused rather than resolved. A stage after a pipe may not bind standard input at its own root: a | b < f and a | b << value are type errors, because the feed answers every read b makes for the stage's whole run and leaves a writing for nobody. Drop the pipe, run the producer as its own statement, or spawn it if the two were meant to run at once. A read redirected deeper inside the stage — within a block, or on one command among several — supplies that command alone and stays legal.
For a regular file, > writes a temporary file beside the destination, flushes it, then renames it over the destination. If the command fails, ral discards the temporary file and leaves the old destination unchanged. It preserves an existing file’s mode and follows a symbolic link to update its target rather than replacing the link.
This operation is failure-atomic, not a locking scheme: concurrent writers may still race. For non-regular destinations, > uses streaming behavior.
Use >~ when readers should observe output as it is produced, or when writing a device, named pipe, or similar destination. 2> is also streaming so diagnostics are not delayed until a command finishes. A streaming write is committed byte by byte as it lands, so it is observed committed even if the body later fails; only an atomic > can be aborted.
7.5. Here strings
<< is a here string, not a heredoc delimiter:
from-json << '{"name":"Ada"}'
It feeds a string value to standard input. It does not add a newline. It removes exactly one leading LF, or one leading CRLF. This makes an indented multiline string convenient while preserving the rest of its contents, including any trailing newline.
Whitespace after << is required. There is no <<< form, and << can only redirect descriptor 0. A bare word after << is rejected because it is usually a mistaken heredoc; use a quoted, interpolated, raw, or dereferenced string value.
Large here strings are written concurrently with the reader, so filling an operating-system pipe cannot deadlock command launch.
7.6. Process completion and the terminal
ral runs a ral-written stage on its own thread and an external stage as a process; every external in the pipeline shares one process group the shell is not a member of. ral launches all stages and waits for all of them. A failure in any stage fails the whole pipeline. If several stages fail, the first failing stage in launch order supplies the reported failure. An escape outranks an ordinary stage failure, and a stop (§8.10) outranks an exit; two stops rank by cause; ties go to the earlier stage. A stage's own reader-gone stop, or an enclosing stage's, is no verdict at all: it ranks under everything. An exit in a stage, or a stop reaching a stage from outside the pipeline, ends the pipeline at once: the other stages are torn down, and what that teardown stops is the pipeline's own to forgive.
A downstream command often stops reading before an upstream producer is done writing, as in:
large-producer | head
An interior edge is dead once its reader stage has ended. A stage feels that death at exactly one place — its next write to that edge — and nowhere else. A ral-written stage's own write to a dead edge raises the break at that write; an external's write ral cannot see, so ral hears it instead: once the reader is gone, ral reads the edge itself, on the read-end duplicate it already holds, discards whatever bytes are pending at that moment (they were owed to a reader that left), and treats the first byte written after them as the kill's cue — SIGKILL on Unix, a distinctive-code TerminateProcess on Windows. A write already blocked on a full pipe when the edge dies is completed into that same read and then judged dead: a write not complete when the edge dies does not complete.
That break — and only that break — is the pipeline's one forgiven death: a non-final stage ral cuts this way keeps no failure, because the rest of its output was owed to nobody. Everything a stage does before its first dead write is its own account and runs to completion: its stderr, its files, its children that never write the edge, its own exit status if it never writes again. Nothing after that write is promised. In the final stage the same condition is the closed-stdout stop: the pipeline's own output crosses the outer boundary, and a caller that stops reading it stops the run, status 141, an escape no try catches (§8.10). A reader-gone stage's guard cleanup runs, shielded (§8.7): a cleanup that halts on anything but its reader-gone stop is the pipeline's verdict; one that ends on it is forgiven; a cleanup's write to the dead edge is stopped, not masked.
A stage whose own redirect statically sends every byte of its stdout to a file, never to the interior edge, never performs the write the cut watches for: cmd > file | next therefore runs cmd's redirect to completion regardless of when next settles, not because it is exempted but because it never reaches the operation the rule cuts.
ral holds a duplicate of each interior edge's read end until that edge's writer stage is reaped or joined, so no interior edge ever delivers a broken-pipe signal or a write error to the stage that writes it. For a ral-written stage the cut is exact — its own write is the operation that raises the break. For a process it lands within the kill's few-microsecond latency after the first dead write. An exit status, once recorded, is never overwritten: the kill precedes the wait, and a kill landing on an already-exited stage changes nothing. This is one rule, stated once, true on both platforms.
A producer that exits on its own account keeps that status, whatever the cause: sh -c 'exit 3' | true still fails with status 3, since it never writes to the dead edge and so is never cut, while yes | head -1 still succeeds, since its next write past the cut is the forgiven death — the same rule on both platforms rather than a broken-pipe signal on one and an exit-order reading on the other. A producer that never writes again is never cut and runs to completion: sleep 1000 | true waits for the sleep, as in every shell. One that must not be cut at all must not be a stage whose reader can leave — its own statement, or spawn.
On Unix, an interactive process-staged pipeline receives the foreground terminal as one process group only when the session owns a terminal lease and final standard output is attached to that terminal. A captured pipeline normally writes to a buffer, so the parent keeps terminal ownership. Ordinary application and bind do not create a pipeline process group.
A ral-written stage runs on a thread of the shell's own process, so it cannot itself read the controlling terminal while that terminal's foreground belongs to the pipeline's external process group: doing so would raise SIGTTIN against the whole shell. A stage with no upstream pipe and nothing else to read from therefore sees immediate EOF rather than falling through to the terminal, on a pipeline that owns the foreground — !{ let l = from-line; echo $l } | cat at an interactive prompt returns at once instead of waiting for a line. An external stage reading the terminal (cat | grep x) is unaffected, since it is a process inside the foreground group.
Windows has no POSIX foreground-terminal handoff. Pipeline members share the console and are supervised as one job.
8. Control flow and failure
ral separates successful values from failure. A computation may successfully
return any value, including false, (), an empty collection, or an empty
string. Failure is a separate control outcome carrying a nonzero status and a
message. This distinction is fundamental: if examines a Bool, while ?
and try examine whether a computation succeeded.
Commands and control forms are checked before execution. Branches must agree on the value they return. A branch that is not selected is not evaluated.
8.1. Sequencing and return
A newline or semicolon sequences computations from left to right:
prepare
let result = calculate
publish $result
Execution stops at the first failure. If every computation succeeds, the
sequence returns the value of its final computation; an empty sequence returns
(). Bytes written by earlier computations remain observable even when a
later computation fails or when the sequence is evaluated under capture.
return value is the primitive successful computation. With no value,
return returns ():
return 42
return
return is not a process exit and does not turn a false Boolean into failure.
A returned Bool does record a status in the shell convention: true records
0 and false records 1. No program can read that status — it is what the
process exits with — and the computation itself succeeded in both cases.
8.2. Conditional choice
The conditional form is:
if condition { then-body }
elsif other-condition { other-body }
else { fallback-body }
Conditions are evaluated in order and must return Bool. The body belonging to
the first true condition runs. If none is true, the else body runs. The
selected body has a fresh lexical scope, so bindings made inside it do not leak
into the surrounding scope.
Each body is a thunk that if forces when it chooses it: a block written out,
or a name holding one, so if $c $yes else $no runs the block $yes or $no.
Any other atom would run before if chose, so it is a parse error: an arm is a
block, or a name holding one. To compute the block first, bind it with
let arm = $arms[a].
With else, both bodies produce one kind of result. A command's value is its
output, so a body that prints joins another that prints, and joins an empty one
(()); joined with a body that returns text, the command's output is captured
as text, so if $c { hostname } else { 'none' } is a String. A body that
returns any other value does not join a command, and if $c { echo big } else
{ return 5 } is refused. Print in both with echo to keep the result a
command. Without else, the conditional is for effects: its result is
always (), whether the body runs or not.
false chooses another branch; it does not cause ? to continue and does not
invoke a try handler.
8.3. Variant elimination with case
case eliminates a variant. Its arms are syntax — one per tag, written out at
the case itself:
let result = case $reply [
`ok: { |value| return $value },
`err: { |error| fail $error },
]
The scrutinee must be a variant. case runs the arm whose tag matches it,
applying it to the variant's payload; a nullary variant passes (). The arm
has a fresh lexical scope for that binding, but it is a branch and not a
function applied to the payload: it runs in the surrounding shell context, so
the arm's own changes remain in place after the case, as an if body's do.
An arm is a thunk of one argument that case forces when it chooses it: a block
with one binder writes the branch out, and a name holds one, so `ok: $handle
runs exactly what `ok: { |p| $handle $p } runs. As for if, an arm is a
block or a name; any other atom is a parse error.
Arm result types must agree, exactly as for if branches and ?
alternatives: the arms join as one kind of result. An arm that prints joins one
that ends silently at (); a command arm joined with one that returns text is
captured as text, and it does not join one that returns another value.
Because the alternatives are syntax, exhaustiveness is decided statically and
always. The typechecker closes the scrutinee's variant row to exactly the arms'
tags, and reports a tag with no arm before anything runs, listing the tags the
case does match. What the grammar refuses is anything that hides the set of
alternatives from that proof: a computed table in place of the arm list, a
... spread arm, a repeated tag, and an empty arm list are all parse errors
(§17.1).
8.4. Fallback chains
The ? operator tries alternatives from left to right:
cached-value ? fetch-value ? fail [status: 1, message: 'no value available']
The first successful alternative supplies the chain's result. An ordinary
failure continues with the next alternative. If every alternative fails, the
chain propagates the final failure. Alternatives must return values of one
type, and each runs in a fresh lexical scope. Alternatives join
as the arms of if do: alternatives that are commands join each other, and
joined with an alternative that returns text they are captured.
Control escapes are not alternatives. In particular, exit and internal
tail-call control pass through a fallback chain rather than selecting its
next arm. Cancellation is checked between arms.
8.5. Raising failure
fail deliberately raises an ordinary, recoverable failure. It accepts one
error record:
fail [status: 64, message: 'bad command line']
A record must contain a nonzero integer status field and a String
message: the message is the text the failure carries. The checker enforces
this shape. A record reaching fail from an unchecked boundary — a decoder's
result, a module's asserted shape — is refused at run time instead. reason,
cmd and site are kept when present.
Status 0 is rejected: use return for success. A status must fit the runtime's
signed exit-code range. Passing the error record received by try to fail
re-raises it faithfully: status, message, reason, cmd, and a site
the record has, all round-trip. A status that disagrees with its reason's
projection (§8.6) is refused, naming the status that reason has; a
`signaled n outside 1 to 127 is refused. Integers and bare message strings
are rejected so failure remains structured at its point of construction.
An external command that exits unsuccessfully and a runtime operation that
cannot complete also produce ordinary failures. They participate in ? and
try in the same way as fail.
8.6. Recovery with try
try evaluates a body and, only if that body fails, calls a one-argument
handler:
let value = try {
read-config
} { |error|
log $error[message]
return default-config
}
On success, try returns the body's value. On recoverable failure, the handler
receives this record:
[
cmd: String,
status: Int,
reason: Reason,
message: String,
site: `some [script: String, line: Int, col: Int] | `none,
]
where
Reason = `exited Int | `signaled Int | `cancelled | `timed-out
| `not-found | `not-runnable | `raised
reason says why the failure happened, and status is its projection:
reason |
Meaning | status |
|---|---|---|
`exited n |
an external command exited with nonzero n |
n |
`signaled s |
an external command died of signal s, not by a cancellation (§8.10) |
128 + s |
`cancelled |
a worker a cancel inside the session stopped, observed by await or poll |
143 |
`timed-out |
a worker reached by a timeout on an enclosing scope, observed likewise | 124 |
`not-found |
no command of that name exists | 127 |
`not-runnable |
the command exists but could not be started | 126 |
`raised |
fail, or a runtime operation that could not complete |
the raised status |
Recovery branches on reason, not on a status number.
cmd names the command whose dispatch failed — a builtin call included — and
is <runtime> when the failure came from no command. The dispatch stamps its
own name on the error as the error passes through, so the innermost failing
dispatch wins, exactly as the innermost source span does. site is the
failure's source position, `none when it has none. The body and handler
join as the arms of if do; in the example, read-config is a command whose
output is captured, so the handler's return gives text as well.
try is control flow, not output capture, and it observes nothing: it reads no
audit trail, retains no bytes, and costs no more than its own frame. Bytes
written to standard output or standard error continue through the surrounding
byte pipes. The error record's message is the structured failure message; it
is not captured stderr. Use audit when command bytes and the full trail are
required — its `err outcome carries this same record (§13.3).
A handler that succeeds recovers the failure and leaves status 0. exit,
internal tail-call control, and a stop (§8.10) bypass the handler: a stop is
not a failure, and try never sees one. The one stop a handler can meet is
the error await or poll reports for a worker a cancel inside the session
stopped or a timeout on an enclosing scope reached, which is the awaiting run's
own failure.
8.7. Cleanup with guard
guard pairs a body with a cleanup block:
guard {
use-resource
} {
release-resource
}
The cleanup runs after the body whether the body succeeds or fails. When the
cleanup completes, the body's own outcome stands: guard returns the body's
value, or its failure remains the result after cleanup.
A cleanup that does not complete pre-empts that outcome, an ordinary failure
exactly as a control escape such as exit. If both the body
and the cleanup halt, the cleanup's signal is the result.
A cleanup is shielded: a stop (§8.10) already in force when it starts, or
landing while it runs, does not end it until the grace is spent. The grace is
a host-set budget (five seconds by default) counted from the instant the stop
landed, whatever the cleanup was doing then, so every cleanup of one
unwinding ends by one instant. A root abort (Ctrl-\) passes the shield. The
shield is ral's own restraint: a signal the terminal or the pipeline sends to
the process group still reaches the cleanup's children, as under any shell,
and a child that dies of it fails as a signalled command does. Within the
grace a cleanup's commands and pipelines run to completion; past it, the stop
ends the cleanup at its next poll, and the cleanup's halt is the result.
A cleanup whose own
failure is not to fail the computation says so: guard { … } { attempt { … } }
suppresses it, and guard { … } { try { … } { |err| … } } reports it and
carries on.
guard takes its value type from the body alone; the cleanup
contributes none. Output from cleanup is real output and is not silently
captured. Bindings created in either block remain local to that block.
8.8. Exiting the session
exit (also spelled quit) requests that the host end the current ral
session:
exit
exit 7
The default status is 0. An explicit status must be an integer in the signed
exit-code range. exit is a control escape, not an ordinary failure: ? and
try do not catch it. await and poll re-raise a worker's exit: an
exit in a worker ends the run that observes it, and no other. A command-line host translates it to the platform's
process exit status; an embedding host receives the structured exit request.
8.9. Diagnostics
Parse, type, and runtime errors identify their source and underline the relevant span. Type errors are reported before any part of the checked program runs. Runtime failures retain the innermost available source position while they unwind; code loaded from another file is diagnosed against that file, not against an unrelated caller.
When a failure has actionable context, ral may add a hint beneath the primary message. A failing external command may also contribute its own exit-status hint. These diagnostics are presentation: recovery uses the structured status, message, command, and source position described above, never by parsing the rendered text.
8.10. Cancellation and signals
Cancellation is cooperative and scoped. The evaluator polls at stable boundaries, including between sequence elements and fallback arms, during collection loops and tail-call steps, and while launching or waiting for pipelines and external commands. A cancelled external process is terminated and reaped before control returns.
A stop is an escape, not a failure: it carries a cause, no reason, and no
try catches it; the run it reaches ends stopped, with the cause's status.
Two boundaries within a program own stops: a worker's boundary owns
cancelled and timed out struck anywhere inside the session — on its own
handle, on a worker that started it, or on the call that owns it (§11.1,
§11.3) — which await and poll report as the awaiter's failure (§8.6); a pipeline owns the stops naming its own
stages, the reader-gone stop of one whose reader left and the teardown it runs
for a sibling's escape, and forgives them (§7.6). Every other stop is the
host's and reaches the top of the run uninterpreted, as exit does. A stop in
force is observed at the next step and, inside a blocked write, read or wait
of ral's own, at the next ten-millisecond slice.
Cancellation causes form an escalation order and never downgrade:
| Cause | Message | Status |
|---|---|---|
| reader gone | its reader ended |
141 |
| foreground interrupt | interrupted |
130 |
| explicit cancellation | cancelled |
143 |
| deadline | timed out |
124 |
| session termination | terminated |
143 |
| root abort | aborted |
131 |
A cause a signal raises reports 128 plus that signal, as a POSIX shell does:
an interrupt 128 + SIGINT, a root abort 128 + SIGQUIT, session termination
128 + SIGTERM. An explicit cancellation reports 128 + SIGTERM, what kill
delivers, and a deadline 124, as timeout(1) does. A pipeline stage
cut short because its reader ended is stopped with the reader-gone cause,
which its pipeline forgives; no try inside the stage sees it. The shell's
own write to its standard output after the reader left is the same stop at
the run's own boundary, and ends the run with status 141 and no diagnostic,
as a SIGPIPE death would. That stop lives in the lost stream itself: a
cleanup after it runs unhurried, and only its own write to that stream ends
it.
A stop reports the same cause, message and status whether it lands before a
command starts or while the command runs. An external command that
dies of the signal ral sends first to tear it down, or of the final kill, is
reported as the cause in force, also when a sandbox's envelope relays that
death as 128 + n. An exit is never read as a signal: exit 130 is the
command's choice.
Only a terminal ral lent can make a command's failure a cancellation.
While a command or pipeline holds the terminal, Ctrl-C, Ctrl-\ or a hangup
comes back to ral when it takes the terminal back. A command that dies of
the key is stopped with that key's cause, and the run it belongs to is
stopped: try never sees it. A pipeline hears the key itself,
whatever its stages do with it. A command that catches the key and carries
on, or exits, has handled it. A signal from anywhere else is the command's
own failure (`signaled), and so is any other death, such as SIGSEGV.
Ctrl-\ on a lent terminal aborts the job it was lent to, and the session
lives on. A command sharing ral's own process group, such as a capture, is
signalled by the terminal and by ral at once, so its death may be read
before ral's own interrupt lands and report the signal rather than the
interrupt; the run stops regardless.
A foreground interrupt affects the work that was running when the interrupt
arrived, including nested runs and the workers of a gather in flight, a
par's among them (§11.3), but not a session worker and not a later prompt. Nothing a worker starts sits where the terminal reaches it:
on Unix its external commands lead sessions of their own and its pipelines a
group the terminal never holds, and on Windows its processes lead console
groups ral never sends a console event. A terminal gesture reaches a worker
only as the session termination or root abort ral itself raises. Explicit
cancellation targets its handle. Deadlines affect their run
scope. Session termination and root abort reach the durable session root and
therefore its workers as well as foreground work.
On Unix, Ctrl-C is a foreground interrupt. At an idle interactive prompt it
cancels the edited line instead. Ctrl-\ requests a root abort; SIGTERM and
SIGHUP request session termination. The batch host escalates repeated
termination signals and may force an immediate process exit when cooperative
cleanup no longer completes. Interactive Ctrl-C itself does not use that
force-exit ladder.
Other hosts translate their native gestures into the same structured causes. For example, an active exarch request treats Ctrl-C or Escape as a foreground interrupt, while an idle key may instead close its interface. Windows uses its console and process-group facilities rather than Unix signals, but preserves the observable cancellation messages and statuses: ral opens a teardown with Ctrl-Break and ends it with one fixed exit code, and a process that ends of either while a cause is in force is reported as that cause. A Ctrl-C exit reads as the interrupt only while the interrupt is in force.
9. Scoped execution and handlers
ral uses two kinds of scope for two different purposes:
- Values are lexically scoped. A function or block sees the bindings that existed where it was written.
- Execution context is dynamically scoped. The effective directory, environment overlay, handlers, and capability restrictions come from the call site and remain active through everything the body calls.
let place = 'definition'
let show = { echo $place }
within [env: [MODE: 'test']] {
let place = 'call site'
show # prints "definition"
echo !{env}[MODE] # prints "test"
}
let bindings are immutable. A binding in a deeper lexical scope shadows an outer binding without changing it. Closures retain their defining lexical environment, while commands run by those closures use the caller’s dynamic context.
9.1. within
within installs a dynamic execution context for one block:
within [
dir: 'project',
env: [MODE: 'test', RETRIES: 3],
handlers: [deploy: { |args| to-json [mock: true, args: $args] }],
handler: { |name args|
fail [status: 127, message: "unexpected command: $name"]
},
] {
deploy staging
}
The options are written in within's own bracket, and it accepts exactly four
names:
dir— the effective working directory;env— environment-variable overrides;handlers— named command handlers;handler— one catch-all command handler.
Unknown names are errors, reported before the program runs. [] is the empty
option set — the form's bracket is not a collection literal, so [] there is
no more the empty list than [dir: 'p'] is a map, and [:] names no options
at all.
The option names are syntax: each is written in the bracket, and its value may be computed.
let d = 'project'
within [dir: $d, env: [MODE: 'test']] { build }
A bundle built elsewhere (within $opts { … }), a spread into the bracket, and
a repeated name are parse errors, each saying so. Whether an option is
present is therefore a fact about the program's text, never about a value: to
set dir only sometimes, lift the condition to the form — if $c { within
[dir: $d] { … } } else { within [] { … } }.
handlers is syntax in a stronger sense: its labels are the command names it
binds in the body, so they must be written out, and handlers: $table is a
parse error.
A map is no options bundle either, and so a genuine one — off from-json, a
plugin's configuration — is refused at the same place: its keys are data, a
form's options are names.
All options are evaluated and validated before the body begins. The environment scope is installed outermost, then the directory scope, then the handler frame. This makes the environment visible while commands resolve beneath the directory and handler scopes.
Validation uses the incoming dynamic context. Options in the same map do not
take effect one by one: for example, dir is resolved before the new env
overlay is installed, so a HOME override beside it does not change how that
dir value is resolved.
The frame remains active for the body’s whole dynamic extent, including functions defined elsewhere, nested blocks, command handlers, spawned work, and every landing of a tail-recursive call. Leaving normally, failing, or escaping restores the surrounding dynamic context.
9.2. Directory scope
dir is converted to a path string, resolved against the current effective directory, checked for filesystem-read authority, and required to name an existing directory.
within [dir: 'src'] {
cat 'main.ral'
}
The scoped directory is used by ral path operations, relative executable lookup, redirects, module lookup, and external children. ral does not change the process-wide working directory; each child is launched in the effective logical directory. This avoids races between concurrent workers.
The working directory is one piece of shell state: cd sets it, and cwd and every relative path read it. dir makes within a local handler for that state. within saves the working directory, sets it to dir, runs the body, and restores what it saved when the body returns, fails, or escapes. A cd inside the body therefore moves only the scope's directory:
cd /usr
within [dir: /usr/share] {
cd man
echo !{cwd} # prints "/usr/share/man"
}
echo !{cwd} # prints "/usr"
Setting the directory on entry is not a cd, so a shell list without `chdir does not refuse it; a cd in the body still needs `chdir. A within without dir leaves the working directory alone, and a cd inside it persists.
An empty path, a missing path, a non-directory, or a path denied by the active filesystem grant is an error before the body runs.
9.3. Environment scope
env must be a map. Each value must be a string, integer, float, or Boolean; non-string scalars are converted to strings.
within [env: [PATH: 'tools:/usr/bin', DEBUG: true]] {
build
}
An inner overlay shadows the same key in an outer overlay. Other keys remain inherited. The effective overlay is used by env, user, home, home and XDG resolution, PATH lookup, RAL_PATH, capability-path resolution, and external child environments.
PWD and OLDPWD cannot be set through within env. PWD is ral’s logical cwd, so use cd or within dir instead; ral keeps no previous directory, so there is no OLDPWD. Lists, maps, blocks, handles, and other non-scalar environment values are rejected.
The previous environment overlay is restored exactly when the scope ends. A spawned worker receives a snapshot of the overlay in force at birth; later changes in either shell do not cross back.
9.4. Named and catch-all handlers
A named handler reinterprets one command name:
within [handlers: [
git: { |args|
echo "git called with: $args"
},
]] {
git status
}
Every value in handlers must be a unary lambda { |args| ... }. It receives the command’s argument list: a List String holding every argument after the head, rendered as text.
The singular handler field is a catch-all and must be a binary lambda { |name args| ... }. It receives the command name as a String and its argument list as a List String.
A bare block, non-lambda value, or lambda with the wrong number of parameters is rejected when the frame is installed. A handler reinterprets a command, not a value binding: an arm stands in for the head it names (standsFor), and is checked against what that head is. An arm for an external, and any name that is neither a handler nor a base frame, stands in for a command, so its value is its output; curl: { |args| echo '{"ok":1}' } is an arm, and curl: { |args| '{"ok":1}' } is refused, since it returns a String where it should write — write it with echo. An arm that returns () is admitted and captures "". An arm for a base frame, such as echo, or for a head that is already a handler, returns what that frame or handler returns, so an alias over an alias keeps its own type. The catch-all stands in for every command in its block, so its value is its output. An arm written for a builtin that returns a value is never reached, because builtins are resolved first (§9.5). A handler may change what a command writes, but not what kind of thing it is; use an explicit codec when conversion is intended.
detach refuses a handled head: a handler runs inside this session, so nothing can be detached. To stub detach, stand in for detach itself; to run the real program, write ^name.
Handlers are command operations, not first-class names. They are invoked in command position and cannot be fetched with $name.
9.5. Command resolution and handler precedence
A bare command head is resolved in this order:
- the lexical value environment, including native commands;
- the innermost named handler for that spelling, searching all active run frames;
- a base command supplied by ral or the embedding host;
- the innermost catch-all handler;
- an external executable found through
PATH.
Named handlers are searched in a complete innermost-first pass before catch-alls. Therefore any named handler, even in an outer frame, outranks every catch-all. Base commands likewise outrank catch-alls. A named handler can intercept a base command because named run frames are checked first.
A lexical binding still wins over every handler. Installing a handler under an already-bound name is allowed, but a normal bare call will not reach it. The explicit head ^name, like a path head such as ./tool or /usr/bin/tool, skips handlers entirely and executes the external of that name.
Aliases use the same named-handler stack but persist beyond a within block until removed or replaced. Scoped handler frames themselves are removed when their within ends.
9.6. Deep, self-masking handlers
Handlers are deep: a frame remains installed after one interception, so every matching command in the rest of the body reaches it.
They are also self-masking. While a matched handler body runs, ral temporarily removes the entire matched frame. A same-name call in that body therefore reaches the next outer named handler, a base command, a catch-all in another frame, or the external command. It never immediately calls itself.
within [handlers: [git: { |args|
echo 'before'
git ...$args # forwards past this frame
echo 'after'
}]] {
git status
}
Masking the frame rather than only one entry also means its sibling named handlers and its catch-all are unavailable while that handler body runs. Older and newly nested frames remain visible. ral restores the removed frame in its original order after success, failure, or escape.
There is no first-class resume. Returning from the handler supplies the intercepted command’s result and execution continues exactly once. A handler may instead fail or forward by calling the command again under self-masking.
Redirects apply to handler dispatch just as they do to other command calls. Bytes emitted by the handler or a command it forwards reach the active redirects and byte pipes.
9.7. State flow and containment
The body of within is a block. Its bindings stay local, and when the body ends within restores exactly what it scoped: the working directory under dir, which undoes any cd made inside (§9.2), the env overlay, and its handler frame. An alias or any other unbracketed shell-state write made inside persists, as in any block, and so does a cd under a within without dir.
Boundary rules are deliberately specific:
- A forced block and a function call keep their bindings private; a
cdor other unbracketed shell-state write inside either persists to the caller. Inside a surroundingwithin [dir:], acdpersists only until thatwithinexits. - Ordinary application and bind run sequentially in the evaluator, so they follow the ordinary function and block rules and do not create pipeline stages.
- Each stage of a pipeline runs as a subshell with a snapshot of the active lexical and dynamic context — a ral-written stage on its own thread over a cloned shell, an external command as a process. Stage-local bindings, cwd changes, environment changes, aliases, and handlers do not return. If every stage succeeds, the pipeline's value is the final stage's. Otherwise the first stage failure observed in launch order is propagated; a control escape takes priority over an ordinary failure. Returned values and audit data cross only through their defined channels.
- A spawned worker receives the closure’s lexical capture and a snapshot of the dynamic context, including directory, environment, handlers, arguments, and grants. Its body runs under that capture;
useandexplaininside it read the creator's session as of the birth. Its cwd, bindings, and later dynamic changes remain private, while its result returns through the handle. - An external process receives the effective cwd and environment at launch. It cannot mutate ral’s shell state.
These containment rules are the same on every supported host. Host differences affect executable lookup, path syntax, and the implementation of process-staged pipelines and process control, not lexical scope, handler precedence, or dynamic restoration.
10. Programs, modules, and session state
A ral program may come from a file, command-line text, standard input, or an interactive session:
ral build.ral release # run a file
ral -c 'echo hello' # run command-line text
ral -s # read a program from standard input
ral # start the REPL when stdin is a terminal
A script is parsed and typechecked before it starts. Runtime-loaded files are parsed and typechecked when use reaches them.
10.1. Program arguments and identity
Arguments after a script path or -c program are returned by args as a list of strings:
ral deploy.ral staging eu-west
let [environment, region] = args
args does not include the ral executable, the script path, or the -c source text. It is empty in the REPL and for a program read from standard input.
$SCRIPT identifies the file containing the reference:
echo $SCRIPT
It is lexical: a reference inside a loaded module names that module, not the file that loaded it. ral fixes the value while compiling the file, so a later call from another file cannot change it. On Windows its path is /-separated (§14.4).
Only real script files have this identity. Using $SCRIPT in the REPL, -c, standard-input programs, preloaded sources, or other synthetic source is a compile-time error.
Without an explicit script or -c, ral starts the REPL when standard input is a terminal and reads a batch program otherwise. -i forces the REPL; -s forces a standard-input program and takes precedence over -i. An explicit script path takes precedence over both.
A successful batch program exits with status 0. exit n exits with n. Parse errors, type errors, runtime errors, and failed external commands produce a nonzero status and an explanatory diagnostic. Process exit statuses are reduced to the range 0 through 255.
10.2. Ambient reads
The live shell is read by six nullary builtins:
| Name | Type | Value |
|---|---|---|
env |
F (Map String) |
Environment variables as a map of String to String. |
cwd |
F String |
The logical working directory, absolute, /-separated on Windows (§14.4). |
home |
F String |
HOME, or USERPROFILE where appropriate, from the effective environment, /-separated on Windows; the directory ~ abbreviates. |
user |
F String |
USER, or USERNAME where appropriate, from the effective environment. |
nproc |
F Int |
Available processor parallelism, never less than 1. |
args |
F [String] |
The current program's argument strings. |
Each is a computation: a let of one binds what it returns, and !{…} splices it. Each is also an ordinary native: a lexical binding of the same name shadows it, and ^env runs the program of that name.
The recorded command status is not among them: there is no status register a
program can read. A failure is an Err that carries its own status, and try
binds that record, so the status is in hand exactly where a program is deciding
what to do about it. Elsewhere it is only what the process exits with.
env combines the host process environment with any active ral overrides. An inner override wins:
within [env: [MODE: 'test', USER: 'builder']] {
echo !{env}[MODE]
echo !{user}
}
Environment overrides are dynamically scoped. They affect env, user, home, home and command lookup, RAL_PATH, and child processes, then disappear when the within body ends. There is no general setenv operation in the language.
PWD and OLDPWD are deliberately absent from env. ral owns its working directory separately so concurrent computations never race over the process-wide current directory. Child commands receive PWD and their actual process working directory from this logical state, and no OLDPWD: ral keeps no previous directory.
cd path changes the session’s logical working directory. It resolves path as every path builtin does, path-prefix sigils included. Relative paths, file operations, module loads without a containing file, command lookup, and child processes all use it. A top-level cd persists into later runs; one made inside within [dir: path] is undone when that within exits (§9.2).
A cd inside a function call or a forced block changes the caller's working directory, as at top level; only the body's bindings stay local.
10.3. Importing a module with use
use path evaluates a file in a fresh top-level scope and returns its public bindings as a record, each at its own type, read by name:
let math = use 'lib/math.ral'
echo $math[mean] [1, 2, 3]
Bindings do not leak into the caller. Names beginning with _ are private and are omitted from the returned record:
# lib/math.ral
let _sum = { |xs| fold add 0 $xs }
let mean = { |xs| $[_sum $xs / length $xs] }
The module’s final expression is evaluated, but use returns the bindings rather than that expression’s value. A module's bindings are heterogeneous and reached by name, so the result is a record: $m[mean] reads one, and a key computed at run time has no type to give.
use is a checked boundary (§7.3): the record is admitted against the fields the script reads, so a read of a name the module does not export fails at the use, and each exported function is held to the type the script uses it at.
use first resolves a path. A ~ or xdg: head expands first, as in any written path, and only a path still relative after that is read relative to the containing file, or relative to cwd when there is no containing file; an absolute one is resolved exactly as any written path is, never joined onto the file's directory; the containing file's own directory is never read for such a head, so a script under a directory named ~ is still found there. If that path does not resolve, ral searches the directories in the effective RAL_PATH, in order. The effective value is read when use runs, so a dynamically scoped within [env: [RAL_PATH: ...]] override controls only loads in that body. RAL_PATH uses the platform’s normal path-list separator: : on Unix and ; on Windows. Each search candidate must be a regular file; a directory with the requested name does not stop the search of later entries.
10.4. Module freshness, cycles, and errors
ral does not cache loaded modules. Every use reads, compiles, and evaluates the file again:
let first = use 'config.ral'
# after config.ral is edited:
let second = use 'config.ral' # sees the new file
This also means module side effects run on every load. Bind a module map once when repeated evaluation is unwanted.
During a nested load, relative imports use the innermost file’s directory. ral keeps the active load chain and rejects a file that appears again in that chain:
use: circular dependency: a.ral -> b.ral -> a.ral
The active load depth is limited to 100 files. Exceeding it is an error rather than an uncontrolled recursion.
use checks filesystem read authority before reading. A denied load fails before the module body runs. A file whose resolved path is not UTF-8 is refused, naming it, before it is read: a source's path travels as text. Missing files, permission failures, parse errors, type errors, cycles, depth overflow, and failures raised by the module retain their useful status and are prefixed with use:. A module that does not compile is reported against its own text, with the line and column of each error, exactly as it would be were it run as a script; a try handler that catches the failure reads the plain message, and its site is the use.
10.5. Persistent sessions
A batch invocation owns one fresh shell and ends with the process. A REPL or embedding host may submit many top-level runs to one session.
Successful top-level changes persist between those runs, including:
letbindings and their inferred types;- aliases and loaded definitions;
- the logical working directory;
- the last status.
Changes completed before a later command fails also persist:
let ready = true
failing-command
A later REPL run can still read $ready; statements after the failure never ran.
A nested block's bindings stay local, but its shell-state writes, such as cd or alias, persist into the session like any other (§2.6). Use within for a temporary directory, environment, or handler change.
Interactive sessions load their rc file unless --norc (or its
--noprofile alias) is given. Login sessions additionally load the system and
user login profiles. These are ral source with host-level result contracts:
each login profile must return Unit, while the rc file must return a
configuration Map. Their evaluation is not transactional, so bindings and
effects completed before a runtime failure, exit, or result-contract check
remain part of the session. Applied RC bindings, aliases, environment
configuration, hooks, and startup behavior likewise belong to that session.
11. Concurrency and process lifetime
ral distinguishes concurrent work by who owns it. Every concurrent block has exactly one owner, fixed at birth:
- a gather: a
gather { … }form (§11.3), or a worker's body, which is a gather of its own. Aspawnorwatchinside a gather is the gather's: its worker hangs under the scope that was current when the gather was entered, and the gather ends it at its own exit; - the session: a
spawnorwatchwith no gather around it, in a run, creates a worker that ends by completing, bycancel, or with the session; - nobody:
detachgives an operating-system process away, so the session no longer owns or controls it.
There is no fourth owner. An ordinary command is not concurrent: it belongs to the current run.
This distinction is visible in the result type. A spawned or watched worker,
whichever its owner, returns a Handle. Surrendered work returns only a
receipt.
11.1. Spawning work
spawn [TITLE] BODY starts a block concurrently and returns its handle at
once:
let build = spawn 'build' {
cargo build
return 'built'
}
echo 'the build is running'
let result = await $build
echo $result
await writes what cargo build printed, then binds 'built' (§11.2).
spawn is a control form, not a function (§3.6). BODY is a block; TITLE,
written before it, is a String naming the work to people: it is the name the worker is
born under, reported in the audit trail (§13.3), in the host's notices, and in
the worker registry (§11.4). It must be non-empty and on one line. The title
may be left out, spawn { cargo build }, and the worker is then named by its
source location, block at <script>, line N. Whether a title is required is
the host's policy, checked when the form runs, before any worker is born: the
ral hosts accept an untitled spawn, and the exarch agent host refuses one,
naming the fix. A spawn with no operand or with three is a parse error.
A pipeline is spawned by naming it in the block:
let filtered = spawn 'errors' { cat large.log | grep error }
ral has no backgrounding operator. A trailing & is a parse error naming
spawn, because the two are not the same operation: a POSIX & makes a
process-group job that fg, bg, and disown address, while spawn makes a
worker addressed by its Handle. Borrowing the spelling would
promise the former and deliver the latter.
The worker receives the lexical environment captured by its block. Values are
immutable, so concurrent workers do not share mutable lexical bindings. Each
worker has its own shell state: changes such as cd, environment overrides,
or status changes in the worker do not mutate the spawning thread's shell
state.
A nested spawn or watch is owned by the enclosing gather: a gather
form (§11.3), or else the worker it was started inside, whose body is a gather
of its own. Cancelling a worker cancels its tree, and a worker finishes only
after its children have, by the exit rules of §11.3: when the worker's block
returns, every child still unclaimed is awaited in birth order, the first
failure being the worker's; when the block fails, exits, or is stopped, every
child is cancelled and drained within the host's cleanup grace (§8.7). A
spawn inside a pipeline stage belongs to the gather the stage runs in. A
spawn in a run, outside any gather, is owned by the session.
A gather's child is not announced: no birth notice and no completion notice
of its own reach the host, since its result and its completion reach the
session through its owner. It is registered (§11.4), so the host's worker cap
bounds it and workers lists it, and its bytes are buffered and written when
it is claimed, as any worker's are. A session worker's birth is announced, and
its completion is reported once.
A session worker is not part of the foreground run. Interrupting or timing out the run that created it does not cancel the worker. A gather's child is part of it: its worker hangs under the gather's entry scope, so an interrupt or a timeout on the run reaches it (§11.3). A worker's standard input is empty rather than inherited from the terminal, so a worker cannot steal interactive input.
Pipelines inside a worker still use byte pipes between stages. Value-style composition remains ordinary application and bind. Concurrency changes lifetime, not the typing of a pipeline.
Buffered output
Each ordinary worker has independent stdout and stderr byte buffers. Its bytes
do not appear on the spawning run's output while it executes. await writes
the bytes to the awaiter's streams (§11.2); poll returns them as data, as
Bytes: those written so far while the worker runs, all of them once it has
settled.
Each buffer is capped at 16 MiB. At the cap ral appends one truncation marker, continues draining the writer, and discards later bytes. Draining continues so a chatty child cannot deadlock on a full kernel pipe, while memory remains bounded. Redirect high-volume output inside the spawned block when the full stream matters:
let server = spawn 'docs server' {
python -m http.server > server.log 2> server.err
}
Explicit redirects bypass the corresponding handle buffer. A watched worker
also has empty completion buffers because its bytes were streamed live, so
await of it writes nothing.
11.2. Handles and eliminators
A handle is a process-local capability for one worker. The core eliminators
are await, race, and cancel; poll and first-settled are its two
observations.
A worker's result is claimed exactly once: by await, by race, by cancel,
or by the gather that owns the worker as it exits (§11.3). Claiming is atomic,
so of two callers trying at once exactly one succeeds. poll and
first-settled observe without claiming: they return data, never write, and
never claim. Both treat a claimed handle as await does: poll of one fails,
and first-settled skips it, failing only when every handle it was given is
claimed. Whether a handle has been claimed is checked at run time; its type
does not record it.
await
await handle behaves as if the handle's block had run at the await. It:
- waits until the worker settles, then claims its result;
- writes the bytes the worker wrote to its stdout to the awaiter's current stdout, then the bytes it wrote to its stderr to the awaiter's current stderr;
- ends as the block ended: it returns the block's value, raises its failure
as it stands (status, message and command included), re-raises its
exitor its stop, and resumes its panic.
The sinks are the awaiter's at the await: inside a capture,
let out = await $h captures the bytes; under 2> f, the worker's stderr
goes to f; inside another worker, they land in that worker's own buffers. A
failure is the awaiting run's, so try recovers it:
try { await $build } { |error|
echo "build failed with status $error[status]"
}
A handle carries its block's type, grade included (§17.3): spawn of a block
of type F_ε A returns a Handle_ε A, printed Handle (Returns A),
Handle Command, or, with the grade a variable, Handle (ν A); and
await : Handle_ε A -> F_ε A. So await of spawn { echo hi } is a command,
and let x = await $h binds "hi" as let x = { echo hi } would; await of
spawn { echo pre; return 5 } writes pre and returns 5, as the block
would inline. Replaying bytes changes nothing about the grade, which says how
a result is produced, not whether anything is written.
The equation holds for the value or failure and for the bytes on the two standard streams; the block's effects happened earlier, concurrently, in the worker's own shell. It does not hold for:
- shell state:
cd, environment overrides and the status set inside the block do not reach the awaiter (§11.1); - the interleaving of the two streams: all of the worker's stdout is written,
then all of its stderr, so a block that alternates
echoandwarnreplays as its stdout followed by its stderr; - a block that spawns: run inline in a run, its inner
spawnwould be a session worker, while as a worker it awaits that child before it settles (§11.3), so the awaiter sees the child's output and failure too; - a cancelled or timed-out worker, which ends with
worker was cancelledorworker timed out(below), an ending the block run inline could not have; exit: anexitin the block ends the run that awaits it, which may be a later run than the one that spawned it.
A result is claimed once. await of a handle whose result is already claimed,
by an earlier await, by race, or by cancel, writes nothing and fails with
await: this worker's result was already claimed, an ordinary failure that
try recovers; the block is not run again. A foreground interruption of
await interrupts the wait, not the worker, and claims nothing; the handle may
be awaited later.
poll
poll handle never waits and never writes. It is the observation that
returns one worker's bytes as data, one of two variants:
`pending [stdout: Bytes, stderr: Bytes, elapsed: Float]
`settled [
stdout: Bytes,
stderr: Bytes,
elapsed: Float,
outcome: <`ok A | `err ErrorRecord>,
]
elapsed is in seconds on a monotonic clock: how long the worker has run so
far, or, once it has settled, how long it ran.
The pending bytes are a cumulative, non-destructive snapshot. Polling does not
consume them, so a later poll or await still sees the complete buffered
output. Repeated pending polls may therefore return progressively longer byte
sequences.
poll claims nothing, on either arm. A settled poll leaves the handle to be
awaited and its entry in the registry (§11.4), so a script may poll until the
worker settles and then await it, its bytes written once. A handle whose
result is already claimed has nothing left to observe: poll of it fails with
poll: this worker's result was already claimed.
poll is total on an unclaimed handle. The settled arm reports worker
failure, including a block stopped by its scope through a cancel or a timeout,
as outcome:err ...; it does not raise that failure. Thuspollitself
succeeds and leaves status zero when it successfully samples either arm. A
handlecancelorawaitclaimed fails, as above. A
worker'sexit, a foreign stop and a panic are re-raised, asawaitre-raises them. The prelude functionis-done handlereturnsfalseforpendingandtruefor either settled outcome, and fails aspoll` does on a
handle already claimed.
first-settled
first-settled handles waits until it observes one handle settle and returns
its 0-based index in the list, an Int. It is the observation of several
handles as poll is of one: it writes nothing and claims nothing, so the
handle it names is still there to await or poll.
let mirrors = [$a, $b]
let k = first-settled $mirrors
let first = await $mirrors[$k]
The wait is cancel-aware, as await's is: a foreground interruption ends the
wait and claims nothing. If several handles are already settled when
inspected, list order breaks the tie. A handle another caller has claimed is
skipped; if every handle given is claimed, first-settled fails with
first-settled: this worker's result was already claimed. An empty list is
an error.
race
race handles waits until it observes one handle settle, then claims the
winner and behaves as await on it: it writes the winner's bytes and ends as
the winner's block ended. Every other handle is claimed, cancelled and drained
before race returns, however the winner ended (§11.3).
let a = spawn 'mirror a' { fetch 'https://a.example/data' }
let b = spawn 'mirror b' { fetch 'https://b.example/data' }
let first = race [$a, $b]
If several handles are already settled when inspected, list order breaks the
tie. A worker stopped from elsewhere counts as settled, with the
worker was cancelled ending. A handle another caller has claimed is skipped.
If every handle given is claimed, whether before race starts or while it
waits, race fails with race: this worker's result was already claimed. An
empty list is an error.
cancel
cancel handle claims the handle, requests explicit cancellation, and removes
the worker from the owning registry. Cancellation is cooperative for ral
computation and is propagated to external child process groups by the process
runtime. A worker may take a short bounded interval to tear its process tree
down. A worker that has already completed is not stopped; its result is
discarded.
Cancellation has no result. cancel of a handle whose result is already claimed
does nothing and returns (). Since cancel claims the handle, a later
await, poll or race of it fails as already claimed.
A worker stopped by anything but its own cancel ends as its own failure:
cancelling a worker cancels every worker it started (§11.1), each of which
ends with worker was cancelled, reason `cancelled, status 143, and a
worker reached by a timeout on an enclosing scope ends with
worker timed out, reason `timed-out, status 124. An await that claims
such a worker raises that ending as the awaiting run's own failure, which
try can recover (§8.6), and poll reports it as that error record. A
cancelled worker's halt claims the workers it started (§11.3), so an await of
one of them from outside meets that ending only if it claimed the worker first;
otherwise it fails as already claimed.
11.3. Structured fan-out: gather, par, race
Fan-out that must leave nothing behind is a gather, not a set of loose spawns.
gather BODY runs BODY as a block that owns the workers spawned inside it:
nothing it started outlives it.
gather {
let b = spawn 'build' { cargo build }
let l = spawn 'lint' { cargo clippy }
return [build: !{await $b}, lint: !{await $l}]
}
gather is a control form (§3.6). BODY is a block, and the form has the
body's type, grade included (§17.7): joining the workers is a run-time effect
that changes no type. Inside the body, every spawn and watch is the
gather's (§11.1), and its worker hangs under the scope that was current when
the gather was entered, so an interrupt or a timeout on the run reaches it.
A within inside the body does not move it. A worker's body is a gather of its
own, and the rules below are the rules a worker applies to its children.
- Return. When the body returns, each child no one has claimed is claimed
and awaited in birth order, as
awaitwould (§11.2): its output joins the gather's own, and the first failure among them is the gather's, the rest being cancelled and drained. A child another caller claimed first is skipped. A normal return joins its children; it never cancels them. - Halt. When the body fails, exits, or is stopped, every child no one has claimed is claimed and cancelled, and the gather waits for them to end before it propagates the halt.
- Drain. On every exit the gather also waits for each child it started
whose worker was cancelled and has not yet ended, however it came to be
cancelled: by the halt rule, by a
cancelin the body, or by acancelthroughworker id(§11.4). A child another caller claimed and is still awaiting, uncancelled, is left to that caller.
Draining is bounded by the host's cleanup grace (§8.7): a gather waits at most that long for its cancelled workers to end. Cancellation is cooperative (§8.10), so a worker that ignores it past the grace runs on after the gather returns, no longer registered.
A handle may leave its gather as a value, but the gather claims its worker at
exit, so a later await of it fails as already claimed (§11.2). A spawn or
watch meant to outlive the expression goes outside any gather, where the
session owns it (§11.1). A gather is synchronous: it ends before the run that
entered it does.
Three standard-prelude functions are written over gather:
let converted = par { |path| convert-one $path } $paths 4
let checks = both { cargo build -q } { cargo clippy -q }
let data = first-of [{ fetch 'https://a.example/data' }, { fetch 'https://b.example/data' }]
par function items jobsis the standard parallel map. At mostjobsworkers run at once; a non-positive limit permits one worker per item. A new item starts as soon as any running worker settles. The first failure observed is raised at once, and the other workers are cancelled and drained. The items are awaited in input order, so each item's output is written in input order and the result is the list of their values in input order, whatever order the workers finished in.both first secondruns two blocks at once and returns the record of their values,[fst: …, snd: …], the fixed-arity form of fan-out into a record. It awaitsfst, thensnd, so it does not fail fast: a failure in either is the call's, and the other is cancelled and drained at the gather's exit.first-of blocksisraceover a fresh spawn of each block, inside a gather: it ends as the first to settle, with its output and value, or its failure, and the others are cancelled and drained before it returns. An empty list is an error.
Each worker these start is titled: par #i for item i of a par,
both.fst and both.snd, and first-of for each of its blocks.
race handles (§11.2) is the form for handles already held, which no gather
of the caller's owns: it claims the winner and cancels and drains every loser
however the winner ended. first-of is the form for fresh blocks.
11.4. Registry and shutdown
Every worker birth — by spawn or watch — is registered at once in the
owning shell. A registry entry holds the worker's id, its title, its owner
(the session, the worker whose body it was born in, or a gather), its state, its
birth stamps (one on a monotonic clock, one on the wall clock, and an end stamp
once its thread ends), and the live handle. Nested workers share the owning
shell's registry.
The id is the worker's identity; the title is for people. worker id returns
the handle of any worker still in the registry, running or with a retained
result, whatever started it. It is a boundary (§7.3): the value the worker
settles with is admitted, when awaited, against the type the script uses it
at. It composes with an eliminator in the same call, await !{worker 3}. An
id that names no entry is an error. Nothing looks a worker up by title.
workers lists the registry as data: one record per entry, running or with a
retained result, in ascending id order:
[
id: Int,
title: String,
owner: <`session | `worker Int | `gather>,
state: <`running | `done Done>,
started: Int,
elapsed: Float,
settled: <`some Int | `none>,
]
where
Done = `ok | `err ErrorRecord | `exited Int | `stopped Cause | `panic String
Cause = `reader-gone | `interrupted | `cancelled | `timed-out
| `terminated | `aborted
| Field | Meaning |
|---|---|
id |
the worker's id, the one worker takes |
title |
the spawn's title or block at name, or a watch's label (a par titles the worker for item i par #i, §11.3) |
owner |
`session for a worker born in a run outside any gather, `worker n for one born in the body of worker n, `gather for one a gather form owns |
state |
`running, or `done with how the block ended: it returned, failed with an error record (§8.6), ran exit with a status, was stopped with a cause (§8.10), or panicked |
started |
when the worker was born, in Unix seconds |
elapsed |
seconds on a monotonic clock: how long it has run so far, or how long it ran |
settled |
`some the Unix second its thread ended, `none while it runs |
A listing is a pure read: it claims no result, removes no entry, and holds no
handle, so a workers result crosses every boundary a value does (§11.7).
worker id is the one way back from a row to its handle.
An entry leaves when:
- its result is claimed: by
await,raceorcancel(§11.2), or by the gather that owns it (§11.3); or - a host retention policy reaps a settled result no one has claimed.
poll, pending or settled, leaves the entry in place.
A worker lives as long as its owner: nothing but cancel, its own
completion, its owner's end — a gather's exit, a parent worker's included — or
the session's end stops it. No policy reaps a running worker.
Hosts may cap live workers. Admission reserves a seat before the thread is
created, so concurrent births cannot overshoot the cap. Only running or
being-born workers occupy seats; settled results retained for observation do
not. A refused birth reports the cap, suggests awaiting or cancelling a
worker, and points at workers to list them.
These bounds are host policy, not core constants. The ral interactive and
batch hosts arm no retention bound or worker cap. The exarch agent host
currently caps live workers at 64 and retains unclaimed settled entries for
256 ral calls.
Every session worker is cancelled when its session is destroyed. Teardown then
waits briefly and boundedly for worker threads to act on cancellation and tear
down their children. The interactive shell first warns once with the ids and
titles of workers still running. Workers do not survive process exit; use
detach when survival is the requirement.
11.5. Host-installed worker forms
The core language provides the spawn and gather forms and the builtins
await, poll, first-settled, race, cancel, worker, and workers;
the standard prelude adds par, both, and first-of over them. watch and detach
are optional host commands: watch is a builtin, and detach is a base handler taking an argument list. If a host
does not install one, its name is absent from that shell's base scope
(watch) or its base frames (detach), and is checked and resolved like any
other unknown command.
Live output with watch
watch label block creates an ordinary worker, owned as a spawn in its
place would be (§11.1), whose output is streamed instead of buffered:
let build = watch 'build' { cargo build }
await $build
Each stdout line is prefixed [build] and each stderr line [build:err].
Sibling watchers serialize complete framed lines, though their lines may
interleave. A partial final line is flushed when the worker settles.
The returned value is an ordinary handle, so await, poll, race, and
cancel apply. Its stdout and stderr buffers remain empty because those bytes
have already been sent to the durable output sink. Programs run through the
ral executable, in interactive and batch modes, install watch. A host whose
output capture ends with each run, including exarch, omits it because a worker
cannot safely retain that run's sink.
Work that survives with detach
detach description command arguments... starts an operating-system process
that the session deliberately stops owning. It takes one argument list: the
description first, then the command and its own arguments.
let receipt = detach 'local documentation server' python -m http.server 8000
echo $receipt[pid]
A handler runs inside the session, so a head a handler intercepts — by an arm
for it, or by the catch-all — cannot be detached, and detach refuses it. The
same holds for a base frame such as echo. ^name runs the real program.
The description must be a non-empty, single-line String. The result is:
[pid: Int, desc: String]
This record is a receipt, not a handle. await, poll, race, and cancel
do not apply. The pid is the process's number at birth, not an enduring
capability; operating systems may later recycle it.
The surviving process has standard input, standard output, and standard error connected to the null device. It has no buffered result and its exit status is unrecoverable. A successful return says that the program was launched; it does not prove that it remained alive or initialized successfully. A detached program must arrange its own logs, and callers must probe the resource it is supposed to provide.
On Unix the implementation double-forks, reparents the grandchild, and creates
a new session. It preserves the logical working directory, environment, and
the sandbox projection active at birth. Inside a synod guest the survivor
keeps its jail's limits and outlives the engine that birthed it, as on the
host; it ends only with the machine. Later grants cannot widen it. An
active grant may refuse the operation by omitting `detach from shell before any process
is born.
detach is Unix-only and is installed only by hosts that also provide a
successful-birth budget. The budget is shared by nested workers, counts only
committed births, and is not replenished because the session cannot observe a
detached process's death. Exarch currently permits 16 births per session.
Neither interactive nor batch ral installs detach; Windows has no such
command.
11.6. Stop signals
ral does not suspend. There is no job table, no fg/bg/jobs/disown, and
no Ctrl-Z gate: a stopped child — by SIGTSTP, SIGTTIN, SIGTTOU, or an external
kill -STOP — is resumed at once by whoever waits on it, the same rule for
every role and every platform that has stops. Ctrl-Z in an interactive command
such as vim therefore does nothing visible: the program stops and is
resumed before the terminal can register the gap. Work meant to run in the
background is spawn, whose handle already carries await for foreground
waiting and cancel for termination; Ctrl-C ends what Ctrl-Z used to park.
11.7. Availability summary
| Facility | Core / portable | ral interactive |
ral batch |
exarch agent host |
|---|---|---|---|---|
spawn, gather (forms) |
yes | yes | yes | yes |
await, poll, first-settled, race, cancel |
yes | yes | yes | yes |
worker, workers |
yes | yes | yes | yes |
is-done, par, both, first-of from the standard prelude |
when that prelude is installed | yes | yes | yes |
watch |
optional | yes | yes | no |
detach |
optional, Unix only | no | no | Unix sessions that enable it |
Handles are local runtime values. They may be stored, passed to functions, and returned through locally evaluated blocks, but they cannot cross a serialized child-evaluation or remote-host boundary. Await the handle before crossing such a boundary.
12. Capabilities and sandboxing
grant temporarily reduces the authority available to its body and to work started by that body.
grant [
exec: [
allow: [`name git, `name rg, `dir `system],
deny: [`name sh],
],
fs: [
read: [`cwd ., `absolute /usr/share],
write: [`cwd build],
deny: [`cwd .git],
],
net: `offline,
shell: [`chdir],
] {
git status
}
The values are ordinary records, lists and variants; the checker types them like any other value (§12.1). Each dimension is independent. Omitting net, for example, says nothing about network access; it inherits the surrounding authority. A written dimension restricts. An empty grant changes nothing:
grant [] {
# ambient authority
}
A grant can never restore authority withheld by an outer grant or a session profile. Nested grants intersect:
grant [exec: [allow: [`name git, `name rg]]] {
grant [exec: [allow: [`name git]]] {
git status # allowed
rg foo # denied
}
}
A denial at any layer remains a denial: an exclusion (a deny entry) beats every allowance, in exec and in fs, however specific the allowance and in whichever layer it sits. Neither a narrower directory nor an exact file reopens an excluded region, and nor do a carrier (an interpreter a script's #! names), the dynamic linker or ral's own binary. A refusal that cites an exclusion says that an allowance cannot override it.
Authority is a ceiling, extended once and then only restricted. A host that composes profiles (exarch's --extend) may extend a base once; within ral there is no extension. In an extension an allowance never removes authority and only an exclusion does, on every axis, including one the base leaves unrestricted.
An exclusion names programs by path and name: it is a guardrail, not a wall. A copy of a program, a program newly written to a folder that is both writable and runnable, or a script fed to an admitted interpreter is a different request that no exclusion sees.
Every list in a grant denotes a set. An entry written twice, in one list or through spreads of two computed lists, adds nothing; no rule refuses a repeat. A target that is both allowed and denied is denied, and a place may be both read and written. An allowance names what it named when it was granted: it keeps the real path it was frozen to (§12.2), while an exclusion follows its name.
12.1. Capability fields
A capability record accepts exactly these names:
execfsneteditorshell
Unknown names are errors, reported before the program runs. exec and fs are nested tables with the fields below; net takes `offline; editor and shell take lists of verbs.
grant's bracket is the form's own, as within's is: [] is the empty grant, each field is written out with a value that may be computed, and a bundle or a map in its place is a parse error. A nested table is a record in the same way: a map (exec: [:] included) and () are refused statically, with the record spelling to use. The empty table is written with an empty list: exec: [allow: []], fs: [read: []].
The tags are types, not strings. A bare string where a place or an exec target belongs, a misspelled tag, a tag with the wrong payload, and net: true are refused statically, before the program runs, with the tags on offer and the nearest one named. A value the checker cannot type (a profile decoded from JSON, a parameter of a generic function) reaches the runtime decoder, which judges the same vocabulary.
Omitting a whole field inherits that dimension. Within fs, an omitted read or write adds no allowances of its own (reads still follow from write and from what the grant runs, §12.4); a present exec table admits only what its allow list names (§12.3).
Thus:
grant [fs: [read: [`cwd .]]] { … }
allows reads beneath the frozen working directory, denies all writes, and leaves every non-filesystem dimension unchanged.
Similarly:
grant [editor: [`read]] { … }
allows editor reads, denies editor writes and TUI access, and leaves non-editor dimensions unchanged.
12.2. Places and selectors
A place names one path. It is resolved when the grant is frozen, against the anchor its tag names:
| Place | Denotes |
|---|---|
`absolute P |
the absolute path P |
`cwd S |
S against the working directory at decode time |
`home S |
S against the effective home |
`tempdir S |
S against the platform temporary directory at decode time |
`gitdir S |
S against the current repository's real Git directory, or the working directory outside a repository |
`xdg-config S, `xdg-data S, `xdg-cache S, `xdg-state S, `xdg-bin S |
S against that XDG base |
A selector names a set of directories that the environment chooses, not a path the author wrote. It is valid only as the payload of dir in an exec list (§12.3):
| Selector | Denotes |
|---|---|
`path |
every absolute directory in the host process's $PATH at decode time; an error if there are none, if an entry is the filesystem root, the home or an ancestor of the home, or if an entry is one `absolute would refuse under the host's rules (a malformed or mixed two-separator head) or is not valid Unicode |
`system |
ral's platform-defined system tool directories, without Homebrew |
a package manager, such as `rustup or `homebrew |
the directories that manager runs programs from, where they exist; nothing if the manager is absent (§12.3) |
Selectors never appear in fs lists: their expansion is held under no anchor, and a $PATH entry can be $HOME or /, so a write grant on one would let a child plant programs where the user's own shell looks.
The payload P is absolute under the host's rules. The payload S is a relative subpath of components separated by / (and \ on Windows): . is the anchor itself, and .. folds at decode and may leave the anchor, as in `cwd ../sibling (the XDG escape guard still judges the result). The empty string, a leading / or \, and a drive prefix X: are refused in S on every platform, so a profile fails alike everywhere.
A payload headed by ~ or xdg:NAME is refused under every tag: those prefixes are expanded when a path is used, not when a grant is read, so `absolute ~/x would name a relative path. The error names the place meant, `home x or `xdg-config x.
At run time, a path headed xdg:NAME followed by a separator (/, and \ on Windows) and a subpath expands to that XDG base with the subpath joined beneath it. The subpath is everything after that one separator. A root of the current volume is dropped, so xdg:config//etc names etc under the config base, never /etc. .. is joined as written and folds with the rest of the path, so it is not confined to the base: xdg:config/../x names x beside the config base. On Windows a subpath that names a volume of its own would replace the base: one with a drive component (C:, C:x), or one opening with two separators, as a share or a namespace head does (\\srv\share, \\?\UNC\srv\share, \\.\pipe\x, and so xdg:config///x). Such a path cannot be answered and is used literally, as an unknown NAME is. Elsewhere every root is the one root, so nothing is refused.
`absolute classifies its payload on the raw string, before any folding, host first: it is live if absolute under the host's rules, dead if absolute only under the other platform's, and refused if under neither.
Raw P |
POSIX | Windows |
|---|---|---|
/x, / |
absolute | dead |
//server/share/x (well-formed UNC) |
absolute | absolute |
C:\x, C:/x, \\server\share\x |
dead | absolute |
\\?\C:\x, //?/C:/x, //?/UNC/server/share/x (verbatim) |
dead | absolute |
\x (root-relative) |
refused | refused: it names no drive |
\??\C:\x (NT object-manager path) |
refused | refused: no grant can name one |
C:x (drive-relative), anything relative |
refused | refused |
\\ or // with no server and share, or with . or .. for either (malformed UNC) |
refused | refused |
/\x, /\srv\share, \/srv/share (two mixed separators) |
refused | refused |
Absoluteness is the kernel's: under POSIX a path is absolute when it opens with /, under Windows when it opens with a drive root or two separators. Two grant rules stand beside it. A root opening with two separators that is no well-formed UNC or verbatim path, or that names the device namespace, is refused under both rules, though Windows would read it as absolute. So is one opening with two separators of different spellings, /\ or \/, outside a verbatim head: Windows reads it as a UNC or device head, while POSIX reads /\x as rooted at / and \/x as relative, so the one spelling would grant different paths on the two platforms. A verbatim root is a Windows grant and is dead under POSIX, though POSIX reads //?/C:/x as the rooted /?/C:/x: its author named a Windows path, and a profile must not grant a different path on another host.
A dead grant names no path on this host, so one profile can contain host-specific entries and load on both platforms; it matches nothing and, in a deny list, denies nothing.
A read, write or run allowance by path names the real path it was frozen to and is never re-resolved: a symlink put in its place afterwards redirects nothing, and the allowance grants nothing there. Creating a granted directory after the grant, or rm -rf dir && mkdir dir, keeps working, since these are path grants, not inode pins; a symlink spelling that existed at the grant and is retargeted later keeps showing its old target; replacing a granted directory, or an ancestor of it, with a symlink refuses every launch that shows it on Linux and Windows, naming it, and reaches nothing on macOS; macOS firmlinks keep working as twins of the real path. An exclusion is re-resolved on every check and so catches whatever its name reaches now, and a bare program name follows the host PATH afresh at each run. A grant whose real path is not valid Unicode is refused when it is frozen. A synod guest grant is not yet frozen inside the guest, so there a redirect made in one run can take effect in the next.
Places, anchors, .., environment-derived bases and symlink identities are frozen when the inline grant or profile is decoded. Later cd, HOME, TMPDIR, XDG, or repository changes do not retarget the grant. An anchored place that freezes relative (a relative HOME or TMPDIR) is refused. Ordinary relative paths and bare strings are rejected: write `cwd relative/path when that is intended.
An XDG place must resolve beneath the effective home directory. ral rejects an unset home needed by `home or an xdg-* tag, an XDG environment override that would escape the home directory, and a .git pointer file that no Git directory claims. Where .git is a file naming the Git directory, `gitdir follows it only if that directory names the working tree back, as a linked worktree's gitdir file does and as a --separate-git-dir repository's core.worktree setting does, since the file itself lies inside the tree the grant confines.
12.3. exec
exec has two lists of targets, allow and deny:
exec: [
allow: [`name git, `name rg, `absolute /opt/tools/lint, `cwd tools/build, `dir `system],
deny: [`name bash, `absolute /usr/bin/curl],
]
A target is one of:
`name N— the bare commandN: the file the hostPATHfinds for it, and the bundled tool of that name. A scopedPATHcannot change what it names.Nmust be a bare command name; a path-shaped payload is refused with a hint to use a place. Path shape is judged under Windows rules on every host, soa\band a drive-headedC:xare refused alike everywhere;- a place (§12.2) — the one executable at that path, frozen to its real path when the grant is decoded. A place that is a directory is refused, with a hint to use
dir; `dir D— every executable beneath each directoryDnames, recursively, frozen to its real path, whereDis a place or a selector. Directory scope is spelled, never inferred from a stat:`cwd tools/buildnames one executable,`dir `cwd toolsnames everything beneathtools.
Bare strings are not targets. Authority over a command does not depend on its arguments: an allowed target is admitted with any arguments, or none, and a denied target is refused. ral has no first-argument or subcommand policy.
Every rule judges the file that will run: its real path, with symbolic links followed. A bare name is resolved against the host PATH afresh at each run (one submitted line, one script or one dispatch), so the program launched is the program judged. So `name git also admits /usr/bin/git spelled `absolute /usr/bin/git, because it is the same file. A deny of a name also vetoes the command by file name wherever it lives, so deny: [`name bash] cannot be avoided through a link or another directory. A denial holds every spelling some filesystem takes for its name, in any case or Unicode form, so it also refuses BASH or a file under a differently cased denied directory; an allowance holds its name only as stored.
There is no specificity. A target is permitted when some allowance covers it and no exclusion does: an exclusion of a directory refuses the exact file or deeper directory an allowance names inside it, a target in both lists is denied, and the refusal names an exclusion that applies. A command that does not exist is never judged: it fails with status 127, or 126 when the file is not executable.
Once an exec table is present, commands not admitted by it are denied: exec: [allow: []] admits no executable (ral's own binary included), and omitting exec inherits. Nested exec tables intersect, and every denial remains effective.
An omitted exec is every program, and a host that extends a base that omits it with a fragment that has exclusions (exarch's --extend) yields every program, less those exclusions. Such a policy restricts only through its exclusions: a layer that denotes no restriction, such as every with no exclusion or an fs that writes the root and excludes nothing, activates no launch hardening (§12.10). On Linux, a program created during a launch on the spine of an excluded path does not run until the next launch, and under a restricted fs the sandbox's private /tmp runs nothing.
Running reads. Whatever a grant lets run by path, directory or name, and whatever it lets be written, may be read: a layer reads its own read and write places and the programs that it and the grants enclosing it let run. An fs-only restriction therefore does not hide the tools an enclosing grant runs; to hide one, exclude it in fs, and an fs exclusion of a run place refuses running it. A nested grant never makes anything readable by naming it in exec. A program that runs only through an omitted exec must be readable under fs, and is refused otherwise: '/opt/x/foo' may run, but it is not readable under the active grant. A trusted interpreter outside the reads and the floor starts its scripts under a restriction that supplies fs alone, as under a restricted exec.
ral's own binary is an ordinary file: ral runs exactly where a grant admits it, by path, directory or name, in the in-process guard and the kernel alike, so under an allow-list that omits it ral -c, a #! ral script and sh -c 'ral …' are refused. A grant that admits the folder holding ral admits ral (reasonable admits ~/.cargo/bin/); a nested ral there is bound by the kernel alone, so bundled-tool rules and verbs do not bind it. A bundled tool runs inside the confined child and reads ral's image: an fs exclusion over ral's binary refuses every bundled tool, on every platform, and an exec exclusion of ral leaves bundled tools alone.
A selector may be the payload of dir:
`path— every absolute directory in the current$PATH; an entry that is the filesystem root, the home or an ancestor of the home is a freeze error, as for an override below, and so is one`absolutewould refuse under the host's rules, or one that is not valid Unicode;`system— ral's platform-defined system tool directories, without Homebrew;- a package manager, such as
`rustup,`cargo,`go,`pyenv,`homebrewor`scoop. The implementation holds them as one table, and the checker's refusal of an unknown selector lists every name.
A package manager's selector is a set of locations: the directories it installs programs into and those its shims dispatch into. Each location has an ordered list of overrides, each a host environment variable with an optional subpath ($CARGO_HOME/bin), and its defaults, each anchored at an absolute path, the home, or an XDG base. A location is expanded in two steps:
- Choose. The first override with a usable value chooses, and is all the location has. A value is usable when it is non-empty and absolute under the host's rules; an empty or relative value is skipped, as
pathskips a relative entry. Where no override is usable, every default is chosen. For a variable that holds a path list (GOPATH) the override is its first entry. - Keep what exists. Only the directories that exist when the grant is frozen are kept. An override that chose a missing directory yields nothing and does not fall back to the defaults: the environment said where the manager is, so a default it disowned is not granted.
A selector is the union of its locations, and a manager that is absent expands to nothing, never to an error. Overrides are read from the host process environment, as path reads $PATH, and never from the variables of a running shell or from a tool's own configuration files; a user who relies on such a file names the directory with `dir `absolute. A home default with no bound home is absent, and a default rooted on the other platform is skipped. A location is read by running (see Running reads above), so it is the narrowest subtree that holds what runs, and holds no credential.
An override whose chosen directory is the filesystem root, the home, or an ancestor of the home is a freeze error that names the variable and asks whether it is set correctly: running reads would make the selector a read of everything. The test is on the directory, not the variable, and follows symbolic links, so GOPATH=~, which chooses ~/bin, is accepted.
Four selectors, `homebrew, `macports, `nix and `pkgsrc, name system-wide package prefixes outside any home, and the read floor (§12.4) includes them wherever they exist, less any chosen directory inside the home: that is the user's, and is readable only where the grant says so.
12.4. fs
fs has three lists of places:
fs: [
read: [`cwd ., `xdg-data .],
write: [`cwd build, `tempdir .],
deny: [`cwd .secrets],
]
readpermits reads under the listed places.writepermits writes under the listed places.denyforbids both reads and writes under the listed places.
name, dir and selectors are refused in these lists. Writing reads: a write allowance also allows reading what it names, in the layer that states it, so the in-process guard reads every write prefix as the kernels do. A present fs table with no read or no write entry grants nothing for that operation, for read nothing beyond the floor; fs: [read: []] keeps only the floor. Deny regions are carve-outs and accumulate across nested grants.
The floor is the platform's fixed set of read-only system paths that any program needs to start (§12.11). Every restricted read admits it, whether or not the grant names it, and the in-process check and the host sandbox agree on it. No nested grant narrows it; only a deny removes a floor path. It admits reads alone: never writes, never execution.
ral resolves the accessed path, including symlinks, before testing prefix membership. Allowance prefixes carry a symlink-resolved identity captured when the policy is decoded and keep it; exclusions are re-resolved on each check. Composition never flattens nested policies into one: each active layer keeps its own frozen prefixes, and a check folds the whole stack afresh — the allow region for a read or write is the intersection of every opining layer's admitted prefixes, the deny region is the union of every layer's carve-outs, and a denial at any layer wins over an allow admitted elsewhere in the stack. Point-of-use checks resolve the live path again. The discard device is always permitted: /dev/null on Unix, \\.\NUL on Windows. On Windows a path whose last component is a reserved DOS device name (NUL, CON, PRN, AUX, COM1–COM9, LPT1–LPT9, and COM¹–COM³ and LPT¹–LPT³ with a superscript digit, in any case and with any extension) is refused; \\.\NUL is the one device name ral accepts. A path opening with two separators that is no well-formed share or verbatim path is resolved as written, .. included: no lexical fold can tell where such a root ends, and folding across it could assemble a share or a drive the path never named. So is a verbatim path spelled \\?\ with backslashes alone: Windows hands its rest to the kernel uninterpreted, / a character of a name and . and .. names. One spelled with a / in its head, such as //?/C:/x, is normalised by Windows like any other path, and . and .. fold past its drive or share. On Windows a file operation on one whose head names neither a share nor a namespace (\\x, \\\srv\share, /\x) fails, rather than reaching a path on the current drive, and so does one that reaches such a path, or one relative to a drive's current directory (C:x), through a symlink's target.
ral checks filesystem operations that it performs itself. A spawned program’s own filesystem access is instead confined by the host sandbox where supported.
A deny may name a path that does not exist yet, inside a write prefix. On macOS the kernel refuses spawned programs its creation. On Windows nothing is granted that could reach it: the allowance is carved around it, and a name made in a carved directory during a run is unreadable to that run (§12.11). On Linux the kernel cannot refuse the creation of a single name, so ral’s own check alone holds it: ral never creates the path, but a spawned program can, and that program and the processes it starts can use what it created. Each external command is confined afresh, so every later command finds the path and is refused it, and what was created stays on disk. A symbolic link created at the path extends the deny to the link’s target.
One write no grant admits: onto a binary the sandbox pinned at startup — bubblewrap on Linux, ral’s own executable where it re-execs itself — judged by inode, so a hard link or a rename since startup names it too. The refusal names the binary and is not a grant denial. Replacing the file by rename is permitted; the session keeps running the copy it pinned.
12.5. net
net takes the one value `offline:
grant [net: `offline] { … }
`offline disables network access for spawned children through the OS sandbox. A grant only narrows, so there is no value that grants network access: omit net to inherit, and an outer `offline cannot be lifted. net: true and net: false are refused statically. ral has no in-process network primitive, so there is no separate ral-level network check.
If the host cannot enforce `offline, ral refuses the confined launch rather than silently running it online.
12.6. detach
detach is a verb of shell (§12.8): it controls whether the body may create a process that the session stops owning.
grant [shell: [`chdir]] {
# detach is refused; spawn remains the session-owned alternative
}
A layer that supplies shell and omits detach denies the birth, anywhere in the active stack; a layer cannot override an outer one's omission. Writing shell: [`chdir] therefore withholds detach, the safe direction, since detach is the authority that outlives the session.
When detachment is allowed, the child is created under the effective
filesystem and network projection and, on hosts that support it, the effective
executable-path projection. That projection remains attached to the survivor
for its lifetime. On Linux the survivor also keeps its envelope's process
namespaces: the receipt's pid names the envelope, not the program; the
survivor's own getpid() is local to its namespace; and from inside any later
grant the survivor is invisible, reachable only through what it serves.
detach itself is not part of the OS profile; it decides whether the survivor
may be born.
The detach command is present only when the host installs it together with a birth budget.
12.7. Recorded decisions
A grant has no dimension for recording. Whether a capability decision is
recorded is decided by the decision, not by the grant that made it: whenever an
audit trail is open — from the language-level audit { ... } form or from batch
--audit — every exec and ral-owned fs denial is recorded as a
`check observation (§13.3), and no allowed check ever is. There is
nothing to ask for and nothing to switch off, so no grant can hide a denial
from a trail its caller opened.
An exec check identifies the command; a point-of-use check also carries its evaluated arguments and its resolved name, while an early head denial has no arguments yet. A filesystem check identifies the operation and the path. A denied head admission also reaches the surface rail, whether or not a trail is open.
grant is transparent to the audit model: it contributes no observation of its
own. Its body's checks, command calls, and redirect reads and writes land
directly in the trail that lexically owns them, and nested grants add no
levels.
Entering a newly composed capability frame can also record a deputy /
flagged check when an executable directory prefix is writable, since the body
could replace or create a program there — one observation per flagged prefix,
naming it in fields[prefix]. This finding reports the confused-deputy shape;
it does not deny it.
The checks on net and on the editor and shell verbs record no individual
observations.
12.8. editor and shell
editor and shell are lists of verbs, each a tag:
editor: [`read, `write, `tui]
shell: [`chdir, `detach]
editor controls the private line-editor interface:
`read— inspect editor text, cursor, history, keymaps, parse state, and related state;`write— change editor text or state and accept input;`tui— open the editor TUI facility.
An _ed-* builtin needs the verb of every operation it performs, checked before its first effect; _ed-state needs both `read and `write.
shell controls what the body may do to the session itself:
`chdir— change the working directory withcd;within [dir: …]scopes a directory and is judged byfsalone;`detach— create a process that the session stops owning (§12.6).
A verb is permitted only where every layer that supplies its list names it, so nested grants intersect. A list naming every verb is neutral, like omitting the field; an empty list withholds every verb, and an unknown verb is an error listing the verbs on offer. Under an extension, as exarch's --extend, the base's verbs are joined by union with the extension's: a verb list has no spelling for an exclusion, so an extension can add a verb its base lacks, a base cannot withhold a verb (detach included) against it, and a base that omits the field keeps every verb.
12.9. Concurrency and inheritance
A spawned worker receives a snapshot of the complete dynamic context, including the active grant stack. It cannot observe a later widening in its creator. Pipeline-stage children likewise inherit the active capabilities through their process boundary.
Authority does not flow back from a worker. A worker may narrow its own authority further, but finishing that inner grant does not change its creator’s stack.
External and bundled commands receive an OS-sandbox projection folded from the effective fs, net, and, where supported, exec policies at the moment of launch.
12.10. The two enforcement layers
ral uses two complementary enforcement layers:
- The in-process guard checks command admission, ral-owned filesystem operations, editor operations,
cd, and detached-process birth. - The OS sandbox confines filesystem and network actions performed directly by a spawned program. On macOS and Linux it also confines executable paths. It enforces the same rules as the guard: an exclusion beats every allowance there too, and no backend makes an exception for a carrier, the dynamic linker or ral's own binary.
The in-process command guard applies on every host, including when no OS sandbox is needed. It cannot see a command that an admitted child launches internally. Conversely, an OS sandbox cannot protect operations ral performs in its own process.
A directory that exec admits is also a place a child could author a program the grant never named. ral does not freeze such a directory against a broader write prefix that covers it: an allowance never withdraws write authority. A broad write over a run-admitted folder therefore lets a child write programs there, including ones the user later runs from their own PATH outside any sandbox, and a name exclusion beneath a writable directory, or beneath an omitted exec, is advisory to descendants. exarch's edit-only launched from the home directory can write ~/.local/bin. The deputy findings name every folder a grant leaves both writable and runnable, each writable island under a runnable folder and none that is wholly excluded on either side; they are recorded only into an open audit trail (§13), by pushing the profile as a session layer under an open trail, for example ral --audit --capabilities exarch/data/edit-only.exarch.ral -c true, which from the home directory records a deputy check for ~/.local/bin.
Dynamic loader injection variables such as LD_PRELOAD, LD_AUDIT, LD_LIBRARY_PATH, and DYLD_* are removed from child environments while a restriction is active. A stack is restricting when some layer denotes something other than no restriction: an exec that is not every program less nothing, an fs that does not write the root with no exclusion, net: `offline, or a verb list that omits a verb; a layer that restricts nothing ([], a policy that denotes everything, or full editor and shell lists) leaves ral ambient, and added to a restricting stack it changes neither the judgments nor the protections. Confined Unix children also receive resource limits; Windows confines process trees with a Job Object.
12.11. Host differences and limits
macOS. ral launches confined children under Seatbelt. Filesystem, offline-network, and executable-path restrictions are kernel-enforced. The executable allow-list also constrains programs launched internally by an admitted child. Since Seatbelt logs filesystem, executable, network, and Mach/IPC denials through one stream, a denial's diagnostic hint (§13.2) is classed by what was actually refused, never assumed to be a filesystem read merely because a hint has to say something. The read floor is /bin, /usr, /lib, /System, /dev, the Command Line Tools and Xcode developer directories, the system-wide package prefixes of §12.3 (`homebrew, `macports, `nix, `pkgsrc) that exist, the dyld cache directory, /etc wholesale, and the xcode-select and runtime-socket directories under /var; Rosetta's runtime is likewise runnable by every confined child.
Linux. ral uses bubblewrap for filesystem restrictions and net: `offline; on x86-64 and aarch64 every confined child also runs under a seccomp deny-set: kernel attack surface (ptrace, bpf, module loading, kexec/reboot/swap, keyrings, userfaultfd, open_by_handle_at, the mount family) is killed with SIGSYS, and user-namespace creation, setns, clone3 and ioctl(TIOCSTI) are refused with an errno — no mounting and no containers inside a grant. ral opens each path the envelope shows itself, never through a symbolic link, and has bubblewrap mount that open file rather than a name, so a path replaced by a link after the grant was read shows nothing the grant did not name, and one whose components became a link refuses the launch; this needs bubblewrap 0.8.0 or later, and under an older one no confined command runs. A grant that limits which programs may run is refused on a kernel without Landlock, and every confined command is refused on a kernel older than 5.9, which cannot close the descriptors ral hands the child before its program starts. A directory the exec grant admits is readable inside, as on macOS. The read floor is /bin, /usr, /lib, /lib64, the files under /etc that dynamic linking, name resolution, user lookup and toolchain resolution need, the system-wide package prefixes of §12.3 (`homebrew, `macports, `nix, `pkgsrc) that exist, and of /sys only what sizes a program (/sys/devices/system/cpu, /sys/kernel/mm/transparent_hugepage); the envelope binds it read-only. The rest of /sys describes the host — its class/net lists the host's interfaces whatever net says — and a grant that wants it lists /sys under read. Every confined child runs in its own ipc and uts namespaces and, wherever the host can mount a fresh procfs, its own pid namespace with its own /proc: on every projection it sees and can signal no host process. Wherever the kernel builds a cgroup namespace the child has one, and on every projection /sys/fs/cgroup is the tree its own /proc/self/cgroup names — ral's cgroup, so a runtime reads its real limits; where the kernel cannot, the child sees the host's tree. A container runtime that masks /proc prevents the pid namespace; the launch still runs, and the table is the container's own. Each other invariant the host cannot build is likewise absent while the launch still runs. A confined command has no controlling terminal. Bubblewrap has no path-based executable filter, so the confined child enters a Landlock domain of its own inside the envelope: executable-path restrictions are kernel-enforced on kernels with Landlock (Linux 5.13 and later), constraining programs launched internally by an admitted child as on macOS, and on Landlock 6 kernels (Linux 6.12 and later) the child can signal no process outside the envelope even where no pid namespace could be built. What the kernel gates is the path passed to execve. Landlock is allow-list only, so a denied path inside an admitted directory is held by admitting that directory's other entries, as they stand when the command launches: a program added there afterwards is refused until the next launch, and a bare-name deny under an admitted directory that a write reaches is held by ral's own check alone, since a child could author a renamed copy there. An excluded path that is a hard link to an admitted file runs, since Landlock admits inodes. Under every program with exclusions, a program created during a launch on the spine of an excluded path waits for the next launch, and the sandbox's private /tmp runs nothing. An admitted dynamic loader or interpreter invoked explicitly on an unadmitted file is refused by ral's own check but not by the kernel. An attenuated exec alone is enough to build the envelope, so a grant that narrows only which programs run is still kernel-enforced.
Windows. A launch whose filesystem region holds every path (exarch's dangerous with an exclusion from --extend, or a restrict file that only excludes) is refused before anything is stamped, since an AppContainer reaches only stamped folders and ral will not stamp every drive; the in-process guard still judges. A Windows sandbox cannot have access taken away beneath a folder it is granted, so an allowance holding an exclusion is carved at each launch: it is granted as its entries as they exist then, less the excluded ones, down to each exclusion, and each directory on the way is granted only the listing of its own names. An entry made in a carved directory during a run is unreadable to that run, the excluded name included; nothing can be created or renamed into a carved directory; a link among its entries is not granted or followed; and its entries' names and listed metadata are visible. A launch is refused whose exclusion lies on or around an allowance, the program it runs included, since nothing can be carved there. Every exclusion is then checked at each launch against the sandbox's own access, and the launch is refused, naming the object, if anything beneath the exclusion is open to it: an entry granted to every sandbox (ALL APPLICATION PACKAGES), or a hard link to a granted file. ral uses a projection-specific AppContainer token, filesystem capability SIDs, and a Job Object. AppContainer is deny-by-default: a child confined only to obtain net: `offline does not automatically retain ordinary access to the user’s working tree. Windows has no path-based executable filter, so an admitted program may execute another visible program internally. Network and UNC grant paths are unsupported. The read floor is %SystemRoot%, %ProgramFiles% and %ProgramFiles(x86)%, which AppContainer already reads. A host's ambient ALL APPLICATION PACKAGES grants may reach beyond the floor: the kernel is then wider than ral's own check, never narrower, and only a less-privileged AppContainer could close it. Filesystem capability entries are attached to NTFS objects; same-volume renames and hard links can therefore preserve or omit authority differently from a purely path-based rule.
On another host, a filesystem restriction fails when no per-command sandbox backend can be built, and net: `offline is explicitly rejected if kernel network enforcement is unavailable.
12.12. Capability profiles
A capability profile is an ordinary .ral script whose terminal value must be
a capability record with exactly the same contract as the one grant accepts:
# read-only.ral
return [
exec: [
allow: [`name git, `name rg],
],
fs: [
read: [`cwd .],
],
net: `offline,
shell: [`chdir],
]
Load one or more session-wide profiles with:
ral --capabilities read-only.ral script.ral
ral --capabilities base.ral,offline.ral script.ral
The loader parses, elaborates, type-checks, and evaluates each profile, then
decodes that terminal value. Bindings created by the script are not projected
into a policy as use bindings would be: the value itself must be the
returned record, or () for a profile with nothing to set, which is the
lattice top. return [...] is the direct way to make that contract explicit.
A profile that ends in a scalar, a list, a block, or any other value is
rejected.
A profile is ascribed grant's table after inference, as at the rc file
(§15.3) and the plugin manifest (§15.5). return [exec: …, net: `offline] is a
record, so exec's misspelling as exect is caught statically, before the
profile script runs at all; so is a misspelled place tag, a bare string in a
list of places, or net: true, since the nested tables are ascribed too.
return ["exec": …] is a map, whose
keys are data rather than the table's labels, and is refused statically with
the spelling to use. A profile whose return the checker cannot type — one
decoded from JSON — runs to completion first, and its keys are then checked
against the same table at the moment the profile is decoded. Either way an
unknown key or a badly-shaped value refuses the whole profile — the session
never starts under it.
All listed profiles use the same load-time home and working directory. They are decoded by the same strict decoder as inline grants. Each decoded profile already contains frozen paths; the profiles are then pushed onto the session's capability stack in listed order, each its own permanent layer above the ambient root, rather than flattened into one composite policy — the ceiling they form is whatever the ordinary per-check fold of that stack decides. Later inline grants may narrow that ceiling but cannot widen it.
A profile computed in the language is a profile file, then, not an inline
bundle: grant's bracket writes its fields, so grant $p[policy] { … } does not
parse. grant [fs: [read: $paths], net: `offline] { … }, with computed values, does.
Compilation or runtime failure in a profile, a terminal value that is not a
record, a map or () where a nested table belongs, unknown fields at any
depth, a bare string where a place or exec target belongs, an unknown tag,
verb, or net value, a tag with the wrong payload, an obsolete argument
restriction (only, or a list of subcommands), a selector in an fs list, an
unwrapped selector or a directory place in an exec list, a path-shaped
name, a place whose payload is not absolute (`absolute) or not a
relative subpath (every other tag), a ~ or xdg: payload, and an anchor
that cannot be frozen are reported as configuration errors. Inline-record
decode errors occur before the grant body runs; session-profile errors prevent the requested
session or batch program from starting.
13. Audit, diagnostics, and testing
ral keeps three kinds of evidence distinct:
- a runtime error is a value-like description of failed computation;
- a diagnostic explains that failure to a person, usually against source text;
- an audit trail records what ran, how it settled, and which bytes crossed its file descriptors.
The distinctions matter. A command may write arbitrary bytes to fd 2 without
failing. A command may fail without writing any bytes to fd 2. ral's explanation
of a failure is therefore not inserted into the command's captured stderr;
it lives in an error record, a diagnostic, or an observation's separate error
field.
Byte pipelines preserve the same separation: their edges transport bytes between stages, while diagnostics and audit data use their own channels. A pipeline edge is never an implicit diagnostic or audit channel.
13.1. Runtime errors
A runtime error has a message, a nonzero status, an optional source span, and
an optional hint. It aborts the current computation until a construct such as
try handles it.
fail raises an error from an error record:
fail [status: 7, message: 'index is stale']
The status field is required, must be an Int, must fit the process exit-code
range, and must be nonzero. message is required and is a String. Extra fields
are allowed and ignored by fail, which permits a caught error record to be
re-raised directly:
try {
refresh-index
} { |error|
log $error[message]
fail $error
}
Other argument shapes are errors. In particular, fail 7 and fail 'broken'
do not abbreviate an error record.
try passes its handler a closed record:
[
cmd: String,
status: Int,
message: String,
site: `some [script: String, line: Int, col: Int] | `none,
]
cmd is the name the failing dispatch stamped on the error, and <runtime>
when no command was involved. site holds the script and one-based source
coordinates, or is `none when no position is available. The record deliberately
contains no output bytes. The failed command's raw fd 1 and fd 2 bytes have
already followed their ordinary destinations; use audit when those bytes must
also be retained as evidence. This is the record audit's `err outcome
and poll's settled `err outcome carry, so failure reads the same
wherever it is met.
Static checking rejects a literal zero status. A dynamically computed
zero is rejected at runtime with a suggestion to use return for a clean
result.
Errors raised by the evaluator use the same path as explicit failures. For
example, a map is indexed by a key computed at runtime, so a key the map does
not carry can only be discovered there; the miss is an ordinary runtime error
naming the absent key. It is catchable by try and recordable by audit; it
is not an implicit Unit, sentinel, or process crash.
13.2. Human diagnostics
The ral executable reports parse, type, and runtime failures on standard
error. A diagnostic normally includes:
- a stable category code;
- the source name and a highlighted source range;
- a concise message and label;
- a secondary range or help text where it clarifies the repair.
Lexer diagnostics use L codes, parser diagnostics use P codes, type
diagnostics use T codes, and runtime diagnostics use R codes. When a span
cannot be resolved safely, ral prints the code, message, and optional help
without inventing a caret in the wrong file. Source spans count bytes
internally; diagnostics convert them to character positions before rendering.
For a one-command interactive input, a compact runtime diagnostic is often more useful than pointing back at the line just entered. It has this general shape:
error: MESSAGE (exit status N)
hint: REPAIR
The status suffix is omitted when the failure message already describes a process failure, and the hint line appears only when a hint exists. Errors in scripts, startup files, aliases, and other stored sources use the source-aware form instead.
A failure the OS sandbox reports for a spawned program is diagnosed from the
kernel's own denial log over that call's window (§12.11), and the hint answers
once for each class of denial the log actually contains, rather than assuming
every denial is the same kind. On macOS, a denied filesystem read or write
names the fully-resolved path — so a symlink inside a granted directory does
not make the link's own directory sufficient — and the grant's matching read
or write set; a denied executable names the path and the grant's exec
allow-list, the only layer a re-exec such as sh -c or find -exec reaches; a
denied socket names the net: `offline grant as the whole of the remedy; and a
denied Mach or IPC lookup names the door itself, quoting the base profile's own
recorded reason for withholding it where one exists — an unexplained door is
still named, since it is usually a harmless probe rather than the failure's
real cause. On Linux, the kernel log carries only the seccomp deny-set: a
denied syscall is described by that deny-set's own reason where it has one,
and otherwise named by number alone.
Colour is presentation, not meaning. It is disabled when the terminal cannot support it or the usual no-colour conditions apply.
Batch-mode exit status distinguishes static phases:
| Failure | Exit status |
|---|---|
| lexing, parsing, or elaboration | 2 |
| type checking | 1 |
runtime error or explicit exit |
the resulting status, clamped to 0–255 |
Successful execution exits 0. Diagnostics go to standard error; they are never a payload and never pipeline traffic.
13.3. The audit trail
audit { body } evaluates body with a fresh lexical trail active and
returns a report:
let report = audit {
echo 'checking'
verify-index
}
case $report[outcome] [
`ok: { |value| return $value },
`err: { |error| echo $error[message] },
]
Auditing is observational. Captured output is teed into the trail while the
same bytes continue to their ordinary fd 1 and fd 2 destinations. audit
does not change where bytes go or turn a returned value into pipeline traffic.
Process-staged pipeline fragments are transported back and merged into the
surrounding trail; crossing a process boundary does not make a stage disappear
from the trail that lexically owns it.
The report is not itself an observation. It has two fields:
[
outcome: <`ok A | `err [cmd: String, status: Int, reason: Reason,
message: String,
site: `some [script: String, line: Int,
col: Int] | `none]>,
trail: [Observation],
]
outcome says how the body settled. `ok carries the body's value.
`err carries exactly the error record try hands its handler, reason
included (§8.6), so
one outcome vocabulary serves try, poll (§11.2), and audit: a reader who
can read one can read all three. Its message is the runtime error's message
alone — a hint is advice for a person, and audit returns data, so no hint is
appended.
audit therefore handles an ordinary runtime error as data: evaluating the
audit expression succeeds, with the failure in outcome. Control escapes are
different — exit propagates out of audit rather than being converted into a
returned report.
Two prelude functions read a report without a case:
| Call | Result |
|---|---|
commands $report |
the `command payloads of $report[trail], in order |
succeeded $report |
Bool, true exactly when $report[outcome] is `ok |
let r = audit { grep -c ERROR app.log }
if !{succeeded $r} {
echo !{bytes-to-string !{commands $r}[0][stdout]}
} else {
echo 'search failed'
}
Settlement order
trail is the flat list of every observation the body's dynamic extent
produced. It is not a tree: an observation carries no children of its own.
Observations appear in settlement order — each is appended when the fact it
records becomes true, not when the construct that produced it began. So a
command's observation lands after the observations of everything that command
ran, and a redirect's write lands when the write settles rather than when the
redirect was parsed. The trail is thus a post-order traversal of what happened.
Nesting is recovered from the timestamps, by containment: one observation's
start–end interval contains another's exactly when the first's extent
contained the second's. Containment is the only nesting the trail carries, and
it needs no parent pointers, so a fragment transported back across a process
boundary stays as readable as one recorded in place.
Observations
Every observation shares this common shape:
| Field | Type | Meaning |
|---|---|---|
site |
`some [script: String, line: Int, col: Int] \| `none |
one-based source position; `none for a run root |
start |
Int |
microseconds since the Unix epoch |
end |
Int |
microseconds since the Unix epoch |
principal |
`some String \| `none |
shell principal when the observation was recorded; `none where nothing named one |
what |
Variant |
which kind of fact this is, together with its own fields |
There is no kind field. The tag of what is the kind, so a reader
dispatches with case and the checker decides exhaustiveness (§8.3):
what: `command [argv: [String], status: Int, origin: <`external | `detached>,
stdout: Bytes, stdout-total: Int,
stderr: Bytes, stderr-total: Int,
error: <`some String | `none>]
| `write [path: String, mode: <`write | `append | `stream>,
outcome: <`committed | `aborted | `failed>,
new-bytes: <`some Bytes | `none>,
old-bytes: <`some Bytes | `none>]
| `read [path: String]
| `grep [scope: String, pattern: String]
| `check [resource: <`exec | `fs | `deputy>, decision: <`denied | `flagged>,
fields: Map String]
| `worker [id: Int, title: String]
| `act [verb: String, subject: <`some String | `none>,
payload: String, refused: Bool]
grep and act are raised by host doors — exarch's grep-files and its
committed harness acts — not by any door in core itself.
A `command observation records an external or bundled command, or a
detach birth:
| Field | Type | Meaning |
|---|---|---|
argv |
[String] |
the shown name first, then its evaluated arguments |
status |
Int |
the outcome's status: 0 for a command that returned, otherwise its error's exit code |
origin |
`external \| `detached |
the door the command came through |
stdout |
Bytes |
retained prefix of fd 1, at most 1 MiB |
stdout-total |
Int |
bytes written to fd 1, 0 without byte capture |
stderr |
Bytes |
retained prefix of fd 2, at most 1 MiB |
stderr-total |
Int |
bytes written to fd 2, 0 without byte capture |
error |
optional String |
ral's runtime error message, `none when the outcome was not a runtime error |
A command's returned value is not among them. argv, the status, and the bytes
are the evidence a later reader can act on; a process-local or executable value
has no honest projection, and an external command has no value to project.
A `write observation records a > / >> / >~ redirect settling:
| Field | Type | Meaning |
|---|---|---|
path |
String |
the redirect's resolved target |
mode |
`write \| `append \| `stream |
how the redirect opened its target |
outcome |
`committed \| `aborted \| `failed |
how the write settled |
new-bytes |
optional Bytes |
the target's whole content after the write |
old-bytes |
optional Bytes |
the target's whole content before it, empty for a new file |
An optional field is a variant, `some of the value or `none. Both
byte fields are always present: an absent image is `none, never a missing
key, so a reader eliminates it with case rather than testing for a key, and
"there was no before-image" stays a fact the trail states rather than one the
reader infers from silence.
A write is observed when its body settles — or, one whose target never opened, as failed there — carrying its target before and after: each image whole, or `none when it cannot be read whole, being past 64 KiB, not a regular file, or not readable under the live grant. A write that did not land carries neither. A streaming write (>>, >~, 2>) is committed whatever the body did, since its bytes landed as they were written; only an atomic > can be aborted.
A `read observation — a < file redirect opening — carries only path.
A `grep observation carries the scope searched and the pattern
searched for. A `check observation records a capability decision and is
described below.
A `worker observation records a session worker's birth; a gather's
child is not announced (§11.1), so its birth records none:
| Field | Type | Meaning |
|---|---|---|
id |
Int |
the worker's registry identifier |
title |
String |
the title it was born under |
An `act observation records an act the host committed:
| Field | Type | Meaning |
|---|---|---|
verb |
String |
the act performed |
subject |
optional String |
what it acted on, where the act names one |
payload |
String |
the act's rendered argument |
refused |
Bool |
true when the host refused the act |
When an atomic > commit fails after the command itself exited cleanly, the
`command observation reports the failed commit, not the child's own
status: status is nonzero and error names the commit failure.
A `command's error field is present even on success, where it is
`none. It is `some only from ral's runtime-error path; it is not
copied from stderr, and a nonzero status need not imply a `some error.
Conversely, stderr contains only bytes observed on fd 2. It never contains
synthetic runtime prose merely because a command failed.
stdout and stderr each retain an exact prefix of at most 1 MiB; no
synthetic marker is inserted. stdout-total and stderr-total count every
byte observed at the tee, so a stream is complete exactly when its total
equals the length of its bytes and truncated when the total is larger.
A detach birth, a direct-spawn pipeline stage, or a command running without
byte capture (CapturePolicy::Off) has empty bytes and total 0.
Neither stream is privileged. These are retention rules, not I/O limits:
every byte still streams to its ordinary destination. Ordinary value capture
(§7) and worker buffers (§11.1) retain their separate 16 MiB limit.
Command observations cover external commands, bundled commands, pipeline
stages, and detach births. Arguments are recorded after evaluation and
rendered to strings as part of argv.
A builtin application is not an observation. The language's own value
fragment — builtins, prelude functions, lexical bindings — performs no effect,
and a builtin that reaches the world does so through a door the trail already
watches: a redirect, an atomic write, a worker's birth, a capability check. The
door records the effect there, as the `write, `read, `worker
or `check it is, so == evaluated a million times inside audit { }
adds nothing to the trail and a file edit lands once, as the write it made.
User-function application is transparent. Control forms and scopes, including
if, case, within, grant, guard, and try, are transparent too.
Collection iteration manufactures no wrapper observation, and the builtin that
performs it records none of its own.
Commands, reads, and writes performed inside a transparent function, form,
scope, or iteration remain visible. They land in the trail that lexically owns
them rather than under a synthetic wrapper. A function that only computes and
returns a value adds no observation at all. A nested audit returns its own
report, while its own observations also merge into an enclosing trail.
try does not consult the trail. The name of the failing command is stamped
on the error by the dispatch that failed, so the innermost failing dispatch
wins, exactly as the innermost source span does. try therefore costs nothing
beyond its own frame: it forces no collection, requests no byte retention, and
adds no observation. Inside an enclosing audit, byte capture remains enabled
— an inner try cannot silence an outer audit.
Capability checks
Capability decisions are recorded as `check observations whenever a trail
is open. grant has no dimension for asking and none for withholding: the
decision itself settles whether it is recorded.
- A denial is always recorded. It is a fact about authority that nothing else in the trail attests.
- An allowed check is never recorded. Everything it would attest is already attested by the command, read, or write it let through.
A denied head admission also reaches the surface rail, whether or not a
trail is open. An fs or full-argv denial reaches the trail alone; the command
it refused still surfaces in its own right, as a failed `command
observation carrying the denial message.
A check's fields are:
| Field | Type | Meaning |
|---|---|---|
resource |
`exec \| `fs \| `deputy |
the resource class judged |
decision |
`denied \| `flagged |
`denied, or the advisory `flagged; the resource's own, so a `deputy is never denied |
fields |
Map String |
the resource-specific detail |
A check is a leaf: no arguments, no I/O, and equal start and end
timestamps. A denial is never encoded as a status, and it manufactures no raw
stderr bytes.
The confused-deputy advisory (§12.7) records one `check per flagged
prefix, with resource: `deputy, decision: `flagged, and
fields: [prefix: …] naming the writable executable-directory prefix. It
reports that shape; it does not deny it.
13.4. Batch audit JSON
For a script or -c program, --audit records the entire run and emits its
report as JSON after execution:
ral --audit script.ral
ral --audit --pretty -c 'echo hello'
--pretty requires --audit. These flags are batch options for an explicit
script or -c; they are not interactive or stdin-script options.
The top-level JSON object is the same two-field report as the language-level
audit form's — outcome and trail — for the whole run rather than one
block. trail is the run's flat list of observations, in settlement order. An
ordinary runtime failure gives `err carrying the run's error record; an
explicit exit at the root is a control escape rather than a failure, and
gives `ok of Unit.
The report does not carry the process exit status. That status is the host's,
--audit does not change it, and a second copy inside the record could only
disagree with it.
The JSON projection favours legibility over round-trip fidelity:
Bytesbecome strings using lossy UTF-8 conversion;Unitbecomesnull;- variants become objects with
tagand, where present,payload; - executable or process-local values such as blocks, functions, natives, and handles become descriptive stubs.
The variant rule is what shapes the report: outcome reads as
{"tag": "ok", "payload": …} or {"tag": "err", "payload": {…}}, and each
observation's what as {"tag": "command", "payload": {…}} and its kin.
Compact mode writes one JSON document followed by a newline. Pretty mode
writes the same data with indentation. Both write to fd 2. Program output is
not suppressed: fd 1 remains ordinary stdout and raw program fd 2 bytes remain
ordinary stderr. Consequently the JSON shares stderr with any bytes the
program writes there, and with optional RAL_TIMING lines. fd 2 is not
promised to be a standalone JSON stream.
On a runtime failure, --audit suppresses the separate human diagnostic
because the report's `err payload already carries ral's explanation. It
does not suppress the program's raw stderr. Parse, elaboration, and type errors
happen before execution, so they produce their ordinary diagnostics and no
report. The program's real exit status is preserved; --audit does not turn a
failed run into a successful one.
13.5. Testing ral programs
The language does not reserve a privileged assertion form. Tests can build
small assertions from ==, fail, and ordinary functions:
let assert-equal = { |name expected actual|
if $[$expected != $actual] {
fail [
status: 1,
message: "assertion failed: $name",
]
}
}
assert-equal 'answer' 42 !{compute-answer}
Expected failures remain explicit:
try {
parse-config 'broken input'
fail [status: 1, message: 'parse unexpectedly succeeded']
} { |error|
assert-equal 'parse status' 2 $error[status]
}
within [handlers: ...] supplies deterministic command doubles without
changing global process state:
within [handlers: [fetch-clock: { |args| echo '12:00' }]] {
assert-equal 'clock' '12:00' !{fetch-clock}
}
The handler receives the command's argument list, a List String. Per-name
handlers follow the ordinary command-dispatch rules, including inside byte
pipelines. A handler stands in for a command, and a command's value is its output, so a
mock must be a faithful one: it writes what the real command would write, and
the test decodes it the same way a real caller would —
!{fetch-clock} captures the mock's line, and a decoder such as from-json
reads a mock that emits structured output.
audit is the execution-observation testing surface. It can assert the
observation sequence, statuses, raw output, capability denials, and source
locations without scraping a human diagnostic, and succeeded and commands
cover the common assertions without a case. Transparent language forms do not
supply nesting assertions; their nested calls appear directly in the trail that
owns them. Tests that compare output bytes should inspect a `command's
stdout or stderr; tests that compare ral's explanation should inspect its
error, or the message of the report's `err payload.
For executable-level checks:
ral --checkorral -nparses, elaborates, and type-checks a script without executing it;ral --dump-astparses and prints the debug AST to stderr without elaborating, type-checking, or executing it;ral --dump-irparses and elaborates, and prints the elaborated phrases to stderr without type-checking or executing them;ral --auditexercises the real program and exposes its audit trail.
The repository's script harness discovers tests/**/*.ral, runs each runnable
script through the real ral executable, and requires a zero exit status.
For the portable deterministic subset, stdout is compared with a sibling
.out file after ignoring carriage returns. RAL_BLESS=1 rewrites those
goldens:
RAL_BLESS=1 cargo test -p ral --test scripts
Those harness conventions test this implementation; they are not additional language semantics.
14. Standard environment
Every normal ral program starts with two layers of public names:
- core builtins, implemented by the runtime for primitive value operations, shell state, structured operating-system queries, codecs, and concurrency;
- the prelude, an ordinary ral module loaded before user code and built from those primitives.
An embedding host may add a third layer. The ral REPL adds interactive commands; exarch adds agent tools and a small helper library. Host additions are not part of the portable core environment.
The standard environment is typed. A builtin that writes to standard output,
rather than returning a value, says so in its type, Command, and the
typechecker records that before execution.
14.1. Discovering the live environment
The running shell is the authority on the names it actually provides:
help
explain map
explain from-json
help prints sorted Language:, Builtins:, and Prelude: sections. The
language section covers every reserved form and Boolean literal. The other
sections use the builtin registry and the prelude's ## documentation, the
same sources used to build the shell. When a host has installed documentation
for a sourced library, help also prints a Library: section.
explain name prints the name's full documentation, inferred type or command
signature where one exists, and where the name comes from. It understands
language forms and literals, core and host builtins, prelude entries,
documented host libraries, session bindings, aliases, handlers, and external
commands. Language entries are short standalone usage guides. The renderer
separates a conventional usage line from its prose and labels the facts it has
as Usage:, Type:, Source:, and Shadows:; a missing fact is omitted. If
no exact documented name exists, it searches documented names
case-insensitively. Regex search is used when regex support is compiled in;
otherwise the search is a substring match.
help is the canonical catalogue of documented standard and host-library
entries, not a list of every command that might be reachable. Arbitrary aliases,
plugin bindings, and programs found on PATH are dynamic. Use explain name
to inspect one of those.
Names beginning with _ are implementation interfaces. They are hidden from
help and are not part of the public scripting contract. Prelude code and
plugins may use the particular private interfaces supplied for them; ordinary
programs should not.
14.2. Values, collections, and comparison
The collection family includes:
each,map, andfilterfor applying a function to list elements;foldfor a left-to-right reduction;sort-listandsort-list-by;range start end, whose end is exclusive;length,is-empty,keys, andhas;ltandgt.
let items = range 0 5
let squares = map { |n| return $[$n * $n] } $items
let names = keys $record
These are value operations: ordinary composition uses application and bind. Higher-order operations stop and propagate an error if their callback fails. Lists remain homogeneous, and the typechecker rejects a callback or accumulator with an incompatible type.
lt and gt compare numbers numerically and strings lexicographically, both of one
type; unsupported or incompatible shapes are type errors. length and
is-empty take text, bytes, a list or a map, and sort-list a list of numbers
or of text. The
predicate family returns Bool. A result of false is a successful
computation, though it records status 1 for shell-style status inspection.
The prelude builds the familiar derived operations from these primitives:
for, reduce, reverse, last, take, drop, take-while, drop-while,
first-where, option-or, elem, contains, nub, zip, flat-map, enumerate,
concat, sum, max, min, maximum, minimum, cross,
group-by, median, and median-by.
14.3. Strings, numbers, and shell text
The string family includes upper, lower, dedent, slice, and
intercalate. string-replace performs one literal replacement and fails
unless there is exactly one match. Regex operations use the explicit re-
prefix: re-match, re-split, re-find-match, re-find-matches,
re-replace, and re-replace-all.
ral-quote renders a string as one ral word that reads back as that string:
bare where it can be, quoted where a bare word would read differently, so
'007' stays text. posix-quote and posix-split are the same pair for POSIX
shell words, for sh and for tools such as fzf that split their options that
way. All three are explicit conversions, not an invitation for ral values to be
re-lexed automatically.
int, float, and str convert scalar values. round x places returns a
Float, rounds halves away from zero, and accepts 0 through 308 decimal places.
floor, ceil, and trunc accept a finite, in-range Float and return an
Int; an Int is already integral and is rejected at the type level.
lines splits a string by the line rule of §7.3. The prelude adds words,
from-words, indent, repeat, pad-left, and pad-right.
It also supplies styled,
ral's whole public ANSI surface: styled style text wraps text in the
escape code named by style — reset, bold, dim, or one of red green
yellow blue magenta cyan — and resets after; black, white, underline,
reverse, and bold-colour variants are not supported styles. styled returns
text unchanged when NO_COLOR is active, stdout is not a terminal, the
selected interactive mode is minimal, or stdout does not support ANSI
colour.
Regex names remain present in builds without regex support so source-level name resolution stays stable, but calling one then fails with a clear feature error.
14.4. Paths and filesystem queries
ral provides structured queries rather than requiring programs to parse the
text output of ls or stat:
cwd,absolute-path,resolve-path, andabbreviate-home;list-dir,file-info, andglob;exists,is-file,is-dir,is-link,is-readable, andis-writable;temp-dirandtemp-file.
absolute-path is lexical: it resolves ~, ., and .. against the logical
working directory without requiring the result to exist. resolve-path
canonicalises through the filesystem, follows symbolic links, and requires an
existing path. resolve-path reads .. as the filesystem does, after the
symbolic links before it; a path ending in a separator names a directory, and a
file or a link to a file under that name is refused, as the operating system
refuses it; / is a directory every query may ask about. abbreviate-home is
for display: it folds a leading home directory to ~. Filesystem reads and
creations are checked against the active filesystem grant. temp-dir and
temp-file return the path they create as a string, so they refuse a temp
directory whose real path is not valid UTF-8, before creating anything in it.
A relative path is read against the working directory, which is always an
absolute path, and on Windows one on a drive or a share: cd and
within [dir: …] refuse any other, and ral does not start in a process that
has none. On Windows two more spellings are relative, and read as Windows
reads them. \x and /x name x at the root of the working directory's
drive or share. C:x names x in the working directory when that is on
C:, and otherwise in the current directory Windows keeps for C: in the ral
process, or at C:\ when it keeps none.
A relative RAL_PATH entry is read against the working directory, and xdg:
reads the XDG_*_HOME of the effective environment, within [env: …]
included.
On Windows, every path ral returns or prints is /-separated, never
\-separated: cwd, home, ~, $SCRIPT, absolute-path,
resolve-path, list-dir and file-info names, glob matches, temp-dir
and temp-file, and the paths named in error messages. There are two
exceptions. A command written as a path is named in its error as the user
wrote it, so a failed .\missing reports .\missing. A path that only the
operating system can read literally, such as \\?\UNC\server\share\x or
\\.\pipe\name, keeps its backslashes, because / is not a separator
there; a \\?\ prefix that can be dropped without changing the path's
meaning is dropped, and the rest is shown with /.
list-dir returns records rather than formatted columns. file-info uses
link metadata and reports fields including name, type, size, timestamps,
readonly state, and symbolic-link target. type is a tag, `file,
`dir, `symlink, or `other; target is `some the link's
contents for a symlink and `none otherwise. An unavailable timestamp is 0.
exists, is-link, and file-info judge a symbolic link at the link itself,
never at the name it points to: a dangling link is judged and reported there,
not as absent. exists and is-link both answer true for a dangling link,
and file-info reports its own symlink type and target rather than failing
as though nothing were there. is-file, is-dir, is-readable, and
is-writable instead resolve through the link, so a dangling link answers
false to each of them.
The prelude adds file-empty, line-count, and extension, the
Option-valued extension of a path's last component: a dotfile such as
.bashrc has none. line-count counts a file's lines by the line rule of
§7.3, in bounded memory. from-lines < $path reads a file's lines as a list;
for bounded-memory processing, redirect the file into fold-lines,
map-lines, filter-lines, or each-line.
Filesystem mutations deliberately use command-shaped tools such as cp, mv,
rm, mkdir, and ln. They are effects with byte-oriented command
interfaces, not duplicate structured builtins.
14.5. Bytes, text, and lines
The codec families are the named boundary between values and bytes:
cat data.json | from-json
to-json $record > data.json
The core environment provides from-bytes, from-string, from-line,
from-lines, from-json, from-jsonl, and from-csv, with corresponding to- encoders,
and ints-to-bytes beside them for bytes given as numbers.
Codecs do not silently reinterpret arbitrary values or output. Their exact
UTF-8, newline, JSON, CSV, and capture rules are specified with pipelines and
input/output.
fold-lines is the bounded-memory primitive for a byte pipe of UTF-8 lines. It
splits by the line rule of §7.3, calls fn accumulator line, and returns the
final accumulator. It does not first build a list, though the accumulator and
callback may of course retain data themselves.
The prelude builds three bounded-memory filters on that primitive:
map-lines fcallsffor each line and echoes the returned value as one output line;filter-lines predicateechoes the original line when the predicate returnstrue;each-line fcallsfand discards its returned value, while any bytes the callback itself writes continue through the active byte pipe.
Use them with a byte pipe or redirect, for example
map-lines $clean < input.txt. Calling a line reader with terminal stdin and
no pipe or redirect is an error rather than an interactive prompt.
from-lines and from-jsonl are decoders like the rest: they read to end of
input and return a list, of the lines and of each non-blank line decoded as
JSON respectively. to-jsonl writes each element of a list as to-json
encodes it, one per line.
14.6. Failure, session control, and concurrency
The core session family includes fail, exit and quit, cd, alias and
unalias, use, ask, echo, warn, surface, clear,
reset, help, and explain. All of them are builtins except echo, which is a base
handler: it takes an argument list, writes each argument's text conversion
separated by single spaces, and ends with one newline.
fail raises a recoverable failure from exactly one error record:
fail [status: 7, message: 'unavailable']
The status field is required, must be an Int in the supported exit-code
range, and must be nonzero. message is required and is a String. Additional
fields are ignored, which lets fail $err re-raise the record supplied by
try. Passing a bare integer, string, bytes value, a record without an integer
status or a String message, or status 0 is an error. exit and quit leave the current program rather than producing
a recoverable failure.
warn message takes one String and writes it, followed by one newline, to
standard error. It returns Unit and puts nothing on the byte channel, so a
let capturing the computation never sees it. This is the whole of
ral's diagnostic surface: there is no redirect that sends standard output to
standard error.
surface value sends a first-order structured event to a host that installed a
surface sink; without one, it safely produces no visible event. ask reads from
the controlling terminal rather than redirected standard input and fails on
end-of-file.
Concurrency primitives are the spawn and gather forms and await, poll,
first-settled, race, cancel, worker, and workers. The prelude adds
is-done, par, both, first-of, attempt, and succeeds. Exact ownership, cancellation, output capture, and
lifetime rules are specified with concurrency.
14.7. Host and build additions
The portable contract is the core registry plus the prelude. A host may install
more names before user code is typechecked; those names then appear in that
shell's help and explain output.
The ral command-line host provides watch. Its interactive form also supplies
plugin-lifecycle commands and private editor interfaces. A plugin may install
validated aliases, hooks, and keybindings; those are session
extensions, not portable standard names.
Exarch installs its agent operations and a documented ral helper library. It
does not provide watch. On supported Unix
hosts it may also provide detach, whose process deliberately outlives ral and
cannot be awaited or cancelled through a handle. These host-owned names must
not be assumed in a portable ral script.
Builds may bundle external-style utilities. The coreutils feature provides a
curated cross-platform set including ls, cat, wc, head, tail, cp,
mv, rm, mkdir, sort, and related tools. Unix builds may add id,
kill, stat, tac, test, and timeout; separate features add cmp,
diff, or rg. A bundled bare name wins over a program of the same name on
PATH, but it still runs as a child executable image with the current cwd,
environment, byte pipes, redirects, status, and capability restrictions.
The standalone ral build does not guarantee bundled coreutils; ordinary
external lookup remains available. Exarch enables its bundled tool set for a
more self-contained environment. Platform-gated tools are absent rather than
registered as commands that can never run.
15. Interactive use and extensions
Interactive editing is a service of the ral program, not part of the core
language. A portable ral program may rely on values, commands, byte pipelines,
types, and the core builtin surface. It must not assume that its
host has a prompt, history, completion, plugins, or editor builtins.
The ral executable adds those facilities when it creates an interactive
session. It installs their builtin signatures before checking startup files or
prompt input, so the type checker sees the exact surface that the session can
execute. Another host may install a different surface or none at all.
15.1. Entering the interactive shell
With no script or -c argument, ral starts an interactive session when
standard input is a terminal. Otherwise it reads standard input to end of file
and runs it as a batch script.
ral # interactive when stdin is a terminal
ral -i # force an interactive session
ral -s # read a batch script from stdin
ral --surface minimal # choose the canonical-input frontend
ral --surface readline # choose the full line editor
ral --norc # skip every startup file
-s takes precedence over -i. A positional script takes precedence over
both. --surface overrides the RC setting of the same name.
The shell collects lines while the parser reports incomplete syntax. It joins
continuation lines with newline characters and evaluates the result as one
top-level run. Abandoning a continuation discards the whole partial input. A
parse error, type error, or runtime error is reported and the next prompt is
shown; exit ends the session.
The interactive evaluator is the ordinary language evaluator. In particular, a byte pipeline still transports bytes at every interior edge, while ordinary application and bind compose values. The frontend does not reinterpret the pipeline operator.
After a successful run, Unit prints nothing and Bytes are written without a
prefix. Other values are displayed with the REPL renderer. Lists and maps use
the bounded pretty-printer; other values use their ordinary display form. A
list of two or more records sharing one field sequence prints one record per
line, each field padded to its column's width, so a workers result reads as a
table and still pastes back as the same list. The
default prefix is => and the default value colour is yellow where colour is
supported.
15.2. Frontends
The ral executable provides two interactive frontends:
| Surface | Contract |
|---|---|
minimal |
Canonical standard-input reads and a > continuation prompt; no raw mode, completion, editor keybindings, ghost text, or highlighting. |
readline |
The default full editor, with history, completion, vi or Emacs editing, plugin keybindings, ghost text, and highlighting. |
Terminal capability is stronger than preference. A dumb terminal, or
RAL_INTERACTIVE_MODE=minimal, forces the minimal surface even when another
surface was requested.
The full frontends complete variables, command-position names, and paths.
Variable candidates come from live non-private bindings. Command candidates
also include installed builtins, handlers, and executables on PATH, and the
working directory's subdirectories, offered with a trailing /. Relative
paths are resolved from ral's logical working directory. Matching is by
prefix, ignoring case unless the typed text has a capital; candidates
extending the typed text are listed alphabetically; only when none does in the
whole request does matching fall back to fuzzy, best first.
A new binding is visible at the next prompt.
History is persisted in the ral configuration directory, with a legacy
~/.ral_history fallback. Consecutive duplicate entries are omitted. Saving
appends the current session's new entries instead of replacing the file, so
concurrent sessions do not erase each other's history.
In a full frontend, Ctrl-C at the prompt abandons the current input and redraws; Ctrl-D on an empty prompt exits cleanly. Ctrl-D within non-empty input performs the editor's ordinary delete operation. Ctrl-C while a command is running interrupts that foreground run. Unix Ctrl-Z is specified with concurrency (§11.6): a stopped process is resumed at once, so Ctrl-Z has no visible effect; ordinary application and bind have no process group to stop at all. Windows has no such signal.
15.3. Startup files and RC configuration
A login session reads these optional profiles in order:
/etc/ral/profile~/.ral_profile
Profiles are ordinary ral scripts sourced for their effects. Their file-level
contract is strict: they must return Unit. A map, scalar, list, or any other
value is rejected with a diagnostic; configuration maps belong in the RC file.
Every interactive session then reads the first existing RC file:
$XDG_CONFIG_HOME/ral/rc, normally~/.config/ral/rc~/.ralrc
If neither exists, ral attempts to create a documented skeleton at the first
available location. --norc, also accepted as --noprofile, skips both login
profiles and the RC file.
The RC file is ordinary ral source and its file-level result must be a record
of configuration keys, or () for a file with nothing to set. Every other
result shape is rejected. For example:
return [
edit-mode: vi,
bell: false,
surface: readline,
recursion-limit: 1024,
prompt: { return " ❯ " },
env: [EDITOR: 'vim', PAGER: 'less'],
bindings: [work: '/srv/work'],
aliases: [ll: { |args| ls -lh ...$args }],
theme: [value-prefix: '⇒ ', value-color: cyan],
startup: { echo 'ready' },
]
The recognized fields are:
| Field | Meaning |
|---|---|
env: Record |
Set environment entries and bindings. PWD and OLDPWD are ignored: ral derives PWD from its logical working directory and keeps no OLDPWD. |
prompt: Block |
Install the zero-argument base-prompt body. |
bindings: Record |
Install lexical values, including functions. |
aliases: Record |
Install block or function values as command aliases; other values become ordinary lexical bindings. |
edit-mode: String |
emacs or vi; default emacs. |
bell: Bool |
Enable or disable the audible line-editor bell; default false. |
surface: String |
minimal or readline; default readline. |
recursion-limit: Int |
A positive function-call recursion limit; default 100_000. |
plugins: Record |
Names each plugin and its options; loaded before the first prompt; see below. |
startup: Block |
A zero-argument block run once after the map is applied. |
theme: Record |
value-prefix: String and value-color: String. The colour is one of black, red, green, yellow, blue, magenta, cyan, white, or none. |
The eleven fields above are the RC file's whole keyset, and it is closed: an unknown top-level key is an error naming the key and the list. The check is over the file's inferred return row, not over its syntax, so a key misspelled inside a spread is caught exactly as one written out is:
let extra = [surfase: 'minimal', env: [:]]
return [...$extra, env: [:]] # refused — 'surfase' is not an RC key
The table is ascribed to the file once it has been inferred, as ML ascribes a
signature. The return must be a record, or (): return ["edit_mde": 'vi']
is a map, whose keys are data, and return [] the empty list; each is refused
before the file runs, with the spelling to use. A file whose return the
checker cannot type — return !{from-json < rc.json} — meets the same keyset,
and the same field types, when the value is applied instead: an unknown key or
a wrongly-typed value both name the mistake and refuse the whole rc,
agreeing with the static check above — the shell starts with defaults. An
unknown key inside theme produces a warning rather than a refusal.
return [edit_mde: 'vi'] never runs the rest of the file: a misspelling is a
compile error and the rc is skipped before its first statement executes. A
file with nothing to set returns (), because there is no empty-record
literal (§4.5) and [] is the empty list.
The same discipline holds of the other two files a host reads against a fixed
keyset — a plugin manifest (§15.5) and a capability profile (§12.12) — and of the
options within and grant take. Each is one declared table, and no label may
be declared at two different types across all of them: an optional field is
absent at the type its label is assigned, so a label has one such type across
a whole check. A build whose tables break that is refused, naming both tables
and the label.
The prompt body may return a value, whose display form becomes the prompt. If
it returns Unit, its captured standard output becomes the prompt, with one
trailing newline removed. Prompt failures are reported and fall back to ❯.
Plugin prompt hooks then transform the base prompt in plugin load order.
Parse, type, read, and runtime failures in a startup file are reported without
preventing the interactive shell from starting. exit stops the current
startup file and boot continues.
RC and profile evaluation is trusted session bootstrap. Command-line
--recursion-limit, --surface, and capability profiles are applied
afterward, so the command line wins and the session capability ceiling narrows
the resulting shell.
15.4. Discovering the installed surface
help lists language forms and literals, installed public builtins, documented
prelude bindings, and any library documentation supplied by the host. Names
beginning with _ are hidden. The lists are sorted.
explain name shows the entry's full documentation, inferred or declared type
where one exists, and where it comes from. For a language entry the full
documentation is a standalone usage guide. Available facts are labelled
Usage:, Type:, Source:, and Shadows:. When no exact entry resolves,
explain performs a case-insensitive search and prints matching public
entries. These commands are informational: a missing query prints a message
and leaves status zero.
Because help is assembled from the live shell, it describes host additions
such as watch and load-plugin only where they are actually installed.
15.5. Plugins
A plugin is an ordinary ral file. Its file-level result is either a manifest
Map directly or a factory taking exactly one options Map and returning that
manifest. No other result shape is accepted.
let change = { |event|
let line = $event[line]
if $[not !{is-empty $line}] { _ed-ghost ' …' } else { _ed-ghost '' }
}
return { |options|
let key = get $options key 'ctrl-g'
return [
name: 'example',
hooks: [buffer-change: $change],
keybindings: [[
key: $key,
handler: { |_event| _ed-insert 'hello' },
]],
aliases: [hi: { |args| echo 'hello' ...$args }],
]
}
The manifest schema is:
[
name: String,
hooks?: Map<String, Block>,
keybindings?: List<[key: String, handler: Block, guard?: String]>,
aliases?: Map<String, Block>,
]
name is required. The other fields default to empty collections, and those
four are the manifest's whole keyset: an unknown top-level key is an error
naming the key and the list. A manifest the file returns is ascribed the
table before the file runs, as at the rc file (§15.3): return [nam: 'example']
is a record, so the misspelled name is caught statically and the plugin file
never runs, and return ["nam": 'example'] is a map, refused statically with
the spelling to use. return () lacks name, and is refused for that. One a
factory returns is checked as it is parsed, against the same table. Each
declared field is checked exactly: ral does not stringify a value of the wrong
type or silently drop a malformed handler. Hook and keybinding handlers each
take exactly one argument. Unknown hook names, wrong handler arity, invalid
key notation, invalid guard regexes, and alias conflicts are load errors.
Validation and registration are atomic; a rejected load leaves no hooks,
keybindings, or aliases installed.
Plugins run with the authority of the live session and current grant stack. A
manifest capabilities: field is rejected because it would not enforce
confinement. Use grant around an invocation that needs narrower authority.
Plugin options must be a first-order map: they cannot contain blocks, handles,
or other non-transportable values.
load-plugin name-or-path searches in this order:
- the
ral/pluginssubdirectory of the user configuration directory; - each directory in
RAL_PATH; - the literal path;
- the literal path with
.ralappended.
The interactive command supplies an empty options map to a factory. A direct manifest rejects non-empty options. To configure a factory, load it from the RC file:
return [
plugins: [
autosuggestion: [:],
fzf-files: [key: 'ctrl-t'],
],
]
Each key in plugins: is a plugin name or a path to a plugin file; a bare
hyphenated name needs no quoting, but a path must be quoted. Each value is
that plugin's options map, forwarded verbatim to the plugin's top-level block
as its single argument; [:] is the empty map, for a plugin taking no
options. A plugin whose value is not a map is reported by name and skipped;
the other plugins still load. unload-plugin name removes that plugin's
hooks, keybindings, and aliases. Loading a duplicate plugin name or unloading
a name that is not loaded is an error.
Plugin aliases occupy the alias namespace. A collision with an existing alias
is a load error. A lexical or native command with the same name is permitted,
but the alias underneath it is then unreachable: a bare head hits the lexical
binding or native first, and ^name is the external of that spelling, not the
alias. explain name still reports it, under shadows:.
15.6. Hook and keybinding events
Every plugin hook takes exactly one argument, and registration checks this
arity. In particular, lifecycle fields are not passed as separate positional
arguments: pre-exec, post-exec, and chpwd each receive one event record.
The recognized hooks are:
| Hook | Argument and result | Time |
|---|---|---|
buffer-change |
[old: String]; returns Unit |
After the text or cursor changes. |
complete |
[words: [String], current: Int, offset: Int]; returns [String] or [[replacement: String, display: String]] |
When completions are requested, before the editor's own. |
pre-exec |
[src: String]; returns the line to run (String), or Unit |
Before one complete prompt input is evaluated. |
post-exec |
[src: String, status: Int]; returns Unit |
After evaluation settles. |
chpwd |
[old: String, new: String]; returns Unit |
After an input line that leaves the session working directory changed. |
prompt |
The current prompt String; returns a String |
Before each prompt render. |
The value of a buffer-change, post-exec, or chpwd hook is ignored. The
prompt hook is deliberately different: it is a string transformer, read like
the session prompt (a returned value, else the captured standard output), so
each successful result becomes the next plugin's input.
Hooks run in plugin load order. A failed lifecycle hook is reported but does
not prevent later plugins' hooks from running or replace the command's status.
A fault is attributed plugin 'name' hook 'event'. Prompt transformers
compose: each successful result becomes the next hook's input, while a failed
hook leaves the current prompt unchanged. The faults of one render print
together, once while they repeat.
pre-exec hooks fold over the input line: each receives the line the previous
one returned, and the last line is the one evaluated. A String result
replaces the line and Unit leaves it. A failed hook is reported and a result
of any other type is reported with a warning; both leave the line as it was, so
no hook can stop every line. A line is refused by rewriting it to a failing
one, such as "fail [status: 1, message: 'refused']"; its status reaches
post-exec through the ordinary evaluation. post-exec's src is the line
that ran.
complete runs when completions are requested for a buffer that lexes, with
the record _ed-parse returns. Each candidate is a String, standing for
itself, or a [replacement, display] record. The first plugin in load order
whose hook returns a non-empty list claims the word: its candidates replace the
text from character offset to the cursor, unfiltered, and no later hook runs.
An empty list or () passes to the next plugin and finally to the editor's own
completion, and so does a result of any other shape, with a warning. The hook
runs aside: its writes to the buffer, ghost text, and highlights are discarded.
buffer-change and complete run on the editing path, so ral protects the
session from a persistently bad handler, braked separately for each plugin and
hook. A single invocation that exceeds its budget (100 milliseconds for
buffer-change, 500 for complete), or three consecutive failures, disables
that plugin's hook for the rest of the session and prints a notice.
A keybinding handler receives [key: String, mode: String]: the binding's key
notation, and the editor's input mode at the press, insert, command, or
replace (insert throughout emacs). A hook reads the editor only through the
_ed-* builtins.
A key notation is one or more chords separated by spaces:
notation := chord (' '+ chord)*
chord := (modifier '-')* key each modifier at most once, any order
modifier := ctrl | alt | shift
key := one character | f1…f12 | tab | enter | escape | up | down
| left | right | home | end | delete | backspace
A modifier is a modifier only while something follows its dash, so ctrl-- is
ctrl with -. shift applies to the keys that are not characters; for a
character the shifted character is itself the key, so A and not shift-a.
ctrl with an uppercase letter is refused, being the same key to a terminal
as the lowercase one; so are ctrl-h, ctrl-i, ctrl-m, and ctrl-[, which a
terminal sends as backspace, tab, enter, and escape, and a control
character written as the key. Each refusal names the spelling to use. A
sequence of several chords must begin with ctrl-x, the prefix the line editor
leaves free for sequences. Ctrl-C and Ctrl-D, with
no other modifier, are reserved and cannot be bound, alone or in a sequence.
Keybindings form one ordered table: plugin load order, then manifest order,
then the frontend's built-in action. A guard is a regular expression matched
against the text left of the cursor. A binding claims its keys only when the
guard matches. Every unmodified key except F1 through F12 already has an editor
action and therefore requires a guard. Modified chords and function keys may
be unguarded. A binding that can never fire loads with a warning naming the
binding that claims its keys first: an earlier unguarded binding on the same
keys, or an unguarded binding on a proper prefix of its sequence.
The minimal frontend does not dispatch buffer-change or complete hooks or
editor keybindings and cannot display ghost text or highlights. Plugin aliases and
the pre-exec, post-exec, chpwd, and prompt hooks remain session
facilities and do not depend on raw-mode editing.
15.7. The editor plugin interface
The _ed-* builtins are installed only by the interactive ral host and are
hidden from help. Except for the pure _ed-hyperlink formatter, they require
the corresponding editor.read, editor.write, or editor.tui authority.
They also require an active plugin handler; calling one at an ordinary prompt
or from a batch host is an error.
Cursor and span offsets count Unicode characters, not UTF-8 bytes.
| Builtin | Contract |
|---|---|
_ed-get |
Return [text, cursor, keymap]; keymap is emacs or vi, the configured keymap. The text left of the cursor is slice $st[text] 0 $st[cursor]. |
_ed-set [text:keep|set Str, cursor:keep|set Int] |
Replace text and/or cursor; `keep leaves a field unchanged and an out-of-range cursor is clamped. |
_ed-set-lbuffer text |
Replace text left of the cursor, preserving the right side. |
_ed-insert text |
Insert at the cursor and advance past the insertion. |
_ed-push |
Save the current buffer on a stack and clear it for another command. |
_ed-accept |
Execute the resulting buffer immediately when the handler returns. |
_ed-history prefix limit |
Return matching history, most recent first; zero means no limit. |
_ed-parse |
Return [words, current, offset] for the simple command at the cursor, open blocks read as closed: words[current] is the word the cursor touches, or a spliced-in '' when it sits in whitespace, and offset is that word's start. words is empty when the buffer does not lex. |
_ed-ghost text |
Set suggestion text; an empty string clears it. |
_ed-highlight spans |
Replace highlight spans of shape [start, end, style]; an empty list clears them. |
_ed-state default updater |
Read, transform, store, and return one persistent cell belonging to the plugin. |
_ed-tui block |
Suspend the editor, run the block, and return [output: String, status: Int]. |
_ed-clipboard text |
Copy text to the system clipboard, natively when local, else by OSC 52; return whether all of it landed. |
_ed-hyperlink uri text |
Return an OSC 8 hyperlink where supported, otherwise return plain text. |
_ed-push and _ed-accept may be combined to preserve the current input while
running a generated command. Ghost text and highlights are display state; they
do not become part of the input buffer.
_ed-tui : ∀ν α. {ν α} -> Returns [output: String, status: Int] runs the body with its
standard output captured while allowing an interactive child to use the
terminal. The body's value is ignored; the captured bytes are decoded as text,
lossily, with one trailing newline removed, and supply output. A body failure
is returned as data in output and status rather than raised. _ed-tui
opens only from a keybinding handler, the run that holds the terminal; from any
other hook, and nested inside another _ed-tui, it is reported as status 1.
Terminal presentation remains capability-dependent. Clipboard emission may
return false; hyperlink formatting may return plain text; colour, ghost text,
and highlights may be suppressed by the selected surface or terminal. These
fallbacks do not change language evaluation.
15.8. Host and platform limits
The following are promises of the ral executable, not requirements on every
ral host:
- persisted history, completion, terminal titles, and the three frontends;
- startup profiles and RC discovery;
- plugins,
_ed-*,load-plugin, andunload-plugin; - terminal signal handling and crash-time terminal restoration.
Foreground terminal ownership depends on Unix process groups and a controlling terminal; Windows has no such handoff and supervises pipeline members as one console job instead. The minimal frontend uses canonical input and offers no raw editor surface. Batch execution and embedded hosts may omit all these facilities; portable code must discover optional installed commands rather than assuming them.
16. Invocation, interoperability, and platforms
The ral language and the ral executable are separate contracts. The language defines ral source, values, byte pipelines, and effects. The executable chooses where source comes from, prepares a session, reports diagnostics, and maps the result to an operating-system exit status.
Direct invocations always parse ral syntax:
ral
ral build.ral release --clean
ral -c 'echo $[2 + 2]'
ral -s < build.ral
ral -c does not accept POSIX shell syntax. Use ral-sh (§16.7), ral invoked by that name, as the registered login shell when programs may call $SHELL -c with POSIX source.
16.1. Invocation modes
ral selects one of four modes:
ralstarts an interactive session when stdin is a terminal.ral SCRIPT [ARG …]reads and runs a script file.ral -c CODE [ARG …]runs inline ral source.ral -sreads ral source from stdin to EOF and runs it as a batch.
On Unix, a bare invocation with non-terminal stdin also selects stdin-script mode:
generate-ral-source | ral
Reading source from stdin consumes that byte stream to EOF before execution. It is therefore already exhausted if the resulting program later tries to read its runtime stdin. File and -c modes do not consume stdin; the program may use it as a byte pipe.
-i forces the interactive session even when stdin is not a terminal. -s forces stdin-script mode and takes precedence over -i. A script positional takes precedence over both.
After SCRIPT, every remaining token is a script argument, even if it begins with -. After -c, the first token is the complete source string and every later token is an argument. ral inserts the option terminator internally, so these are equivalent:
ral task.ral --force
ral -- task.ral --force
ral -c '--version'
Script arguments are returned by args. Arguments are text: one that is not UTF-8 is refused with status 2 rather than altered. $SCRIPT is a lexical string containing the source file’s path. That path is text too: a script, module, startup file, or plugin whose path is not UTF-8 is refused, naming it, before it is read. It is available in script files, including sourced modules under their own names, but is rejected in the REPL, -c, and synthetic preloaded source.
16.2. Batch processing
A batch run parses, elaborates, typechecks, and only then evaluates. Typechecking is always performed because it also annotates the program: each computation a value demand captures is wrapped in its capture, and each pipeline stage records its type.
The batch-only inspection flags are:
-n,--check— parse, elaborate, and typecheck without executing;--dump-ast— parse and print the syntax tree to stderr without elaborating or executing;--dump-ir— parse and elaborate, and print the phrases to stderr without typechecking or executing; at most one of--check,--dump-ast,--dump-ir;--audit— execute and print the run's audit report as JSON on stderr;--pretty— pretty-print that JSON; it requires--audit.
These flags require a script path or -c; they are not accepted for a bare interactive or stdin-script invocation.
The checker and evaluator receive the same batch builtin surface. A command cannot pass --check by being treated as one kind of builtin and then run as another.
The emitted JSON is one report for the whole batch run, its trail a flat list
in settlement order. `command observations exist only for external or
bundled command executions and detach births; a builtin application records
nothing of its own, its effects landing through the write, read, and worker
doors (§13.3). Function application, control forms such as if, case,
try, guard, within, grant, audit, spawn, and gather, and loop
iterations record nothing of their own either (a session spawn's birth
lands through the worker door); the commands executed inside them land in the
open trail directly. Each observation carries its evaluated arguments, status,
source location, timing, and principal, plus the bytes it captured.
Batch stdout and stderr remain ordinary byte streams. A program's final ral value is not an operating-system pipe protocol: interior pipeline stages exchange bytes, while final values are reported through the ral result boundary. Explicit adapters perform any conversion between values and bytes.
16.3. Results, diagnostics, and exit status
Parse and elaboration failures print source-labelled diagnostics and exit 2. Type errors exit 1. Failure to read the script file exits 1. A malformed or unreadable --capabilities profile exits 2.
At runtime:
- a normal completion exits 0;
exit NreturnsN;- a raised runtime error prints a source-labelled diagnostic and returns that error’s status.
The final process status is clamped to 0..=255, and --audit does not change it — the JSON report does not carry it. With --audit, the runtime failure is represented in the report's `err outcome and the duplicate ordinary diagnostic is suppressed.
A foreground batch launched from a terminal may lend that terminal to an interactive child. A backgrounded or non-terminal batch has no such authority.
16.4. Interactive startup
-l or --login requests a login session. On Unix, an executable name whose basename starts with - does the same. Login affects only an interactive session: ral -l -c CODE and ral -l SCRIPT remain ordinary batch runs and do not source interactive startup files.
An interactive login session processes, in order:
/etc/ral/profile, if present;~/.ral_profile, if present;- the ordinary rc file.
A login profile is ral source run for its effects and must return Unit. Returning a map or another value is reported as an error; interactive startup continues.
Every interactive session then reads $XDG_CONFIG_HOME/ral/rc or ~/.ralrc. The rc file must return a configuration map. If neither file exists, ral may create a default rc skeleton and reports the created path. Parse, type, runtime, or return-contract errors are reported, no invalid configuration map is applied, and the user still receives a shell. An exit from a startup file stops that file without terminating the session.
Startup evaluation is not transactional. A read, parse, or type error prevents
that file from running, and an invalid RC result prevents configuration-map
application, but bindings and effects produced before a runtime failure,
exit, or return-contract check are not rolled back. In every case boot
continues to the interactive shell.
--norc, also accepted as --noprofile, suppresses login profiles and the rc file. Command-line --recursion-limit and --surface values are applied after the rc and therefore override it. Session --capabilities profiles are also applied after startup files: startup is operator-trusted setup, and the capability profile becomes the ceiling for subsequent work. A relative profile path names a file in the directory ral was started in, even when a startup file or an earlier profile changes directory.
16.5. Interactive surfaces
--surface minimal or --surface readline selects the frontend. Without the flag, the rc setting is used, then readline by default.
RAL_INTERACTIVE_MODE=minimal forces the canonical-stdin frontend regardless of the selected surface. An unknown value warns interactively and falls back to automatic probing.
16.6. Compatibility flags and environment
For tools that pass traditional shell flags blindly, ral accepts:
-e— accepted with no effect;-u— accepted with no effect;-i,-s, and-l— with the meanings above.
ral seeds a stable dynamic environment at boot. Inherited values win; a variable the host does not bind at all takes a default for PATH, SHELL, TERM, and LANG. ral reads the environment as text: a variable whose value is not UTF-8 is absent from env and from ral's own lookups, and reaches children with its bytes untouched. HOME, USER, and LOGNAME are seeded from the host alone: where the host binds none, the variable stays unbound rather than taking an invented value, and ~ is then an error naming HOME rather than a directory. It increments SHLVL and supplies OS_NAME, OS_ARCH, and OS_FAMILY. Recognised terminal and multiplexer variables are retained when present.
PWD and OLDPWD are not exposed through env. ral owns the working directory as shell state so parallel work cannot race through the process-wide current directory. Each external child receives the correct PWD and actual launch directory; an inherited OLDPWD is removed, since ral keeps no previous directory.
RAL_PATH is a platform-separated list used to find modules and plugins. RAL_TIMING, when present, prints batch phase timings to stderr.
On Unix, ral refuses a setuid invocation (effective and real user id differ) before it does anything else — before argument parsing, and before any of the four modes above is selected. The refusal is therefore unconditional: it does not depend on which mode the arguments would otherwise name.
16.7. ral-sh: the POSIX bridge
ral is intentionally not POSIX-compatible. Registering ral itself as $SHELL can break ssh host command, scp, rsync, Git-over-SSH, editors, multiplexers, and other programs that expect $SHELL -c to parse POSIX syntax.
On Unix, install ral-sh as the login shell instead. ral-sh is ral invoked by that name, a symlink installed beside it: the name in argv[0] is the one bit a login shell's caller preserves. Under that name ral reads no source before choosing a target; the first matching rule over the arguments after argv[0] applies:
- any short-option cluster containing
c, or any argument that is neither such a cluster nor--login(a positional,--,-, an opaque--longoption), goes to/bin/sh, so-lc …and-l script.shboth do; - otherwise a short-option cluster containing
lori, or--login, goes toral; - otherwise no arguments with both stdin and stdout attached to terminals goes to
ral; - every other invocation goes to
/bin/sh.
/usr/local/bin/ral-sh -lc 'printf "%s\n" ok' # /bin/sh
/usr/local/bin/ral-sh -l # ral
The /bin/sh branch forwards arguments without requiring UTF-8, and replaces the process with the literal path /bin/sh: no PATH lookup. If the invoked name begins with -, it preserves that login-shell convention as -sh. The ral branch does not exec: the same process continues as ral, which sees the leading - itself. Failure to replace the process exits 127. The setuid refusal applies first, under either name.
16.8. Platform distinctions and limits
Unix. Terminal detection selects a bare REPL or stdin-script mode. Login-shell argv[0], process-group foreground ownership, immediate resume-on-stop (§11.6), and ral-sh are Unix facilities. Signals are translated into cancellation and conventional process statuses; the shell restores terminal ownership after foreground children, and on exit hands the terminal back to the process group it started in.
Windows. A bare invocation defaults to the interactive mode because the CLI does not infer stdin-script mode from Windows terminal state; use -s for source redirected on stdin. ral enables virtual-terminal processing where available. Unix process groups and stop signals do not exist. Direct execution refuses .bat and .cmd images, named so once Windows drops trailing dots and blanks (run.bat., run.bat), because their additional cmd.exe quoting pass has no safe general argument encoding; invoke cmd.exe explicitly only if that risk is acceptable. ral-sh is a Unix login-shell bridge and is not a usable Windows shell dispatcher.
On every host, external commands communicate through operating-system byte pipes. A pipeline has no typed-value wire format: values cross only through the final ral result boundary or ordinary application/bind, and explicit adapters perform conversions between values and bytes. Paths, executable lookup, path-list separators, default system paths, and sandbox enforcement otherwise follow the host rules described in their respective sections.
17. Formal language definition
This section collects the implemented core language in a compact notation. It is descriptive, not a claim of soundness, completeness, progress, or preservation. The parser, typechecker, annotated IR, and evaluator remain the authorities when this presentation is ambiguous.
Metavariables used below are:
xfor an identifier,wfor a word,sfor a string, andnfor an integer;pfor a binding pattern,vfor a value term, andM,Nfor computations;A,Bfor value types,C,Dfor computation types, andρfor a row;T,E,Hfor values that are thunks, which a branching form forces.
17.1. Concrete grammar
The lexer supplies words, quoted strings, interpolation segments, tags,
redirect tokens, newlines, and punctuation. Its mode is set by the innermost
open delimiter: inside [...] and $[...] newlines are whitespace and commas
punctuate, and inside $[...] alone the spellings <, >, <=, >=, !=,
&&, ||, +, -, *, /, %, =, and == are operator tokens of their own,
ending the word before them, a numeral being read whole. A newline or ; separates
statements. The
shared stage parser requires every stage to end at a statement boundary, |,
?, a closing delimiter, or end of input; juxtaposed same-line statements are
not admitted.
The following EBNF omits lexical escape details and source-span bookkeeping.
NL denotes a newline and sep denotes NL or ;.
program ::= sep* (statement sep+)* statement? sep*
statement ::= binding | chain
binding ::= "let" pattern "=" NL* chain
chain ::= pipeline ((NL* "?" NL*) pipeline)*
pipeline ::= stage ((NL* "|" NL*) stage)*
stage ::= (return | conditional | case | scope-form | command)
redirects
return ::= "return" atom?
conditional ::= "if" NL* atom NL* atom
(NL* "elsif" NL* atom NL* atom)*
(NL* "else" NL* atom)?
case ::= "case" NL* atom NL* "[" case-arm ("," case-arm)* ","? "]"
case-arm ::= tag-key ":" atom
tag-key ::= "`" identifier
scope-form ::= "try" atom atom
| "guard" atom atom
| "within" options atom
| "grant" options atom
| "audit" atom
| "spawn" atom? atom
| "gather" atom
options ::= "[" "]"
| "[" option ("," option)* ","? "]"
option ::= identifier ":" atom
command ::= head (argument | redirect)*
head ::= "^" bare-name | atom
argument ::= atom | "..." atom
atom ::= primary index*
primary ::= word-value | block | collection | unit-literal
word-value ::= lexical-word
| quoted-string
| interpolated-string
| variable
| expr-block
| force
| tag
index ::= "[" word-value "]" /* adjacent to a target other than $(name) */
force ::= "!" variable index* | "!" primary
variable ::= "$" identifier | "$(" identifier ")"
expr-block ::= "$[" expression "]"
expression ::= operand (infix-op operand)* /* by the precedence below */
operand ::= "(" expression ")" | "-" operand | "not" operand | atom
tag ::= "`" identifier atom?
block ::= "{" program "}"
| "{" "|" pattern+ "|" program "}"
collection ::= list | record | map
list ::= "[" list-items? "]"
list-items ::= list-item ("," list-item)* ","?
list-item ::= atom | "..." atom
record ::= "[" record-entry ("," record-entry)* ","? "]"
record-entry ::= static-key ":" atom | "..." atom
map ::= "[:]"
| "[" map-entry ("," map-entry)* ","? "]"
map-entry ::= map-key ":" atom | "..." atom
map-key ::= identifier | quoted-string | interpolated-string | variable
unit-literal ::= "(" ")"
pattern ::= "_" | identifier | list-pattern | map-pattern
list-pattern ::= "[" pattern-list? "]"
pattern-list ::= pattern ("," pattern)* ("," "..." identifier)?
| "..." identifier
map-pattern ::= "[" map-pattern-entry
("," map-pattern-entry)* ","? "]"
map-pattern-entry ::= static-key ":" pattern
static-key ::= identifier | quoted-string
redirects ::= redirect*
redirect ::= fd? ">" word-value
| fd? ">>" word-value
| fd? ">~" word-value
| fd? ">&" fd
| fd? "<" word-value
| fd? "<<" word-value
A redirect list binds each stream at most once (§7.4); the parser refuses a second binding.
For <<, an explicit descriptor, if present, must be 0. For >&, the source
descriptor must not be 1 when the target is 2: 1>&2 and its short spelling
>&2 are rejected in favour of warn (§7.4). [] is the empty
list and [:] the empty map. Otherwise a collection's entries decide: any
data key, "…" or $name, makes it a map, labels alone a record, no entry
at all a list (§4.5). A pattern's keys are labels only, since it cannot bind
through data. A tag is no key at all: tag-key occurs only in a case arm.
A case's arm list is a production of its own, and no expression may stand in
its place: a spread among the arms and
a repeated tag are both rejected, and the list may not be empty. This is what
makes the set of alternatives a fact the parser establishes (§8.3).
The atom after an if condition, after else, and after a case tag is an
arm, and an arm is a block or a name: a literal { … } or { |p| … }, or a
$name. Any other atom would be hoisted and run before the form chose, so it
is refused, with the advice to bind the block first (let arm = $arms[a]).
Which lexical words are literals rather than names is the numeral grammar of
§4.1, and the expression grammar inside $[...] reads numbers by that same
rule.
A plain identifier in head position enters ordinary name dispatch. A word
containing a path separator (§3.5) or a ~/ path is a direct path head.
^name requests external-name lookup. A literal word, a bare ~, a $[...]
block, and every other atom are value heads. With no arguments or redirects, a
value head is simply its value.
The parser curries a multi-parameter block:
{|p q| M} = {|p| {|q| M}}
Each pattern binds all its names simultaneously and may not bind one name more than once. A list rest pattern is terminal.
? and | admit newlines on either side.
An operator spelling inside $[...] is a token of its own, not a word.
Inside $[...], expressions have the following precedence, from low to high:
||
&&
== != < > <= >=
+ -
* / %
unary - and not
Binary operators associate left. && and || short-circuit. An operand is
any atom of the value grammar — a numeral, a quoted string, a variable, an
indexed value, a forced block, a collection — or a parenthesized expression;
() is the unit atom, not an empty group. $[...] therefore adds no node of
its own to the tree: it is the lexical mode in which the operators can be
written, and which values each operator accepts is the type system's question
(§4.8 for ==; arithmetic and ordering want numbers). One case is settled
earlier: a bare word that is not a numeral is a string here as everywhere, so
under -, arithmetic, or ordering it can never typecheck, and ral refuses it
at parse time, asking whether $name was meant.
17.2. Core terms
Surface syntax elaborates into a call-by-push-value IR. Its essential value and computation forms are:
v ::= unit | true | false | n | f | s | bytes
| [v1, ..., vn] | {l1 = v1, ..., ln = vn}
| `l | `l v | thunk M | handle A
M ::= return v
| force v
| lambda p . M
| M v
| M to p . N
| exec h [v1, ..., vn]
| M1 | ... | Mn
| M1 ? ... ? Mn
| if v T E
| case v of {`l1 H1, ..., `ln Hn}
| M1 ; ... ; Mn
| letrec {xi = vi}
| scope op
| capture M
| decode v
op ::= try v1 v2 | guard v1 v2 | within v1 v2 | grant v1 v2 | audit v
| spawn [v1] v2 | gather v
| redirect M redirects
T, E and each Hi are values, thunks the form forces when it chooses one.
Every scope op but the last takes its body as a thunk, because a scope installs
an effect before the body runs, spawn runs its own on a worker, and a value
is what can be held unrun. redirect
takes the computation itself: its body is an application or a nested scope,
neither of which can fuse its own redirects the way exec does.
capture and decode are the two forms no program can name: the checker
writes them and nothing else does (§17.5). They are core terms all the same,
because they survive elaboration and the evaluator matches on them.
Command calls elaborate either to exec, which enters command dispatch, or to
application when the head resolves to a bound value. A trailing redirect on an
application or scope form becomes a redirect scope; redirects on exec remain
part of the atomic command launch.
An if arm is a thunk of the computation it runs, forced in place. A case
arm is a thunk of one argument: case applies the chosen arm to the variant's
payload. A literal { |p| M } and a name holding one are the same value, so
both spellings reach the same arm.
Blocks elaborate to thunks. A parameterized block elaborates to a thunk whose
computation is a function. Adjacent recursive name bindings whose right-hand
sides are blocks or lambdas elaborate to one simultaneous letrec group.
17.3. Types and rows
Value and computation types are:
A, B ::= Unit | Bool | Int | Float | String | Bytes
| List A | Map A | Record ρ | Variant ρ
| U C | Handle_ε A | α
ρ ::= · | l : A, ρ | r
C, D ::= F_ε A | A -> C | β ε ::= p | w | ε̂
Map A is a homogeneous string-keyed map. Record ρ is a row-typed record;
Variant ρ is a tagged sum over the same row machinery. Bare record labels
and tag labels occupy distinct alphabets and do not unify. A nullary variant
has payload type Unit.
U C classifies a suspended computation. F_ε A classifies a computation that
produces an A, and carries a grade ε saying how: F_p A produces the value
A, and F_w Unit produces output and nothing else. A command — an
external, a builtin that writes, or a handler arm standing in for one — has type
F_w Unit, printed Command; F_p A is printed Returns A, and a grade
variable prints from the alphabet ν ξ ο π, so retry reads
∀α ν. Integer -> {ν α} -> ν α. So { echo hi } is {Command} and { 'hi' }
is {Returns String}, and { echo pre; return 5 } is {Returns Integer}: a
grade says whether a computation's result is its output, not whether it writes.
A computation of grade w has value type Unit. A grade is atomic: a grade
variable is bound to p or w and to nothing else, so grades are quantified
like any variable but are never weak and need no occurs check. A type Command A
does not exist. Handle_ε A is a suspended producer: await of it is an
F_ε A, and it prints as the producer it suspends, Handle (Returns A) or
Handle Command (§11.2).
The checker is Hindley-Milner with value-, computation-, and row-level
unification variables. Let-bound names are generalized against the current
environment and instantiated freshly at use sites. A variable marked weak is
treated as free in the environment: the mark is permanent for the unit, is
inherited by any variable unified with a weak one, and is never quantified, so
every use of the name shares one type for it. There is no value restriction. A
scheme that leaves a weak variable unquantified keeps it weak in later runs, where
it is re-seeded as a fresh weak variable. A boundary — a door through which a
value of a shape the program did not decide enters typed code: the decoders,
use, worker, the exarch host's answers, _ed-state — makes the
variables of its result weak and records the result type on the call. Once the
unit is solved that type is frozen as a site on the call, which the door
admits the value against: each position's head, each free variable's kind, and
for one comparable variable, number or text, agreed across the unit. A
session data binding with weak residuals is admitted again, against this unit's
uses, before the unit runs. Recursive groups are
inferred against monomorphic self-bindings, then generalized after those
self-bindings leave the environment. Runtime-created top-level bindings carry
their closed schemes into later runs.
A value-type variable carries a kind: a set of admissible heads and a deep
bit. A row variable carries a deep bit alone. The unconstrained kind any has
every head and is shallow; five are named, and a scheme prints them
∀α:number. …:
number Int, Float + - * /, unary -
comparable Int, Float, String < > <= >=, lt, gt, sort-list, int, float
scalar Bool, Int, Float, String each interpolation part
sized String, Bytes, List, Map length, is-empty
data (deep) every head but U and Handle == !=, str, the encoders
% is Int -> Int -> Int. Two variables unite at the meet of their kinds, the
heads both admit with the deep bits or-ed, and an empty meet is a static error
(T0074, which cites both uses); a meet with a single nullary head binds the
variable to it. Binding a kinded variable to a structure requires the
structure's head to be admitted, and a deep kind imposes data on every
component: a list or map element, the fields of a record and the payloads of a
variant, and, through the tail row variable's deep bit, every field later added.
A kinded variable still free at the end of a unit needs no default, since every
admissible type supports the operation. A label read v[l] on a variable is
decided by the kind when it admits Map and not Record (sized), or the
reverse. A computed index c[k] is the one deferred relation over a container
and its key, c = [e] ∧ k = Int ∨ c = Map e ∧ k = String: the head of c or
of k, or a kind that admits one disjunct and not the other, selects it, and an
integer literal key is the list case outright. Its three variables are weak, so
they are shared by every use in the unit, and one still undecided when the unit
ends is a static error (T0075); a key that is neither Int nor String is
refused by kind (T0074).
The principal judgments are:
Γ ⊢v v : A value inference
Γ ⊢c M : C computation inference
Γ ⊢p p : A ⇒ Γ' pattern binding
Γ ⊢ M ⇝ C ; K inference with constraints K
K ⊢ C1 ~ C2 unification
Types are regular trees, and every cycle crosses a data edge: a list element, a
map element, a record field, or a variant payload. The edges U, ->, F and
Handle are not data, so binding a variable to a structure that reaches it
along those edges alone is refused, as in w $w, or a function that returns
itself. A cyclic type prints as μβ. Map β.
A failed constraint records both the source span at which it was introduced
and a provenance reason, such as argument application, pipeline stage shape,
conditional branches, fallback branches, try arms, scope body, handler
shape, a case arm's payload, or the handler a case arm names. Diagnostics
render the structural mismatch together with that provenance; provenance does
not change the unification relation.
Representative rules are:
Γ ⊢v v : A
---------------------------- Return
Γ ⊢c return v : F_p A
Γ ⊢v v : U C
---------------------------- Force
Γ ⊢c force v : C
Γ, p:A ⊢c M : C
---------------------------- Lambda
Γ ⊢c lambda p.M : A -> C
Γ ⊢c M : A -> C Γ ⊢v v : A
-------------------------------- App
Γ ⊢c M v : C
Pattern inference recursively constrains list elements to one element type,
record fields to their labelled types, and a list rest name to List A. A
binding generalizes its bound value as §17.5's Let says.
Heads and commands. A bare head c has one class, decided by the lookup
order of §6.3 and §9.5, which the runtime dispatches in too:
class(c) = binding(σ) c is a lexical or session binding, of scheme σ
| row(σ) c is a builtin value row, of scheme σ
| arm(c', σ) a handler is in scope for c, standing in for c' at σ = standsFor(c')
| external otherwise, and always for ^c, ./p and ~/p
A row's scheme says what it produces. The rows that write — the encoders
(to-json, to-string, to-line, to-lines, to-jsonl, to-csv,
to-bytes, ints-to-bytes), echo, help, explain, clear and reset —
end in Command, and a row that writes only when applied at its full arity:
to-json alone is a function, and to-json $x is a command. The rest end in
Returns A, with warn among them, since standard error is not the byte
channel. A few rows are polymorphic in the grade: each, fold, fold-lines
and audit run a body of either grade, stream its output and keep its value;
watch, like the spawn form, keeps the body's grade in the Handle_ε A it
returns, and await and race give it back (§11.2); fail, exit and diverges inhabit
every grade. A
handler does not change the answer, since an arm stands in for the head it names
and keeps that head's class; an arm for the name of a value row is never
reached, because builtins resolve first.
A command takes an argv and produces output:
class(c) ∈ {external, arm(external)} Γ ⊢ ā : argv
----------------------------------------------------- Ext
Γ ⊢c c ā : F_w Unit
class(c) = row(σ) σ ⊑ A1 -> ... -> An -> C Γ ⊢v ai : Ai
--------------------------------------------------------------- Row
Γ ⊢c c a1 ... an : C
Γ ⊢ ā : argv says every element renders as text, and a spread spreads a list.
A redirect of standard output discharges the grade: if Γ ⊢c M : F_ε A then
M > f has type F_p A, because the bytes have gone to the file and the
computation's value is what remains. Stdin and stderr redirects, and 2>&1,
leave the grade alone.
17.4. Pipe composition and sequences
A | is an operating-system byte wire fixed by stage position. A stage feeds
the next by writing, so every stage but the last has type F_ε Unit for some
grade ε, and is not a decoder; the pipeline's value is the final stage's. A
stage after a pipe binds no standard input at its own root.
Γ ⊢c Mi : F_εi Unit Mi is no decoder (1 ≤ i < n) Γ ⊢c Mn : F_ε A
Mi binds no stdin at its root, for 1 < i ≤ n
------------------------------------------------------------------------------ Pipe
Γ ⊢c M1 | ... | Mn : F_ε A
A decoder (from-bytes, from-string, from-line, from-lines, from-json,
from-jsonl, from-csv, from-json-at) returns a value and writes nothing,
so it ends the byte pipeline. It is refused as a non-final stage by its own
mark, before its type is read: for a boundary ∀α. F α unification would
otherwise set α := Unit and let cat data.json | from-json | wc -l through to
fail at the decode. '3' | cat and [1, 2, 3] | f are refused because a value
in stage position writes nothing to the pipe; each { |x| echo $x } $xs | cat
is accepted, since each streams what its body writes. A stage whose type is
A -> C is still waiting for an argument and is rejected as well, and the
diagnostic says to apply it rather than pipe into it.
A stage whose root carries < f or << value is rejected: the feed answers that
stage's reads for its whole run, so the producer before it writes for nobody
(§7.4). Each premise reads one stage, never a pair.
Operationally, M | N:
- connects
stdout(M)tostdin(N)with an operating-system byte pipe; - runs both stages under the process discipline of §7.1;
- takes its value from
N.
Neither side promises traffic. A producer that writes nothing yields an ordinary end of input; a consumer that never reads leaves the bytes unread, and once it ends, ral ends the blocked producer itself (§7.6). No returned value is ever placed on a wire, and no implicit codec is inserted at an edge. Value-style composition uses the ordinary application and bind rules instead.
The final stage is not held to Unit, so a block literal there is well
typed: it is a value, and the stage returns a thunk and writes nothing.
cat f | { from-line } # accepted: f's bytes go unread, the thunk is returned
cat f | !{ from-line } # accepted: the block runs as a stage and decodes them
The first does nothing and is not diagnosed; no rule in ral inspects how a stage was spelled to decide whether a pipeline is well formed.
A sequence is its tail:
Γ ⊢c M : F_ε A Γ ⊢c N : C
----------------------------- Seq
Γ ⊢c M ; N : C
Every earlier element runs for its effects. Its value is discarded, it is never captured, and what it writes goes to the current standard output, which inside a capture is the capture's buffer and inside a non-final pipeline stage is that stage's pipe. A sequence whose final element is a function is that function.
The diverging forms are fail and exit/quit. Neither returns, so each takes
whatever type and grade its context asks of it: in if c { exit 1 } else { return 'hello' }
the conditional is F_p String, decided by the arm that returns. Divergence is a
typing fact only; exit remains a control escape that ? and try do not
catch (§8.8).
17.5. Capture at a binding boundary
A command's value is its output. Whether a let binds what a command writes is
decided by the type of its right-hand side, not by how it is spelled. Two forms,
written by the checker and nameable by no program, turn a command into text:
Γ ⊢c M : F_w Unit Γ ⊢v v : Bytes
------------------------- Cap -------------------------------- Decode
Γ ⊢c capture M : F_p Bytes Γ ⊢c decode v : F_p String
⌈M⌉ = capture M to d . decode d : F_p String the capture of a command
capture M runs M with its standard output replaced by a buffer and returns
the buffer's bytes. decode drops one trailing LF or CRLF and decodes
strict UTF-8, as from-line does to its input; invalid UTF-8 is an error, with
the hint to use from-bytes. ⌈·⌉ is the one coercion the checker inserts; its
companion is a zero-cost upcast from F_p Unit to F_w Unit, which a ()-
producer takes where a command is demanded and which does nothing at run time.
There is no path from F_p Unit to F_p String, so a let of a ()-producer
binds ().
⌈·⌉ is placed where a value is demanded of a command, and only there:
- Bind. The whole right-hand side of a
let, a named pattern orM to x . Nhas typeF_w Unit: it is captured, solet x = Mislet x = M | from-linefor everyMthat is a command, whatever its form — a block, a call, a variable holding a block, a pipeline, anifwith command arms. - Argument. An argument whose type ends, after its own parameters, in
F_w Unit, handed to a function parameter that ends inF_p βat the same arity, is coerced andβisString. A literal block has its innermost body captured; a block in hand is wrapped in a lambda that captures its call. This is howmap { |f| echo $f } [1, 2]is["1", "2"]. - Join. An arm of type
F_w Unitwhose join settles on a value (§17.6) is captured like an argument.
Nowhere else is a coercion inserted: a command flowing into a bare variable, or
a demand that resolves only later, leaves an ordinary mismatch whose hint names
the fix. A tail Run is never coerced.
The bind rule:
Γ ⊢c M : F_p A Γ, p : Gen_Γ(A) ⊢c N : C
------------------------------------------- Let
Γ ⊢c let p = M ; N : C
Γ ⊢c M : F_w Unit Γ, p : String ⊢c N : C
--------------------------------------------- Let-cap
Γ ⊢c let p = M ; N : C elaborated ⌈M⌉ to p . N
Γ ⊢c M : F_ε̂ A ε̂ := p, then Let
A let is a demand for a value, which is the only place a grade variable is
defaulted: let k = { |g| g 1 } is ∀α ν. {{Integer -> ν α} -> ν α} and
defaults nothing, while let v = g 1 with g of that parameter type fixes
ν := p. A grade disagreement elsewhere is a mismatch of Command against
Returns A.
The user-facing rule is §6.8's: a command's value is its output; a let
binds it.
Gen_Γ quantifies the variables, grade variables included, that are neither
free in Γ nor weak (§17.3). A top-level let is a bind; a top-level run is a
statement, tail included, and its own report is never captured. The
elaborator's hoists are ordinary binds, so let x = cat f | grep "a$p" captures
the pipeline, and "today: !{date +%F}" interpolates the date. let _ = cmd
captures and drops: a surface _ is a fresh name, not a discard.
A right-hand side of type A -> C is a function: evaluating it builds a
closure, and the binder receives U (A -> C).
A discarded statement is never captured on its own. The whole captured
computation is one buffer, so let x = !{ echo a; echo b } prints nothing and
binds "a\nb", while let x = !{ let y = echo a; echo b } prints nothing and
binds b, the inner let capturing a. A stand-in dispatched for the captured
command runs whole under the capture: under let x = curl, the arm curl: { |a|
echo note; echo body } binds "note\nbody".
audit absorbs its body: audit { grep -c ERROR app.log } has type
F_p Report(Unit) and outcome `ok (), while the bytes its body wrote sit
in the trail's observations. each, fold and fold-lines absorb a command
body likewise: they run it, stream its output, and keep its value, which for a
command is (). spawn and watch do not absorb: the handle keeps the body's
grade, so let x = await $h binds what a command block wrote, as
let x = { … } would.
17.6. Branch and variant rules
Every form that takes a suspended command takes a thunk, and forces the one it
chooses. A literal { … } is that thunk, so if $c { echo hi } else { () } and
if $c $yes else $no are one rule; an arm is a block or a name, and any other
atom is refused at elaboration (§17.1). The arms are joined by the rule below:
Γ ⊢v v : Bool Γ ⊢v T : U C Γ ⊢v E : U C
---------------------------------------------- If
Γ ⊢c if v T E : C
Γ ⊢v v : Variant(`l1 : A1, ..., `ln : An) Γ ⊢v Hi : U (Ai -> C) (1 ≤ i ≤ n)
--------------------------------------------------------------------------------- Case
Γ ⊢c case v of {`l1 H1, ..., `ln Hn} : C
Γ ⊢v T : U C Γ ⊢v H : U (Error -> C)
---------------------------------------- Try
Γ ⊢c try T H : C
Here C is the join of the arms' result types R1 ... Rn, each taken after
the arm's own parameters. Every arm is inferred first, against a fresh result;
then, in program order:
- if some arm is
F_p AwithAnotUnit(or not yet known), the join isF_p Aand everyF_w Unitarm is captured (§17.5), sotry { fetch } { |e| return 'none' }isF_p String; - otherwise, if some arm is
F_w Unit, the join isF_w Unit, everyF_p Unitarm is upcast, and grade variables becomew, soif $c { echo $x } else { warn skip }is aCommand; - otherwise the arms unify.
if $c { hostname } else { echo b } is one Command, captured once by an
enclosing let. The join is decided in program order and holds no pending
constraint, so it is principal for the rule as stated but not independent of
statement order: { |t| if c { echo hi } else { !$t }; let w = !$t; $w }
gives t the type {Command}, where the same block with the let first gives
{Returns String}.
guard, within and grant are typed likewise (§17.7). Additional elsif
arms nest, each else holding a thunk whose body is the next if. A
conditional without else wraps its lone arm as { body; () }, so the whole
form is a ()-producer, and whatever the body writes goes to the current
output. A fallback chain M ? N is try M { |_| N }, so its arms join in the
same way.
if $c { echo hi } else { () } and try { rm $f } { |_| () } type as they
stand, as commands. if $c { echo big } else { 'small' } is a String, the
command's output captured. if $c { echo big } else { return 5 } is refused,
String against Integer, and the hint names the command arm: one arm is a
command, whose value is its output; return a string there, or end the command
arm with ; return (). The empty arm of a captured conditional is written
{ '' } or { () }: let x = if $c { echo hi } else { () } binds "" when the
empty arm runs.
The Case rule closes the scrutinee's row to exactly the arms' tags, which the
syntax fixes: coverage is therefore decided wherever the rule fires, and both a
tag the arms miss and an arm for a tag the scrutinee cannot carry are static
errors. An open scrutinee row instead absorbs an arm label it has not been
seen to construct — that is principal row inference (§17.3), not a gap in the
coverage proof. case applies the chosen arm to the payload, () for a nullary
tag.
17.7. Scope signatures and stand-in arms
Let Error be the closed error record
{cmd:String, status:Int, message:String, site:S}, where
S = `some {script:String, line:Int, col:Int} | `none — the one
vocabulary try, poll, and audit all report failure in. The scope forms
take their bodies as thunks and are typed by them:
body : U (F_ε A)
------------------------------ Within / Grant
within opts body : F_ε A
grant caps body : F_ε A
body : U (F_ε A)
cleanup : U (F_ε' B)
------------------------------ Guard
guard body cleanup : F_ε A
body : U (F_ε A)
------------------------------ Audit
audit body : F_p {outcome : <`ok A | `err Error>, trail : List Observation}
title : String body : U (F_ε A)
------------------------------ Spawn
spawn [title] body : F_p (Handle_ε A)
body : U (F_ε A)
------------------------------ Gather
gather body : F_ε A
spawn's title is optional; the handle keeps the body's grade (§11.2).
gather passes its body's type through, grade included: joining its workers
is a run-time effect that changes no type (§11.3).
try is §17.6's rule. within, grant and guard have their body's type,
grade included.
guard's cleanup runs for its effects and failures alone: its value is
discarded, what it writes goes to the current output, and its type is not
joined with the body's. The scope-body, handler-shape, and arm-result
constraints retain ordinary diagnostic provenance and source spans.
within additionally types known option fields and installs schemes for
literal handler entries while checking its body. grant types known capability
fields. Unknown, spread, or dynamic option keys remain subject to runtime
decoding.
An arm stands in for the head it names, and is checked against what that head
is. standsFor(c) is the scheme of the base frame or arm already in force under
c, and [String] -> F_w Unit for anything else:
standsFor(c) = σ c is a base frame, or already has an arm, of scheme σ
= [String] -> F_w Unit otherwise
Γ ⊢v V : U (standsFor(c)) Γ ⊢v V : U (String -> [String] -> F_w Unit)
------------------------ Arm ------------------------------------------- Catch-all
Γ ⊢ [c : V] ok Γ ⊢ [handler : V] ok
So an arm for an external stands in at [String] -> Command, and its value is
its output: curl: { |args| echo '{"ok":1}' } is an arm, and
curl: { |args| '{"ok":1}' } is refused, since it returns a String where it
should write. An arm that returns () is admitted as a ()-producer upcast to
a command, and under let x = curl it captures "". An arm for echo, a base
frame that writes, has echo's own scheme. An arm for detach, a base frame
that returns, has detach's scheme and returns a receipt. A call through an arm
types as its head does. The catch-all stands in for every command in its block,
so { |name args| cd $name } is valid. An arm installed at run time, by alias or from a bundle, is checked by
the same rule when it is installed.
17.8. Operational outcomes
Evaluation is a small-step machine, presented here at the level the rest of this section uses; the implementation is exactly this shape, one Rust match arm per rule. A state is a focus over a stack of frames, stepped against a store:
⟨M, Γ⟩ / κ, Σ ⟶ ⟨N, Γ'⟩ / κ', Σ'
R / κ, Σ (κ nonempty: meets its top frame next step)
X / κ, Σ (climbs to the first frame whose rule catches it)
- The focus is either a closure ⟨M, Γ⟩ — a computation paired with the
environment its free variables read — a terminal
Rmeeting the frame atopκ, or a breakXclimbing past it. κis a stack of frames, one per enclosingto, application, or scope form still open; a frame carries the environment it resumes under,Γ, so a binder's extent is exactly the frame pushed for it and nothing wider. Applying a thunk puts its closure in focus directly, in place, and pushes no frame —force(thunk M) = M— so a tail call costs no stack: depth is|κ|, and a call in tail position leaves it unchanged.Σis everything not lexical: current directory, environment overrides, handlers, capabilities, status, and audit state. Lexical bindings live inΓ, never inΣ.Rhas two shapes,value vorlambda ⟨M, Γ⟩— a λ-closure standing for an unapplied function, sincelambda p.Mis never a value (§17.2). Only anApplyframe consumes alambdaby β; every other frame's hole wants a value, and meeting alambdathere is a case the checker excludes by η-expanding every arrow-typed computation into a thunked λ.Xhas two shapes,error eandescape q(q ::= exit n). A break climbsκpast every frame whose rule does not catch it, each frame's own rule running once on the way up to close what it opened — a redirect settled, a handler frame popped, an audit scope closed.tryand?are among the few frames an ordinary error does not climb past; nothing catches an escape but the run boundary itself.
The central rules are:
⟨return v, Γ⟩ / κ, Σ ⟶ value v / κ, Σ
⟨M to p.N, Γ⟩ / κ, Σ ⟶ ⟨M, Γ⟩ / (to p.N, Γ) :: κ, Σ To-push
value v / (to p.N, Γ) :: κ, Σ ⟶ ⟨N, Γ[p ↦ v]⟩ / κ, Σ To-return
error e / (to p.N, Γ) :: κ, Σ ⟶ error e / κ, Σ Propagate
M to p.N pushes its frame before M runs, carrying the environment N
resumes under; p ↦ v is built from that frame's own Γ when M returns, so
p scopes over N alone. A sequence M;N is M to _.N: it evaluates left
to right, stops on the first error or escape, and its value is its last
statement's. if evaluates its Boolean condition and forces only the chosen
arm, in place and over the same Γ: a literal thunk runs with the current
environment and closes nothing. case evaluates the scrutinee, selects the arm
carrying its tag, pushes an Apply frame holding the payload, () for a
nullary tag, and forces the arm. The arm is a branch and not an ordinary
function call: it runs in the ambient shell context, so the arm's own effects
on the shell outlive the case.
Recursion is one frame form, Rec, n-ary: letrec {xi = vi} binds every
group member's name to the thunk of its own projection and puts the chosen
member in focus; a recursive reference forces its name, which re-enters Rec
and re-extends from the outer Γ. No body is ever rewritten; a group of one
is Levy's rec f. M.
A Define at the top level — let x = v outside any block — extends the
session environment, not a frame's: it is installed as it is reached, so a
later top-level phrase, or a use inside one, sees it, and a run that halts
has installed exactly the Defines that ran before the halt.
The stack is capped: a rule that would push a frame first checks |κ| <
stack_limit (session.stack_limit, default 100 000, the --recursion-limit /
rc recursion-limit: knob) and raises rather than pushing, before any
effect the frame would hold undo for — a sink swap, a redirect's file open, a
capability push — so a refused push commits nothing.
A multi-stage pipeline is a configuration, not a sub-evaluation: each stage is
this same machine started fresh, over the empty stack, in its own process; the
parent's Pipeline rule launches the group, joins the stages' outcomes and
returns them to the parent's own κ like any other rule's terminal — no
frame stands for the running group, since nothing runs beneath it. No frame ever crosses a stage boundary — bytes cross
by the wiring of §7.1, and the bindings a stage's Γ starts from cross the
same way, as store rather than continuation.
For try body handler, a body value is returned unchanged. A body error is
converted to E and passed to the handler. An escape bypasses the handler.
Standard output and standard error remain live; try does not capture them.
M ? N elaborates to try M { |_| N } (§8.4), so a value from M wins, an
ordinary error from M runs N, and an escape from M propagates. A longer
chain M1 ? ... ? Mn nests the same way, right-associated, so it reports the
last error when every arm fails.
For guard body cleanup, cleanup runs after every body value or ordinary body
error. A cleanup value leaves the body outcome intact. A cleanup error and a
cleanup escape alike take priority over that outcome and propagate.
audit body opens a trail and evaluates body with it active, then returns
the report: the body's outcome, `ok of its value or `err of E,
beside the trail. An ordinary body error settles into `err; an escape
propagates past the report. Within the trail, `command observations are
recorded only for external or bundled commands and detach births; a builtin
application is not an observation, and its effects are recorded at the doors
they pass through (§13.3). User-function application, control scopes, and
iteration machinery are transparent: they record no observation of their own,
while the commands, reads, and writes reached inside them remain visible.
Observations are appended in settlement order, so a command's own observation
follows those of everything it ran; the start–end intervals, not the list
structure, recover which extent contained which.
A `check observation is a distinct tag from a `command. The
in-process guards record every filesystem and execution denial while a trail
is open, and no allowed check ever; entering a capability layer may also record
a flagged confused-deputy prefix. With no trail open, capability checks add no
observations.
fail r requires an error record with a nonzero integer status and optional
String or Bytes message; it raises an ordinary error. exit n raises an
exit escape and is not caught by ? or try.
Cancellation is polled at evaluator and process boundaries. A poll of a
cancelled scope raises an ordinary error carrying the strongest cause's fixed
message and status. The run boundary polls again after evaluation, so recovery
inside a final try cannot settle a cancelled run successfully. External
children are torn down and reaped before their cancelled computation returns.
17.9. Grounding and execution
Successful checking is followed by annotation, a structural rebuild of the IR.
All remaining type, row and computation substitutions are applied; quantified
schemes are closed; each pipeline node records the value type of each stage; and
each computation the checker captured (§17.5) is wrapped in capture under decode.
The machine decides nothing about capture: it runs the capture the checker
wrote. The capture frame has three rules, with buf a fresh buffer and prev
the standard-output sink it replaces:
⟨capture M, Γ⟩ / κ, Σ ⟶ ⟨M, Γ⟩ / (cap prev) :: κ, Σ[stdout ↦ buf] Cap-push
value v / (cap prev) :: κ, Σ ⟶ value bytes(buf) / κ, Σ[stdout ↦ prev] Cap-return
X / (cap prev) :: κ, Σ ⟶ X / κ, Σ[stdout ↦ prev], buf written to prev Cap-halt
Cap-return ignores v. A buffer that grows past 16 MiB is an error, and it
halts by Cap-halt. Everything the body writes goes to the buffer — a command's
output, a stand-in's every statement, a pipeline's final stage — because
everything the body writes is the command's output. A discarded statement writes
to the current standard output, which inside a capture is the buffer. A captured
final pipeline stage is a capture node, so it runs as a thread stage: its
standard input is the pipe and its buffer takes what the command writes.
A handler arm dispatched under a capture runs where the command would have run, so its writes land in the buffer.
Evaluation begins only from this annotated IR. A type mismatch is therefore a
static error, not a request for the runtime to guess a codec. Runtime checks
remain for genuinely dynamic facts: missing record keys, bounds, dynamic option
maps, operating-system failures, cancellation, and external exit statuses. A
case is not among them: its alternatives are syntax, so its coverage is
settled here.