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 at the start of the line after the command that it follows. The
next branch must start on the same line as ?.
read-primary
? read-backup
Do not put a newline after ?:
read-primary ?
read-backup # error
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/, abbreviates the current user's home directory, whichhomereturns (§10.2):~/srcis"!{home}/src". Anywhere else~is an ordinary character, sofoo~barand~bob/xare plain words.,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.
Backslash has no special meaning 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.
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
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.
A splice ends where its own closing delimiter does, so a [ written after
$(name), $[...], or !{...} is text: "$(red)[$host]" is a colour
followed by a bracketed host. Only $name and !$name, which have no
closing delimiter, continue into the [key] groups written immediately
after them. To index a delimited form, wrap it: "$[!{cmd}[k]]".
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 static key can be a bare name or a quoted string. A computed key uses a
string 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).
A bracketed literal that opens with : is a map: [:] is the empty one and
[:, a: 1, b: 2] a map on keys written out. Otherwise its keys decide. A
literal every one of whose entries has a static key is a record; a literal
with any computed key is a map, because a keyset settled at run time cannot
carry a type per key; a literal with no entry at all is a list.
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
[:, …] 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
use 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. 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
equal a b and == or != inside $[...] use the same structural equality.
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: equal 42 hello is refused, and equal 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 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, and audit 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 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 |
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 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, and audit 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. 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 service-handle, 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>&1 |
Send standard error wherever standard output goes. |
< path |
Read standard input from a file. |
<< value |
Read standard input from a string value. |
The default descriptor is 0 for input redirects and 1 for output redirects. 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. A control escape such as return or exit takes precedence over an ordinary stage failure.
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. The same condition in the final stage remains its own failure: the final stage's output crosses the pipeline's outer boundary, not an interior edge, so a caller that stops reading the pipeline's own output is a failure that stage reports for itself.
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. Other
fields are ignored when fail raises the error.
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 its essential status and message. 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: `just [script: String, line: Int, col: Int] | `none,
]
where
Reason = `exited Int | `signaled Int | `cancelled Cause
| `not-found | `not-runnable | `raised
Cause = `interrupted | `cancelled | `timed-out | `terminated | `aborted
| `reader-gone
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 c |
cancellation by cause c (§8.10) |
the cause's status |
`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. fail ignores
reason: fail $error re-raises status and message, and the new error's
reason is `raised.
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 and
internal tail-call control bypass the handler. Cancellation may be observed
as a recoverable error inside the body, but cancellation is sticky:
recovering it cannot make the enclosing run complete successfully. That
includes a cancellation a command's end reports: a key pressed on a terminal
ral lent is struck on the run (§8.10).
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 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. 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.
Cancellation causes form an escalation order and never downgrade:
| Cause | reason |
Message | Status |
|---|---|---|---|
| foreground interrupt | `cancelled `interrupted |
interrupted |
130 |
| explicit cancellation | `cancelled `cancelled |
cancelled |
143 |
| deadline | `cancelled `timed-out |
timed out |
124 |
| session termination | `cancelled `terminated |
terminated |
143 |
| root abort | `cancelled `aborted |
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 fails with `cancelled `reader-gone
and status 141, the SIGPIPE status; a try inside that stage's body can
observe it.
A cancellation reports the same reason, 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 reports that key's cancellation, and the run it belongs to is
cancelled, so try cannot recover 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, but not a detached worker and not a later prompt. Nothing a detached 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 detached 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 shell: [chdir: false] 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.
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. |
home |
F String |
HOME, or USERPROFILE where appropriate, from the effective environment; 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 relative to the containing file, or relative to cwd when there is no containing file. 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. 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 still owns it:
- an ordinary command belongs to the current run;
spawnandwatchcreate a worker owned by the session;servicecreates a session-owned worker exempt from ordinary worker leases;detachgives an operating-system process away, so the session no longer owns or controls it.
This distinction is visible in the result type. Session-owned work returns a
Handle. Surrendered work returns only a receipt.
11.1. Spawning work
spawn starts a block concurrently and returns immediately:
let build = spawn {
cargo build
return 'built'
}
echo 'the build is running'
let result = await $build
echo $result[value]
A pipeline is spawned by naming it in the block:
let filtered = spawn { 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
session-owned 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 remains owned by the same session.
A worker is not part of the foreground run. Interrupting or timing out the run that created it does not cancel the worker. The worker's standard input is empty rather than inherited from the terminal, so detached work 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.
11.2. Handles and eliminators
A handle is a process-local capability for one worker. The core eliminators
are await, poll, race, and cancel.
await
await handle blocks until the worker settles. On success it returns:
[
value: A,
stdout: Bytes,
stderr: Bytes,
]
A is the return type of the spawned block. The type of the handle and the
type of value are linked, so a consumer cannot assume a different result
type.
If the worker fails, exits, or panics, await raises that failure rather than
returning the record. Use try when failure is expected:
try {
let result = await $build
use $result[value]
} { |error|
echo "build failed with status $error[status]"
}
The settled result is cached. Repeating await on a completed handle returns
the same value and bytes. A foreground interruption of await interrupts the
wait, not the durable worker; the handle may be observed later.
poll
poll handle never waits. It returns one of two variants:
`pending [stdout: Bytes, stderr: Bytes]
`settled [
stdout: Bytes,
stderr: Bytes,
outcome: <`ok A | `err ErrorRecord>,
]
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.
The settled arm reports worker failure as outcome:err ...; it does not
raise that failure. Thuspollitself succeeds and leaves status zero when it
successfully samples either arm. The prelude functionis-done handlereturnsfalseforpendingandtrue` for either settled outcome.
race
race handles waits until it observes one handle settle, returns the same
record as await, and cancels every remaining live handle:
let a = spawn { fetch 'https://a.example/data' }
let b = spawn { fetch 'https://b.example/data' }
let first = race [$a, $b]
If several handles are already settled when inspected, list order breaks the tie. A failed winner is re-raised after the losers have been cancelled. An empty list, or a list with no live or settled handle left to observe, is an error.
cancel
cancel 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.
Cancellation has no result. await and poll on a cancelled handle fail with
handle is cancelled; the diagnostic suggests using try around await.
Cancelling a handle that has already completed does not destroy its cached
outcome.
11.3. Buffered output and par
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 and
settled poll return them as Bytes; pending poll exposes the bytes written
so far.
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 {
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.
par function items jobs is the standard parallel map:
let converted = par { |path| convert-one $path } $paths 4
At most jobs workers run at once. A non-positive limit permits one worker per
item. Results preserve input order even when workers finish out of order.
par unwraps each await record and returns only the list of value fields;
use explicit spawn and await when stdout or stderr is needed. If an item
fails, par cancels every remaining sibling before re-raising the original
failure.
11.4. Registry, leases, and shutdown
Every spawn, watch, and service birth is registered at once
in the owning shell. A registry entry has a stable wN identity, description,
start time, lifetime class, state, and the live handle. Nested workers share
the owning shell's registry.
An entry leaves when:
await, settledpoll, orraceobserves its result;cancelremoves it;- a host worker lease reaps it; or
- a host retention policy reaps an unclaimed settled result.
A pending poll leaves the entry in place. Listing workers is also a pure observation and renews nothing.
Hosts may give ordinary workers an idle-observation lease with an absolute
backstop. poll, await, and race renew the idle clock whenever they name a
handle; continuous waiting renews it on every sweep. Observation can postpone
idle reaping but never the absolute backstop. A lease reap cancels the worker
and records a notice explaining whether the idle bound or backstop fired.
Hosts may also 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 and suggests awaiting or cancelling a worker.
These bounds are host policy, not core constants. The ral interactive and
batch hosts arm no worker lease, retention bound, or worker cap. The exarch
agent host currently grants ordinary workers a one-hour idle bound, a 24-hour
absolute backstop, a cap of 64 live workers, and retention of 256 ral calls for
unclaimed settled entries.
All worker classes remain owned by the session and are cancelled when that
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 identities 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 spawn, await, poll, race, and cancel.
watch, service, and detach are optional host commands: watch and
service are builtins, 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, service) 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 session worker 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.
Durable session work with service
service description block creates a buffered worker with no idle lease and
no absolute backstop:
let server = service 'local documentation server' {
python -m http.server 8000
}
The description must be a non-empty, single-line String. It is the service's
stable explanation in host listings. A service still occupies a live-worker
seat and still ends on explicit cancel, session destruction, or process
exit. “Durable” means exempt from neglect-based reaping, not independent of
the host process.
Agent hosts with ordinary worker leases may install service; exarch does.
The ral executable omits it because its ordinary workers already have no
idle lease or backstop. A host may provide an id-based way to recover service
handles; exarch exposes its live services in a host-owned register and provides
service-handle id.
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. Later grants cannot widen it. An
active grant may refuse the operation with detach: false 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, await, poll, race, cancel |
yes | yes | yes | yes |
par, is-done from the standard prelude |
when that prelude is installed | yes | yes | yes |
watch |
optional | yes | yes | no |
service |
optional | no | no | yes |
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: [
git: ['status', 'diff'],
sh: 'deny',
],
fs: [
read: ['cwd:', '/usr/share'],
write: ['cwd:build'],
deny: ['cwd:.git'],
],
net: false,
detach: false,
] {
git status
}
Each dimension is independent. Omitting net, for example, says nothing about network access; it inherits the surrounding authority. 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: [git: ['status', 'diff']]] {
grant [exec: [git: ['status']]] {
git status # allowed
git diff # denied
}
}
A denial at any layer remains a denial.
12.1. Capability fields
A capability record accepts exactly these names:
execfsnetdetacheditorshell
Unknown names are errors, reported before the program runs. net and detach
are Boolean; the other four are structured, and their interiors are the
decoder's to judge.
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.
Omitting a whole field inherits that dimension. Once a structured field such as fs, editor, or shell is present, omitted members inside it take their restrictive default.
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: true]] { … }
allows editor reads, denies editor writes and TUI access, and leaves non-editor dimensions unchanged.
12.2. exec
exec names commands or paths, each with a policy:
exec: [
git: ['status', 'diff'],
rg: 'allow',
bash: 'deny',
'/opt/tools/': 'allow',
]
A literal command accepts:
'allow'— any arguments;'deny'— no invocation;- a non-empty list of strings — only invocations whose first argument is in that list.
The subcommand check examines only the first argument. A command with no first argument is denied by a subcommand policy.
Every rule judges the file that will run: its real path, with symbolic links followed. A path key names one file, frozen to its real path when the grant is decoded. A bare key names the file the host PATH finds for it, and the bundled tool of that name; a scoped PATH cannot change what it names. So git: 'allow' also admits /usr/bin/git spelled as a path, because it is the same file.
A key ending in / names every executable beneath that directory, frozen to its real path. Directory keys accept only 'allow' or 'deny'. A literal denial also vetoes the command by file name wherever it lives, so bash: 'deny' cannot be avoided through a link or another directory.
The most specific rule decides: a veto, then a literal key, then the deepest covering directory; rules of equal specificity meet, so an equal allow and deny resolves to deny. 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 map is present, commands not admitted by that map are denied. Nested exec maps intersect, subcommand lists intersect, and every denial remains effective.
Two exec-only shorthands expand when the grant is decoded:
'path:'— every absolute directory in the current$PATH;'system:'— ral’s platform-defined system tool directories.
Both take only 'allow' or 'deny'. path: is an error if $PATH contains no absolute directories.
12.3. fs
fs has three path lists:
fs: [
read: ['cwd:', 'xdg:data'],
write: ['cwd:build', 'tempdir:'],
deny: ['cwd:.secrets'],
]
readpermits reads under the listed prefixes.writepermits writes under the listed prefixes.denyforbids both reads and writes under the listed prefixes.
Read and write authority are separate: write permission does not imply read permission. A present fs map with no read or no write entry grants nothing for that operation. Deny regions are carve-outs and accumulate across nested grants.
ral resolves the accessed path, including symlinks, before testing prefix membership. Policy prefixes also carry a symlink-resolved identity captured when the policy is decoded. 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. /dev/null is always permitted as a discard device.
ral checks filesystem operations that it performs itself. A spawned program’s own filesystem access is instead confined by the host sandbox where supported.
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.4. Path sigils and freezing
Capability paths must be absolute after expansion. These sigils make portable absolute prefixes:
~or~/subpath— the effective home directory;xdg:config,xdg:data,xdg:cache,xdg:state,xdg:runtime, and their subpaths;cwd:— the working directory at decode time;tempdir:— the platform temporary directory at decode time;gitdir:— the current repository’s real Git directory, orcwd:outside a repository. Where.gitis a file naming that directory, the directory must name the working tree back — as a linked worktree’sgitdirfile does, and as a--separate-git-dirrepository’score.worktreesetting does — since the file itself lies inside the tree the grant confines.
Sigils, . and .., 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.
Ordinary relative paths are rejected. Use cwd:relative/path when that is intended.
An XDG sigil must resolve beneath the effective home directory. ral rejects an unknown XDG name, an unset home needed by ~ or xdg:, an XDG environment override that would escape the home directory, or a .git pointer file no Git directory claims. A path rooted for another operating-system family is ignored as a dead, non-matching grant so one profile can contain host-specific entries.
12.5. net
net is a Boolean:
grant [net: false] { … }
false disables network access for spawned children through the OS sandbox. true leaves network authority available unless an outer layer already disabled it. ral has no in-process network primitive, so there is no separate ral-level network check.
If the host cannot enforce net: false, ral refuses the confined launch rather than silently running it online.
12.6. detach
detach is a Boolean controlling whether the body may create a process that the session stops owning.
grant [detach: false] {
# detach is refused; spawn and service remain session-owned alternatives
}
A single false anywhere in the active stack denies the birth. true cannot override an outer false.
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 net, detach, editor, and shell Boolean checks record no individual
checks.
12.8. editor and shell
editor controls the private line-editor interface:
editor: [
read: true,
write: false,
tui: false,
]
Its fields are:
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.
shell currently controls working-directory changes:
shell: [chdir: false]
When chdir is false, cd is denied. These booleans are intersected independently across nested grants.
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, subcommands, 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.
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 express first-argument subcommand rules and cannot protect operations ral performs in its own process.
Dynamic loader injection variables such as LD_PRELOAD, LD_AUDIT, LD_LIBRARY_PATH, and DYLD_* are removed from child environments while capabilities are active. 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. Subcommand restrictions remain an in-process check on the original invocation. 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.
Linux. ral uses bubblewrap for filesystem restrictions and net: false; 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_DUMP_SANDBOX_PROFILE prints the set. Under a restricted fs the envelope always binds the system read-only: /bin, /usr, /lib, /lib64, the files under /etc that dynamic linking, name resolution, user lookup and toolchain resolution need, and of /sys only what sizes a program (/sys/devices/system/cpu, /sys/kernel/mm/transparent_hugepage). 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 RAL_DUMP_SANDBOX_PROFILE reports the table as the container's own, and each other invariant the host does not hold. 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, and Landlock is allow-list only, so both a denied path inside an admitted directory and an admitted dynamic loader or interpreter invoked explicitly on an unadmitted file are refused by ral's own check but not by the kernel. RAL_DUMP_SANDBOX_PROFILE reports whether the host holds each of these. An attenuated exec alone is enough to build the envelope, so a grant that narrows only which programs run is still kernel-enforced.
Windows. 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: false 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. 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: false 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: [
git: ['status', 'diff'],
rg: 'allow',
],
fs: [
read: ['cwd:'],
],
net: false,
detach: false,
]
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: false] is a
record, so exec's misspelling as exect is caught statically, before the
profile script runs at all. return [:, exec: …, net: false] 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: $paths, net: false] { … }, with computed values, does.
Compilation or runtime failure in a profile, a non-map terminal value,
malformed maps, wrong Boolean types, non-string path or subcommand entries,
empty subcommand lists, unknown fields, unresolved required sigils, and invalid
relative paths are reported as configuration errors. Inline-map 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: `just [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: false bit 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: `just [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 |
`just [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 |
String |
shell principal when the observation was recorded; empty 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: String,
stdout: Bytes, stderr: Bytes, error: String]
| `write [path: String, mode: String, outcome: String,
new_bytes: <`just Bytes | `none>,
old_bytes: <`just Bytes | `none>]
| `read [path: String]
| `grep [scope: String, pattern: String]
| `check [resource: String, decision: String, fields: Map String]
| `worker [id: Int, cmd: String, class: String]
| `act [verb: String, subject: <`just 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 |
String |
external or detached |
stdout |
Bytes |
raw bytes observed on fd 1 |
stderr |
Bytes |
raw bytes observed on fd 2 |
error |
String |
ral's runtime error message, or the empty string |
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 |
String |
write, append, or stream |
outcome |
String |
committed, aborted, or failed |
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, `just 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 worker's birth:
| Field | Type | Meaning |
|---|---|---|
id |
Int |
the worker's registry identifier |
cmd |
String |
what it was born to run |
class |
String |
worker or durable |
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 the
empty string. It is populated only from ral's runtime-error path; it is not
copied from stderr, and a nonzero status need not imply a nonempty error.
Conversely, stderr contains only bytes observed on fd 2. It never contains
synthetic runtime prose merely because a command failed.
stdout and stderr are retained under one rule: each is capped at 16 MiB —
the same capture cap a worker's buffers use (§11.3) — and at the cap ral
appends one truncation marker and discards the rest. Neither stream is
privileged over the other. These are retention rules, not I/O limits: every
byte still streams to its ordinary destination.
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 equal applied 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 |
String |
exec, fs, or deputy |
decision |
String |
denied, or the advisory flagged |
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;- non-finite floats become
null; - 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 equal, fail, and ordinary functions:
let assert-equal = { |name expected actual|
if $[not !{equal $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 --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 Builtins: and Prelude: sections. It uses 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 summary, full inferred type or command
signature, and where the name resolves. It understands core and host builtins,
prelude entries, documented host libraries, session bindings, aliases,
handlers, and external commands. 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 primitive 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;equal,lt, andgt.
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.
equal is structural over data, and its two arguments have one 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, 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. abbreviate-home is for display: it folds a leading home
directory to ~. Filesystem reads and creations are checked against the
active filesystem grant.
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. 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 spawn, await, poll, race, and cancel.
They operate on typed handles. The prelude adds par, is-done, attempt,
succeeds, and defer. 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, the durable service operation, 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 --surface structural # request the structural frontend
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. The
default prefix is => and the default value colour is yellow where colour is
supported.
15.2. Frontends
The ral executable provides three 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. |
structural |
A feature-gated terminal projection of the same session, including typed bindings, worksheet information, and handles. It uses the same completion and plugin dispatch rules as readline. |
Terminal capability is stronger than preference. A dumb terminal, or
RAL_INTERACTIVE_MODE=minimal, forces the minimal surface even when another
surface was requested. If structural is requested but the binary lacks that
feature or the terminal cannot enter raw mode, ral warns and falls back to
readline.
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. Relative
paths are resolved from ral's logical working directory. Matching is fuzzy;
ties are stable and alphabetical. 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, readline, or structural; 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 the 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 documentation, inferred or declared type, and
where name resolution would find it. 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_buf: String, line: String, pos: Int, history: List<String>, keymap: String, state: Any]; returns Unit |
After the text or cursor changes. |
pre-exec |
[src: String]; returns 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 four Unit result contracts above are enforced. A non-Unit result is a
hook failure, not an ignored value. The prompt hook is deliberately
different: it is a string transformer, so each successful String 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. Prompt transformers compose: each successful result becomes the next hook's input, while a failed or non-string result leaves the current prompt unchanged.
buffer-change runs on the editing path, so ral protects the session from a
persistently bad handler. A single invocation that exceeds 100 milliseconds,
or three consecutive failures, disables that plugin's buffer-change hook for
the rest of the session and prints a notice. _ed-tui is unavailable inside
buffer-change.
A keybinding handler receives:
[line: String, cursor: Int, history: List<String>, keymap: String, state: Any]
Key names are tab, enter, escape, arrows, home, end, delete,
backspace, f1 through f12, a single character, ctrl-character, or
alt-character. Ctrl-C and Ctrl-D are reserved and cannot be bound.
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 key 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 later binding hidden by an earlier unguarded binding loads with
a warning naming the earlier owner.
The minimal frontend does not dispatch buffer-change 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]. |
_ed-text, _ed-cursor, _ed-keymap, _ed-lbuffer |
Read the whole buffer, cursor, active keymap, or text left of the 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 |
Attempt an OSC 52 clipboard write; return whether the terminal accepted emission. |
_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. Nested
_ed-tui calls and calls from buffer-change are 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 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. $SCRIPT is a lexical string containing the source file’s name. 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;--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, and audit, and loop iterations record
nothing of their own either; 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.
16.5. Interactive surfaces
--surface minimal, --surface readline, or --surface structural 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. A structural surface that is unavailable in the build or cannot obtain raw terminal mode warns and falls back to readline.
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; otherwise it supplies defaults for PATH, SHELL, TERM, and LANG. 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. It interprets no source; it only chooses a target and replaces itself:
- any short-option cluster containing
cgoes to/bin/sh, even-lc; -l,-i, or--loginwithout-cgoes toral;- 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
ral-sh forwards arguments without requiring UTF-8. If its own executable name begins with -, it preserves that login-shell convention as -ral or -sh. It looks for ral beside itself, falling back to ral on PATH. Failure to replace itself exits 127. Both ral and ral-sh refuse a setuid invocation.
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.
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 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 words that end
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 "=" chain
chain ::= pipeline (NL? "?" pipeline)*
pipeline ::= stage ((NL* "|" NL*) stage)*
stage ::= return
| conditional
| case
| scope-form
| command
return ::= "return" atom?
conditional ::= "if" atom atom
(NL* "elsif" atom atom)*
(NL* "else" atom)?
case ::= "case" atom "[" case-arm ("," case-arm)* ","? "]"
case-arm ::= tag-key ":" atom
tag-key ::= "`" identifier
scope-form ::= "try" atom atom redirects
| "guard" atom atom redirects
| "within" atom atom redirects
| "grant" atom atom redirects
| "audit" atom redirects
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 its target */
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-entry)* ","? "]"
map-entry ::= map-key ":" atom | "..." atom
map-key ::= identifier | quoted-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
computed key makes it a map, static keys alone a record, no entry at all a
list (§4.5). A map literal may additionally use a dynamic $name key, which a
pattern cannot bind through. 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 slash
path 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.
The ? continuation admits at most one newline before ? and none after it.
| admits newlines on either side.
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
| 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 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.
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, service-handle, 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 == !=, equal, 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,
spawn, watch, service and audit run a body of either grade, stream its
output and keep its value; 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, fold-lines, spawn, watch,
service and defer absorb a command body likewise: they run it, stream its
output, and keep its value, which for a command is ().
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 = `just {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}
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.