ral — specification

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 ral program;
  • 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:

  1. A return value for ral.
  2. 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:

  1. It binds a string value to salutation.
  2. It binds a parameterized block to greet. The block remembers salutation.
  3. It applies greet and binds the returned string to message.
  4. It reads $message, writes it as bytes with echo, and streams those bytes through the external command tr.

The output is:

HELLO, WORLD

The following sections define each of these rules precisely.

3. Source text and program structure

A ral program is a sequence of statements. A script can use a newline or a semicolon (;) to separate statements.

echo 'first'
echo 'second'; echo 'third'

An empty program does nothing and succeeds. Blank lines and extra semicolons between statements have no effect.

3.1. Source text

Source text must be valid UTF-8. When ral loads a script, a module, a -c program, or a program read from standard input, it converts Windows line endings (CRLF) and old-style CR line endings to LF. Source therefore has the same meaning on every platform, however it was supplied.

Spaces and tabs separate words. They do not separate statements. A newline usually ends the current statement. A semicolon always ends it.

There is no general backslash continuation. A backslash outside a double-quoted string is an ordinary character. Double-quoted strings have their own escape and line rules; see Strings.

3.2. Comments

# starts a comment when it occurs where a new word could start. The comment ends at the next newline or at the end of the source.

# A complete comment line.
echo 'ready'  # A comment after a command.
curl http://example.test/page#part

The last # is part of the URL. It does not start a comment because it occurs inside a word.

A run of # characters followed immediately by ' starts a raw string, not a comment. For example, #'can't'# is one string. See Strings for the full rule.

ral has no block comments. A comment does not cancel an open delimiter. In an interactive session, ral asks for more input when a comment follows an open { or [. In a complete script, an unclosed delimiter is an error.

A script can start with a conventional interpreter line:

#!/usr/bin/env ral

ral reads this line as a comment.

3.3. Newlines and continuation

A newline can continue a statement only in the cases in this section. A semicolon cannot continue a statement in any of these cases.

Pipelines

| can occur before or after a newline. Blank lines and comment lines are also allowed at that point.

generate
| transform |
  consume

These three lines form one pipeline. The following text is an error because a semicolon ends the first statement:

generate; | consume

Failure chains

? can occur before or after a newline, exactly as | can. Blank lines and comment lines are also allowed at that point.

read-primary
? read-backup

Bindings and control forms

The right-hand side of a let binding can start on a later line when the newline follows =.

let report =
  build-report

The condition and body of if and elsif can start on later lines. elsif and else can also start on the line after the preceding branch. A case's scrutinee and its arm list can be separated by newlines, and so can its arms.

if
  $ready
  { deploy }
else
  { explain-delay }

case
  $result
  [`ok: { |value| use $value }, `err: { |error| fail $error }]

A newline after return ends that statement. It does not attach the value on the next line.

return
42          # a new statement

Command arguments do not continue onto the next line merely because the next line is indented. Bind a long argument list to a value and spread it when you need to split such a command across source lines.

3.4. Braces, brackets, and parentheses

Braces ({ ... }) contain a block. Newlines inside a block still separate statements.

Brackets ([ ... ]) contain a collection, pattern, index, or expression block. Newlines are whitespace while the bracket is the innermost open delimiter. Collection entries still need commas; a newline does not replace a comma.

let ports = [
  8080,
  8081,
]

let prompt = [render: {
  let mark = '>'
  return $mark
}]

The block in the second example is inside a map, but its own braces are the innermost delimiters. Its newline therefore separates the let and return statements.

Parentheses have three uses: () alone is the unit literal (§4.1), they group expressions inside $[...], and they delimit names in $(name). They are not a general command-grouping form.

Postfix indexing requires no space before [. Thus $item[key] is one indexed value, while $item [key] is two values in a command. A ! reaches over a dereference's keys, so !$item[key] forces the field; any other primary is forced first and indexed after, so !{cmd}[key] selects from the forced result.

ral reports mismatched or unclosed delimiters. It also rejects source with more than 64 nested forms. This limit prevents an implementation stack overflow.

3.5. Words and punctuation

A bare word is source text that needs no quotes. Most visible characters can occur in a bare word. Spaces, tabs, line endings, and these characters end a bare word or start another language form:

|  {  }  [  ]  $  ^  !  <  >  "  '  `  (  )  ;

Some characters depend on their position:

  • # starts a comment only at the start of a word. It remains literal inside a word.
  • ~ at the start of a word, standing alone or followed by a path separator (/, or \ on Windows; see below), abbreviates the current user's home directory, which home returns (§10.2): ~/src is "!{home}/src". Anywhere else ~ is an ordinary character, so neither foo~bar nor ~bob/x names a home directory.
  • , separates items while [...] is the innermost form. Outside brackets, it can be part of a word, as in --features=a,b.
  • : is punctuation before whitespace, a newline, ], or the end of input. It remains part of forms such as localhost: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+1 is a word and a >= b writes to a file named =.
  • ... is the spread marker where a new word can start. It can occur inside a longer word.

Quote a word when there is any doubt. Quoting prevents punctuation in its contents from changing the program structure.

Whitespace separates words, and nothing joins them. Two words written with nothing between them, such as --prefix=$d or 'a'"b", are an error, not two arguments and not one: write --prefix= $d for two, or "--prefix=$d" for one. Only the grammar's own attachments touch: an index after a $name or a forced block ($h[k], !{f}[k]), a spread before its list (...$xs), and a redirect before its target (>file). Except for redirects, these attachments must touch: ^ls, ...$xs, !$x, $h[k]. A space after ^, ..., or ! is an error. The rule holds in every position, not only in a command's arguments: if $c{a}, try{a}{b}, and [a'b'] are errors like --prefix=$d.

Backslash escapes nothing in a bare word. For example, C:\Users\name is one bare word. A dot is also ordinary, so .env and archive.tar are bare words.

A bare word containing a path separator is a path, and as a command head it runs that path directly (§6.2). / is a separator on every platform; \ is one on Windows only. So on Windows C:\Users\name and .\build are paths, while on Linux and macOS a\b is an ordinary word, which as a command head is a bare name searched on PATH.

3.6. Names and reserved words

A binding name starts with an ASCII letter or _. Later characters can be ASCII letters, digits, _, or -. Names are case-sensitive.

These words cannot be binding names or block parameters:

if  elsif  else  let  return  case
within  grant  try  guard  audit  spawn  gather
true  false

This restriction is contextual. For example, a quoted occurrence is always string data, and ^name uses an external command name rather than a binding.

3.7. Statements and stages

A statement is either a let binding or a command chain. A command chain can contain pipelines joined by ?. A pipeline contains one or more stages joined by |.

let is a statement form. It cannot occur after | or ?.

let result = produce | consume

produce | let result = consume   # error
produce ? let result = fallback  # error

The complete formal grammar is in §17.

4. Values and data

A value is data. ral does not split it into words or read it again as source text. For example, a string that contains spaces remains one value and one command argument.

ral has these kinds of value:

Kind Meaning
Unit One value that carries no information
Bool true or false
Int A signed 64-bit integer
Float A finite 64-bit floating-point number
String Unicode text
Bytes An arbitrary finite sequence of bytes
List An ordered sequence of values
record or map Values stored under string keys
variant A tag with an optional payload
block or function A suspended command
handle A reference to concurrent work

Blocks, functions, and handles are values, but later sections define how to run or inspect them.

4.1. Scalar literals

The literals true and false produce Bool values. () is the unit literal, producing the one Unit value. It is punctuation rather than a word, exactly like [] and [:]; the bare word unit is ordinary text. A word shaped like a numeral produces a number:

numeral   ::= integer | float
integer   ::= sign? digit+
float     ::= sign? ( digit+ "." digit* | digit* "." digit+ ) exponent?
exponent  ::= ("e" | "E") sign? digit+
sign      ::= "+" | "-"
digit     ::= "0" … "9"

An integer produces an Int, a float produces a Float. A float must carry a decimal point, and digits on one side of it are enough, so .5 and 2. are both floats. An exponent alone is not: 1e6 has no decimal point, so it is a word. There are no digit separators and no other base — 1_000 and 0x10 are words. Leading zeros and a leading + are allowed and carry no meaning.

A numeral outside the range of its kind is a word instead. An Int must fit a signed 64-bit integer, so 9223372036854775808 is text. Every Float is finite, so a spelling that would overflow to infinity, such as 1.8e308, is text too. The float conversion likewise rejects NaN and positive or negative infinity, and floating-point arithmetic fails if its result would be non-finite.

return ()
return true
return 42
return -7
return 3.14
return 1.0e6

An unquoted word that is not a numeral is a String. Thus return hello returns the text hello.

Numerals denote numbers in every position

This reading does not depend on where the word stands. A numeral is its number as a command argument, as a bound value, as a redirect target — everywhere a word may appear. 007 is the number 7 and 1.50 is the number 1.5, wherever they are written.

Each number in turn has exactly one printed spelling: the shortest decimal that reads back as the same number. A Float keeps its decimal point, so 3.0 prints as 3.0 rather than 3, and it takes exponent form where plain digits would run away, so the number ten to the three hundredth prints as 1.0e300 rather than as 301 characters. A canonical numeral therefore crosses the shell unchanged, and a non-canonical one is normalized on the way out:

Written Printed
007 7
+5 5
-0 0
.5 0.5
1.50 1.5
3.0 3.0
1.50e300 1.5e300

Every printed number is itself a numeral of the grammar above, and reading it back gives the number it came from. Printing and reading therefore compose to the identity on numbers, and a printed float never degrades into text. The grammar is unchanged by this: 1e6 still has no decimal point and is still a word — it is the printer that never produces such a spelling.

Quote a word to keep its bytes: '007' is three characters and '1.50' is four. Wherever ral renders a String back as source text — a completion, a quoted argument — it quotes a numeral-shaped one for the same reason. The edge worth knowing is a version-like token that is also a numeral: 3.10 is the number 3.1 and prints as 3.1; write '3.10' when the digits name something rather than count it.

4.2. Strings

A String contains valid UTF-8 text. length counts Unicode characters, not UTF-8 bytes.

Single quotes make literal text. They do not process escapes or interpolation:

return 'literal $name and \n'

Add matching # fences when the text contains a single quote. Add more # characters when the text contains a possible closing fence:

return #'it's literal'#
return ##'body with '# inside'##

Literal strings may span lines. Their contents are exact after source-text normalization. A line ending written as CRLF or a lone CR in loaded source therefore appears as LF in the value.

Double quotes process escapes and interpolation:

return "hello $name"
return "field: $record[key]"
return "host: !{hostname | from-line}"
return "sum: $[2 + 3]"

$(name) marks the exact end of a name when the following text could be part of it. A splice is $name, $(name), $[...], !{...}, or !$name, and means what the same text means outside the string. A $ or ! that opens none of these is text.

$(name) takes nothing after it, inside a string or out: "$(red)[$host]" is a colour followed by a bracketed host, and $(red)[x] outside a string is two words that touch (§3.5). Every other splice is indexed by the [key] groups written immediately after it, exactly as outside a string: "$h[k]", "!{f}[k]", and "$[$xs][0]" select from the value.

A double-quoted string that begins with ~ followed by / or by the closing quote begins with the home directory, exactly as a bare word does (§3.5): "~/x" and "!{home}/x" mean the same. A ~ anywhere else in the string, and any ~ in a literal string, is text; "\~/x" begins with a literal ~.

The supported escapes are \n, \r, \t, \\, \0, \e, \", \$, \!, \~, \xNN, \u{X..}, and backslash followed by a line ending. \xNN accepts one ASCII byte from 00 to 7F. \u{X..} accepts one valid Unicode scalar value written with one to six hexadecimal digits. An unknown or malformed escape is an error.

Interpolation accepts Bool, Int, Float, and String, each in its normal text form. It rejects (), bytes, collections, variants, blocks, functions, and handles. () is nothing to print, so it is not a word either: write the text '()' if that is meant. Use an explicit conversion or select a field instead.

4.3. Bytes

Bytes can contain any byte, including NUL and invalid UTF-8. There is no byte literal. Codecs and input operations create byte values. to-bytes writes a Bytes value through unchanged; ints-to-bytes builds bytes from a list of integers from 0 through 255, which is how a program spells out bytes without a literal for them. Section 7 defines byte pipes and the codecs that cross between bytes and values.

length counts bytes. Equality compares bytes exactly.

Displaying bytes decodes them with replacement characters for invalid UTF-8. This display is only for inspection; it can lose information. from-string performs a strict UTF-8 decode and fails on invalid input.

4.4. Lists

A list is ordered. Its elements must have compatible types. Use commas between elements. A trailing comma is allowed.

let names = ['Ada', 'Grace', 'Edsger']
let empty = []

... inserts the elements of another list at that position. It creates a new list and does not change the source list.

let middle = [2, 3]
return [1, ...$middle, 4]

Spreading a value that is not a list is an error. A literal's parts are evaluated left to right, and a failing spread fails where it is written.

4.5. Records and maps

Records and maps use the same runtime value: a string-keyed collection. They differ in what the checker knows.

  • A record has known fields. Its fields may hold different kinds of value.
  • A map has keys computed at runtime. Its values must have compatible types.
let server = [host: 'db.example', port: 5432]
let key = 'primary'
let ports = [$key: 5432]
let empty_map = [:]

A key is a label or data, and its form says which. A label is a bare name or a 'single-quoted' word. A data key is a string: "double-quoted", whether or not it interpolates, or a value such as $key. Any computed key that is not a String is an error. Keys are unique in the resulting value and iterate in sorted order. A tag is not a key, in a literal or in a pattern (§4.6).

[:] is the empty map, and the only literal that opens with :. Otherwise a literal's keys decide. A literal every one of whose keys is a label is a record; a literal with any data key is a map, because a keyset that is data cannot carry a type per key; a literal with no entry at all is a list. So ["a": 1, "b": 2] is a map on keys written out, where [a: 1, b: 2] is a record.

There is no empty-record literal: [] is the empty list, not [a: 1] with its fields removed. A contract file (an rc file, a plugin manifest, a capability profile; §12.12, §15.3, §15.5) that has nothing to set returns (), which it reads as the empty keyset; one that returns a record is ascribed its declared keyset before the file runs at all, and one that returns a map or [] is refused there, with the spelling to use.

let record = [host: 'db', port: 5432]
let by_data = ["host": 'db', "port": 'https']
let computed = [$key: 5432]

In a map literal every explicit entry wins over every spread entry, regardless of position, and if spread entries conflict with each other the first spread entry wins. Writing the same static key twice in one literal is an error, so within a literal the earlier entry always wins; a duplicate arising from computed keys, which the checker cannot see, produces a warning and the last entry wins.

A record literal with a spread is an update of that one base: [...$r, k: v]. The ... comes first and there is only one; a spread after a field, and a second spread, are parse errors. The fields written after it replace fields the base already has, each at whatever type it is written, and the rest of the base keeps its place in the result. A spread never adds a field: naming one the base lacks is refused, with the three ways to carry a new field: give the record the field from the start, nest the record ([cfg: $cfg, total: 3]), or make $cfg a map ([...$cfg, "total": 3]). Nothing is appended after an unknown remainder, so defaults are not merged over an unknown record; absence over an unknown record travels as a variant.

Spreading a value that is not a record or map is an error. Spreading does not change the source value. A literal's parts are evaluated left to right, and a failing spread fails where it is written.

4.6. Variants

A variant records one named outcome. It can carry no payload or one value:

let absent = `none
let answer = `ok 42
let problem = `error [message: 'not found']

The tag takes the next value atom as its payload. So two tags in a row nest: `a `b is one value, `a carrying `b. To pass two tags as two arguments, bind the first: let a = `a and then f $a `b. A statement boundary, comma, closing bracket, pipe, ?, or redirect leaves it without a payload. Section 8 defines case, which chooses one arm by tag.

A tag says which of several alternatives, and a key says which part of one value, so the two alphabets belong to different type formers and never meet. A tag keys nothing: [`dev: 8080] is a parse error, in a literal and in a pattern alike. The one place a tag stands before a : is a case arm (§8.3), which is syntax rather than a record.

4.7. Indexing

Indexing is postfix syntax. The [ must immediately follow the value being indexed.

$items[0]
$server[host]
$config[database][port]

Lists use zero-based, non-negative Int indices. Records and maps use String keys. A key written out selects a record field, or reads that key when the value is a map, and then has the map's element type; which it is follows from the value's type, never from statement order, and a block's parameter that nothing else fixes is read as a record. An integer literal ($xs[0]) indexes a list. Any other key ($m[$k]) is computed: an Int indexes a list and a String a map, and the container type, the key type and the element type are fixed once for the whole program, so a helper that indexes with a computed key is accepted at the one container type its program uses and refused when nothing fixes which. The prelude's get $m key default supplies a fallback. env returns a map of strings, so !{env}[HOME] is a String. A missing key, an out-of-range list index, a key of the wrong kind, or an attempt to index another value kind is an error.

4.8. Equality and ordering

== and != inside $[...] compare data structurally. The two operands have one type, and it is data (§17.3): no block, function or handle at any depth.

  • Unit, booleans, strings, and bytes compare by value.
  • Numbers compare numerically.
  • Lists compare their elements in order.
  • Records and maps compare their key-value pairs without regard to insertion order.
  • Variants compare their tag and payload.

Blocks, functions, and handles have no equality operation. Comparing one is a static error, including when it is nested in a collection, and so is comparing values of two different types: $[42 == hello] is refused, and $[3 == 3.0] too, since the checker keeps Int and Float apart.

Ordering is defined only for numbers and strings, and both operands have one type: <, >, <=, >=, lt and gt refuse a list, a record, a Bool, and a number against text. Integer-to-integer ordering keeps the full 64-bit integer precision. String ordering is lexicographic. + - * / take two numbers of one type (Int or Float) and % two Ints; "a" + "b" is refused, and joining text is interpolation.

4.9. Display

ral has a text conversion for command output and a value renderer for interactive hosts. They serve different purposes.

Text conversion writes strings without quotes and every other scalar value in its usual form. It shows lists, records, maps, and variants with brackets and tags. It shows bytes as lossy UTF-8, and a function, a block, or a handle in an abbreviated form such as <|x| block>. str and to-string expose it: it is total on data, every value having a text conversion. This form is readable but is not always valid ral source and is not a serialization format.

Only text, numbers and true/false go into an interpolated string ("n: $n"), and () is nothing to print: neither a word nor an interpolant. A list, a record or a map is rendered with !{to-json $x}, a list of text joined with !{intercalate ', ' $xs}, and the text () is written '()'.

The interactive value renderer quotes strings with a safe hash fence, writes () for Unit, and lays out collections to fit the available width. Maps display in sorted key order. A host may shorten deeply nested collections or a long string stored directly under a map key. It must mark any omitted content. Lists and top-level strings are not shortened by the standard ral renderer.

5. Bindings, patterns, blocks, and functions

let gives a name to the result of a command or to a value.

let branch = git branch --show-current
let port = 8080
let greeting = 'hello'

The first binding runs git and binds its result. The other two bind values without running an external command. Use $name to read a binding outside command-head position.

echo "$greeting from port $port"

A bare name in command-head position follows the lookup rules in Commands and name lookup. This is why let branch = git ... runs git, while let greeting = 'hello' binds a quoted value.

5.1. Immutable bindings and scope

A binding cannot be changed. A later let with the same name creates a new binding that hides the old one in the current scope.

let colour = 'blue'
let colour = 'green'
echo $colour                 # green

Bindings use lexical scope. Code uses the bindings that were visible where that code was defined.

let rate = 10
let add-rate = { |n| return $[$n + $rate] }
let rate = 20
add-rate 5                   # 15

The function keeps the first rate. A binding at its call site cannot change that captured value.

Every name is known before the program runs. Reading $name where no binding or builtin of that name is in scope is a static error, not a failure at run time, and ral suggests the nearest names that are in scope. Environment variables are not names: read them as !{env}[NAME].

echo $colur                  # error: undefined variable: $colur
                             # help: did you mean `$colour`?

A - inside a name continues it, so "$os-gnu" reads $os-gnu; "$(os)-gnu" ends it. (A trailing - does not: "$os-$arch" is $os, a -, and $arch.)

echo "$os-gnu"               # error: undefined variable: $os-gnu
                             # help: `$os` is defined, and `-` continues a name: write `$(os)` to end it there

A binding shadows a command of the same name, at every scope, whatever is on the current PATH: a program means the same on every host. ^name reaches the external command.

let dd = { echo mine }
dd                           # mine
^dd --version                # the binary on PATH

Prelude functions and builtins can be hidden the same way, although ral warns when a binding hides a prelude name.

A let is polymorphic: the type of a bound function generalises over every type variable that the rest of the program does not also mention, so let first = { |xs| return $xs[0] } can be applied to lists of any element type. The exception is a weak type variable — the type of a value whose shape the program did not decide, such as decoded data, or of a computed index. A weak variable has one type for the whole program, every use of it must agree, and a type that shows one prints as _α.

5.2. Patterns

The left-hand side of let is a pattern. Function parameters use the same patterns.

Pattern Meaning
name Bind the complete value.
_ Ignore the value.
[first, second] Bind a list of exactly that length.
[first, ...rest] Bind the first item and bind the remaining list to rest.
[host: h, port: p] Read named fields from a map or record.

Patterns can be nested.

let [first, ...rest] = [10, 20, 30]
let [name: user, address: [city: city]] = $account

One pattern cannot bind the same name twice. ral reports this as a parse error, including when the repeated name is nested or used as a list tail.

A list pattern without ...rest must match the complete list. It is an error if the list has too few or too many items. A map pattern requires every field it names. Extra map fields are allowed.

A pattern either binds every name or binds none of them. If any nested part does not match, ral reports an error and leaves the scope unchanged. try can catch this runtime error.

Patterns are structural. They cannot test for a literal value. Use if for a Boolean decision and case for a variant.

5.3. Plain blocks

A plain block stores commands as a value. Creating it does not run its body.

let announce = { echo 'ready' }

Force a block with ! to run it:

!$announce
!{echo 'run this anonymous block'}

! means force. It is not Boolean negation. Use not for Boolean negation inside $[...].

A bound plain block is also forced when its bare name is the command head. These two statements are equivalent:

announce
!$announce

A block captures lexical bindings when the block is created. Its body runs in a fresh local scope. Bindings created by the body do not escape. Shell state is not local: a cd or other unbracketed shell-state write persists (§2.6). The block's output, result, failure, and audit records remain observable.

let outside = 'outer'
let result = !{
  let outside = 'inner'
  let local = 42
  return $outside
}

echo $result                  # inner
echo $outside                 # outer
echo $local                   # error: no such binding

An empty block returns (). A non-empty block returns the result of its last statement. Earlier statement results are discarded.

5.4. Functions

A parameterised block is a function. Put one or more space-separated patterns between the two | characters.

let add = { |x y| return $[$x + $y] }
let describe = { |[name: name, port: port]| echo "$name:$port" }

Do not put commas between parameters. { || ... } is invalid; use a plain block { ... } when there are no parameters.

Each parameter is a separate pattern. A later parameter can therefore use the same name as an earlier parameter. The later binding hides the earlier one in the remaining function body.

let keep-second = { |item item| return $item }
keep-second 'first' 'second'  # second

Call a function by putting it in command-head position:

add 2 3                       # 5
describe [name: 'web', port: 443]

Use $function when passing a function as data.

let increment = { |n| return $[$n + 1] }
map $increment [1, 2, 3]

Without $, increment in argument position is the string "increment".

Each argument is applied from left to right. If a call supplies too few arguments, it returns a new function that waits for the remaining arguments.

let add = { |x y| return $[$x + $y] }
let add-five = add 5
add-five 3                    # 8

If arguments remain after the function returns, ral applies them to the returned value. This is valid when that value is another function. Otherwise, ral reports a type error or a runtime error.

A function call gets a fresh local binding scope. Its parameters and local let bindings do not escape. The function still acts in the caller's shell: for example, a successful cd in a function changes the caller's current directory, exactly as it does in a forced plain block.

5.5. return

return value produces value as the successful result of the current statement. return with no value produces (). It accepts at most one value.

Despite its name, return is not an early-exit statement. It does not skip later statements in a block, function, or file.

let example = {
  return 1
  return 2
}

!$example                     # 2

Use if, case, or a failure to choose which computation runs. ral has no non-local early return.

5.6. Top-level runs and local bodies

A top-level run is one complete script, one -c program, one submitted REPL entry, or one exarch tool call. When the host keeps the session open, successful top-level bindings are available to the next top-level run.

If a top-level run fails, effects completed before the failure still remain, and so do the bindings it established: a let that landed before the failing statement stays bound in the session. A binding after the failing statement is never evaluated.

let before = 1
failing-command
let after = 2

After this failed top-level run, before remains bound in the session and after does not exist.

Forced plain blocks are local bodies. The bodies used by within, grant, try, guard, audit, spawn, and gather follow the same local rule. Their bindings do not become top-level session bindings.

5.7. Recursion and forward references

A simple named block binding can refer to itself.

let countdown = { |n|
  if $[$n == 0] { return () }
  else { countdown $[$n - 1] }
}

Named blocks in the same statement list can also refer to each other in any source order. This supports mutual recursion and helper functions written after their callers.

let even = { |n|
  if $[$n == 0] { return true }
  else { odd $[$n - 1] }
}

let odd = { |n|
  if $[$n == 0] { return false }
  else { even $[$n - 1] }
}

Forward references apply only to simple named bindings whose right-hand side is a plain or parameterised block. A value must still be bound before an earlier statement can read it.

When a name is defined more than once, a reference inside such a block uses the nearest preceding definition. If there is no preceding block definition, it can use the first later block definition in the same statement list. This keeps ordinary shadowing while allowing forward block references.

Tail-recursive calls reuse the current evaluator frame. Non-tail recursion is limited by the configured recursion limit and fails with a clear error if it exceeds that limit.

The formal account of function application, recursion, and inferred types is in §17.

6. Commands and name resolution

A command starts with a head. The head decides whether ral applies a value, invokes a handler, or starts a program. Arguments do not use command lookup.

6.1. Command and value contexts

A statement and the right-hand side of let are command contexts:

hostname
let host = hostname

Both lines run hostname. The second line binds its result.

Arguments, return operands, collection elements, and interpolations are value contexts:

echo hostname
return hostname
let tools = [hostname, uname]

Here, hostname and uname are strings. Use $ to read a binding:

echo $host
return $host

A command written as a collection entry, [n: length $xs], is refused, and the diagnostic offers its spelling as a value, [n: !{length $xs}].

A value written alone in command context returns itself. This lets a literal or collection be the result of a block:

{ 'ready' }
{ [host: 'example.net', port: 443] }

6.2. Head forms

The spelling of the head selects one of four paths.

Form Examples Meaning
Bare head git status, map $f $xs Use the lookup order in §6.3.
Path head ./build, /usr/bin/env, ~/bin/tool, .\build (Windows) Run that path directly.
Explicit value head $f 3, !$factory 3 Apply the value. Never search for a program.
Caret head ^git status Skip value bindings, then continue through handlers and program lookup.

A head is a path when it contains a path separator: /, or on Windows also \ (§3.5). A path head does not consult bindings, handlers, bundled commands, or PATH. ral expands ~ when it resolves the command. A relative path uses ral's current logical directory.

A literal word, a bare ~, and a $[...] block are value heads wherever they stand: let h = ~ binds the home directory, and 42 foo or ~ foo is a type error rather than a missing command.

An explicit value head stays in the value world. If ral cannot apply it, ral reports an error. It does not reinterpret the value as a command name.

A bare $f with no arguments is only a value:

let copy = $f       # store f
let result = f 3    # apply f
let same = $f 3     # apply f explicitly

^name is valid only at the start of a command. Its operand must be a plain name, not a path. The caret skips user bindings, prelude functions, builtins, and the entire handler stack — named handlers, base handlers, and catch-all handlers alike — and executes the external command of that name, subject to active capabilities. It is, in this respect, the same as a path head such as ./tool or /usr/bin/tool.

For example, ^echo is the operating system's echo, not ral's — a handler for echo, and ral's own base echo handler, are both skipped.

The bare names within, grant, try, guard, audit, spawn, and gather are reserved control forms. A caret form such as ^try uses caret lookup instead.

6.3. Bare-head lookup

ral resolves an ordinary bare head in this order. The first match wins.

  1. 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 +%s is a static error that says date is bound to an Int, and ^date runs the command.
  2. A named user handler. This includes persistent handlers installed by alias and scoped handlers installed by within [handlers: …]. The innermost handler for the name wins.
  3. A base handler. ral's own commands that take an argument list live here. echo is the standard example, and a host may install more, such as detach. A base handler has no arity: it takes the whole argument list, a List String.
  4. A catch-all handler. within [handler: …] handles the call only if no binding, named handler, or base handler claimed it.
  5. A bundled or external command. A bundled command wins when the current build supplies that bare name. Otherwise, ral searches the effective PATH.

Prelude functions behave like user functions: use their bare name as a head, and $name when passing one as a value.

Builtins are functions held as values: each takes the arguments its entry declares, and supports partial application and higher-order use. A base handler is not a value. It is reached in command position only, so $echo is an error.

A named handler may share a name with a binding or builtin. The ordinary bare name still selects the binding. ^name is the external command name: it skips bindings, natives, and every handler, base frames included — ^echo is the operating system's echo, not ral's.

6.4. Handlers and aliases

A named handler or alias takes one parameter: the command's argument list. ral renders the arguments after the head and passes them as one List String:

within [handlers: [deploy: { |args| audit-deploy $args }]] {
    deploy 'prod' '--wait'
}

A catch-all handler takes the dispatched name and the argument list:

within [handler: { |name args| log-call $name $args }] {
    unknown-command 'arg'
}

An arm receives strings because a command receives strings. A handler stands in for a command, and it can only do that if it consumes what a call to that command would send; an arm that expected typed arguments would not be interchangeable with the command it replaces. So an arm cannot do arithmetic on $args[0]: it holds that argument's rendering, '1' rather than 1. Use int or float when the arm wants a number.

ral checks handler arity when the handler is installed. It also checks that the arm stands in for the head it names (§9.4). A handler for echo, for example, stands in for a command, so its value is its output.

Handlers are self-masking. While a selected handler runs, its own frame is temporarily absent. A call to the same name from its body reaches an outer handler, a base handler, a catch-all handler, or an external command:

within [handlers: [git: { |args|
    warn 'running git'
    git ...$args
}]] {
    git status
}

Any named handler takes priority over any catch-all handler, even when the catch-all is in a more deeply nested scope. A base handler also takes priority over every catch-all handler.

6.5. Application and arity

ral passes arguments in two ways, and they share nothing. A value — a block, a lambda, or a builtin — is applied: it takes one argument at a time, at the arity its definition declares. A handler, a base handler, or an external or bundled command instead receives an argument list, and has no arity at all; see Arguments and spreading. This section is about application.

Parameterized blocks and builtins are curried:

let add = { |a b| $[$a + $b] }
let add-two = add 2
let five = add-two 3

Too few arguments return a partial application. The exact number runs the body. If arguments remain after the call returns, ral tries to apply them to the returned value. It reports an arity error when that value cannot be applied.

ral checks known arity and argument-type errors before execution. An application's arity is always known, so the arguments written at a call site are exactly the arguments that arrive. Nothing in that position stands for an unknown number of them, because ... forms an argument list and a value takes none.

6.6. Arguments and spreading

Command arguments are values. Ordinary and quoted words produce strings. Numeric and Boolean literals keep their types. $name supplies a bound value, and !{…} supplies a forced block's result. What becomes of them next depends on which of the two mechanisms of Application and arity the head uses.

An argument list is the flat sequence of arguments that a base handler, a handler or alias arm, or a bundled or external command receives. Its type is List String.

A base handler and a handler or alias arm receive it rendered. ral converts every argument with the text conversion of Display, the same conversion str performs and echo writes. That conversion is total, so no argument is refused there, and nothing crosses an argument list as an unrendered ral value:

echo hello [a: 1]             # hello [a: 1]
let f = { |x| $x }
echo $f                       # <|x| block>

A bundled or external command receives operating-system arguments instead, and those are bytes. That is a second boundary, and a narrower one; its rule is below.

...value spreads a list into an argument list:

let flags = ['--all', '--long']
ls ...$flags '/tmp'

The spread value must be a list. Its elements are inserted in order at that position, and they are rendered like any other argument.

A value receives no argument list. It takes its arguments by application, one at a time, at the arity its definition declares, so a spread among them has nothing to spread into. ral reports a spread in the argument position of a value — a block, a builtin, or anything reached through a binding — before execution:

let numbers = [1, 2]
let add = { |a b| $[$a + $b] }
add ...$numbers               # error: a value takes no argument list
add 1 2                       # 3
echo ...$numbers              # 1 2

The same ... marker also builds a list ([1, ...$middle, 4]) and matches the tail of one ([first, ...rest]). Those are the forms of Lists and Patterns: they construct and destructure a list, and neither forms an argument list.

Operating-system arguments must become text, and there the conversion is partial rather than total. ral formats strings, integers, floats, Booleans, Unit, and tagged values. Unit becomes the argument (). ral rejects bytes, lists, maps, records, blocks, functions, builtins, and handles. Spread a list, and send bytes through a byte pipe or decode them first.

ral reports that rejection before execution when the argument's type states the shape, so nothing is started:

let r = [a: 1]
cat $r                        # error: cannot pass [a: Integer] to external command 'cat'

Where the type does not state it — a parameter, or a value decoded at run time — the shape is known only to the run, and ral reports the same rejection at the call instead. A spread's elements are always reported at the call, because how many arguments a spread contributes is known only then, and an empty spread contributes none.

Arguments are never split, globbed, or parsed again. A string containing spaces remains one argument.

6.7. External commands

A bare external name uses the effective PATH. A within [env: [PATH: …]] scope changes that search for its body. ral resolves the command once, then starts that program with the current logical directory, environment, redirects, and capability restrictions.

When the build supplies a bundled command with the requested bare name, the bundled command wins over a same-named file on PATH. It still runs as a child command with the same byte-pipe, failure, audit, and sandbox behavior as an external program. Use a path head to select a particular system binary.

A missing bare command fails with status 127. A file found on PATH but not executable fails with status 126, as does a path naming a directory; the diagnostic says which, and a path head offers itself as text (#'…'#). A command that never became a process is not advised to attempt. A non-zero process result is a failure, except in a non-final byte-pipe stage that was still running when the stage reading it ended.

6.8. Returned values and emitted bytes

A command can return a ral value and emit bytes independently.

  • In statement position, bytes go to the current output unless redirected. The returned value is discarded.
  • A command's value is its output: a let whose 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:

  1. On push, the standard-output sink becomes a fresh buffer and the previous sink is held.
  2. 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.
  3. 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 final CRLF, is removed;
  • no other bytes are changed.

Invalid UTF-8 is an error. Use from-bytes when the byte payload is binary.

A command that writes nothing captures "". Bytes written by a statement that is not the tail of the captured computation go to the surrounding stream and take no part in the binding.

When the final stage returns a value, the value simply comes back from that stage's own thread: a ral-written final stage runs machine::evaluate over its own closure and returns the result directly, no serialisation and no separate channel involved.

let greeting = echo hello       # "hello"
let data = command | from-bytes # Bytes

Capture is of the whole computation: everything the block writes is its value.

let answer = !{ echo first; echo second }
# prints nothing; answer is "first\nsecond"

A redirect of standard output discharges the demand: let saved = to-json 1 > f binds ().

Captures nest. Output goes to the nearest enclosing sink, which inside a capture is that capture's buffer. A let inside a captured block captures its own command: let x = !{ let y = echo a; echo b } prints nothing and binds b. If a captured computation fails, the bytes it wrote are flushed to the sink the capture replaced rather than silently lost.

An in-memory capture keeps at most 16 MiB. A capture that reaches the cap fails rather than bind a truncated value — the bytes captured are the value, so a prefix would be the wrong one. The failure is ordinary: the buffered bytes are flushed by the third rule above, and try catches the error. Redirect output to a file when it may be larger.

7.3. Explicit codecs

Codecs make every conversion between values and bytes visible in the source. A decoder reads its byte input and returns a value:

Decoder Result Rules
from-bytes Bytes Preserves every byte.
from-string String Requires valid UTF-8.
from-line String Requires valid UTF-8 and removes one final LF or CRLF.
from-lines [String] Splits by the line rule below and replaces invalid UTF-8 in each line with the replacement character.
from-json a ral value, checked Requires valid UTF-8 and JSON. JSON null becomes Unit, a whole number an Int, a number with a fraction a Float.
from-jsonl a list of ral values, checked Splits by the line rule below and decodes each line as from-json does. A blank line, holding only spaces, tabs, and carriage returns, holds no value. An error names its line.
from-json-at tokens a ral value, checked Decodes as from-json does, then reads the value at the RFC 6901 reference tokens tokens, a list of String: an object member by exact key, an array element by canonical decimal. A token that does not resolve is an error naming the pointer prefix and the object's keys or the array's length.
from-csv a list of maps from header to text Uses the header row as map keys. Fields are strings; duplicate headers are rejected.

Decoders are command forms. They read the preceding byte pipe, or standard input when there is no preceding stage; only from-json-at takes an argument.

Checked boundaries

The shape of a decoded value is the file's, not the program's, so a decoder's result is a boundary: the checker gives it one type for the whole unit, _α, which every use of the value fixes (§5.1), and the decoder admits the value against that type before the program sees it. let n = $[$doc[size] + 1] over {"size": "12kB"} fails at the decode, naming where:

from-json: the value at `/size` is text (`'12kB'`), but this script uses it as an Int — line 3

A mismatch names an RFC 6901 pointer into the value, what was found, what the script uses it as, and the line of the use. An open record (the fields the script reads) admits extra fields; a closed record literal refuses them. Two decodes the program compares with < agree on numbers or on text, and are refused at the second decode otherwise. A JSON number keeps its kind: 3 is an Int and 2.5 a Float, so a position used as a Float refuses 3, and float at the use accepts both. A decoder never converts a value to fit its type.

The same discipline admits what use (§10.3), an awaited worker (§11.4), and the exarch host's answers (exarch-agents, exarch-pins, exarch-transcript) let in.

A decoder is a legal pipeline tail:

let document = cat data.json | from-json
length $document

The decoder consumes bytes and returns a value as the pipeline's result. A decoder writes nothing, so it cannot feed a further stage: cat data.json | from-json | wc -l is a type error, since a stage feeds the next by writing and from-json returns a value. Bind the decoded value and use ordinary application, or encode it again explicitly before another byte pipeline.

An encoder takes its value as an ordinary argument and writes bytes:

Encoder Accepted value Output
to-bytes Bytes Those bytes, unchanged.
ints-to-bytes a list of integers from 0 through 255 The bytes they number.
to-string any value Its ral textual form.
to-line any value Its textual form followed by a newline.
to-lines a list of values Each element's textual form followed by a newline.
to-json a JSON-representable value UTF-8 JSON. Bytes become an array of integers; variants become tagged objects.
to-jsonl a list of JSON-representable values Each element as to-json encodes it, followed by a newline.
to-csv a list of maps of text UTF-8 CSV; columns are the first row's keys in sorted order.

to-json rejects values with no faithful JSON representation, including blocks, lambdas, and handles. to-jsonl rejects a list holding one, names its index, and writes nothing. To decode a value already held in memory, encode it into a byte pipe first:

to-string $json_text | from-json

Lines

One rule says where a line ends:

  • A line terminator is LF or CRLF. The CR of a CRLF belongs to the terminator, and only that one CR does.
  • A lone CR is ordinary text, never a terminator, whether mid-line or at the end of input.
  • The final line need not be terminated.
  • Each line strips its own terminator, so mixed endings are fine.
Input, as a ral string Lines
"" []
"a" ["a"]
"a\n" ["a"]
"\n" [""]
"a\n\n" ["a", ""]
"\n\na" ["", "", "a"]
"a\r\nb\nc\r\n" ["a", "b", "c"]
"a\r\n\r\nb" ["a", "", "b"]
"a\rb\n" ["a\rb"]
"a\r" ["a\r"]
"a\r\r\n" ["a\r"]

Every line reader splits by this rule — from-lines, from-jsonl, fold-lines, and the prelude's map-lines, filter-lines, each-line, and line-count — as does lines on a string. Capture (§7.2), from-line, and ask do not split: they remove at most one terminator from the end of what they read, so "a\r\n\r\n" becomes "a\r\n" and "a\r" stays "a\r".

ral writes LF alone: to-line, to-lines, to-jsonl, and echo never write CRLF. to-lines terminates every element, so to-lines [] writes nothing, to-lines [""] writes one LF, and from-lines inverts to-lines on every list of strings in which no element contains LF or ends with CR.

7.4. Redirects

Redirects attach input or output to one command or compound command:

Form Meaning
> path Replace a file atomically when possible.
>~ path Truncate the file and stream output into it.
>> path Append output.
2> path Stream standard error into a truncated file.
2>> path Append standard error to a file.
2>~ path Truncate the file and stream standard error into it.
2>&1 Send standard error wherever standard output goes.
< path Read standard input from a file.
<< value Read standard input from a string value.

Any stage takes trailing redirects, if and case included: if $ok { a } { b } > log. A command's redirects may also stand between its arguments: cmd a > f b.

These nine spellings are the whole redirect vocabulary: < path, << value, > path, >> path, >~ path, 2> path, 2>> path, 2>~ path and 2>&1. The digit 2 names standard error and is the only fd prefix; 1> is refused with advice to put a space before the > to pass 1 as an argument (echo 1>file would otherwise write an empty file). A redirect list binds streams: each of standard input, output, and error is bound at most once, and a second binding is a parse error with a caret on the second redirect. cmd > a > b, cmd 2> e 2>&1, cmd 2>&1 2> e, and cmd < a << b are all refused with "standard output is redirected twice; which one do you mean?" or its input and error counterpart. Position carries no meaning: 2>&1 sends standard error wherever standard output ends up, so cmd 2>&1 > f and cmd > f 2>&1 are one command. Redirect targets are evaluated before their files are opened, and the files open in one fixed order: standard input, output, error. Relative paths use the scoped logical working directory.

ral has no numbered descriptors beyond these three streams. A redirect naming any other file descriptor — 3> path, 4< path, >&5 — is rejected outright, with a diagnostic naming standard input, output, and error as the only three that exist; there is no general facility for managing a spawned process's own inherited handles.

Descriptor duplication runs in that one direction only. 1>&2, and its short spelling >&2, are rejected: their only honest use is the shell idiom for writing a diagnostic, and a diagnostic is what warn is for. The message names warn, and names 2>&1 too, since a program holding the exchange backwards means that one.

A redirect on a stage overrides the wire the pipeline would otherwise use. In cmd > file | next, cmd writes to file and next sees end of input.

Across a | the same collision is refused rather than resolved. A stage after a pipe may not bind standard input at its own root: a | b < f and a | b << value are type errors, because the feed answers every read b makes for the stage's whole run and leaves a writing for nobody. Drop the pipe, run the producer as its own statement, or spawn it if the two were meant to run at once. A read redirected deeper inside the stage — within a block, or on one command among several — supplies that command alone and stays legal.

For a regular file, > writes a temporary file beside the destination, flushes it, then renames it over the destination. If the command fails, ral discards the temporary file and leaves the old destination unchanged. It preserves an existing file’s mode and follows a symbolic link to update its target rather than replacing the link.

This operation is failure-atomic, not a locking scheme: concurrent writers may still race. For non-regular destinations, > uses streaming behavior.

Use >~ when readers should observe output as it is produced, or when writing a device, named pipe, or similar destination. 2> is also streaming so diagnostics are not delayed until a command finishes. A streaming write is committed byte by byte as it lands, so it is observed committed even if the body later fails; only an atomic > can be aborted.

7.5. Here strings

<< is a here string, not a heredoc delimiter:

from-json << '{"name":"Ada"}'

It feeds a string value to standard input. It does not add a newline. It removes exactly one leading LF, or one leading CRLF. This makes an indented multiline string convenient while preserving the rest of its contents, including any trailing newline.

Whitespace after << is required. There is no <<< form, and << can only redirect descriptor 0. A bare word after << is rejected because it is usually a mistaken heredoc; use a quoted, interpolated, raw, or dereferenced string value.

Large here strings are written concurrently with the reader, so filling an operating-system pipe cannot deadlock command launch.

7.6. Process completion and the terminal

ral runs a ral-written stage on its own thread and an external stage as a process; every external in the pipeline shares one process group the shell is not a member of. ral launches all stages and waits for all of them. A failure in any stage fails the whole pipeline. If several stages fail, the first failing stage in launch order supplies the reported failure. An escape outranks an ordinary stage failure, and a stop (§8.10) outranks an exit; two stops rank by cause; ties go to the earlier stage. A stage's own reader-gone stop, or an enclosing stage's, is no verdict at all: it ranks under everything. An exit in a stage, or a stop reaching a stage from outside the pipeline, ends the pipeline at once: the other stages are torn down, and what that teardown stops is the pipeline's own to forgive.

A downstream command often stops reading before an upstream producer is done writing, as in:

large-producer | head

An interior edge is dead once its reader stage has ended. A stage feels that death at exactly one place — its next write to that edge — and nowhere else. A ral-written stage's own write to a dead edge raises the break at that write; an external's write ral cannot see, so ral hears it instead: once the reader is gone, ral reads the edge itself, on the read-end duplicate it already holds, discards whatever bytes are pending at that moment (they were owed to a reader that left), and treats the first byte written after them as the kill's cue — SIGKILL on Unix, a distinctive-code TerminateProcess on Windows. A write already blocked on a full pipe when the edge dies is completed into that same read and then judged dead: a write not complete when the edge dies does not complete.

That break — and only that break — is the pipeline's one forgiven death: a non-final stage ral cuts this way keeps no failure, because the rest of its output was owed to nobody. Everything a stage does before its first dead write is its own account and runs to completion: its stderr, its files, its children that never write the edge, its own exit status if it never writes again. Nothing after that write is promised. In the final stage the same condition is the closed-stdout stop: the pipeline's own output crosses the outer boundary, and a caller that stops reading it stops the run, status 141, an escape no try catches (§8.10). A reader-gone stage's guard cleanup runs, shielded (§8.7): a cleanup that halts on anything but its reader-gone stop is the pipeline's verdict; one that ends on it is forgiven; a cleanup's write to the dead edge is stopped, not masked.

A stage whose own redirect statically sends every byte of its stdout to a file, never to the interior edge, never performs the write the cut watches for: cmd > file | next therefore runs cmd's redirect to completion regardless of when next settles, not because it is exempted but because it never reaches the operation the rule cuts.

ral holds a duplicate of each interior edge's read end until that edge's writer stage is reaped or joined, so no interior edge ever delivers a broken-pipe signal or a write error to the stage that writes it. For a ral-written stage the cut is exact — its own write is the operation that raises the break. For a process it lands within the kill's few-microsecond latency after the first dead write. An exit status, once recorded, is never overwritten: the kill precedes the wait, and a kill landing on an already-exited stage changes nothing. This is one rule, stated once, true on both platforms.

A producer that exits on its own account keeps that status, whatever the cause: sh -c 'exit 3' | true still fails with status 3, since it never writes to the dead edge and so is never cut, while yes | head -1 still succeeds, since its next write past the cut is the forgiven death — the same rule on both platforms rather than a broken-pipe signal on one and an exit-order reading on the other. A producer that never writes again is never cut and runs to completion: sleep 1000 | true waits for the sleep, as in every shell. One that must not be cut at all must not be a stage whose reader can leave — its own statement, or spawn.

On Unix, an interactive process-staged pipeline receives the foreground terminal as one process group only when the session owns a terminal lease and final standard output is attached to that terminal. A captured pipeline normally writes to a buffer, so the parent keeps terminal ownership. Ordinary application and bind do not create a pipeline process group.

A ral-written stage runs on a thread of the shell's own process, so it cannot itself read the controlling terminal while that terminal's foreground belongs to the pipeline's external process group: doing so would raise SIGTTIN against the whole shell. A stage with no upstream pipe and nothing else to read from therefore sees immediate EOF rather than falling through to the terminal, on a pipeline that owns the foreground — !{ let l = from-line; echo $l } | cat at an interactive prompt returns at once instead of waiting for a line. An external stage reading the terminal (cat | grep x) is unaffected, since it is a process inside the foreground group.

Windows has no POSIX foreground-terminal handoff. Pipeline members share the console and are supervised as one job.

8. Control flow and failure

ral separates successful values from failure. A computation may successfully return any value, including false, (), an empty collection, or an empty string. Failure is a separate control outcome carrying a nonzero status and a message. This distinction is fundamental: if examines a Bool, while ? and try examine whether a computation succeeded.

Commands and control forms are checked before execution. Branches must agree on the value they return. A branch that is not selected is not evaluated.

8.1. Sequencing and return

A newline or semicolon sequences computations from left to right:

prepare
let result = calculate
publish $result

Execution stops at the first failure. If every computation succeeds, the sequence returns the value of its final computation; an empty sequence returns (). Bytes written by earlier computations remain observable even when a later computation fails or when the sequence is evaluated under capture.

return value is the primitive successful computation. With no value, return returns ():

return 42
return

return is not a process exit and does not turn a false Boolean into failure. A returned Bool does record a status in the shell convention: true records 0 and false records 1. No program can read that status — it is what the process exits with — and the computation itself succeeded in both cases.

8.2. Conditional choice

The conditional form is:

if condition { then-body }
elsif other-condition { other-body }
else { fallback-body }

Conditions are evaluated in order and must return Bool. The body belonging to the first true condition runs. If none is true, the else body runs. The selected body has a fresh lexical scope, so bindings made inside it do not leak into the surrounding scope.

Each body is a thunk that if forces when it chooses it: a block written out, or a name holding one, so if $c $yes else $no runs the block $yes or $no. Any other atom would run before if chose, so it is a parse error: an arm is a block, or a name holding one. To compute the block first, bind it with let arm = $arms[a].

With else, both bodies produce one kind of result. A command's value is its output, so a body that prints joins another that prints, and joins an empty one (()); joined with a body that returns text, the command's output is captured as text, so if $c { hostname } else { 'none' } is a String. A body that returns any other value does not join a command, and if $c { echo big } else { return 5 } is refused. Print in both with echo to keep the result a command. Without else, the conditional is for effects: its result is always (), whether the body runs or not.

false chooses another branch; it does not cause ? to continue and does not invoke a try handler.

8.3. Variant elimination with case

case eliminates a variant. Its arms are syntax — one per tag, written out at the case itself:

let result = case $reply [
  `ok:  { |value| return $value },
  `err: { |error| fail $error },
]

The scrutinee must be a variant. case runs the arm whose tag matches it, applying it to the variant's payload; a nullary variant passes (). The arm has a fresh lexical scope for that binding, but it is a branch and not a function applied to the payload: it runs in the surrounding shell context, so the arm's own changes remain in place after the case, as an if body's do.

An arm is a thunk of one argument that case forces when it chooses it: a block with one binder writes the branch out, and a name holds one, so `ok: $handle runs exactly what `ok: { |p| $handle $p } runs. As for if, an arm is a block or a name; any other atom is a parse error.

Arm result types must agree, exactly as for if branches and ? alternatives: the arms join as one kind of result. An arm that prints joins one that ends silently at (); a command arm joined with one that returns text is captured as text, and it does not join one that returns another value.

Because the alternatives are syntax, exhaustiveness is decided statically and always. The typechecker closes the scrutinee's variant row to exactly the arms' tags, and reports a tag with no arm before anything runs, listing the tags the case does match. What the grammar refuses is anything that hides the set of alternatives from that proof: a computed table in place of the arm list, a ... spread arm, a repeated tag, and an empty arm list are all parse errors (§17.1).

8.4. Fallback chains

The ? operator tries alternatives from left to right:

cached-value ? fetch-value ? fail [status: 1, message: 'no value available']

The first successful alternative supplies the chain's result. An ordinary failure continues with the next alternative. If every alternative fails, the chain propagates the final failure. Alternatives must return values of one type, and each runs in a fresh lexical scope. Alternatives join as the arms of if do: alternatives that are commands join each other, and joined with an alternative that returns text they are captured.

Control escapes are not alternatives. In particular, exit and internal tail-call control pass through a fallback chain rather than selecting its next arm. Cancellation is checked between arms.

8.5. Raising failure

fail deliberately raises an ordinary, recoverable failure. It accepts one error record:

fail [status: 64, message: 'bad command line']

A record must contain a nonzero integer status field and a String message: the message is the text the failure carries. The checker enforces this shape. A record reaching fail from an unchecked boundary — a decoder's result, a module's asserted shape — is refused at run time instead. reason, cmd and site are kept when present.

Status 0 is rejected: use return for success. A status must fit the runtime's signed exit-code range. Passing the error record received by try to fail re-raises it faithfully: status, message, reason, cmd, and a site the record has, all round-trip. A status that disagrees with its reason's projection (§8.6) is refused, naming the status that reason has; a `signaled n outside 1 to 127 is refused. Integers and bare message strings are rejected so failure remains structured at its point of construction.

An external command that exits unsuccessfully and a runtime operation that cannot complete also produce ordinary failures. They participate in ? and try in the same way as fail.

8.6. Recovery with try

try evaluates a body and, only if that body fails, calls a one-argument handler:

let value = try {
  read-config
} { |error|
  log $error[message]
  return default-config
}

On success, try returns the body's value. On recoverable failure, the handler receives this record:

[
  cmd: String,
  status: Int,
  reason: Reason,
  message: String,
  site: `some [script: String, line: Int, col: Int] | `none,
]

where

Reason = `exited Int | `signaled Int | `cancelled | `timed-out
       | `not-found | `not-runnable | `raised

reason says why the failure happened, and status is its projection:

reason Meaning status
`exited n an external command exited with nonzero n n
`signaled s an external command died of signal s, not by a cancellation (§8.10) 128 + s
`cancelled a worker a cancel inside the session stopped, observed by await or poll 143
`timed-out a worker reached by a timeout on an enclosing scope, observed likewise 124
`not-found no command of that name exists 127
`not-runnable the command exists but could not be started 126
`raised fail, or a runtime operation that could not complete the raised status

Recovery branches on reason, not on a status number.

cmd names the command whose dispatch failed — a builtin call included — and is <runtime> when the failure came from no command. The dispatch stamps its own name on the error as the error passes through, so the innermost failing dispatch wins, exactly as the innermost source span does. site is the failure's source position, `none when it has none. The body and handler join as the arms of if do; in the example, read-config is a command whose output is captured, so the handler's return gives text as well.

try is control flow, not output capture, and it observes nothing: it reads no audit trail, retains no bytes, and costs no more than its own frame. Bytes written to standard output or standard error continue through the surrounding byte pipes. The error record's message is the structured failure message; it is not captured stderr. Use audit when command bytes and the full trail are required — its `err outcome carries this same record (§13.3).

A handler that succeeds recovers the failure and leaves status 0. exit, internal tail-call control, and a stop (§8.10) bypass the handler: a stop is not a failure, and try never sees one. The one stop a handler can meet is the error await or poll reports for a worker a cancel inside the session stopped or a timeout on an enclosing scope reached, which is the awaiting run's own failure.

8.7. Cleanup with guard

guard pairs a body with a cleanup block:

guard {
  use-resource
} {
  release-resource
}

The cleanup runs after the body whether the body succeeds or fails. When the cleanup completes, the body's own outcome stands: guard returns the body's value, or its failure remains the result after cleanup.

A cleanup that does not complete pre-empts that outcome, an ordinary failure exactly as a control escape such as exit. If both the body and the cleanup halt, the cleanup's signal is the result.

A cleanup is shielded: a stop (§8.10) already in force when it starts, or landing while it runs, does not end it until the grace is spent. The grace is a host-set budget (five seconds by default) counted from the instant the stop landed, whatever the cleanup was doing then, so every cleanup of one unwinding ends by one instant. A root abort (Ctrl-\) passes the shield. The shield is ral's own restraint: a signal the terminal or the pipeline sends to the process group still reaches the cleanup's children, as under any shell, and a child that dies of it fails as a signalled command does. Within the grace a cleanup's commands and pipelines run to completion; past it, the stop ends the cleanup at its next poll, and the cleanup's halt is the result. A cleanup whose own failure is not to fail the computation says so: guard { … } { attempt { … } } suppresses it, and guard { … } { try { … } { |err| … } } reports it and carries on.

guard takes its value type from the body alone; the cleanup contributes none. Output from cleanup is real output and is not silently captured. Bindings created in either block remain local to that block.

8.8. Exiting the session

exit (also spelled quit) requests that the host end the current ral session:

exit
exit 7

The default status is 0. An explicit status must be an integer in the signed exit-code range. exit is a control escape, not an ordinary failure: ? and try do not catch it. await and poll re-raise a worker's exit: an exit in a worker ends the run that observes it, and no other. A command-line host translates it to the platform's process exit status; an embedding host receives the structured exit request.

8.9. Diagnostics

Parse, type, and runtime errors identify their source and underline the relevant span. Type errors are reported before any part of the checked program runs. Runtime failures retain the innermost available source position while they unwind; code loaded from another file is diagnosed against that file, not against an unrelated caller.

When a failure has actionable context, ral may add a hint beneath the primary message. A failing external command may also contribute its own exit-status hint. These diagnostics are presentation: recovery uses the structured status, message, command, and source position described above, never by parsing the rendered text.

8.10. Cancellation and signals

Cancellation is cooperative and scoped. The evaluator polls at stable boundaries, including between sequence elements and fallback arms, during collection loops and tail-call steps, and while launching or waiting for pipelines and external commands. A cancelled external process is terminated and reaped before control returns.

A stop is an escape, not a failure: it carries a cause, no reason, and no try catches it; the run it reaches ends stopped, with the cause's status. Two boundaries within a program own stops: a worker's boundary owns cancelled and timed out struck anywhere inside the session — on its own handle, on a worker that started it, or on the call that owns it (§11.1, §11.3) — which await and poll report as the awaiter's failure (§8.6); a pipeline owns the stops naming its own stages, the reader-gone stop of one whose reader left and the teardown it runs for a sibling's escape, and forgives them (§7.6). Every other stop is the host's and reaches the top of the run uninterpreted, as exit does. A stop in force is observed at the next step and, inside a blocked write, read or wait of ral's own, at the next ten-millisecond slice.

Cancellation causes form an escalation order and never downgrade:

Cause Message Status
reader gone its reader ended 141
foreground interrupt interrupted 130
explicit cancellation cancelled 143
deadline timed out 124
session termination terminated 143
root abort aborted 131

A cause a signal raises reports 128 plus that signal, as a POSIX shell does: an interrupt 128 + SIGINT, a root abort 128 + SIGQUIT, session termination 128 + SIGTERM. An explicit cancellation reports 128 + SIGTERM, what kill delivers, and a deadline 124, as timeout(1) does. A pipeline stage cut short because its reader ended is stopped with the reader-gone cause, which its pipeline forgives; no try inside the stage sees it. The shell's own write to its standard output after the reader left is the same stop at the run's own boundary, and ends the run with status 141 and no diagnostic, as a SIGPIPE death would. That stop lives in the lost stream itself: a cleanup after it runs unhurried, and only its own write to that stream ends it.

A stop reports the same cause, message and status whether it lands before a command starts or while the command runs. An external command that dies of the signal ral sends first to tear it down, or of the final kill, is reported as the cause in force, also when a sandbox's envelope relays that death as 128 + n. An exit is never read as a signal: exit 130 is the command's choice.

Only a terminal ral lent can make a command's failure a cancellation. While a command or pipeline holds the terminal, Ctrl-C, Ctrl-\ or a hangup comes back to ral when it takes the terminal back. A command that dies of the key is stopped with that key's cause, and the run it belongs to is stopped: try never sees it. A pipeline hears the key itself, whatever its stages do with it. A command that catches the key and carries on, or exits, has handled it. A signal from anywhere else is the command's own failure (`signaled), and so is any other death, such as SIGSEGV. Ctrl-\ on a lent terminal aborts the job it was lent to, and the session lives on. A command sharing ral's own process group, such as a capture, is signalled by the terminal and by ral at once, so its death may be read before ral's own interrupt lands and report the signal rather than the interrupt; the run stops regardless.

A foreground interrupt affects the work that was running when the interrupt arrived, including nested runs and the workers of a gather in flight, a par's among them (§11.3), but not a session worker and not a later prompt. Nothing a worker starts sits where the terminal reaches it: on Unix its external commands lead sessions of their own and its pipelines a group the terminal never holds, and on Windows its processes lead console groups ral never sends a console event. A terminal gesture reaches a worker only as the session termination or root abort ral itself raises. Explicit cancellation targets its handle. Deadlines affect their run scope. Session termination and root abort reach the durable session root and therefore its workers as well as foreground work.

On Unix, Ctrl-C is a foreground interrupt. At an idle interactive prompt it cancels the edited line instead. Ctrl-\ requests a root abort; SIGTERM and SIGHUP request session termination. The batch host escalates repeated termination signals and may force an immediate process exit when cooperative cleanup no longer completes. Interactive Ctrl-C itself does not use that force-exit ladder.

Other hosts translate their native gestures into the same structured causes. For example, an active exarch request treats Ctrl-C or Escape as a foreground interrupt, while an idle key may instead close its interface. Windows uses its console and process-group facilities rather than Unix signals, but preserves the observable cancellation messages and statuses: ral opens a teardown with Ctrl-Break and ends it with one fixed exit code, and a process that ends of either while a cause is in force is reported as that cause. A Ctrl-C exit reads as the interrupt only while the interrupt is in force.

9. Scoped execution and handlers

ral uses two kinds of scope for two different purposes:

  • Values are lexically scoped. A function or block sees the bindings that existed where it was written.
  • Execution context is dynamically scoped. The effective directory, environment overlay, handlers, and capability restrictions come from the call site and remain active through everything the body calls.
let place = 'definition'
let show = { echo $place }

within [env: [MODE: 'test']] {
    let place = 'call site'
    show                 # prints "definition"
    echo !{env}[MODE]    # prints "test"
}

let bindings are immutable. A binding in a deeper lexical scope shadows an outer binding without changing it. Closures retain their defining lexical environment, while commands run by those closures use the caller’s dynamic context.

9.1. within

within installs a dynamic execution context for one block:

within [
    dir: 'project',
    env: [MODE: 'test', RETRIES: 3],
    handlers: [deploy: { |args| to-json [mock: true, args: $args] }],
    handler: { |name args|
        fail [status: 127, message: "unexpected command: $name"]
    },
] {
    deploy staging
}

The options are written in within's own bracket, and it accepts exactly four names:

  • dir — the effective working directory;
  • env — environment-variable overrides;
  • handlers — named command handlers;
  • handler — one catch-all command handler.

Unknown names are errors, reported before the program runs. [] is the empty option set — the form's bracket is not a collection literal, so [] there is no more the empty list than [dir: 'p'] is a map, and [:] names no options at all.

The option names are syntax: each is written in the bracket, and its value may be computed.

let d = 'project'
within [dir: $d, env: [MODE: 'test']] { build }

A bundle built elsewhere (within $opts { … }), a spread into the bracket, and a repeated name are parse errors, each saying so. Whether an option is present is therefore a fact about the program's text, never about a value: to set dir only sometimes, lift the condition to the form — if $c { within [dir: $d] { … } } else { within [] { … } }.

handlers is syntax in a stronger sense: its labels are the command names it binds in the body, so they must be written out, and handlers: $table is a parse error.

A map is no options bundle either, and so a genuine one — off from-json, a plugin's configuration — is refused at the same place: its keys are data, a form's options are names.

All options are evaluated and validated before the body begins. The environment scope is installed outermost, then the directory scope, then the handler frame. This makes the environment visible while commands resolve beneath the directory and handler scopes.

Validation uses the incoming dynamic context. Options in the same map do not take effect one by one: for example, dir is resolved before the new env overlay is installed, so a HOME override beside it does not change how that dir value is resolved.

The frame remains active for the body’s whole dynamic extent, including functions defined elsewhere, nested blocks, command handlers, spawned work, and every landing of a tail-recursive call. Leaving normally, failing, or escaping restores the surrounding dynamic context.

9.2. Directory scope

dir is converted to a path string, resolved against the current effective directory, checked for filesystem-read authority, and required to name an existing directory.

within [dir: 'src'] {
    cat 'main.ral'
}

The scoped directory is used by ral path operations, relative executable lookup, redirects, module lookup, and external children. ral does not change the process-wide working directory; each child is launched in the effective logical directory. This avoids races between concurrent workers.

The working directory is one piece of shell state: cd sets it, and cwd and every relative path read it. dir makes within a local handler for that state. within saves the working directory, sets it to dir, runs the body, and restores what it saved when the body returns, fails, or escapes. A cd inside the body therefore moves only the scope's directory:

cd /usr
within [dir: /usr/share] {
    cd man
    echo !{cwd}      # prints "/usr/share/man"
}
echo !{cwd}          # prints "/usr"

Setting the directory on entry is not a cd, so a shell list without `chdir does not refuse it; a cd in the body still needs `chdir. A within without dir leaves the working directory alone, and a cd inside it persists.

An empty path, a missing path, a non-directory, or a path denied by the active filesystem grant is an error before the body runs.

9.3. Environment scope

env must be a map. Each value must be a string, integer, float, or Boolean; non-string scalars are converted to strings.

within [env: [PATH: 'tools:/usr/bin', DEBUG: true]] {
    build
}

An inner overlay shadows the same key in an outer overlay. Other keys remain inherited. The effective overlay is used by env, user, home, home and XDG resolution, PATH lookup, RAL_PATH, capability-path resolution, and external child environments.

PWD and OLDPWD cannot be set through within env. PWD is ral’s logical cwd, so use cd or within dir instead; ral keeps no previous directory, so there is no OLDPWD. Lists, maps, blocks, handles, and other non-scalar environment values are rejected.

The previous environment overlay is restored exactly when the scope ends. A spawned worker receives a snapshot of the overlay in force at birth; later changes in either shell do not cross back.

9.4. Named and catch-all handlers

A named handler reinterprets one command name:

within [handlers: [
    git: { |args|
        echo "git called with: $args"
    },
]] {
    git status
}

Every value in handlers must be a unary lambda { |args| ... }. It receives the command’s argument list: a List String holding every argument after the head, rendered as text.

The singular handler field is a catch-all and must be a binary lambda { |name args| ... }. It receives the command name as a String and its argument list as a List String.

A bare block, non-lambda value, or lambda with the wrong number of parameters is rejected when the frame is installed. A handler reinterprets a command, not a value binding: an arm stands in for the head it names (standsFor), and is checked against what that head is. An arm for an external, and any name that is neither a handler nor a base frame, stands in for a command, so its value is its output; curl: { |args| echo '{"ok":1}' } is an arm, and curl: { |args| '{"ok":1}' } is refused, since it returns a String where it should write — write it with echo. An arm that returns () is admitted and captures "". An arm for a base frame, such as echo, or for a head that is already a handler, returns what that frame or handler returns, so an alias over an alias keeps its own type. The catch-all stands in for every command in its block, so its value is its output. An arm written for a builtin that returns a value is never reached, because builtins are resolved first (§9.5). A handler may change what a command writes, but not what kind of thing it is; use an explicit codec when conversion is intended.

detach refuses a handled head: a handler runs inside this session, so nothing can be detached. To stub detach, stand in for detach itself; to run the real program, write ^name.

Handlers are command operations, not first-class names. They are invoked in command position and cannot be fetched with $name.

9.5. Command resolution and handler precedence

A bare command head is resolved in this order:

  1. the lexical value environment, including native commands;
  2. the innermost named handler for that spelling, searching all active run frames;
  3. a base command supplied by ral or the embedding host;
  4. the innermost catch-all handler;
  5. 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 cd or other unbracketed shell-state write inside either persists to the caller. Inside a surrounding within [dir:], a cd persists only until that within exits.
  • 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; use and explain inside it read the creator's session as of the birth. Its cwd, bindings, and later dynamic changes remain private, while its result returns through the handle.
  • An external process receives the effective cwd and environment at launch. It cannot mutate ral’s shell state.

These containment rules are the same on every supported host. Host differences affect executable lookup, path syntax, and the implementation of process-staged pipelines and process control, not lexical scope, handler precedence, or dynamic restoration.

10. Programs, modules, and session state

A ral program may come from a file, command-line text, standard input, or an interactive session:

ral build.ral release     # run a file
ral -c 'echo hello'       # run command-line text
ral -s                     # read a program from standard input
ral                        # start the REPL when stdin is a terminal

A script is parsed and typechecked before it starts. Runtime-loaded files are parsed and typechecked when use reaches them.

10.1. Program arguments and identity

Arguments after a script path or -c program are returned by args as a list of strings:

ral deploy.ral staging eu-west
let [environment, region] = args

args does not include the ral executable, the script path, or the -c source text. It is empty in the REPL and for a program read from standard input.

$SCRIPT identifies the file containing the reference:

echo $SCRIPT

It is lexical: a reference inside a loaded module names that module, not the file that loaded it. ral fixes the value while compiling the file, so a later call from another file cannot change it. On Windows its path is /-separated (§14.4).

Only real script files have this identity. Using $SCRIPT in the REPL, -c, standard-input programs, preloaded sources, or other synthetic source is a compile-time error.

Without an explicit script or -c, ral starts the REPL when standard input is a terminal and reads a batch program otherwise. -i forces the REPL; -s forces a standard-input program and takes precedence over -i. An explicit script path takes precedence over both.

A successful batch program exits with status 0. exit n exits with n. Parse errors, type errors, runtime errors, and failed external commands produce a nonzero status and an explanatory diagnostic. Process exit statuses are reduced to the range 0 through 255.

10.2. Ambient reads

The live shell is read by six nullary builtins:

Name Type Value
env F (Map String) Environment variables as a map of String to String.
cwd F String The logical working directory, absolute, /-separated on Windows (§14.4).
home F String HOME, or USERPROFILE where appropriate, from the effective environment, /-separated on Windows; the directory ~ abbreviates.
user F String USER, or USERNAME where appropriate, from the effective environment.
nproc F Int Available processor parallelism, never less than 1.
args F [String] The current program's argument strings.

Each is a computation: a let of one binds what it returns, and !{…} splices it. Each is also an ordinary native: a lexical binding of the same name shadows it, and ^env runs the program of that name.

The recorded command status is not among them: there is no status register a program can read. A failure is an Err that carries its own status, and try binds that record, so the status is in hand exactly where a program is deciding what to do about it. Elsewhere it is only what the process exits with.

env combines the host process environment with any active ral overrides. An inner override wins:

within [env: [MODE: 'test', USER: 'builder']] {
    echo !{env}[MODE]
    echo !{user}
}

Environment overrides are dynamically scoped. They affect env, user, home, home and command lookup, RAL_PATH, and child processes, then disappear when the within body ends. There is no general setenv operation in the language.

PWD and OLDPWD are deliberately absent from env. ral owns its working directory separately so concurrent computations never race over the process-wide current directory. Child commands receive PWD and their actual process working directory from this logical state, and no OLDPWD: ral keeps no previous directory.

cd path changes the session’s logical working directory. It resolves path as every path builtin does, path-prefix sigils included. Relative paths, file operations, module loads without a containing file, command lookup, and child processes all use it. A top-level cd persists into later runs; one made inside within [dir: path] is undone when that within exits (§9.2).

A cd inside a function call or a forced block changes the caller's working directory, as at top level; only the body's bindings stay local.

10.3. Importing a module with use

use path evaluates a file in a fresh top-level scope and returns its public bindings as a record, each at its own type, read by name:

let math = use 'lib/math.ral'
echo $math[mean] [1, 2, 3]

Bindings do not leak into the caller. Names beginning with _ are private and are omitted from the returned record:

# lib/math.ral
let _sum = { |xs| fold add 0 $xs }
let mean = { |xs| $[_sum $xs / length $xs] }

The module’s final expression is evaluated, but use returns the bindings rather than that expression’s value. A module's bindings are heterogeneous and reached by name, so the result is a record: $m[mean] reads one, and a key computed at run time has no type to give.

use is a checked boundary (§7.3): the record is admitted against the fields the script reads, so a read of a name the module does not export fails at the use, and each exported function is held to the type the script uses it at.

use first resolves a path. A ~ or xdg: head expands first, as in any written path, and only a path still relative after that is read relative to the containing file, or relative to cwd when there is no containing file; an absolute one is resolved exactly as any written path is, never joined onto the file's directory; the containing file's own directory is never read for such a head, so a script under a directory named ~ is still found there. If that path does not resolve, ral searches the directories in the effective RAL_PATH, in order. The effective value is read when use runs, so a dynamically scoped within [env: [RAL_PATH: ...]] override controls only loads in that body. RAL_PATH uses the platform’s normal path-list separator: : on Unix and ; on Windows. Each search candidate must be a regular file; a directory with the requested name does not stop the search of later entries.

10.4. Module freshness, cycles, and errors

ral does not cache loaded modules. Every use reads, compiles, and evaluates the file again:

let first = use 'config.ral'
# after config.ral is edited:
let second = use 'config.ral' # sees the new file

This also means module side effects run on every load. Bind a module map once when repeated evaluation is unwanted.

During a nested load, relative imports use the innermost file’s directory. ral keeps the active load chain and rejects a file that appears again in that chain:

use: circular dependency: a.ral -> b.ral -> a.ral

The active load depth is limited to 100 files. Exceeding it is an error rather than an uncontrolled recursion.

use checks filesystem read authority before reading. A denied load fails before the module body runs. A file whose resolved path is not UTF-8 is refused, naming it, before it is read: a source's path travels as text. Missing files, permission failures, parse errors, type errors, cycles, depth overflow, and failures raised by the module retain their useful status and are prefixed with use:. A module that does not compile is reported against its own text, with the line and column of each error, exactly as it would be were it run as a script; a try handler that catches the failure reads the plain message, and its site is the use.

10.5. Persistent sessions

A batch invocation owns one fresh shell and ends with the process. A REPL or embedding host may submit many top-level runs to one session.

Successful top-level changes persist between those runs, including:

  • let bindings and their inferred types;
  • aliases and loaded definitions;
  • the logical working directory;
  • the last status.

Changes completed before a later command fails also persist:

let ready = true
failing-command

A later REPL run can still read $ready; statements after the failure never ran.

A nested block's bindings stay local, but its shell-state writes, such as cd or alias, persist into the session like any other (§2.6). Use within for a temporary directory, environment, or handler change.

Interactive sessions load their rc file unless --norc (or its --noprofile alias) is given. Login sessions additionally load the system and user login profiles. These are ral source with host-level result contracts: each login profile must return Unit, while the rc file must return a configuration Map. Their evaluation is not transactional, so bindings and effects completed before a runtime failure, exit, or result-contract check remain part of the session. Applied RC bindings, aliases, environment configuration, hooks, and startup behavior likewise belong to that session.

11. Concurrency and process lifetime

ral distinguishes concurrent work by who owns it. Every concurrent block has exactly one owner, fixed at birth:

  • a gather: a gather { … } form (§11.3), or a worker's body, which is a gather of its own. A spawn or watch inside a gather is the gather's: its worker hangs under the scope that was current when the gather was entered, and the gather ends it at its own exit;
  • the session: a spawn or watch with no gather around it, in a run, creates a worker that ends by completing, by cancel, or with the session;
  • nobody: detach gives an operating-system process away, so the session no longer owns or controls it.

There is no fourth owner. An ordinary command is not concurrent: it belongs to the current run.

This distinction is visible in the result type. A spawned or watched worker, whichever its owner, returns a Handle. Surrendered work returns only a receipt.

11.1. Spawning work

spawn [TITLE] BODY starts a block concurrently and returns its handle at once:

let build = spawn 'build' {
    cargo build
    return 'built'
}

echo 'the build is running'
let result = await $build
echo $result

await writes what cargo build printed, then binds 'built' (§11.2).

spawn is a control form, not a function (§3.6). BODY is a block; TITLE, written before it, is a String naming the work to people: it is the name the worker is born under, reported in the audit trail (§13.3), in the host's notices, and in the worker registry (§11.4). It must be non-empty and on one line. The title may be left out, spawn { cargo build }, and the worker is then named by its source location, block at <script>, line N. Whether a title is required is the host's policy, checked when the form runs, before any worker is born: the ral hosts accept an untitled spawn, and the exarch agent host refuses one, naming the fix. A spawn with no operand or with three is a parse error.

A pipeline is spawned by naming it in the block:

let filtered = spawn 'errors' { cat large.log | grep error }

ral has no backgrounding operator. A trailing & is a parse error naming spawn, because the two are not the same operation: a POSIX & makes a process-group job that fg, bg, and disown address, while spawn makes a worker addressed by its Handle. Borrowing the spelling would promise the former and deliver the latter.

The worker receives the lexical environment captured by its block. Values are immutable, so concurrent workers do not share mutable lexical bindings. Each worker has its own shell state: changes such as cd, environment overrides, or status changes in the worker do not mutate the spawning thread's shell state.

A nested spawn or watch is owned by the enclosing gather: a gather form (§11.3), or else the worker it was started inside, whose body is a gather of its own. Cancelling a worker cancels its tree, and a worker finishes only after its children have, by the exit rules of §11.3: when the worker's block returns, every child still unclaimed is awaited in birth order, the first failure being the worker's; when the block fails, exits, or is stopped, every child is cancelled and drained within the host's cleanup grace (§8.7). A spawn inside a pipeline stage belongs to the gather the stage runs in. A spawn in a run, outside any gather, is owned by the session.

A gather's child is not announced: no birth notice and no completion notice of its own reach the host, since its result and its completion reach the session through its owner. It is registered (§11.4), so the host's worker cap bounds it and workers lists it, and its bytes are buffered and written when it is claimed, as any worker's are. A session worker's birth is announced, and its completion is reported once.

A session worker is not part of the foreground run. Interrupting or timing out the run that created it does not cancel the worker. A gather's child is part of it: its worker hangs under the gather's entry scope, so an interrupt or a timeout on the run reaches it (§11.3). A worker's standard input is empty rather than inherited from the terminal, so a worker cannot steal interactive input.

Pipelines inside a worker still use byte pipes between stages. Value-style composition remains ordinary application and bind. Concurrency changes lifetime, not the typing of a pipeline.

Buffered output

Each ordinary worker has independent stdout and stderr byte buffers. Its bytes do not appear on the spawning run's output while it executes. await writes the bytes to the awaiter's streams (§11.2); poll returns them as data, as Bytes: those written so far while the worker runs, all of them once it has settled.

Each buffer is capped at 16 MiB. At the cap ral appends one truncation marker, continues draining the writer, and discards later bytes. Draining continues so a chatty child cannot deadlock on a full kernel pipe, while memory remains bounded. Redirect high-volume output inside the spawned block when the full stream matters:

let server = spawn 'docs server' {
    python -m http.server > server.log 2> server.err
}

Explicit redirects bypass the corresponding handle buffer. A watched worker also has empty completion buffers because its bytes were streamed live, so await of it writes nothing.

11.2. Handles and eliminators

A handle is a process-local capability for one worker. The core eliminators are await, race, and cancel; poll and first-settled are its two observations.

A worker's result is claimed exactly once: by await, by race, by cancel, or by the gather that owns the worker as it exits (§11.3). Claiming is atomic, so of two callers trying at once exactly one succeeds. poll and first-settled observe without claiming: they return data, never write, and never claim. Both treat a claimed handle as await does: poll of one fails, and first-settled skips it, failing only when every handle it was given is claimed. Whether a handle has been claimed is checked at run time; its type does not record it.

await

await handle behaves as if the handle's block had run at the await. It:

  1. waits until the worker settles, then claims its result;
  2. writes the bytes the worker wrote to its stdout to the awaiter's current stdout, then the bytes it wrote to its stderr to the awaiter's current stderr;
  3. ends as the block ended: it returns the block's value, raises its failure as it stands (status, message and command included), re-raises its exit or its stop, and resumes its panic.

The sinks are the awaiter's at the await: inside a capture, let out = await $h captures the bytes; under 2> f, the worker's stderr goes to f; inside another worker, they land in that worker's own buffers. A failure is the awaiting run's, so try recovers it:

try { await $build } { |error|
    echo "build failed with status $error[status]"
}

A handle carries its block's type, grade included (§17.3): spawn of a block of type F_ε A returns a Handle_ε A, printed Handle (Returns A), Handle Command, or, with the grade a variable, Handle (ν A); and await : Handle_ε A -> F_ε A. So await of spawn { echo hi } is a command, and let x = await $h binds "hi" as let x = { echo hi } would; await of spawn { echo pre; return 5 } writes pre and returns 5, as the block would inline. Replaying bytes changes nothing about the grade, which says how a result is produced, not whether anything is written.

The equation holds for the value or failure and for the bytes on the two standard streams; the block's effects happened earlier, concurrently, in the worker's own shell. It does not hold for:

  • shell state: cd, environment overrides and the status set inside the block do not reach the awaiter (§11.1);
  • the interleaving of the two streams: all of the worker's stdout is written, then all of its stderr, so a block that alternates echo and warn replays as its stdout followed by its stderr;
  • a block that spawns: run inline in a run, its inner spawn would be a session worker, while as a worker it awaits that child before it settles (§11.3), so the awaiter sees the child's output and failure too;
  • a cancelled or timed-out worker, which ends with worker was cancelled or worker timed out (below), an ending the block run inline could not have;
  • exit: an exit in the block ends the run that awaits it, which may be a later run than the one that spawned it.

A result is claimed once. await of a handle whose result is already claimed, by an earlier await, by race, or by cancel, writes nothing and fails with await: this worker's result was already claimed, an ordinary failure that try recovers; the block is not run again. A foreground interruption of await interrupts the wait, not the worker, and claims nothing; the handle may be awaited later.

poll

poll handle never waits and never writes. It is the observation that returns one worker's bytes as data, one of two variants:

`pending [stdout: Bytes, stderr: Bytes, elapsed: Float]

`settled [
    stdout: Bytes,
    stderr: Bytes,
    elapsed: Float,
    outcome: <`ok A | `err ErrorRecord>,
]

elapsed is in seconds on a monotonic clock: how long the worker has run so far, or, once it has settled, how long it ran.

The pending bytes are a cumulative, non-destructive snapshot. Polling does not consume them, so a later poll or await still sees the complete buffered output. Repeated pending polls may therefore return progressively longer byte sequences.

poll claims nothing, on either arm. A settled poll leaves the handle to be awaited and its entry in the registry (§11.4), so a script may poll until the worker settles and then await it, its bytes written once. A handle whose result is already claimed has nothing left to observe: poll of it fails with poll: this worker's result was already claimed.

poll is total on an unclaimed handle. The settled arm reports worker failure, including a block stopped by its scope through a cancel or a timeout, as outcome:err ...; it does not raise that failure. Thuspollitself succeeds and leaves status zero when it successfully samples either arm. A handlecancelorawaitclaimed fails, as above. A worker'sexit, a foreign stop and a panic are re-raised, asawaitre-raises them. The prelude functionis-done handlereturnsfalseforpendingandtruefor either settled outcome, and fails aspoll` does on a handle already claimed.

first-settled

first-settled handles waits until it observes one handle settle and returns its 0-based index in the list, an Int. It is the observation of several handles as poll is of one: it writes nothing and claims nothing, so the handle it names is still there to await or poll.

let mirrors = [$a, $b]
let k = first-settled $mirrors
let first = await $mirrors[$k]

The wait is cancel-aware, as await's is: a foreground interruption ends the wait and claims nothing. If several handles are already settled when inspected, list order breaks the tie. A handle another caller has claimed is skipped; if every handle given is claimed, first-settled fails with first-settled: this worker's result was already claimed. An empty list is an error.

race

race handles waits until it observes one handle settle, then claims the winner and behaves as await on it: it writes the winner's bytes and ends as the winner's block ended. Every other handle is claimed, cancelled and drained before race returns, however the winner ended (§11.3).

let a = spawn 'mirror a' { fetch 'https://a.example/data' }
let b = spawn 'mirror b' { fetch 'https://b.example/data' }
let first = race [$a, $b]

If several handles are already settled when inspected, list order breaks the tie. A worker stopped from elsewhere counts as settled, with the worker was cancelled ending. A handle another caller has claimed is skipped. If every handle given is claimed, whether before race starts or while it waits, race fails with race: this worker's result was already claimed. An empty list is an error.

cancel

cancel handle claims the handle, requests explicit cancellation, and removes the worker from the owning registry. Cancellation is cooperative for ral computation and is propagated to external child process groups by the process runtime. A worker may take a short bounded interval to tear its process tree down. A worker that has already completed is not stopped; its result is discarded.

Cancellation has no result. cancel of a handle whose result is already claimed does nothing and returns (). Since cancel claims the handle, a later await, poll or race of it fails as already claimed.

A worker stopped by anything but its own cancel ends as its own failure: cancelling a worker cancels every worker it started (§11.1), each of which ends with worker was cancelled, reason `cancelled, status 143, and a worker reached by a timeout on an enclosing scope ends with worker timed out, reason `timed-out, status 124. An await that claims such a worker raises that ending as the awaiting run's own failure, which try can recover (§8.6), and poll reports it as that error record. A cancelled worker's halt claims the workers it started (§11.3), so an await of one of them from outside meets that ending only if it claimed the worker first; otherwise it fails as already claimed.

11.3. Structured fan-out: gather, par, race

Fan-out that must leave nothing behind is a gather, not a set of loose spawns. gather BODY runs BODY as a block that owns the workers spawned inside it: nothing it started outlives it.

gather {
    let b = spawn 'build' { cargo build }
    let l = spawn 'lint'  { cargo clippy }
    return [build: !{await $b}, lint: !{await $l}]
}

gather is a control form (§3.6). BODY is a block, and the form has the body's type, grade included (§17.7): joining the workers is a run-time effect that changes no type. Inside the body, every spawn and watch is the gather's (§11.1), and its worker hangs under the scope that was current when the gather was entered, so an interrupt or a timeout on the run reaches it. A within inside the body does not move it. A worker's body is a gather of its own, and the rules below are the rules a worker applies to its children.

  • Return. When the body returns, each child no one has claimed is claimed and awaited in birth order, as await would (§11.2): its output joins the gather's own, and the first failure among them is the gather's, the rest being cancelled and drained. A child another caller claimed first is skipped. A normal return joins its children; it never cancels them.
  • Halt. When the body fails, exits, or is stopped, every child no one has claimed is claimed and cancelled, and the gather waits for them to end before it propagates the halt.
  • Drain. On every exit the gather also waits for each child it started whose worker was cancelled and has not yet ended, however it came to be cancelled: by the halt rule, by a cancel in the body, or by a cancel through worker id (§11.4). A child another caller claimed and is still awaiting, uncancelled, is left to that caller.

Draining is bounded by the host's cleanup grace (§8.7): a gather waits at most that long for its cancelled workers to end. Cancellation is cooperative (§8.10), so a worker that ignores it past the grace runs on after the gather returns, no longer registered.

A handle may leave its gather as a value, but the gather claims its worker at exit, so a later await of it fails as already claimed (§11.2). A spawn or watch meant to outlive the expression goes outside any gather, where the session owns it (§11.1). A gather is synchronous: it ends before the run that entered it does.

Three standard-prelude functions are written over gather:

let converted = par { |path| convert-one $path } $paths 4
let checks = both { cargo build -q } { cargo clippy -q }
let data = first-of [{ fetch 'https://a.example/data' }, { fetch 'https://b.example/data' }]
  • par function items jobs is the standard parallel map. At most jobs workers run at once; a non-positive limit permits one worker per item. A new item starts as soon as any running worker settles. The first failure observed is raised at once, and the other workers are cancelled and drained. The items are awaited in input order, so each item's output is written in input order and the result is the list of their values in input order, whatever order the workers finished in.
  • both first second runs two blocks at once and returns the record of their values, [fst: …, snd: …], the fixed-arity form of fan-out into a record. It awaits fst, then snd, so it does not fail fast: a failure in either is the call's, and the other is cancelled and drained at the gather's exit.
  • first-of blocks is race over a fresh spawn of each block, inside a gather: it ends as the first to settle, with its output and value, or its failure, and the others are cancelled and drained before it returns. An empty list is an error.

Each worker these start is titled: par #i for item i of a par, both.fst and both.snd, and first-of for each of its blocks.

race handles (§11.2) is the form for handles already held, which no gather of the caller's owns: it claims the winner and cancels and drains every loser however the winner ended. first-of is the form for fresh blocks.

11.4. Registry and shutdown

Every worker birth — by spawn or watch — is registered at once in the owning shell. A registry entry holds the worker's id, its title, its owner (the session, the worker whose body it was born in, or a gather), its state, its birth stamps (one on a monotonic clock, one on the wall clock, and an end stamp once its thread ends), and the live handle. Nested workers share the owning shell's registry.

The id is the worker's identity; the title is for people. worker id returns the handle of any worker still in the registry, running or with a retained result, whatever started it. It is a boundary (§7.3): the value the worker settles with is admitted, when awaited, against the type the script uses it at. It composes with an eliminator in the same call, await !{worker 3}. An id that names no entry is an error. Nothing looks a worker up by title.

workers lists the registry as data: one record per entry, running or with a retained result, in ascending id order:

[
    id: Int,
    title: String,
    owner: <`session | `worker Int | `gather>,
    state: <`running | `done Done>,
    started: Int,
    elapsed: Float,
    settled: <`some Int | `none>,
]

where

Done = `ok | `err ErrorRecord | `exited Int | `stopped Cause | `panic String
Cause = `reader-gone | `interrupted | `cancelled | `timed-out
      | `terminated | `aborted
Field Meaning
id the worker's id, the one worker takes
title the spawn's title or block at name, or a watch's label (a par titles the worker for item i par #i, §11.3)
owner `session for a worker born in a run outside any gather, `worker n for one born in the body of worker n, `gather for one a gather form owns
state `running, or `done with how the block ended: it returned, failed with an error record (§8.6), ran exit with a status, was stopped with a cause (§8.10), or panicked
started when the worker was born, in Unix seconds
elapsed seconds on a monotonic clock: how long it has run so far, or how long it ran
settled `some the Unix second its thread ended, `none while it runs

A listing is a pure read: it claims no result, removes no entry, and holds no handle, so a workers result crosses every boundary a value does (§11.7). worker id is the one way back from a row to its handle.

An entry leaves when:

  • its result is claimed: by await, race or cancel (§11.2), or by the gather that owns it (§11.3); or
  • a host retention policy reaps a settled result no one has claimed.

poll, pending or settled, leaves the entry in place.

A worker lives as long as its owner: nothing but cancel, its own completion, its owner's end — a gather's exit, a parent worker's included — or the session's end stops it. No policy reaps a running worker.

Hosts may cap live workers. Admission reserves a seat before the thread is created, so concurrent births cannot overshoot the cap. Only running or being-born workers occupy seats; settled results retained for observation do not. A refused birth reports the cap, suggests awaiting or cancelling a worker, and points at workers to list them.

These bounds are host policy, not core constants. The ral interactive and batch hosts arm no retention bound or worker cap. The exarch agent host currently caps live workers at 64 and retains unclaimed settled entries for 256 ral calls.

Every session worker is cancelled when its session is destroyed. Teardown then waits briefly and boundedly for worker threads to act on cancellation and tear down their children. The interactive shell first warns once with the ids and titles of workers still running. Workers do not survive process exit; use detach when survival is the requirement.

11.5. Host-installed worker forms

The core language provides the spawn and gather forms and the builtins await, poll, first-settled, race, cancel, worker, and workers; the standard prelude adds par, both, and first-of over them. watch and detach are optional host commands: watch is a builtin, and detach is a base handler taking an argument list. If a host does not install one, its name is absent from that shell's base scope (watch) or its base frames (detach), and is checked and resolved like any other unknown command.

Live output with watch

watch label block creates an ordinary worker, owned as a spawn in its place would be (§11.1), whose output is streamed instead of buffered:

let build = watch 'build' { cargo build }
await $build

Each stdout line is prefixed [build] and each stderr line [build:err]. Sibling watchers serialize complete framed lines, though their lines may interleave. A partial final line is flushed when the worker settles.

The returned value is an ordinary handle, so await, poll, race, and cancel apply. Its stdout and stderr buffers remain empty because those bytes have already been sent to the durable output sink. Programs run through the ral executable, in interactive and batch modes, install watch. A host whose output capture ends with each run, including exarch, omits it because a worker cannot safely retain that run's sink.

Work that survives with detach

detach description command arguments... starts an operating-system process that the session deliberately stops owning. It takes one argument list: the description first, then the command and its own arguments.

let receipt = detach 'local documentation server' python -m http.server 8000
echo $receipt[pid]

A handler runs inside the session, so a head a handler intercepts — by an arm for it, or by the catch-all — cannot be detached, and detach refuses it. The same holds for a base frame such as echo. ^name runs the real program.

The description must be a non-empty, single-line String. The result is:

[pid: Int, desc: String]

This record is a receipt, not a handle. await, poll, race, and cancel do not apply. The pid is the process's number at birth, not an enduring capability; operating systems may later recycle it.

The surviving process has standard input, standard output, and standard error connected to the null device. It has no buffered result and its exit status is unrecoverable. A successful return says that the program was launched; it does not prove that it remained alive or initialized successfully. A detached program must arrange its own logs, and callers must probe the resource it is supposed to provide.

On Unix the implementation double-forks, reparents the grandchild, and creates a new session. It preserves the logical working directory, environment, and the sandbox projection active at birth. Inside a synod guest the survivor keeps its jail's limits and outlives the engine that birthed it, as on the host; it ends only with the machine. Later grants cannot widen it. An active grant may refuse the operation by omitting `detach from shell before any process is born.

detach is Unix-only and is installed only by hosts that also provide a successful-birth budget. The budget is shared by nested workers, counts only committed births, and is not replenished because the session cannot observe a detached process's death. Exarch currently permits 16 births per session. Neither interactive nor batch ral installs detach; Windows has no such command.

11.6. Stop signals

ral does not suspend. There is no job table, no fg/bg/jobs/disown, and no Ctrl-Z gate: a stopped child — by SIGTSTP, SIGTTIN, SIGTTOU, or an external kill -STOP — is resumed at once by whoever waits on it, the same rule for every role and every platform that has stops. Ctrl-Z in an interactive command such as vim therefore does nothing visible: the program stops and is resumed before the terminal can register the gap. Work meant to run in the background is spawn, whose handle already carries await for foreground waiting and cancel for termination; Ctrl-C ends what Ctrl-Z used to park.

11.7. Availability summary

Facility Core / portable ral interactive ral batch exarch agent host
spawn, gather (forms) yes yes yes yes
await, poll, first-settled, race, cancel yes yes yes yes
worker, workers yes yes yes yes
is-done, par, both, first-of from the standard prelude when that prelude is installed yes yes yes
watch optional yes yes no
detach optional, Unix only no no Unix sessions that enable it

Handles are local runtime values. They may be stored, passed to functions, and returned through locally evaluated blocks, but they cannot cross a serialized child-evaluation or remote-host boundary. Await the handle before crossing such a boundary.

12. Capabilities and sandboxing

grant temporarily reduces the authority available to its body and to work started by that body.

grant [
    exec: [
        allow: [`name git, `name rg, `dir `system],
        deny:  [`name sh],
    ],
    fs: [
        read:  [`cwd ., `absolute /usr/share],
        write: [`cwd build],
        deny:  [`cwd .git],
    ],
    net:   `offline,
    shell: [`chdir],
] {
    git status
}

The values are ordinary records, lists and variants; the checker types them like any other value (§12.1). Each dimension is independent. Omitting net, for example, says nothing about network access; it inherits the surrounding authority. A written dimension restricts. An empty grant changes nothing:

grant [] {
    # ambient authority
}

A grant can never restore authority withheld by an outer grant or a session profile. Nested grants intersect:

grant [exec: [allow: [`name git, `name rg]]] {
    grant [exec: [allow: [`name git]]] {
        git status   # allowed
        rg foo       # denied
    }
}

A denial at any layer remains a denial: an exclusion (a deny entry) beats every allowance, in exec and in fs, however specific the allowance and in whichever layer it sits. Neither a narrower directory nor an exact file reopens an excluded region, and nor do a carrier (an interpreter a script's #! names), the dynamic linker or ral's own binary. A refusal that cites an exclusion says that an allowance cannot override it.

Authority is a ceiling, extended once and then only restricted. A host that composes profiles (exarch's --extend) may extend a base once; within ral there is no extension. In an extension an allowance never removes authority and only an exclusion does, on every axis, including one the base leaves unrestricted.

An exclusion names programs by path and name: it is a guardrail, not a wall. A copy of a program, a program newly written to a folder that is both writable and runnable, or a script fed to an admitted interpreter is a different request that no exclusion sees.

Every list in a grant denotes a set. An entry written twice, in one list or through spreads of two computed lists, adds nothing; no rule refuses a repeat. A target that is both allowed and denied is denied, and a place may be both read and written. An allowance names what it named when it was granted: it keeps the real path it was frozen to (§12.2), while an exclusion follows its name.

12.1. Capability fields

A capability record accepts exactly these names:

  • exec
  • fs
  • net
  • editor
  • shell

Unknown names are errors, reported before the program runs. exec and fs are nested tables with the fields below; net takes `offline; editor and shell take lists of verbs.

grant's bracket is the form's own, as within's is: [] is the empty grant, each field is written out with a value that may be computed, and a bundle or a map in its place is a parse error. A nested table is a record in the same way: a map (exec: [:] included) and () are refused statically, with the record spelling to use. The empty table is written with an empty list: exec: [allow: []], fs: [read: []].

The tags are types, not strings. A bare string where a place or an exec target belongs, a misspelled tag, a tag with the wrong payload, and net: true are refused statically, before the program runs, with the tags on offer and the nearest one named. A value the checker cannot type (a profile decoded from JSON, a parameter of a generic function) reaches the runtime decoder, which judges the same vocabulary.

Omitting a whole field inherits that dimension. Within fs, an omitted read or write adds no allowances of its own (reads still follow from write and from what the grant runs, §12.4); a present exec table admits only what its allow list names (§12.3).

Thus:

grant [fs: [read: [`cwd .]]] { … }

allows reads beneath the frozen working directory, denies all writes, and leaves every non-filesystem dimension unchanged.

Similarly:

grant [editor: [`read]] { … }

allows editor reads, denies editor writes and TUI access, and leaves non-editor dimensions unchanged.

12.2. Places and selectors

A place names one path. It is resolved when the grant is frozen, against the anchor its tag names:

Place Denotes
`absolute P the absolute path P
`cwd S S against the working directory at decode time
`home S S against the effective home
`tempdir S S against the platform temporary directory at decode time
`gitdir S S against the current repository's real Git directory, or the working directory outside a repository
`xdg-config S, `xdg-data S, `xdg-cache S, `xdg-state S, `xdg-bin S S against that XDG base

A selector names a set of directories that the environment chooses, not a path the author wrote. It is valid only as the payload of dir in an exec list (§12.3):

Selector Denotes
`path every absolute directory in the host process's $PATH at decode time; an error if there are none, if an entry is the filesystem root, the home or an ancestor of the home, or if an entry is one `absolute would refuse under the host's rules (a malformed or mixed two-separator head) or is not valid Unicode
`system ral's platform-defined system tool directories, without Homebrew
a package manager, such as `rustup or `homebrew the directories that manager runs programs from, where they exist; nothing if the manager is absent (§12.3)

Selectors never appear in fs lists: their expansion is held under no anchor, and a $PATH entry can be $HOME or /, so a write grant on one would let a child plant programs where the user's own shell looks.

The payload P is absolute under the host's rules. The payload S is a relative subpath of components separated by / (and \ on Windows): . is the anchor itself, and .. folds at decode and may leave the anchor, as in `cwd ../sibling (the XDG escape guard still judges the result). The empty string, a leading / or \, and a drive prefix X: are refused in S on every platform, so a profile fails alike everywhere.

A payload headed by ~ or xdg:NAME is refused under every tag: those prefixes are expanded when a path is used, not when a grant is read, so `absolute ~/x would name a relative path. The error names the place meant, `home x or `xdg-config x.

At run time, a path headed xdg:NAME followed by a separator (/, and \ on Windows) and a subpath expands to that XDG base with the subpath joined beneath it. The subpath is everything after that one separator. A root of the current volume is dropped, so xdg:config//etc names etc under the config base, never /etc. .. is joined as written and folds with the rest of the path, so it is not confined to the base: xdg:config/../x names x beside the config base. On Windows a subpath that names a volume of its own would replace the base: one with a drive component (C:, C:x), or one opening with two separators, as a share or a namespace head does (\\srv\share, \\?\UNC\srv\share, \\.\pipe\x, and so xdg:config///x). Such a path cannot be answered and is used literally, as an unknown NAME is. Elsewhere every root is the one root, so nothing is refused.

`absolute classifies its payload on the raw string, before any folding, host first: it is live if absolute under the host's rules, dead if absolute only under the other platform's, and refused if under neither.

Raw P POSIX Windows
/x, / absolute dead
//server/share/x (well-formed UNC) absolute absolute
C:\x, C:/x, \\server\share\x dead absolute
\\?\C:\x, //?/C:/x, //?/UNC/server/share/x (verbatim) dead absolute
\x (root-relative) refused refused: it names no drive
\??\C:\x (NT object-manager path) refused refused: no grant can name one
C:x (drive-relative), anything relative refused refused
\\ or // with no server and share, or with . or .. for either (malformed UNC) refused refused
/\x, /\srv\share, \/srv/share (two mixed separators) refused refused

Absoluteness is the kernel's: under POSIX a path is absolute when it opens with /, under Windows when it opens with a drive root or two separators. Two grant rules stand beside it. A root opening with two separators that is no well-formed UNC or verbatim path, or that names the device namespace, is refused under both rules, though Windows would read it as absolute. So is one opening with two separators of different spellings, /\ or \/, outside a verbatim head: Windows reads it as a UNC or device head, while POSIX reads /\x as rooted at / and \/x as relative, so the one spelling would grant different paths on the two platforms. A verbatim root is a Windows grant and is dead under POSIX, though POSIX reads //?/C:/x as the rooted /?/C:/x: its author named a Windows path, and a profile must not grant a different path on another host.

A dead grant names no path on this host, so one profile can contain host-specific entries and load on both platforms; it matches nothing and, in a deny list, denies nothing.

A read, write or run allowance by path names the real path it was frozen to and is never re-resolved: a symlink put in its place afterwards redirects nothing, and the allowance grants nothing there. Creating a granted directory after the grant, or rm -rf dir && mkdir dir, keeps working, since these are path grants, not inode pins; a symlink spelling that existed at the grant and is retargeted later keeps showing its old target; replacing a granted directory, or an ancestor of it, with a symlink refuses every launch that shows it on Linux and Windows, naming it, and reaches nothing on macOS; macOS firmlinks keep working as twins of the real path. An exclusion is re-resolved on every check and so catches whatever its name reaches now, and a bare program name follows the host PATH afresh at each run. A grant whose real path is not valid Unicode is refused when it is frozen. A synod guest grant is not yet frozen inside the guest, so there a redirect made in one run can take effect in the next.

Places, anchors, .., environment-derived bases and symlink identities are frozen when the inline grant or profile is decoded. Later cd, HOME, TMPDIR, XDG, or repository changes do not retarget the grant. An anchored place that freezes relative (a relative HOME or TMPDIR) is refused. Ordinary relative paths and bare strings are rejected: write `cwd relative/path when that is intended.

An XDG place must resolve beneath the effective home directory. ral rejects an unset home needed by `home or an xdg-* tag, an XDG environment override that would escape the home directory, and a .git pointer file that no Git directory claims. Where .git is a file naming the Git directory, `gitdir follows it only if that directory names the working tree back, as a linked worktree's gitdir file does and as a --separate-git-dir repository's core.worktree setting does, since the file itself lies inside the tree the grant confines.

12.3. exec

exec has two lists of targets, allow and deny:

exec: [
    allow: [`name git, `name rg, `absolute /opt/tools/lint, `cwd tools/build, `dir `system],
    deny:  [`name bash, `absolute /usr/bin/curl],
]

A target is one of:

  • `name N — the bare command N: the file the host PATH finds for it, and the bundled tool of that name. A scoped PATH cannot change what it names. N must be a bare command name; a path-shaped payload is refused with a hint to use a place. Path shape is judged under Windows rules on every host, so a\b and a drive-headed C:x are refused alike everywhere;
  • a place (§12.2) — the one executable at that path, frozen to its real path when the grant is decoded. A place that is a directory is refused, with a hint to use dir;
  • `dir D — every executable beneath each directory D names, recursively, frozen to its real path, where D is a place or a selector. Directory scope is spelled, never inferred from a stat: `cwd tools/build names one executable, `dir `cwd tools names everything beneath tools.

Bare strings are not targets. Authority over a command does not depend on its arguments: an allowed target is admitted with any arguments, or none, and a denied target is refused. ral has no first-argument or subcommand policy.

Every rule judges the file that will run: its real path, with symbolic links followed. A bare name is resolved against the host PATH afresh at each run (one submitted line, one script or one dispatch), so the program launched is the program judged. So `name git also admits /usr/bin/git spelled `absolute /usr/bin/git, because it is the same file. A deny of a name also vetoes the command by file name wherever it lives, so deny: [`name bash] cannot be avoided through a link or another directory. A denial holds every spelling some filesystem takes for its name, in any case or Unicode form, so it also refuses BASH or a file under a differently cased denied directory; an allowance holds its name only as stored.

There is no specificity. A target is permitted when some allowance covers it and no exclusion does: an exclusion of a directory refuses the exact file or deeper directory an allowance names inside it, a target in both lists is denied, and the refusal names an exclusion that applies. A command that does not exist is never judged: it fails with status 127, or 126 when the file is not executable.

Once an exec table is present, commands not admitted by it are denied: exec: [allow: []] admits no executable (ral's own binary included), and omitting exec inherits. Nested exec tables intersect, and every denial remains effective.

An omitted exec is every program, and a host that extends a base that omits it with a fragment that has exclusions (exarch's --extend) yields every program, less those exclusions. Such a policy restricts only through its exclusions: a layer that denotes no restriction, such as every with no exclusion or an fs that writes the root and excludes nothing, activates no launch hardening (§12.10). On Linux, a program created during a launch on the spine of an excluded path does not run until the next launch, and under a restricted fs the sandbox's private /tmp runs nothing.

Running reads. Whatever a grant lets run by path, directory or name, and whatever it lets be written, may be read: a layer reads its own read and write places and the programs that it and the grants enclosing it let run. An fs-only restriction therefore does not hide the tools an enclosing grant runs; to hide one, exclude it in fs, and an fs exclusion of a run place refuses running it. A nested grant never makes anything readable by naming it in exec. A program that runs only through an omitted exec must be readable under fs, and is refused otherwise: '/opt/x/foo' may run, but it is not readable under the active grant. A trusted interpreter outside the reads and the floor starts its scripts under a restriction that supplies fs alone, as under a restricted exec.

ral's own binary is an ordinary file: ral runs exactly where a grant admits it, by path, directory or name, in the in-process guard and the kernel alike, so under an allow-list that omits it ral -c, a #! ral script and sh -c 'ral …' are refused. A grant that admits the folder holding ral admits ral (reasonable admits ~/.cargo/bin/); a nested ral there is bound by the kernel alone, so bundled-tool rules and verbs do not bind it. A bundled tool runs inside the confined child and reads ral's image: an fs exclusion over ral's binary refuses every bundled tool, on every platform, and an exec exclusion of ral leaves bundled tools alone.

A selector may be the payload of dir:

  • `path — every absolute directory in the current $PATH; an entry that is the filesystem root, the home or an ancestor of the home is a freeze error, as for an override below, and so is one `absolute would refuse under the host's rules, or one that is not valid Unicode;
  • `system — ral's platform-defined system tool directories, without Homebrew;
  • a package manager, such as `rustup, `cargo, `go, `pyenv, `homebrew or `scoop. The implementation holds them as one table, and the checker's refusal of an unknown selector lists every name.

A package manager's selector is a set of locations: the directories it installs programs into and those its shims dispatch into. Each location has an ordered list of overrides, each a host environment variable with an optional subpath ($CARGO_HOME/bin), and its defaults, each anchored at an absolute path, the home, or an XDG base. A location is expanded in two steps:

  1. Choose. The first override with a usable value chooses, and is all the location has. A value is usable when it is non-empty and absolute under the host's rules; an empty or relative value is skipped, as path skips a relative entry. Where no override is usable, every default is chosen. For a variable that holds a path list (GOPATH) the override is its first entry.
  2. Keep what exists. Only the directories that exist when the grant is frozen are kept. An override that chose a missing directory yields nothing and does not fall back to the defaults: the environment said where the manager is, so a default it disowned is not granted.

A selector is the union of its locations, and a manager that is absent expands to nothing, never to an error. Overrides are read from the host process environment, as path reads $PATH, and never from the variables of a running shell or from a tool's own configuration files; a user who relies on such a file names the directory with `dir `absolute. A home default with no bound home is absent, and a default rooted on the other platform is skipped. A location is read by running (see Running reads above), so it is the narrowest subtree that holds what runs, and holds no credential.

An override whose chosen directory is the filesystem root, the home, or an ancestor of the home is a freeze error that names the variable and asks whether it is set correctly: running reads would make the selector a read of everything. The test is on the directory, not the variable, and follows symbolic links, so GOPATH=~, which chooses ~/bin, is accepted.

Four selectors, `homebrew, `macports, `nix and `pkgsrc, name system-wide package prefixes outside any home, and the read floor (§12.4) includes them wherever they exist, less any chosen directory inside the home: that is the user's, and is readable only where the grant says so.

12.4. fs

fs has three lists of places:

fs: [
    read:  [`cwd ., `xdg-data .],
    write: [`cwd build, `tempdir .],
    deny:  [`cwd .secrets],
]
  • read permits reads under the listed places.
  • write permits writes under the listed places.
  • deny forbids both reads and writes under the listed places.

name, dir and selectors are refused in these lists. Writing reads: a write allowance also allows reading what it names, in the layer that states it, so the in-process guard reads every write prefix as the kernels do. A present fs table with no read or no write entry grants nothing for that operation, for read nothing beyond the floor; fs: [read: []] keeps only the floor. Deny regions are carve-outs and accumulate across nested grants.

The floor is the platform's fixed set of read-only system paths that any program needs to start (§12.11). Every restricted read admits it, whether or not the grant names it, and the in-process check and the host sandbox agree on it. No nested grant narrows it; only a deny removes a floor path. It admits reads alone: never writes, never execution.

ral resolves the accessed path, including symlinks, before testing prefix membership. Allowance prefixes carry a symlink-resolved identity captured when the policy is decoded and keep it; exclusions are re-resolved on each check. Composition never flattens nested policies into one: each active layer keeps its own frozen prefixes, and a check folds the whole stack afresh — the allow region for a read or write is the intersection of every opining layer's admitted prefixes, the deny region is the union of every layer's carve-outs, and a denial at any layer wins over an allow admitted elsewhere in the stack. Point-of-use checks resolve the live path again. The discard device is always permitted: /dev/null on Unix, \\.\NUL on Windows. On Windows a path whose last component is a reserved DOS device name (NUL, CON, PRN, AUX, COM1–COM9, LPT1–LPT9, and COM¹–COM³ and LPT¹–LPT³ with a superscript digit, in any case and with any extension) is refused; \\.\NUL is the one device name ral accepts. A path opening with two separators that is no well-formed share or verbatim path is resolved as written, .. included: no lexical fold can tell where such a root ends, and folding across it could assemble a share or a drive the path never named. So is a verbatim path spelled \\?\ with backslashes alone: Windows hands its rest to the kernel uninterpreted, / a character of a name and . and .. names. One spelled with a / in its head, such as //?/C:/x, is normalised by Windows like any other path, and . and .. fold past its drive or share. On Windows a file operation on one whose head names neither a share nor a namespace (\\x, \\\srv\share, /\x) fails, rather than reaching a path on the current drive, and so does one that reaches such a path, or one relative to a drive's current directory (C:x), through a symlink's target.

ral checks filesystem operations that it performs itself. A spawned program’s own filesystem access is instead confined by the host sandbox where supported.

A deny may name a path that does not exist yet, inside a write prefix. On macOS the kernel refuses spawned programs its creation. On Windows nothing is granted that could reach it: the allowance is carved around it, and a name made in a carved directory during a run is unreadable to that run (§12.11). On Linux the kernel cannot refuse the creation of a single name, so ral’s own check alone holds it: ral never creates the path, but a spawned program can, and that program and the processes it starts can use what it created. Each external command is confined afresh, so every later command finds the path and is refused it, and what was created stays on disk. A symbolic link created at the path extends the deny to the link’s target.

One write no grant admits: onto a binary the sandbox pinned at startup — bubblewrap on Linux, ral’s own executable where it re-execs itself — judged by inode, so a hard link or a rename since startup names it too. The refusal names the binary and is not a grant denial. Replacing the file by rename is permitted; the session keeps running the copy it pinned.

12.5. net

net takes the one value `offline:

grant [net: `offline] { … }

`offline disables network access for spawned children through the OS sandbox. A grant only narrows, so there is no value that grants network access: omit net to inherit, and an outer `offline cannot be lifted. net: true and net: false are refused statically. ral has no in-process network primitive, so there is no separate ral-level network check.

If the host cannot enforce `offline, ral refuses the confined launch rather than silently running it online.

12.6. detach

detach is a verb of shell (§12.8): it controls whether the body may create a process that the session stops owning.

grant [shell: [`chdir]] {
    # detach is refused; spawn remains the session-owned alternative
}

A layer that supplies shell and omits detach denies the birth, anywhere in the active stack; a layer cannot override an outer one's omission. Writing shell: [`chdir] therefore withholds detach, the safe direction, since detach is the authority that outlives the session.

When detachment is allowed, the child is created under the effective filesystem and network projection and, on hosts that support it, the effective executable-path projection. That projection remains attached to the survivor for its lifetime. On Linux the survivor also keeps its envelope's process namespaces: the receipt's pid names the envelope, not the program; the survivor's own getpid() is local to its namespace; and from inside any later grant the survivor is invisible, reachable only through what it serves. detach itself is not part of the OS profile; it decides whether the survivor may be born.

The detach command is present only when the host installs it together with a birth budget.

12.7. Recorded decisions

A grant has no dimension for recording. Whether a capability decision is recorded is decided by the decision, not by the grant that made it: whenever an audit trail is open — from the language-level audit { ... } form or from batch --audit — every exec and ral-owned fs denial is recorded as a `check observation (§13.3), and no allowed check ever is. There is nothing to ask for and nothing to switch off, so no grant can hide a denial from a trail its caller opened.

An exec check identifies the command; a point-of-use check also carries its evaluated arguments and its resolved name, while an early head denial has no arguments yet. A filesystem check identifies the operation and the path. A denied head admission also reaches the surface rail, whether or not a trail is open.

grant is transparent to the audit model: it contributes no observation of its own. Its body's checks, command calls, and redirect reads and writes land directly in the trail that lexically owns them, and nested grants add no levels.

Entering a newly composed capability frame can also record a deputy / flagged check when an executable directory prefix is writable, since the body could replace or create a program there — one observation per flagged prefix, naming it in fields[prefix]. This finding reports the confused-deputy shape; it does not deny it.

The checks on net and on the editor and shell verbs record no individual observations.

12.8. editor and shell

editor and shell are lists of verbs, each a tag:

editor: [`read, `write, `tui]
shell:  [`chdir, `detach]

editor controls the private line-editor interface:

  • `read — inspect editor text, cursor, history, keymaps, parse state, and related state;
  • `write — change editor text or state and accept input;
  • `tui — open the editor TUI facility.

An _ed-* builtin needs the verb of every operation it performs, checked before its first effect; _ed-state needs both `read and `write.

shell controls what the body may do to the session itself:

  • `chdir — change the working directory with cd; within [dir: …] scopes a directory and is judged by fs alone;
  • `detach — create a process that the session stops owning (§12.6).

A verb is permitted only where every layer that supplies its list names it, so nested grants intersect. A list naming every verb is neutral, like omitting the field; an empty list withholds every verb, and an unknown verb is an error listing the verbs on offer. Under an extension, as exarch's --extend, the base's verbs are joined by union with the extension's: a verb list has no spelling for an exclusion, so an extension can add a verb its base lacks, a base cannot withhold a verb (detach included) against it, and a base that omits the field keeps every verb.

12.9. Concurrency and inheritance

A spawned worker receives a snapshot of the complete dynamic context, including the active grant stack. It cannot observe a later widening in its creator. Pipeline-stage children likewise inherit the active capabilities through their process boundary.

Authority does not flow back from a worker. A worker may narrow its own authority further, but finishing that inner grant does not change its creator’s stack.

External and bundled commands receive an OS-sandbox projection folded from the effective fs, net, and, where supported, exec policies at the moment of launch.

12.10. The two enforcement layers

ral uses two complementary enforcement layers:

  1. The in-process guard checks command admission, ral-owned filesystem operations, editor operations, cd, and detached-process birth.
  2. The OS sandbox confines filesystem and network actions performed directly by a spawned program. On macOS and Linux it also confines executable paths. It enforces the same rules as the guard: an exclusion beats every allowance there too, and no backend makes an exception for a carrier, the dynamic linker or ral's own binary.

The in-process command guard applies on every host, including when no OS sandbox is needed. It cannot see a command that an admitted child launches internally. Conversely, an OS sandbox cannot protect operations ral performs in its own process.

A directory that exec admits is also a place a child could author a program the grant never named. ral does not freeze such a directory against a broader write prefix that covers it: an allowance never withdraws write authority. A broad write over a run-admitted folder therefore lets a child write programs there, including ones the user later runs from their own PATH outside any sandbox, and a name exclusion beneath a writable directory, or beneath an omitted exec, is advisory to descendants. exarch's edit-only launched from the home directory can write ~/.local/bin. The deputy findings name every folder a grant leaves both writable and runnable, each writable island under a runnable folder and none that is wholly excluded on either side; they are recorded only into an open audit trail (§13), by pushing the profile as a session layer under an open trail, for example ral --audit --capabilities exarch/data/edit-only.exarch.ral -c true, which from the home directory records a deputy check for ~/.local/bin.

Dynamic loader injection variables such as LD_PRELOAD, LD_AUDIT, LD_LIBRARY_PATH, and DYLD_* are removed from child environments while a restriction is active. A stack is restricting when some layer denotes something other than no restriction: an exec that is not every program less nothing, an fs that does not write the root with no exclusion, net: `offline, or a verb list that omits a verb; a layer that restricts nothing ([], a policy that denotes everything, or full editor and shell lists) leaves ral ambient, and added to a restricting stack it changes neither the judgments nor the protections. Confined Unix children also receive resource limits; Windows confines process trees with a Job Object.

12.11. Host differences and limits

macOS. ral launches confined children under Seatbelt. Filesystem, offline-network, and executable-path restrictions are kernel-enforced. The executable allow-list also constrains programs launched internally by an admitted child. Since Seatbelt logs filesystem, executable, network, and Mach/IPC denials through one stream, a denial's diagnostic hint (§13.2) is classed by what was actually refused, never assumed to be a filesystem read merely because a hint has to say something. The read floor is /bin, /usr, /lib, /System, /dev, the Command Line Tools and Xcode developer directories, the system-wide package prefixes of §12.3 (`homebrew, `macports, `nix, `pkgsrc) that exist, the dyld cache directory, /etc wholesale, and the xcode-select and runtime-socket directories under /var; Rosetta's runtime is likewise runnable by every confined child.

Linux. ral uses bubblewrap for filesystem restrictions and net: `offline; on x86-64 and aarch64 every confined child also runs under a seccomp deny-set: kernel attack surface (ptrace, bpf, module loading, kexec/reboot/swap, keyrings, userfaultfd, open_by_handle_at, the mount family) is killed with SIGSYS, and user-namespace creation, setns, clone3 and ioctl(TIOCSTI) are refused with an errno — no mounting and no containers inside a grant. ral opens each path the envelope shows itself, never through a symbolic link, and has bubblewrap mount that open file rather than a name, so a path replaced by a link after the grant was read shows nothing the grant did not name, and one whose components became a link refuses the launch; this needs bubblewrap 0.8.0 or later, and under an older one no confined command runs. A grant that limits which programs may run is refused on a kernel without Landlock, and every confined command is refused on a kernel older than 5.9, which cannot close the descriptors ral hands the child before its program starts. A directory the exec grant admits is readable inside, as on macOS. The read floor is /bin, /usr, /lib, /lib64, the files under /etc that dynamic linking, name resolution, user lookup and toolchain resolution need, the system-wide package prefixes of §12.3 (`homebrew, `macports, `nix, `pkgsrc) that exist, and of /sys only what sizes a program (/sys/devices/system/cpu, /sys/kernel/mm/transparent_hugepage); the envelope binds it read-only. The rest of /sys describes the host — its class/net lists the host's interfaces whatever net says — and a grant that wants it lists /sys under read. Every confined child runs in its own ipc and uts namespaces and, wherever the host can mount a fresh procfs, its own pid namespace with its own /proc: on every projection it sees and can signal no host process. Wherever the kernel builds a cgroup namespace the child has one, and on every projection /sys/fs/cgroup is the tree its own /proc/self/cgroup names — ral's cgroup, so a runtime reads its real limits; where the kernel cannot, the child sees the host's tree. A container runtime that masks /proc prevents the pid namespace; the launch still runs, and the table is the container's own. Each other invariant the host cannot build is likewise absent while the launch still runs. A confined command has no controlling terminal. Bubblewrap has no path-based executable filter, so the confined child enters a Landlock domain of its own inside the envelope: executable-path restrictions are kernel-enforced on kernels with Landlock (Linux 5.13 and later), constraining programs launched internally by an admitted child as on macOS, and on Landlock 6 kernels (Linux 6.12 and later) the child can signal no process outside the envelope even where no pid namespace could be built. What the kernel gates is the path passed to execve. Landlock is allow-list only, so a denied path inside an admitted directory is held by admitting that directory's other entries, as they stand when the command launches: a program added there afterwards is refused until the next launch, and a bare-name deny under an admitted directory that a write reaches is held by ral's own check alone, since a child could author a renamed copy there. An excluded path that is a hard link to an admitted file runs, since Landlock admits inodes. Under every program with exclusions, a program created during a launch on the spine of an excluded path waits for the next launch, and the sandbox's private /tmp runs nothing. An admitted dynamic loader or interpreter invoked explicitly on an unadmitted file is refused by ral's own check but not by the kernel. An attenuated exec alone is enough to build the envelope, so a grant that narrows only which programs run is still kernel-enforced.

Windows. A launch whose filesystem region holds every path (exarch's dangerous with an exclusion from --extend, or a restrict file that only excludes) is refused before anything is stamped, since an AppContainer reaches only stamped folders and ral will not stamp every drive; the in-process guard still judges. A Windows sandbox cannot have access taken away beneath a folder it is granted, so an allowance holding an exclusion is carved at each launch: it is granted as its entries as they exist then, less the excluded ones, down to each exclusion, and each directory on the way is granted only the listing of its own names. An entry made in a carved directory during a run is unreadable to that run, the excluded name included; nothing can be created or renamed into a carved directory; a link among its entries is not granted or followed; and its entries' names and listed metadata are visible. A launch is refused whose exclusion lies on or around an allowance, the program it runs included, since nothing can be carved there. Every exclusion is then checked at each launch against the sandbox's own access, and the launch is refused, naming the object, if anything beneath the exclusion is open to it: an entry granted to every sandbox (ALL APPLICATION PACKAGES), or a hard link to a granted file. ral uses a projection-specific AppContainer token, filesystem capability SIDs, and a Job Object. AppContainer is deny-by-default: a child confined only to obtain net: `offline does not automatically retain ordinary access to the user’s working tree. Windows has no path-based executable filter, so an admitted program may execute another visible program internally. Network and UNC grant paths are unsupported. The read floor is %SystemRoot%, %ProgramFiles% and %ProgramFiles(x86)%, which AppContainer already reads. A host's ambient ALL APPLICATION PACKAGES grants may reach beyond the floor: the kernel is then wider than ral's own check, never narrower, and only a less-privileged AppContainer could close it. Filesystem capability entries are attached to NTFS objects; same-volume renames and hard links can therefore preserve or omit authority differently from a purely path-based rule.

On another host, a filesystem restriction fails when no per-command sandbox backend can be built, and net: `offline is explicitly rejected if kernel network enforcement is unavailable.

12.12. Capability profiles

A capability profile is an ordinary .ral script whose terminal value must be a capability record with exactly the same contract as the one grant accepts:

# read-only.ral
return [
    exec: [
        allow: [`name git, `name rg],
    ],
    fs: [
        read: [`cwd .],
    ],
    net:   `offline,
    shell: [`chdir],
]

Load one or more session-wide profiles with:

ral --capabilities read-only.ral script.ral
ral --capabilities base.ral,offline.ral script.ral

The loader parses, elaborates, type-checks, and evaluates each profile, then decodes that terminal value. Bindings created by the script are not projected into a policy as use bindings would be: the value itself must be the returned record, or () for a profile with nothing to set, which is the lattice top. return [...] is the direct way to make that contract explicit. A profile that ends in a scalar, a list, a block, or any other value is rejected.

A profile is ascribed grant's table after inference, as at the rc file (§15.3) and the plugin manifest (§15.5). return [exec: …, net: `offline] is a record, so exec's misspelling as exect is caught statically, before the profile script runs at all; so is a misspelled place tag, a bare string in a list of places, or net: true, since the nested tables are ascribed too. return ["exec": …] is a map, whose keys are data rather than the table's labels, and is refused statically with the spelling to use. A profile whose return the checker cannot type — one decoded from JSON — runs to completion first, and its keys are then checked against the same table at the moment the profile is decoded. Either way an unknown key or a badly-shaped value refuses the whole profile — the session never starts under it.

All listed profiles use the same load-time home and working directory. They are decoded by the same strict decoder as inline grants. Each decoded profile already contains frozen paths; the profiles are then pushed onto the session's capability stack in listed order, each its own permanent layer above the ambient root, rather than flattened into one composite policy — the ceiling they form is whatever the ordinary per-check fold of that stack decides. Later inline grants may narrow that ceiling but cannot widen it.

A profile computed in the language is a profile file, then, not an inline bundle: grant's bracket writes its fields, so grant $p[policy] { … } does not parse. grant [fs: [read: $paths], net: `offline] { … }, with computed values, does.

Compilation or runtime failure in a profile, a terminal value that is not a record, a map or () where a nested table belongs, unknown fields at any depth, a bare string where a place or exec target belongs, an unknown tag, verb, or net value, a tag with the wrong payload, an obsolete argument restriction (only, or a list of subcommands), a selector in an fs list, an unwrapped selector or a directory place in an exec list, a path-shaped name, a place whose payload is not absolute (`absolute) or not a relative subpath (every other tag), a ~ or xdg: payload, and an anchor that cannot be frozen are reported as configuration errors. Inline-record decode errors occur before the grant body runs; session-profile errors prevent the requested session or batch program from starting.

13. Audit, diagnostics, and testing

ral keeps three kinds of evidence distinct:

  • a runtime error is a value-like description of failed computation;
  • a diagnostic explains that failure to a person, usually against source text;
  • an audit trail records what ran, how it settled, and which bytes crossed its file descriptors.

The distinctions matter. A command may write arbitrary bytes to fd 2 without failing. A command may fail without writing any bytes to fd 2. ral's explanation of a failure is therefore not inserted into the command's captured stderr; it lives in an error record, a diagnostic, or an observation's separate error field.

Byte pipelines preserve the same separation: their edges transport bytes between stages, while diagnostics and audit data use their own channels. A pipeline edge is never an implicit diagnostic or audit channel.

13.1. Runtime errors

A runtime error has a message, a nonzero status, an optional source span, and an optional hint. It aborts the current computation until a construct such as try handles it.

fail raises an error from an error record:

fail [status: 7, message: 'index is stale']

The status field is required, must be an Int, must fit the process exit-code range, and must be nonzero. message is required and is a String. Extra fields are allowed and ignored by fail, which permits a caught error record to be re-raised directly:

try {
    refresh-index
} { |error|
    log $error[message]
    fail $error
}

Other argument shapes are errors. In particular, fail 7 and fail 'broken' do not abbreviate an error record.

try passes its handler a closed record:

[
    cmd:     String,
    status:  Int,
    message: String,
    site:    `some [script: String, line: Int, col: Int] | `none,
]

cmd is the name the failing dispatch stamped on the error, and <runtime> when no command was involved. site holds the script and one-based source coordinates, or is `none when no position is available. The record deliberately contains no output bytes. The failed command's raw fd 1 and fd 2 bytes have already followed their ordinary destinations; use audit when those bytes must also be retained as evidence. This is the record audit's `err outcome and poll's settled `err outcome carry, so failure reads the same wherever it is met.

Static checking rejects a literal zero status. A dynamically computed zero is rejected at runtime with a suggestion to use return for a clean result.

Errors raised by the evaluator use the same path as explicit failures. For example, a map is indexed by a key computed at runtime, so a key the map does not carry can only be discovered there; the miss is an ordinary runtime error naming the absent key. It is catchable by try and recordable by audit; it is not an implicit Unit, sentinel, or process crash.

13.2. Human diagnostics

The ral executable reports parse, type, and runtime failures on standard error. A diagnostic normally includes:

  • a stable category code;
  • the source name and a highlighted source range;
  • a concise message and label;
  • a secondary range or help text where it clarifies the repair.

Lexer diagnostics use L codes, parser diagnostics use P codes, type diagnostics use T codes, and runtime diagnostics use R codes. When a span cannot be resolved safely, ral prints the code, message, and optional help without inventing a caret in the wrong file. Source spans count bytes internally; diagnostics convert them to character positions before rendering.

For a one-command interactive input, a compact runtime diagnostic is often more useful than pointing back at the line just entered. It has this general shape:

error: MESSAGE (exit status N)
hint: REPAIR

The status suffix is omitted when the failure message already describes a process failure, and the hint line appears only when a hint exists. Errors in scripts, startup files, aliases, and other stored sources use the source-aware form instead.

A failure the OS sandbox reports for a spawned program is diagnosed from the kernel's own denial log over that call's window (§12.11), and the hint answers once for each class of denial the log actually contains, rather than assuming every denial is the same kind. On macOS, a denied filesystem read or write names the fully-resolved path — so a symlink inside a granted directory does not make the link's own directory sufficient — and the grant's matching read or write set; a denied executable names the path and the grant's exec allow-list, the only layer a re-exec such as sh -c or find -exec reaches; a denied socket names the net: `offline grant as the whole of the remedy; and a denied Mach or IPC lookup names the door itself, quoting the base profile's own recorded reason for withholding it where one exists — an unexplained door is still named, since it is usually a harmless probe rather than the failure's real cause. On Linux, the kernel log carries only the seccomp deny-set: a denied syscall is described by that deny-set's own reason where it has one, and otherwise named by number alone.

Colour is presentation, not meaning. It is disabled when the terminal cannot support it or the usual no-colour conditions apply.

Batch-mode exit status distinguishes static phases:

Failure Exit status
lexing, parsing, or elaboration 2
type checking 1
runtime error or explicit exit the resulting status, clamped to 0–255

Successful execution exits 0. Diagnostics go to standard error; they are never a payload and never pipeline traffic.

13.3. The audit trail

audit { body } evaluates body with a fresh lexical trail active and returns a report:

let report = audit {
    echo 'checking'
    verify-index
}

case $report[outcome] [
    `ok:  { |value| return $value },
    `err: { |error| echo $error[message] },
]

Auditing is observational. Captured output is teed into the trail while the same bytes continue to their ordinary fd 1 and fd 2 destinations. audit does not change where bytes go or turn a returned value into pipeline traffic. Process-staged pipeline fragments are transported back and merged into the surrounding trail; crossing a process boundary does not make a stage disappear from the trail that lexically owns it.

The report is not itself an observation. It has two fields:

[
    outcome: <`ok A | `err [cmd: String, status: Int, reason: Reason,
                            message: String,
                            site: `some [script: String, line: Int,
                                         col: Int] | `none]>,
    trail:   [Observation],
]

outcome says how the body settled. `ok carries the body's value. `err carries exactly the error record try hands its handler, reason included (§8.6), so one outcome vocabulary serves try, poll (§11.2), and audit: a reader who can read one can read all three. Its message is the runtime error's message alone — a hint is advice for a person, and audit returns data, so no hint is appended.

audit therefore handles an ordinary runtime error as data: evaluating the audit expression succeeds, with the failure in outcome. Control escapes are different — exit propagates out of audit rather than being converted into a returned report.

Two prelude functions read a report without a case:

Call Result
commands $report the `command payloads of $report[trail], in order
succeeded $report Bool, true exactly when $report[outcome] is `ok
let r = audit { grep -c ERROR app.log }
if !{succeeded $r} {
    echo !{bytes-to-string !{commands $r}[0][stdout]}
} else {
    echo 'search failed'
}

Settlement order

trail is the flat list of every observation the body's dynamic extent produced. It is not a tree: an observation carries no children of its own.

Observations appear in settlement order — each is appended when the fact it records becomes true, not when the construct that produced it began. So a command's observation lands after the observations of everything that command ran, and a redirect's write lands when the write settles rather than when the redirect was parsed. The trail is thus a post-order traversal of what happened.

Nesting is recovered from the timestamps, by containment: one observation's start–end interval contains another's exactly when the first's extent contained the second's. Containment is the only nesting the trail carries, and it needs no parent pointers, so a fragment transported back across a process boundary stays as readable as one recorded in place.

Observations

Every observation shares this common shape:

Field Type Meaning
site `some [script: String, line: Int, col: Int] \| `none one-based source position; `none for a run root
start Int microseconds since the Unix epoch
end Int microseconds since the Unix epoch
principal `some String \| `none shell principal when the observation was recorded; `none where nothing named one
what Variant which kind of fact this is, together with its own fields

There is no kind field. The tag of what is the kind, so a reader dispatches with case and the checker decides exhaustiveness (§8.3):

what: `command [argv: [String], status: Int, origin: <`external | `detached>,
                stdout: Bytes, stdout-total: Int,
                stderr: Bytes, stderr-total: Int,
                error: <`some String | `none>]
    | `write   [path: String, mode: <`write | `append | `stream>,
                outcome: <`committed | `aborted | `failed>,
                new-bytes: <`some Bytes | `none>,
                old-bytes: <`some Bytes | `none>]
    | `read    [path: String]
    | `grep    [scope: String, pattern: String]
    | `check   [resource: <`exec | `fs | `deputy>, decision: <`denied | `flagged>,
                fields: Map String]
    | `worker  [id: Int, title: String]
    | `act     [verb: String, subject: <`some String | `none>,
                payload: String, refused: Bool]

grep and act are raised by host doors — exarch's grep-files and its committed harness acts — not by any door in core itself.

A `command observation records an external or bundled command, or a detach birth:

Field Type Meaning
argv [String] the shown name first, then its evaluated arguments
status Int the outcome's status: 0 for a command that returned, otherwise its error's exit code
origin `external \| `detached the door the command came through
stdout Bytes retained prefix of fd 1, at most 1 MiB
stdout-total Int bytes written to fd 1, 0 without byte capture
stderr Bytes retained prefix of fd 2, at most 1 MiB
stderr-total Int bytes written to fd 2, 0 without byte capture
error optional String ral's runtime error message, `none when the outcome was not a runtime error

A command's returned value is not among them. argv, the status, and the bytes are the evidence a later reader can act on; a process-local or executable value has no honest projection, and an external command has no value to project.

A `write observation records a > / >> / >~ redirect settling:

Field Type Meaning
path String the redirect's resolved target
mode `write \| `append \| `stream how the redirect opened its target
outcome `committed \| `aborted \| `failed how the write settled
new-bytes optional Bytes the target's whole content after the write
old-bytes optional Bytes the target's whole content before it, empty for a new file

An optional field is a variant, `some of the value or `none. Both byte fields are always present: an absent image is `none, never a missing key, so a reader eliminates it with case rather than testing for a key, and "there was no before-image" stays a fact the trail states rather than one the reader infers from silence.

A write is observed when its body settles — or, one whose target never opened, as failed there — carrying its target before and after: each image whole, or `none when it cannot be read whole, being past 64 KiB, not a regular file, or not readable under the live grant. A write that did not land carries neither. A streaming write (>>, >~, 2>) is committed whatever the body did, since its bytes landed as they were written; only an atomic > can be aborted.

A `read observation — a < file redirect opening — carries only path. A `grep observation carries the scope searched and the pattern searched for. A `check observation records a capability decision and is described below.

A `worker observation records a session worker's birth; a gather's child is not announced (§11.1), so its birth records none:

Field Type Meaning
id Int the worker's registry identifier
title String the title it was born under

An `act observation records an act the host committed:

Field Type Meaning
verb String the act performed
subject optional String what it acted on, where the act names one
payload String the act's rendered argument
refused Bool true when the host refused the act

When an atomic > commit fails after the command itself exited cleanly, the `command observation reports the failed commit, not the child's own status: status is nonzero and error names the commit failure.

A `command's error field is present even on success, where it is `none. It is `some only from ral's runtime-error path; it is not copied from stderr, and a nonzero status need not imply a `some error. Conversely, stderr contains only bytes observed on fd 2. It never contains synthetic runtime prose merely because a command failed.

stdout and stderr each retain an exact prefix of at most 1 MiB; no synthetic marker is inserted. stdout-total and stderr-total count every byte observed at the tee, so a stream is complete exactly when its total equals the length of its bytes and truncated when the total is larger. A detach birth, a direct-spawn pipeline stage, or a command running without byte capture (CapturePolicy::Off) has empty bytes and total 0. Neither stream is privileged. These are retention rules, not I/O limits: every byte still streams to its ordinary destination. Ordinary value capture (§7) and worker buffers (§11.1) retain their separate 16 MiB limit.

Command observations cover external commands, bundled commands, pipeline stages, and detach births. Arguments are recorded after evaluation and rendered to strings as part of argv.

A builtin application is not an observation. The language's own value fragment — builtins, prelude functions, lexical bindings — performs no effect, and a builtin that reaches the world does so through a door the trail already watches: a redirect, an atomic write, a worker's birth, a capability check. The door records the effect there, as the `write, `read, `worker or `check it is, so == evaluated a million times inside audit { } adds nothing to the trail and a file edit lands once, as the write it made.

User-function application is transparent. Control forms and scopes, including if, case, within, grant, guard, and try, are transparent too. Collection iteration manufactures no wrapper observation, and the builtin that performs it records none of its own.

Commands, reads, and writes performed inside a transparent function, form, scope, or iteration remain visible. They land in the trail that lexically owns them rather than under a synthetic wrapper. A function that only computes and returns a value adds no observation at all. A nested audit returns its own report, while its own observations also merge into an enclosing trail.

try does not consult the trail. The name of the failing command is stamped on the error by the dispatch that failed, so the innermost failing dispatch wins, exactly as the innermost source span does. try therefore costs nothing beyond its own frame: it forces no collection, requests no byte retention, and adds no observation. Inside an enclosing audit, byte capture remains enabled — an inner try cannot silence an outer audit.

Capability checks

Capability decisions are recorded as `check observations whenever a trail is open. grant has no dimension for asking and none for withholding: the decision itself settles whether it is recorded.

  • A denial is always recorded. It is a fact about authority that nothing else in the trail attests.
  • An allowed check is never recorded. Everything it would attest is already attested by the command, read, or write it let through.

A denied head admission also reaches the surface rail, whether or not a trail is open. An fs or full-argv denial reaches the trail alone; the command it refused still surfaces in its own right, as a failed `command observation carrying the denial message.

A check's fields are:

Field Type Meaning
resource `exec \| `fs \| `deputy the resource class judged
decision `denied \| `flagged `denied, or the advisory `flagged; the resource's own, so a `deputy is never denied
fields Map String the resource-specific detail

A check is a leaf: no arguments, no I/O, and equal start and end timestamps. A denial is never encoded as a status, and it manufactures no raw stderr bytes.

The confused-deputy advisory (§12.7) records one `check per flagged prefix, with resource: `deputy, decision: `flagged, and fields: [prefix: …] naming the writable executable-directory prefix. It reports that shape; it does not deny it.

13.4. Batch audit JSON

For a script or -c program, --audit records the entire run and emits its report as JSON after execution:

ral --audit script.ral
ral --audit --pretty -c 'echo hello'

--pretty requires --audit. These flags are batch options for an explicit script or -c; they are not interactive or stdin-script options.

The top-level JSON object is the same two-field report as the language-level audit form's — outcome and trail — for the whole run rather than one block. trail is the run's flat list of observations, in settlement order. An ordinary runtime failure gives `err carrying the run's error record; an explicit exit at the root is a control escape rather than a failure, and gives `ok of Unit.

The report does not carry the process exit status. That status is the host's, --audit does not change it, and a second copy inside the record could only disagree with it.

The JSON projection favours legibility over round-trip fidelity:

  • Bytes become strings using lossy UTF-8 conversion;
  • Unit becomes null;
  • variants become objects with tag and, where present, payload;
  • executable or process-local values such as blocks, functions, natives, and handles become descriptive stubs.

The variant rule is what shapes the report: outcome reads as {"tag": "ok", "payload": …} or {"tag": "err", "payload": {…}}, and each observation's what as {"tag": "command", "payload": {…}} and its kin.

Compact mode writes one JSON document followed by a newline. Pretty mode writes the same data with indentation. Both write to fd 2. Program output is not suppressed: fd 1 remains ordinary stdout and raw program fd 2 bytes remain ordinary stderr. Consequently the JSON shares stderr with any bytes the program writes there, and with optional RAL_TIMING lines. fd 2 is not promised to be a standalone JSON stream.

On a runtime failure, --audit suppresses the separate human diagnostic because the report's `err payload already carries ral's explanation. It does not suppress the program's raw stderr. Parse, elaboration, and type errors happen before execution, so they produce their ordinary diagnostics and no report. The program's real exit status is preserved; --audit does not turn a failed run into a successful one.

13.5. Testing ral programs

The language does not reserve a privileged assertion form. Tests can build small assertions from ==, fail, and ordinary functions:

let assert-equal = { |name expected actual|
    if $[$expected != $actual] {
        fail [
            status: 1,
            message: "assertion failed: $name",
        ]
    }
}

assert-equal 'answer' 42 !{compute-answer}

Expected failures remain explicit:

try {
    parse-config 'broken input'
    fail [status: 1, message: 'parse unexpectedly succeeded']
} { |error|
    assert-equal 'parse status' 2 $error[status]
}

within [handlers: ...] supplies deterministic command doubles without changing global process state:

within [handlers: [fetch-clock: { |args| echo '12:00' }]] {
    assert-equal 'clock' '12:00' !{fetch-clock}
}

The handler receives the command's argument list, a List String. Per-name handlers follow the ordinary command-dispatch rules, including inside byte pipelines. A handler stands in for a command, and a command's value is its output, so a mock must be a faithful one: it writes what the real command would write, and the test decodes it the same way a real caller would — !{fetch-clock} captures the mock's line, and a decoder such as from-json reads a mock that emits structured output.

audit is the execution-observation testing surface. It can assert the observation sequence, statuses, raw output, capability denials, and source locations without scraping a human diagnostic, and succeeded and commands cover the common assertions without a case. Transparent language forms do not supply nesting assertions; their nested calls appear directly in the trail that owns them. Tests that compare output bytes should inspect a `command's stdout or stderr; tests that compare ral's explanation should inspect its error, or the message of the report's `err payload.

For executable-level checks:

  • ral --check or ral -n parses, elaborates, and type-checks a script without executing it;
  • ral --dump-ast parses and prints the debug AST to stderr without elaborating, type-checking, or executing it;
  • ral --dump-ir parses and elaborates, and prints the elaborated phrases to stderr without type-checking or executing them;
  • ral --audit exercises the real program and exposes its audit trail.

The repository's script harness discovers tests/**/*.ral, runs each runnable script through the real ral executable, and requires a zero exit status. For the portable deterministic subset, stdout is compared with a sibling .out file after ignoring carriage returns. RAL_BLESS=1 rewrites those goldens:

RAL_BLESS=1 cargo test -p ral --test scripts

Those harness conventions test this implementation; they are not additional language semantics.

14. Standard environment

Every normal ral program starts with two layers of public names:

  • core builtins, implemented by the runtime for primitive value operations, shell state, structured operating-system queries, codecs, and concurrency;
  • the prelude, an ordinary ral module loaded before user code and built from those primitives.

An embedding host may add a third layer. The ral REPL adds interactive commands; exarch adds agent tools and a small helper library. Host additions are not part of the portable core environment.

The standard environment is typed. A builtin that writes to standard output, rather than returning a value, says so in its type, Command, and the typechecker records that before execution.

14.1. Discovering the live environment

The running shell is the authority on the names it actually provides:

help
explain map
explain from-json

help prints sorted Language:, Builtins:, and Prelude: sections. The language section covers every reserved form and Boolean literal. The other sections use the builtin registry and the prelude's ## documentation, the same sources used to build the shell. When a host has installed documentation for a sourced library, help also prints a Library: section.

explain name prints the name's full documentation, inferred type or command signature where one exists, and where the name comes from. It understands language forms and literals, core and host builtins, prelude entries, documented host libraries, session bindings, aliases, handlers, and external commands. Language entries are short standalone usage guides. The renderer separates a conventional usage line from its prose and labels the facts it has as Usage:, Type:, Source:, and Shadows:; a missing fact is omitted. If no exact documented name exists, it searches documented names case-insensitively. Regex search is used when regex support is compiled in; otherwise the search is a substring match.

help is the canonical catalogue of documented standard and host-library entries, not a list of every command that might be reachable. Arbitrary aliases, plugin bindings, and programs found on PATH are dynamic. Use explain name to inspect one of those.

Names beginning with _ are implementation interfaces. They are hidden from help and are not part of the public scripting contract. Prelude code and plugins may use the particular private interfaces supplied for them; ordinary programs should not.

14.2. Values, collections, and comparison

The collection family includes:

  • each, map, and filter for applying a function to list elements;
  • fold for a left-to-right reduction;
  • sort-list and sort-list-by;
  • range start end, whose end is exclusive;
  • length, is-empty, keys, and has;
  • lt and gt.
let items = range 0 5
let squares = map { |n| return $[$n * $n] } $items
let names = keys $record

These are value operations: ordinary composition uses application and bind. Higher-order operations stop and propagate an error if their callback fails. Lists remain homogeneous, and the typechecker rejects a callback or accumulator with an incompatible type.

lt and gt compare numbers numerically and strings lexicographically, both of one type; unsupported or incompatible shapes are type errors. length and is-empty take text, bytes, a list or a map, and sort-list a list of numbers or of text. The predicate family returns Bool. A result of false is a successful computation, though it records status 1 for shell-style status inspection.

The prelude builds the familiar derived operations from these primitives: for, reduce, reverse, last, take, drop, take-while, drop-while, first-where, option-or, elem, contains, nub, zip, flat-map, enumerate, concat, sum, max, min, maximum, minimum, cross, group-by, median, and median-by.

14.3. Strings, numbers, and shell text

The string family includes upper, lower, dedent, slice, and intercalate. string-replace performs one literal replacement and fails unless there is exactly one match. Regex operations use the explicit re- prefix: re-match, re-split, re-find-match, re-find-matches, re-replace, and re-replace-all.

ral-quote renders a string as one ral word that reads back as that string: bare where it can be, quoted where a bare word would read differently, so '007' stays text. posix-quote and posix-split are the same pair for POSIX shell words, for sh and for tools such as fzf that split their options that way. All three are explicit conversions, not an invitation for ral values to be re-lexed automatically.

int, float, and str convert scalar values. round x places returns a Float, rounds halves away from zero, and accepts 0 through 308 decimal places. floor, ceil, and trunc accept a finite, in-range Float and return an Int; an Int is already integral and is rejected at the type level.

lines splits a string by the line rule of §7.3. The prelude adds words, from-words, indent, repeat, pad-left, and pad-right. It also supplies styled, ral's whole public ANSI surface: styled style text wraps text in the escape code named by style — reset, bold, dim, or one of red green yellow blue magenta cyan — and resets after; black, white, underline, reverse, and bold-colour variants are not supported styles. styled returns text unchanged when NO_COLOR is active, stdout is not a terminal, the selected interactive mode is minimal, or stdout does not support ANSI colour.

Regex names remain present in builds without regex support so source-level name resolution stays stable, but calling one then fails with a clear feature error.

14.4. Paths and filesystem queries

ral provides structured queries rather than requiring programs to parse the text output of ls or stat:

  • cwd, absolute-path, resolve-path, and abbreviate-home;
  • list-dir, file-info, and glob;
  • exists, is-file, is-dir, is-link, is-readable, and is-writable;
  • temp-dir and temp-file.

absolute-path is lexical: it resolves ~, ., and .. against the logical working directory without requiring the result to exist. resolve-path canonicalises through the filesystem, follows symbolic links, and requires an existing path. resolve-path reads .. as the filesystem does, after the symbolic links before it; a path ending in a separator names a directory, and a file or a link to a file under that name is refused, as the operating system refuses it; / is a directory every query may ask about. abbreviate-home is for display: it folds a leading home directory to ~. Filesystem reads and creations are checked against the active filesystem grant. temp-dir and temp-file return the path they create as a string, so they refuse a temp directory whose real path is not valid UTF-8, before creating anything in it.

A relative path is read against the working directory, which is always an absolute path, and on Windows one on a drive or a share: cd and within [dir: …] refuse any other, and ral does not start in a process that has none. On Windows two more spellings are relative, and read as Windows reads them. \x and /x name x at the root of the working directory's drive or share. C:x names x in the working directory when that is on C:, and otherwise in the current directory Windows keeps for C: in the ral process, or at C:\ when it keeps none.

A relative RAL_PATH entry is read against the working directory, and xdg: reads the XDG_*_HOME of the effective environment, within [env: …] included.

On Windows, every path ral returns or prints is /-separated, never \-separated: cwd, home, ~, $SCRIPT, absolute-path, resolve-path, list-dir and file-info names, glob matches, temp-dir and temp-file, and the paths named in error messages. There are two exceptions. A command written as a path is named in its error as the user wrote it, so a failed .\missing reports .\missing. A path that only the operating system can read literally, such as \\?\UNC\server\share\x or \\.\pipe\name, keeps its backslashes, because / is not a separator there; a \\?\ prefix that can be dropped without changing the path's meaning is dropped, and the rest is shown with /.

list-dir returns records rather than formatted columns. file-info uses link metadata and reports fields including name, type, size, timestamps, readonly state, and symbolic-link target. type is a tag, `file, `dir, `symlink, or `other; target is `some the link's contents for a symlink and `none otherwise. An unavailable timestamp is 0.

exists, is-link, and file-info judge a symbolic link at the link itself, never at the name it points to: a dangling link is judged and reported there, not as absent. exists and is-link both answer true for a dangling link, and file-info reports its own symlink type and target rather than failing as though nothing were there. is-file, is-dir, is-readable, and is-writable instead resolve through the link, so a dangling link answers false to each of them.

The prelude adds file-empty, line-count, and extension, the Option-valued extension of a path's last component: a dotfile such as .bashrc has none. line-count counts a file's lines by the line rule of §7.3, in bounded memory. from-lines < $path reads a file's lines as a list; for bounded-memory processing, redirect the file into fold-lines, map-lines, filter-lines, or each-line.

Filesystem mutations deliberately use command-shaped tools such as cp, mv, rm, mkdir, and ln. They are effects with byte-oriented command interfaces, not duplicate structured builtins.

14.5. Bytes, text, and lines

The codec families are the named boundary between values and bytes:

cat data.json | from-json
to-json $record > data.json

The core environment provides from-bytes, from-string, from-line, from-lines, from-json, from-jsonl, and from-csv, with corresponding to- encoders, and ints-to-bytes beside them for bytes given as numbers. Codecs do not silently reinterpret arbitrary values or output. Their exact UTF-8, newline, JSON, CSV, and capture rules are specified with pipelines and input/output.

fold-lines is the bounded-memory primitive for a byte pipe of UTF-8 lines. It splits by the line rule of §7.3, calls fn accumulator line, and returns the final accumulator. It does not first build a list, though the accumulator and callback may of course retain data themselves.

The prelude builds three bounded-memory filters on that primitive:

  • map-lines f calls f for each line and echoes the returned value as one output line;
  • filter-lines predicate echoes the original line when the predicate returns true;
  • each-line f calls f and discards its returned value, while any bytes the callback itself writes continue through the active byte pipe.

Use them with a byte pipe or redirect, for example map-lines $clean < input.txt. Calling a line reader with terminal stdin and no pipe or redirect is an error rather than an interactive prompt.

from-lines and from-jsonl are decoders like the rest: they read to end of input and return a list, of the lines and of each non-blank line decoded as JSON respectively. to-jsonl writes each element of a list as to-json encodes it, one per line.

14.6. Failure, session control, and concurrency

The core session family includes fail, exit and quit, cd, alias and unalias, use, ask, echo, warn, surface, clear, reset, help, and explain. All of them are builtins except echo, which is a base handler: it takes an argument list, writes each argument's text conversion separated by single spaces, and ends with one newline.

fail raises a recoverable failure from exactly one error record:

fail [status: 7, message: 'unavailable']

The status field is required, must be an Int in the supported exit-code range, and must be nonzero. message is required and is a String. Additional fields are ignored, which lets fail $err re-raise the record supplied by try. Passing a bare integer, string, bytes value, a record without an integer status or a String message, or status 0 is an error. exit and quit leave the current program rather than producing a recoverable failure.

warn message takes one String and writes it, followed by one newline, to standard error. It returns Unit and puts nothing on the byte channel, so a let capturing the computation never sees it. This is the whole of ral's diagnostic surface: there is no redirect that sends standard output to standard error.

surface value sends a first-order structured event to a host that installed a surface sink; without one, it safely produces no visible event. ask reads from the controlling terminal rather than redirected standard input and fails on end-of-file.

Concurrency primitives are the spawn and gather forms and await, poll, first-settled, race, cancel, worker, and workers. The prelude adds is-done, par, both, first-of, attempt, and succeeds. Exact ownership, cancellation, output capture, and lifetime rules are specified with concurrency.

14.7. Host and build additions

The portable contract is the core registry plus the prelude. A host may install more names before user code is typechecked; those names then appear in that shell's help and explain output.

The ral command-line host provides watch. Its interactive form also supplies plugin-lifecycle commands and private editor interfaces. A plugin may install validated aliases, hooks, and keybindings; those are session extensions, not portable standard names.

Exarch installs its agent operations and a documented ral helper library. It does not provide watch. On supported Unix hosts it may also provide detach, whose process deliberately outlives ral and cannot be awaited or cancelled through a handle. These host-owned names must not be assumed in a portable ral script.

Builds may bundle external-style utilities. The coreutils feature provides a curated cross-platform set including ls, cat, wc, head, tail, cp, mv, rm, mkdir, sort, and related tools. Unix builds may add id, kill, stat, tac, test, and timeout; separate features add cmp, diff, or rg. A bundled bare name wins over a program of the same name on PATH, but it still runs as a child executable image with the current cwd, environment, byte pipes, redirects, status, and capability restrictions.

The standalone ral build does not guarantee bundled coreutils; ordinary external lookup remains available. Exarch enables its bundled tool set for a more self-contained environment. Platform-gated tools are absent rather than registered as commands that can never run.

15. Interactive use and extensions

Interactive editing is a service of the ral program, not part of the core language. A portable ral program may rely on values, commands, byte pipelines, types, and the core builtin surface. It must not assume that its host has a prompt, history, completion, plugins, or editor builtins.

The ral executable adds those facilities when it creates an interactive session. It installs their builtin signatures before checking startup files or prompt input, so the type checker sees the exact surface that the session can execute. Another host may install a different surface or none at all.

15.1. Entering the interactive shell

With no script or -c argument, ral starts an interactive session when standard input is a terminal. Otherwise it reads standard input to end of file and runs it as a batch script.

ral                         # interactive when stdin is a terminal
ral -i                      # force an interactive session
ral -s                      # read a batch script from stdin
ral --surface minimal       # choose the canonical-input frontend
ral --surface readline      # choose the full line editor
ral --norc                  # skip every startup file

-s takes precedence over -i. A positional script takes precedence over both. --surface overrides the RC setting of the same name.

The shell collects lines while the parser reports incomplete syntax. It joins continuation lines with newline characters and evaluates the result as one top-level run. Abandoning a continuation discards the whole partial input. A parse error, type error, or runtime error is reported and the next prompt is shown; exit ends the session.

The interactive evaluator is the ordinary language evaluator. In particular, a byte pipeline still transports bytes at every interior edge, while ordinary application and bind compose values. The frontend does not reinterpret the pipeline operator.

After a successful run, Unit prints nothing and Bytes are written without a prefix. Other values are displayed with the REPL renderer. Lists and maps use the bounded pretty-printer; other values use their ordinary display form. A list of two or more records sharing one field sequence prints one record per line, each field padded to its column's width, so a workers result reads as a table and still pastes back as the same list. The default prefix is => and the default value colour is yellow where colour is supported.

15.2. Frontends

The ral executable provides two interactive frontends:

Surface Contract
minimal Canonical standard-input reads and a > continuation prompt; no raw mode, completion, editor keybindings, ghost text, or highlighting.
readline The default full editor, with history, completion, vi or Emacs editing, plugin keybindings, ghost text, and highlighting.

Terminal capability is stronger than preference. A dumb terminal, or RAL_INTERACTIVE_MODE=minimal, forces the minimal surface even when another surface was requested.

The full frontends complete variables, command-position names, and paths. Variable candidates come from live non-private bindings. Command candidates also include installed builtins, handlers, and executables on PATH, and the working directory's subdirectories, offered with a trailing /. Relative paths are resolved from ral's logical working directory. Matching is by prefix, ignoring case unless the typed text has a capital; candidates extending the typed text are listed alphabetically; only when none does in the whole request does matching fall back to fuzzy, best first. A new binding is visible at the next prompt.

History is persisted in the ral configuration directory, with a legacy ~/.ral_history fallback. Consecutive duplicate entries are omitted. Saving appends the current session's new entries instead of replacing the file, so concurrent sessions do not erase each other's history.

In a full frontend, Ctrl-C at the prompt abandons the current input and redraws; Ctrl-D on an empty prompt exits cleanly. Ctrl-D within non-empty input performs the editor's ordinary delete operation. Ctrl-C while a command is running interrupts that foreground run. Unix Ctrl-Z is specified with concurrency (§11.6): a stopped process is resumed at once, so Ctrl-Z has no visible effect; ordinary application and bind have no process group to stop at all. Windows has no such signal.

15.3. Startup files and RC configuration

A login session reads these optional profiles in order:

  1. /etc/ral/profile
  2. ~/.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:

  1. $XDG_CONFIG_HOME/ral/rc, normally ~/.config/ral/rc
  2. ~/.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 "!{abbreviate-home !{cwd}} ❯ " },
    env: [EDITOR: 'vim', PAGER: 'less'],
    bindings: [work: '/srv/work'],
    aliases: [ll: { |args| ls -lh ...$args }],
    theme: [value-prefix: '⇒ ', value-color: cyan],
    startup: { echo 'ready' },
]

The recognized fields are:

Field Meaning
env: Record Set environment entries and bindings. PWD and OLDPWD are ignored: ral derives PWD from its logical working directory and keeps no OLDPWD.
prompt: Block Install the zero-argument base-prompt body.
bindings: Record Install lexical values, including functions.
aliases: Record Install block or function values as command aliases; other values become ordinary lexical bindings.
edit-mode: String emacs or vi; default emacs.
bell: Bool Enable or disable the audible line-editor bell; default false.
surface: String minimal or readline; default readline.
recursion-limit: Int A positive function-call recursion limit; default 100_000.
plugins: Record Names each plugin and its options; loaded before the first prompt; see below.
startup: Block A zero-argument block run once after the map is applied.
theme: Record value-prefix: String and value-color: String. The colour is one of black, red, green, yellow, blue, magenta, cyan, white, or none.

The eleven fields above are the RC file's whole keyset, and it is closed: an unknown top-level key is an error naming the key and the list. The check is over the file's inferred return row, not over its syntax, so a key misspelled inside a spread is caught exactly as one written out is:

let extra = [surfase: 'minimal', env: [:]]
return [...$extra, env: [:]]        # refused — 'surfase' is not an RC key

The table is ascribed to the file once it has been inferred, as ML ascribes a signature. The return must be a record, or (): return ["edit_mde": 'vi'] is a map, whose keys are data, and return [] the empty list; each is refused before the file runs, with the spelling to use. A file whose return the checker cannot type — return !{from-json < rc.json} — meets the same keyset, and the same field types, when the value is applied instead: an unknown key or a wrongly-typed value both name the mistake and refuse the whole rc, agreeing with the static check above — the shell starts with defaults. An unknown key inside theme produces a warning rather than a refusal.

return [edit_mde: 'vi'] never runs the rest of the file: a misspelling is a compile error and the rc is skipped before its first statement executes. A file with nothing to set returns (), because there is no empty-record literal (§4.5) and [] is the empty list.

The same discipline holds of the other two files a host reads against a fixed keyset — a plugin manifest (§15.5) and a capability profile (§12.12) — and of the options within and grant take. Each is one declared table, and no label may be declared at two different types across all of them: an optional field is absent at the type its label is assigned, so a label has one such type across a whole check. A build whose tables break that is refused, naming both tables and the label.

The prompt body may return a value, whose display form becomes the prompt. If it returns Unit, its captured standard output becomes the prompt, with one trailing newline removed. Prompt failures are reported and fall back to ❯. Plugin prompt hooks then transform the base prompt in plugin load order.

Parse, type, read, and runtime failures in a startup file are reported without preventing the interactive shell from starting. exit stops the current startup file and boot continues.

RC and profile evaluation is trusted session bootstrap. Command-line --recursion-limit, --surface, and capability profiles are applied afterward, so the command line wins and the session capability ceiling narrows the resulting shell.

15.4. Discovering the installed surface

help lists language forms and literals, installed public builtins, documented prelude bindings, and any library documentation supplied by the host. Names beginning with _ are hidden. The lists are sorted.

explain name shows the entry's full documentation, inferred or declared type where one exists, and where it comes from. For a language entry the full documentation is a standalone usage guide. Available facts are labelled Usage:, Type:, Source:, and Shadows:. When no exact entry resolves, explain performs a case-insensitive search and prints matching public entries. These commands are informational: a missing query prints a message and leaves status zero.

Because help is assembled from the live shell, it describes host additions such as watch and load-plugin only where they are actually installed.

15.5. Plugins

A plugin is an ordinary ral file. Its file-level result is either a manifest Map directly or a factory taking exactly one options Map and returning that manifest. No other result shape is accepted.

let change = { |event|
    let line = $event[line]
    if $[not !{is-empty $line}] { _ed-ghost ' …' } else { _ed-ghost '' }
}

return { |options|
    let key = get $options key 'ctrl-g'
    return [
        name: 'example',
        hooks: [buffer-change: $change],
        keybindings: [[
            key: $key,
            handler: { |_event| _ed-insert 'hello' },
        ]],
        aliases: [hi: { |args| echo 'hello' ...$args }],
    ]
}

The manifest schema is:

[
    name: String,
    hooks?: Map<String, Block>,
    keybindings?: List<[key: String, handler: Block, guard?: String]>,
    aliases?: Map<String, Block>,
]

name is required. The other fields default to empty collections, and those four are the manifest's whole keyset: an unknown top-level key is an error naming the key and the list. A manifest the file returns is ascribed the table before the file runs, as at the rc file (§15.3): return [nam: 'example'] is a record, so the misspelled name is caught statically and the plugin file never runs, and return ["nam": 'example'] is a map, refused statically with the spelling to use. return () lacks name, and is refused for that. One a factory returns is checked as it is parsed, against the same table. Each declared field is checked exactly: ral does not stringify a value of the wrong type or silently drop a malformed handler. Hook and keybinding handlers each take exactly one argument. Unknown hook names, wrong handler arity, invalid key notation, invalid guard regexes, and alias conflicts are load errors. Validation and registration are atomic; a rejected load leaves no hooks, keybindings, or aliases installed.

Plugins run with the authority of the live session and current grant stack. A manifest capabilities: field is rejected because it would not enforce confinement. Use grant around an invocation that needs narrower authority. Plugin options must be a first-order map: they cannot contain blocks, handles, or other non-transportable values.

load-plugin name-or-path searches in this order:

  1. the ral/plugins subdirectory of the user configuration directory;
  2. each directory in RAL_PATH;
  3. the literal path;
  4. the literal path with .ral appended.

The interactive command supplies an empty options map to a factory. A direct manifest rejects non-empty options. To configure a factory, load it from the RC file:

return [
    plugins: [
        autosuggestion: [:],
        fzf-files:      [key: 'ctrl-t'],
    ],
]

Each key in plugins: is a plugin name or a path to a plugin file; a bare hyphenated name needs no quoting, but a path must be quoted. Each value is that plugin's options map, forwarded verbatim to the plugin's top-level block as its single argument; [:] is the empty map, for a plugin taking no options. A plugin whose value is not a map is reported by name and skipped; the other plugins still load. unload-plugin name removes that plugin's hooks, keybindings, and aliases. Loading a duplicate plugin name or unloading a name that is not loaded is an error.

Plugin aliases occupy the alias namespace. A collision with an existing alias is a load error. A lexical or native command with the same name is permitted, but the alias underneath it is then unreachable: a bare head hits the lexical binding or native first, and ^name is the external of that spelling, not the alias. explain name still reports it, under shadows:.

15.6. Hook and keybinding events

Every plugin hook takes exactly one argument, and registration checks this arity. In particular, lifecycle fields are not passed as separate positional arguments: pre-exec, post-exec, and chpwd each receive one event record. The recognized hooks are:

Hook Argument and result Time
buffer-change [old: String]; returns Unit After the text or cursor changes.
complete [words: [String], current: Int, offset: Int]; returns [String] or [[replacement: String, display: String]] When completions are requested, before the editor's own.
pre-exec [src: String]; returns the line to run (String), or Unit Before one complete prompt input is evaluated.
post-exec [src: String, status: Int]; returns Unit After evaluation settles.
chpwd [old: String, new: String]; returns Unit After an input line that leaves the session working directory changed.
prompt The current prompt String; returns a String Before each prompt render.

The value of a buffer-change, post-exec, or chpwd hook is ignored. The prompt hook is deliberately different: it is a string transformer, read like the session prompt (a returned value, else the captured standard output), so each successful result becomes the next plugin's input.

Hooks run in plugin load order. A failed lifecycle hook is reported but does not prevent later plugins' hooks from running or replace the command's status. A fault is attributed plugin 'name' hook 'event'. Prompt transformers compose: each successful result becomes the next hook's input, while a failed hook leaves the current prompt unchanged. The faults of one render print together, once while they repeat.

pre-exec hooks fold over the input line: each receives the line the previous one returned, and the last line is the one evaluated. A String result replaces the line and Unit leaves it. A failed hook is reported and a result of any other type is reported with a warning; both leave the line as it was, so no hook can stop every line. A line is refused by rewriting it to a failing one, such as "fail [status: 1, message: 'refused']"; its status reaches post-exec through the ordinary evaluation. post-exec's src is the line that ran.

complete runs when completions are requested for a buffer that lexes, with the record _ed-parse returns. Each candidate is a String, standing for itself, or a [replacement, display] record. The first plugin in load order whose hook returns a non-empty list claims the word: its candidates replace the text from character offset to the cursor, unfiltered, and no later hook runs. An empty list or () passes to the next plugin and finally to the editor's own completion, and so does a result of any other shape, with a warning. The hook runs aside: its writes to the buffer, ghost text, and highlights are discarded.

buffer-change and complete run on the editing path, so ral protects the session from a persistently bad handler, braked separately for each plugin and hook. A single invocation that exceeds its budget (100 milliseconds for buffer-change, 500 for complete), or three consecutive failures, disables that plugin's hook for the rest of the session and prints a notice.

A keybinding handler receives [key: String, mode: String]: the binding's key notation, and the editor's input mode at the press, insert, command, or replace (insert throughout emacs). A hook reads the editor only through the _ed-* builtins.

A key notation is one or more chords separated by spaces:

notation := chord (' '+ chord)*
chord    := (modifier '-')* key        each modifier at most once, any order
modifier := ctrl | alt | shift
key      := one character | f1…f12 | tab | enter | escape | up | down
          | left | right | home | end | delete | backspace

A modifier is a modifier only while something follows its dash, so ctrl-- is ctrl with -. shift applies to the keys that are not characters; for a character the shifted character is itself the key, so A and not shift-a. ctrl with an uppercase letter is refused, being the same key to a terminal as the lowercase one; so are ctrl-h, ctrl-i, ctrl-m, and ctrl-[, which a terminal sends as backspace, tab, enter, and escape, and a control character written as the key. Each refusal names the spelling to use. A sequence of several chords must begin with ctrl-x, the prefix the line editor leaves free for sequences. Ctrl-C and Ctrl-D, with no other modifier, are reserved and cannot be bound, alone or in a sequence.

Keybindings form one ordered table: plugin load order, then manifest order, then the frontend's built-in action. A guard is a regular expression matched against the text left of the cursor. A binding claims its keys only when the guard matches. Every unmodified key except F1 through F12 already has an editor action and therefore requires a guard. Modified chords and function keys may be unguarded. A binding that can never fire loads with a warning naming the binding that claims its keys first: an earlier unguarded binding on the same keys, or an unguarded binding on a proper prefix of its sequence.

The minimal frontend does not dispatch buffer-change or complete hooks or editor keybindings and cannot display ghost text or highlights. Plugin aliases and the pre-exec, post-exec, chpwd, and prompt hooks remain session facilities and do not depend on raw-mode editing.

15.7. The editor plugin interface

The _ed-* builtins are installed only by the interactive ral host and are hidden from help. Except for the pure _ed-hyperlink formatter, they require the corresponding editor.read, editor.write, or editor.tui authority. They also require an active plugin handler; calling one at an ordinary prompt or from a batch host is an error.

Cursor and span offsets count Unicode characters, not UTF-8 bytes.

Builtin Contract
_ed-get Return [text, cursor, keymap]; keymap is emacs or vi, the configured keymap. The text left of the cursor is slice $st[text] 0 $st[cursor].
_ed-set [text:keep|set Str, cursor:keep|set Int] Replace text and/or cursor; `keep leaves a field unchanged and an out-of-range cursor is clamped.
_ed-set-lbuffer text Replace text left of the cursor, preserving the right side.
_ed-insert text Insert at the cursor and advance past the insertion.
_ed-push Save the current buffer on a stack and clear it for another command.
_ed-accept Execute the resulting buffer immediately when the handler returns.
_ed-history prefix limit Return matching history, most recent first; zero means no limit.
_ed-parse Return [words, current, offset] for the simple command at the cursor, open blocks read as closed: words[current] is the word the cursor touches, or a spliced-in '' when it sits in whitespace, and offset is that word's start. words is empty when the buffer does not lex.
_ed-ghost text Set suggestion text; an empty string clears it.
_ed-highlight spans Replace highlight spans of shape [start, end, style]; an empty list clears them.
_ed-state default updater Read, transform, store, and return one persistent cell belonging to the plugin.
_ed-tui block Suspend the editor, run the block, and return [output: String, status: Int].
_ed-clipboard text Copy text to the system clipboard, natively when local, else by OSC 52; return whether all of it landed.
_ed-hyperlink uri text Return an OSC 8 hyperlink where supported, otherwise return plain text.

_ed-push and _ed-accept may be combined to preserve the current input while running a generated command. Ghost text and highlights are display state; they do not become part of the input buffer.

_ed-tui : ∀ν α. {ν α} -> Returns [output: String, status: Int] runs the body with its standard output captured while allowing an interactive child to use the terminal. The body's value is ignored; the captured bytes are decoded as text, lossily, with one trailing newline removed, and supply output. A body failure is returned as data in output and status rather than raised. _ed-tui opens only from a keybinding handler, the run that holds the terminal; from any other hook, and nested inside another _ed-tui, it is reported as status 1.

Terminal presentation remains capability-dependent. Clipboard emission may return false; hyperlink formatting may return plain text; colour, ghost text, and highlights may be suppressed by the selected surface or terminal. These fallbacks do not change language evaluation.

15.8. Host and platform limits

The following are promises of the ral executable, not requirements on every ral host:

  • persisted history, completion, terminal titles, and the three frontends;
  • startup profiles and RC discovery;
  • plugins, _ed-*, load-plugin, and unload-plugin;
  • terminal signal handling and crash-time terminal restoration.

Foreground terminal ownership depends on Unix process groups and a controlling terminal; Windows has no such handoff and supervises pipeline members as one console job instead. The minimal frontend uses canonical input and offers no raw editor surface. Batch execution and embedded hosts may omit all these facilities; portable code must discover optional installed commands rather than assuming them.

16. Invocation, interoperability, and platforms

The ral language and the ral executable are separate contracts. The language defines ral source, values, byte pipelines, and effects. The executable chooses where source comes from, prepares a session, reports diagnostics, and maps the result to an operating-system exit status.

Direct invocations always parse ral syntax:

ral
ral build.ral release --clean
ral -c 'echo $[2 + 2]'
ral -s < build.ral

ral -c does not accept POSIX shell syntax. Use ral-sh (§16.7), ral invoked by that name, as the registered login shell when programs may call $SHELL -c with POSIX source.

16.1. Invocation modes

ral selects one of four modes:

  • ral starts 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 -s reads ral source from stdin to EOF and runs it as a batch.

On Unix, a bare invocation with non-terminal stdin also selects stdin-script mode:

generate-ral-source | ral

Reading source from stdin consumes that byte stream to EOF before execution. It is therefore already exhausted if the resulting program later tries to read its runtime stdin. File and -c modes do not consume stdin; the program may use it as a byte pipe.

-i forces the interactive session even when stdin is not a terminal. -s forces stdin-script mode and takes precedence over -i. A script positional takes precedence over both.

After SCRIPT, every remaining token is a script argument, even if it begins with -. After -c, the first token is the complete source string and every later token is an argument. ral inserts the option terminator internally, so these are equivalent:

ral task.ral --force
ral -- task.ral --force
ral -c '--version'

Script arguments are returned by args. Arguments are text: one that is not UTF-8 is refused with status 2 rather than altered. $SCRIPT is a lexical string containing the source file’s path. That path is text too: a script, module, startup file, or plugin whose path is not UTF-8 is refused, naming it, before it is read. It is available in script files, including sourced modules under their own names, but is rejected in the REPL, -c, and synthetic preloaded source.

16.2. Batch processing

A batch run parses, elaborates, typechecks, and only then evaluates. Typechecking is always performed because it also annotates the program: each computation a value demand captures is wrapped in its capture, and each pipeline stage records its type.

The batch-only inspection flags are:

  • -n, --check — parse, elaborate, and typecheck without executing;
  • --dump-ast — parse and print the syntax tree to stderr without elaborating or executing;
  • --dump-ir — parse and elaborate, and print the phrases to stderr without typechecking or executing; at most one of --check, --dump-ast, --dump-ir;
  • --audit — execute and print the run's audit report as JSON on stderr;
  • --pretty — pretty-print that JSON; it requires --audit.

These flags require a script path or -c; they are not accepted for a bare interactive or stdin-script invocation.

The checker and evaluator receive the same batch builtin surface. A command cannot pass --check by being treated as one kind of builtin and then run as another.

The emitted JSON is one report for the whole batch run, its trail a flat list in settlement order. `command observations exist only for external or bundled command executions and detach births; a builtin application records nothing of its own, its effects landing through the write, read, and worker doors (§13.3). Function application, control forms such as if, case, try, guard, within, grant, audit, spawn, and gather, and loop iterations record nothing of their own either (a session spawn's birth lands through the worker door); the commands executed inside them land in the open trail directly. Each observation carries its evaluated arguments, status, source location, timing, and principal, plus the bytes it captured.

Batch stdout and stderr remain ordinary byte streams. A program's final ral value is not an operating-system pipe protocol: interior pipeline stages exchange bytes, while final values are reported through the ral result boundary. Explicit adapters perform any conversion between values and bytes.

16.3. Results, diagnostics, and exit status

Parse and elaboration failures print source-labelled diagnostics and exit 2. Type errors exit 1. Failure to read the script file exits 1. A malformed or unreadable --capabilities profile exits 2.

At runtime:

  • a normal completion exits 0;
  • exit N returns N;
  • 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:

  1. /etc/ral/profile, if present;
  2. ~/.ral_profile, if present;
  3. the ordinary rc file.

A login profile is ral source run for its effects and must return Unit. Returning a map or another value is reported as an error; interactive startup continues.

Every interactive session then reads $XDG_CONFIG_HOME/ral/rc or ~/.ralrc. The rc file must return a configuration map. If neither file exists, ral may create a default rc skeleton and reports the created path. Parse, type, runtime, or return-contract errors are reported, no invalid configuration map is applied, and the user still receives a shell. An exit from a startup file stops that file without terminating the session.

Startup evaluation is not transactional. A read, parse, or type error prevents that file from running, and an invalid RC result prevents configuration-map application, but bindings and effects produced before a runtime failure, exit, or return-contract check are not rolled back. In every case boot continues to the interactive shell.

--norc, also accepted as --noprofile, suppresses login profiles and the rc file. Command-line --recursion-limit and --surface values are applied after the rc and therefore override it. Session --capabilities profiles are also applied after startup files: startup is operator-trusted setup, and the capability profile becomes the ceiling for subsequent work. A relative profile path names a file in the directory ral was started in, even when a startup file or an earlier profile changes directory.

16.5. Interactive surfaces

--surface minimal or --surface readline selects the frontend. Without the flag, the rc setting is used, then readline by default.

RAL_INTERACTIVE_MODE=minimal forces the canonical-stdin frontend regardless of the selected surface. An unknown value warns interactively and falls back to automatic probing.

16.6. Compatibility flags and environment

For tools that pass traditional shell flags blindly, ral accepts:

  • -e — accepted with no effect;
  • -u — accepted with no effect;
  • -i, -s, and -l — with the meanings above.

ral seeds a stable dynamic environment at boot. Inherited values win; a variable the host does not bind at all takes a default for PATH, SHELL, TERM, and LANG. ral reads the environment as text: a variable whose value is not UTF-8 is absent from env and from ral's own lookups, and reaches children with its bytes untouched. HOME, USER, and LOGNAME are seeded from the host alone: where the host binds none, the variable stays unbound rather than taking an invented value, and ~ is then an error naming HOME rather than a directory. It increments SHLVL and supplies OS_NAME, OS_ARCH, and OS_FAMILY. Recognised terminal and multiplexer variables are retained when present.

PWD and OLDPWD are not exposed through env. ral owns the working directory as shell state so parallel work cannot race through the process-wide current directory. Each external child receives the correct PWD and actual launch directory; an inherited OLDPWD is removed, since ral keeps no previous directory.

RAL_PATH is a platform-separated list used to find modules and plugins. RAL_TIMING, when present, prints batch phase timings to stderr.

On Unix, ral refuses a setuid invocation (effective and real user id differ) before it does anything else — before argument parsing, and before any of the four modes above is selected. The refusal is therefore unconditional: it does not depend on which mode the arguments would otherwise name.

16.7. ral-sh: the POSIX bridge

ral is intentionally not POSIX-compatible. Registering ral itself as $SHELL can break ssh host command, scp, rsync, Git-over-SSH, editors, multiplexers, and other programs that expect $SHELL -c to parse POSIX syntax.

On Unix, install ral-sh as the login shell instead. ral-sh is ral invoked by that name, a symlink installed beside it: the name in argv[0] is the one bit a login shell's caller preserves. Under that name ral reads no source before choosing a target; the first matching rule over the arguments after argv[0] applies:

  • any short-option cluster containing c, or any argument that is neither such a cluster nor --login (a positional, --, -, an opaque --long option), goes to /bin/sh, so -lc … and -l script.sh both do;
  • otherwise a short-option cluster containing l or i, or --login, goes to ral;
  • otherwise no arguments with both stdin and stdout attached to terminals goes to ral;
  • every other invocation goes to /bin/sh.
/usr/local/bin/ral-sh -lc 'printf "%s\n" ok'  # /bin/sh
/usr/local/bin/ral-sh -l                       # ral

The /bin/sh branch forwards arguments without requiring UTF-8, and replaces the process with the literal path /bin/sh: no PATH lookup. If the invoked name begins with -, it preserves that login-shell convention as -sh. The ral branch does not exec: the same process continues as ral, which sees the leading - itself. Failure to replace the process exits 127. The setuid refusal applies first, under either name.

16.8. Platform distinctions and limits

Unix. Terminal detection selects a bare REPL or stdin-script mode. Login-shell argv[0], process-group foreground ownership, immediate resume-on-stop (§11.6), and ral-sh are Unix facilities. Signals are translated into cancellation and conventional process statuses; the shell restores terminal ownership after foreground children, and on exit hands the terminal back to the process group it started in.

Windows. A bare invocation defaults to the interactive mode because the CLI does not infer stdin-script mode from Windows terminal state; use -s for source redirected on stdin. ral enables virtual-terminal processing where available. Unix process groups and stop signals do not exist. Direct execution refuses .bat and .cmd images, named so once Windows drops trailing dots and blanks (run.bat., run.bat), because their additional cmd.exe quoting pass has no safe general argument encoding; invoke cmd.exe explicitly only if that risk is acceptable. ral-sh is a Unix login-shell bridge and is not a usable Windows shell dispatcher.

On every host, external commands communicate through operating-system byte pipes. A pipeline has no typed-value wire format: values cross only through the final ral result boundary or ordinary application/bind, and explicit adapters perform conversions between values and bytes. Paths, executable lookup, path-list separators, default system paths, and sandbox enforcement otherwise follow the host rules described in their respective sections.

17. Formal language definition

This section collects the implemented core language in a compact notation. It is descriptive, not a claim of soundness, completeness, progress, or preservation. The parser, typechecker, annotated IR, and evaluator remain the authorities when this presentation is ambiguous.

Metavariables used below are:

  • x for an identifier, w for a word, s for a string, and n for an integer;
  • p for a binding pattern, v for a value term, and M, N for computations;
  • A, B for value types, C, D for computation types, and ρ for a row;
  • T, E, H for values that are thunks, which a branching form forces.

17.1. Concrete grammar

The lexer supplies words, quoted strings, interpolation segments, tags, redirect tokens, newlines, and punctuation. Its mode is set by the innermost open delimiter: inside [...] and $[...] newlines are whitespace and commas punctuate, and inside $[...] alone the spellings <, >, <=, >=, !=, &&, ||, +, -, *, /, %, =, and == are operator tokens of their own, ending the word before them, a numeral being read whole. A newline or ; separates statements. The shared stage parser requires every stage to end at a statement boundary, |, ?, a closing delimiter, or end of input; juxtaposed same-line statements are not admitted.

The following EBNF omits lexical escape details and source-span bookkeeping. NL denotes a newline and sep denotes NL or ;.

program       ::= sep* (statement sep+)* statement? sep*
statement     ::= binding | chain

binding       ::= "let" pattern "=" NL* chain
chain         ::= pipeline ((NL* "?" NL*) pipeline)*
pipeline      ::= stage ((NL* "|" NL*) stage)*

stage         ::= (return | conditional | case | scope-form | command)
                  redirects

return        ::= "return" atom?
conditional   ::= "if" NL* atom NL* atom
                  (NL* "elsif" NL* atom NL* atom)*
                  (NL* "else" NL* atom)?
case          ::= "case" NL* atom NL* "[" case-arm ("," case-arm)* ","? "]"
case-arm      ::= tag-key ":" atom
tag-key       ::= "`" identifier

scope-form    ::= "try" atom atom
                | "guard" atom atom
                | "within" options atom
                | "grant" options atom
                | "audit" atom
                | "spawn" atom? atom
                | "gather" atom
options       ::= "[" "]"
                | "[" option ("," option)* ","? "]"
option        ::= identifier ":" atom

command       ::= head (argument | redirect)*
head          ::= "^" bare-name | atom
argument      ::= atom | "..." atom

atom          ::= primary index*
primary       ::= word-value | block | collection | unit-literal
word-value    ::= lexical-word
                | quoted-string
                | interpolated-string
                | variable
                | expr-block
                | force
                | tag
index         ::= "[" word-value "]"        /* adjacent to a target other than $(name) */
force         ::= "!" variable index* | "!" primary
variable      ::= "$" identifier | "$(" identifier ")"
expr-block    ::= "$[" expression "]"
expression    ::= operand (infix-op operand)*    /* by the precedence below */
operand       ::= "(" expression ")" | "-" operand | "not" operand | atom
tag           ::= "`" identifier atom?

block         ::= "{" program "}"
                | "{" "|" pattern+ "|" program "}"

collection    ::= list | record | map
list          ::= "[" list-items? "]"
list-items    ::= list-item ("," list-item)* ","?
list-item     ::= atom | "..." atom
record        ::= "[" record-entry ("," record-entry)* ","? "]"
record-entry  ::= static-key ":" atom | "..." atom
map           ::= "[:]"
                | "[" map-entry ("," map-entry)* ","? "]"
map-entry     ::= map-key ":" atom | "..." atom
map-key       ::= identifier | quoted-string | interpolated-string | variable

unit-literal  ::= "(" ")"

pattern       ::= "_" | identifier | list-pattern | map-pattern
list-pattern  ::= "[" pattern-list? "]"
pattern-list  ::= pattern ("," pattern)* ("," "..." identifier)?
                | "..." identifier
map-pattern   ::= "[" map-pattern-entry
                  ("," map-pattern-entry)* ","? "]"
map-pattern-entry ::= static-key ":" pattern
static-key    ::= identifier | quoted-string

redirects     ::= redirect*
redirect      ::= fd? ">" word-value
                | fd? ">>" word-value
                | fd? ">~" word-value
                | fd? ">&" fd
                | fd? "<" word-value
                | fd? "<<" word-value

A redirect list binds each stream at most once (§7.4); the parser refuses a second binding. For <<, an explicit descriptor, if present, must be 0. For >&, the source descriptor must not be 1 when the target is 2: 1>&2 and its short spelling >&2 are rejected in favour of warn (§7.4). [] is the empty list and [:] the empty map. Otherwise a collection's entries decide: any data key, "…" or $name, makes it a map, labels alone a record, no entry at all a list (§4.5). A pattern's keys are labels only, since it cannot bind through data. A tag is no key at all: tag-key occurs only in a case arm.

A case's arm list is a production of its own, and no expression may stand in its place: a spread among the arms and a repeated tag are both rejected, and the list may not be empty. This is what makes the set of alternatives a fact the parser establishes (§8.3).

The atom after an if condition, after else, and after a case tag is an arm, and an arm is a block or a name: a literal { … } or { |p| … }, or a $name. Any other atom would be hoisted and run before the form chose, so it is refused, with the advice to bind the block first (let arm = $arms[a]).

Which lexical words are literals rather than names is the numeral grammar of §4.1, and the expression grammar inside $[...] reads numbers by that same rule.

A plain identifier in head position enters ordinary name dispatch. A word containing a path separator (§3.5) or a ~/ path is a direct path head. ^name requests external-name lookup. A literal word, a bare ~, a $[...] block, and every other atom are value heads. With no arguments or redirects, a value head is simply its value.

The parser curries a multi-parameter block:

{|p q| M}  =  {|p| {|q| M}}

Each pattern binds all its names simultaneously and may not bind one name more than once. A list rest pattern is terminal.

? and | admit newlines on either side.

An operator spelling inside $[...] is a token of its own, not a word.

Inside $[...], expressions have the following precedence, from low to high:

||
&&
== != < > <= >=
+ -
* / %
unary - and not

Binary operators associate left. && and || short-circuit. An operand is any atom of the value grammar — a numeral, a quoted string, a variable, an indexed value, a forced block, a collection — or a parenthesized expression; () is the unit atom, not an empty group. $[...] therefore adds no node of its own to the tree: it is the lexical mode in which the operators can be written, and which values each operator accepts is the type system's question (§4.8 for ==; arithmetic and ordering want numbers). One case is settled earlier: a bare word that is not a numeral is a string here as everywhere, so under -, arithmetic, or ordering it can never typecheck, and ral refuses it at parse time, asking whether $name was meant.

17.2. Core terms

Surface syntax elaborates into a call-by-push-value IR. Its essential value and computation forms are:

v ::= unit | true | false | n | f | s | bytes
    | [v1, ..., vn] | {l1 = v1, ..., ln = vn}
    | `l | `l v | thunk M | handle A

M ::= return v
    | force v
    | lambda p . M
    | M v
    | M to p . N
    | exec h [v1, ..., vn]
    | M1 | ... | Mn
    | M1 ? ... ? Mn
    | if v T E
    | case v of {`l1 H1, ..., `ln Hn}
    | M1 ; ... ; Mn
    | letrec {xi = vi}
    | scope op
    | capture M
    | decode v

op ::= try v1 v2 | guard v1 v2 | within v1 v2 | grant v1 v2 | audit v
     | spawn [v1] v2 | gather v
     | redirect M redirects

T, E and each Hi are values, thunks the form forces when it chooses one. Every scope op but the last takes its body as a thunk, because a scope installs an effect before the body runs, spawn runs its own on a worker, and a value is what can be held unrun. redirect takes the computation itself: its body is an application or a nested scope, neither of which can fuse its own redirects the way exec does.

capture and decode are the two forms no program can name: the checker writes them and nothing else does (§17.5). They are core terms all the same, because they survive elaboration and the evaluator matches on them.

Command calls elaborate either to exec, which enters command dispatch, or to application when the head resolves to a bound value. A trailing redirect on an application or scope form becomes a redirect scope; redirects on exec remain part of the atomic command launch.

An if arm is a thunk of the computation it runs, forced in place. A case arm is a thunk of one argument: case applies the chosen arm to the variant's payload. A literal { |p| M } and a name holding one are the same value, so both spellings reach the same arm.

Blocks elaborate to thunks. A parameterized block elaborates to a thunk whose computation is a function. Adjacent recursive name bindings whose right-hand sides are blocks or lambdas elaborate to one simultaneous letrec group.

17.3. Types and rows

Value and computation types are:

A, B ::= Unit | Bool | Int | Float | String | Bytes
       | List A | Map A | Record ρ | Variant ρ
       | U C | Handle_ε A | α

ρ ::= · | l : A, ρ | r

C, D ::= F_ε A | A -> C | β       ε ::= p | w | ε̂

Map A is a homogeneous string-keyed map. Record ρ is a row-typed record; Variant ρ is a tagged sum over the same row machinery. Bare record labels and tag labels occupy distinct alphabets and do not unify. A nullary variant has payload type Unit.

U C classifies a suspended computation. F_ε A classifies a computation that produces an A, and carries a grade ε saying how: F_p A produces the value A, and F_w Unit produces output and nothing else. A command — an external, a builtin that writes, or a handler arm standing in for one — has type F_w Unit, printed Command; F_p A is printed Returns A, and a grade variable prints from the alphabet ν ξ ο π, so retry reads ∀α ν. Integer -> {ν α} -> ν α. So { echo hi } is {Command} and { 'hi' } is {Returns String}, and { echo pre; return 5 } is {Returns Integer}: a grade says whether a computation's result is its output, not whether it writes. A computation of grade w has value type Unit. A grade is atomic: a grade variable is bound to p or w and to nothing else, so grades are quantified like any variable but are never weak and need no occurs check. A type Command A does not exist. Handle_ε A is a suspended producer: await of it is an F_ε A, and it prints as the producer it suspends, Handle (Returns A) or Handle Command (§11.2).

The checker is Hindley-Milner with value-, computation-, and row-level unification variables. Let-bound names are generalized against the current environment and instantiated freshly at use sites. A variable marked weak is treated as free in the environment: the mark is permanent for the unit, is inherited by any variable unified with a weak one, and is never quantified, so every use of the name shares one type for it. There is no value restriction. A scheme that leaves a weak variable unquantified keeps it weak in later runs, where it is re-seeded as a fresh weak variable. A boundary — a door through which a value of a shape the program did not decide enters typed code: the decoders, use, worker, the exarch host's answers, _ed-state — makes the variables of its result weak and records the result type on the call. Once the unit is solved that type is frozen as a site on the call, which the door admits the value against: each position's head, each free variable's kind, and for one comparable variable, number or text, agreed across the unit. A session data binding with weak residuals is admitted again, against this unit's uses, before the unit runs. Recursive groups are inferred against monomorphic self-bindings, then generalized after those self-bindings leave the environment. Runtime-created top-level bindings carry their closed schemes into later runs.

A value-type variable carries a kind: a set of admissible heads and a deep bit. A row variable carries a deep bit alone. The unconstrained kind any has every head and is shallow; five are named, and a scheme prints them ∀α:number. …:

number       Int, Float                          + - * /, unary -
comparable   Int, Float, String                  < > <= >=, lt, gt, sort-list, int, float
scalar       Bool, Int, Float, String            each interpolation part
sized        String, Bytes, List, Map            length, is-empty
data (deep)  every head but U and Handle         == !=, str, the encoders

% is Int -> Int -> Int. Two variables unite at the meet of their kinds, the heads both admit with the deep bits or-ed, and an empty meet is a static error (T0074, which cites both uses); a meet with a single nullary head binds the variable to it. Binding a kinded variable to a structure requires the structure's head to be admitted, and a deep kind imposes data on every component: a list or map element, the fields of a record and the payloads of a variant, and, through the tail row variable's deep bit, every field later added. A kinded variable still free at the end of a unit needs no default, since every admissible type supports the operation. A label read v[l] on a variable is decided by the kind when it admits Map and not Record (sized), or the reverse. A computed index c[k] is the one deferred relation over a container and its key, c = [e] ∧ k = Int ∨ c = Map e ∧ k = String: the head of c or of k, or a kind that admits one disjunct and not the other, selects it, and an integer literal key is the list case outright. Its three variables are weak, so they are shared by every use in the unit, and one still undecided when the unit ends is a static error (T0075); a key that is neither Int nor String is refused by kind (T0074).

The principal judgments are:

Γ ⊢v v : A                         value inference
Γ ⊢c M : C                         computation inference
Γ ⊢p p : A ⇒ Γ'                    pattern binding
Γ ⊢ M ⇝ C ; K                      inference with constraints K
K ⊢ C1 ~ C2                        unification

Types are regular trees, and every cycle crosses a data edge: a list element, a map element, a record field, or a variant payload. The edges U, ->, F and Handle are not data, so binding a variable to a structure that reaches it along those edges alone is refused, as in w $w, or a function that returns itself. A cyclic type prints as μβ. Map β.

A failed constraint records both the source span at which it was introduced and a provenance reason, such as argument application, pipeline stage shape, conditional branches, fallback branches, try arms, scope body, handler shape, a case arm's payload, or the handler a case arm names. Diagnostics render the structural mismatch together with that provenance; provenance does not change the unification relation.

Representative rules are:

Γ ⊢v v : A
---------------------------- Return
Γ ⊢c return v : F_p A

Γ ⊢v v : U C
---------------------------- Force
Γ ⊢c force v : C

Γ, p:A ⊢c M : C
---------------------------- Lambda
Γ ⊢c lambda p.M : A -> C

Γ ⊢c M : A -> C    Γ ⊢v v : A
-------------------------------- App
Γ ⊢c M v : C

Pattern inference recursively constrains list elements to one element type, record fields to their labelled types, and a list rest name to List A. A binding generalizes its bound value as §17.5's Let says.

Heads and commands. A bare head c has one class, decided by the lookup order of §6.3 and §9.5, which the runtime dispatches in too:

class(c) = binding(σ)     c is a lexical or session binding, of scheme σ
         | row(σ)         c is a builtin value row, of scheme σ
         | arm(c', σ)     a handler is in scope for c, standing in for c' at σ = standsFor(c')
         | external       otherwise, and always for ^c, ./p and ~/p

A row's scheme says what it produces. The rows that write — the encoders (to-json, to-string, to-line, to-lines, to-jsonl, to-csv, to-bytes, ints-to-bytes), echo, help, explain, clear and reset — end in Command, and a row that writes only when applied at its full arity: to-json alone is a function, and to-json $x is a command. The rest end in Returns A, with warn among them, since standard error is not the byte channel. A few rows are polymorphic in the grade: each, fold, fold-lines and audit run a body of either grade, stream its output and keep its value; watch, like the spawn form, keeps the body's grade in the Handle_ε A it returns, and await and race give it back (§11.2); fail, exit and diverges inhabit every grade. A handler does not change the answer, since an arm stands in for the head it names and keeps that head's class; an arm for the name of a value row is never reached, because builtins resolve first.

A command takes an argv and produces output:

class(c) ∈ {external, arm(external)}    Γ ⊢ ā : argv
----------------------------------------------------- Ext
Γ ⊢c c ā : F_w Unit

class(c) = row(σ)    σ ⊑ A1 -> ... -> An -> C    Γ ⊢v ai : Ai
--------------------------------------------------------------- Row
Γ ⊢c c a1 ... an : C

Γ ⊢ ā : argv says every element renders as text, and a spread spreads a list. A redirect of standard output discharges the grade: if Γ ⊢c M : F_ε A then M > f has type F_p A, because the bytes have gone to the file and the computation's value is what remains. Stdin and stderr redirects, and 2>&1, leave the grade alone.

17.4. Pipe composition and sequences

A | is an operating-system byte wire fixed by stage position. A stage feeds the next by writing, so every stage but the last has type F_ε Unit for some grade ε, and is not a decoder; the pipeline's value is the final stage's. A stage after a pipe binds no standard input at its own root.

Γ ⊢c Mi : F_εi Unit    Mi is no decoder    (1 ≤ i < n)    Γ ⊢c Mn : F_ε A
Mi binds no stdin at its root, for 1 < i ≤ n
------------------------------------------------------------------------------ Pipe
Γ ⊢c M1 | ... | Mn : F_ε A

A decoder (from-bytes, from-string, from-line, from-lines, from-json, from-jsonl, from-csv, from-json-at) returns a value and writes nothing, so it ends the byte pipeline. It is refused as a non-final stage by its own mark, before its type is read: for a boundary ∀α. F α unification would otherwise set α := Unit and let cat data.json | from-json | wc -l through to fail at the decode. '3' | cat and [1, 2, 3] | f are refused because a value in stage position writes nothing to the pipe; each { |x| echo $x } $xs | cat is accepted, since each streams what its body writes. A stage whose type is A -> C is still waiting for an argument and is rejected as well, and the diagnostic says to apply it rather than pipe into it. A stage whose root carries < f or << value is rejected: the feed answers that stage's reads for its whole run, so the producer before it writes for nobody (§7.4). Each premise reads one stage, never a pair.

Operationally, M | N:

  1. connects stdout(M) to stdin(N) with an operating-system byte pipe;
  2. runs both stages under the process discipline of §7.1;
  3. 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:

  1. Bind. The whole right-hand side of a let, a named pattern or M to x . N has type F_w Unit: it is captured, so let x = M is let x = M | from-line for every M that is a command, whatever its form — a block, a call, a variable holding a block, a pipeline, an if with command arms.
  2. Argument. An argument whose type ends, after its own parameters, in F_w Unit, handed to a function parameter that ends in F_p β at the same arity, is coerced and β is String. A literal block has its innermost body captured; a block in hand is wrapped in a lambda that captures its call. This is how map { |f| echo $f } [1, 2] is ["1", "2"].
  3. Join. An arm of type F_w Unit whose join settles on a value (§17.6) is captured like an argument.

Nowhere else is a coercion inserted: a command flowing into a bare variable, or a demand that resolves only later, leaves an ordinary mismatch whose hint names the fix. A tail Run is never coerced.

The bind rule:

Γ ⊢c M : F_p A    Γ, p : Gen_Γ(A) ⊢c N : C
------------------------------------------- Let
Γ ⊢c let p = M ; N : C

Γ ⊢c M : F_w Unit    Γ, p : String ⊢c N : C
--------------------------------------------- Let-cap
Γ ⊢c let p = M ; N : C        elaborated  ⌈M⌉ to p . N

Γ ⊢c M : F_ε̂ A      ε̂ := p, then Let

A let is a demand for a value, which is the only place a grade variable is defaulted: let k = { |g| g 1 } is ∀α ν. {{Integer -> ν α} -> ν α} and defaults nothing, while let v = g 1 with g of that parameter type fixes ν := p. A grade disagreement elsewhere is a mismatch of Command against Returns A.

The user-facing rule is §6.8's: a command's value is its output; a let binds it.

Gen_Γ quantifies the variables, grade variables included, that are neither free in Γ nor weak (§17.3). A top-level let is a bind; a top-level run is a statement, tail included, and its own report is never captured. The elaborator's hoists are ordinary binds, so let x = cat f | grep "a$p" captures the pipeline, and "today: !{date +%F}" interpolates the date. let _ = cmd captures and drops: a surface _ is a fresh name, not a discard.

A right-hand side of type A -> C is a function: evaluating it builds a closure, and the binder receives U (A -> C).

A discarded statement is never captured on its own. The whole captured computation is one buffer, so let x = !{ echo a; echo b } prints nothing and binds "a\nb", while let x = !{ let y = echo a; echo b } prints nothing and binds b, the inner let capturing a. A stand-in dispatched for the captured command runs whole under the capture: under let x = curl, the arm curl: { |a| echo note; echo body } binds "note\nbody".

audit absorbs its body: audit { grep -c ERROR app.log } has type F_p Report(Unit) and outcome `ok (), while the bytes its body wrote sit in the trail's observations. each, fold and fold-lines absorb a command body likewise: they run it, stream its output, and keep its value, which for a command is (). spawn and watch do not absorb: the handle keeps the body's grade, so let x = await $h binds what a command block wrote, as let x = { … } would.

17.6. Branch and variant rules

Every form that takes a suspended command takes a thunk, and forces the one it chooses. A literal { … } is that thunk, so if $c { echo hi } else { () } and if $c $yes else $no are one rule; an arm is a block or a name, and any other atom is refused at elaboration (§17.1). The arms are joined by the rule below:

Γ ⊢v v : Bool    Γ ⊢v T : U C    Γ ⊢v E : U C
---------------------------------------------- If
Γ ⊢c if v T E : C

Γ ⊢v v : Variant(`l1 : A1, ..., `ln : An)    Γ ⊢v Hi : U (Ai -> C)    (1 ≤ i ≤ n)
--------------------------------------------------------------------------------- Case
Γ ⊢c case v of {`l1 H1, ..., `ln Hn} : C

Γ ⊢v T : U C    Γ ⊢v H : U (Error -> C)
---------------------------------------- Try
Γ ⊢c try T H : C

Here C is the join of the arms' result types R1 ... Rn, each taken after the arm's own parameters. Every arm is inferred first, against a fresh result; then, in program order:

  • if some arm is F_p A with A not Unit (or not yet known), the join is F_p A and every F_w Unit arm is captured (§17.5), so try { fetch } { |e| return 'none' } is F_p String;
  • otherwise, if some arm is F_w Unit, the join is F_w Unit, every F_p Unit arm is upcast, and grade variables become w, so if $c { echo $x } else { warn skip } is a Command;
  • otherwise the arms unify.

if $c { hostname } else { echo b } is one Command, captured once by an enclosing let. The join is decided in program order and holds no pending constraint, so it is principal for the rule as stated but not independent of statement order: { |t| if c { echo hi } else { !$t }; let w = !$t; $w } gives t the type {Command}, where the same block with the let first gives {Returns String}.

guard, within and grant are typed likewise (§17.7). Additional elsif arms nest, each else holding a thunk whose body is the next if. A conditional without else wraps its lone arm as { body; () }, so the whole form is a ()-producer, and whatever the body writes goes to the current output. A fallback chain M ? N is try M { |_| N }, so its arms join in the same way.

if $c { echo hi } else { () } and try { rm $f } { |_| () } type as they stand, as commands. if $c { echo big } else { 'small' } is a String, the command's output captured. if $c { echo big } else { return 5 } is refused, String against Integer, and the hint names the command arm: one arm is a command, whose value is its output; return a string there, or end the command arm with ; return (). The empty arm of a captured conditional is written { '' } or { () }: let x = if $c { echo hi } else { () } binds "" when the empty arm runs.

The Case rule closes the scrutinee's row to exactly the arms' tags, which the syntax fixes: coverage is therefore decided wherever the rule fires, and both a tag the arms miss and an arm for a tag the scrutinee cannot carry are static errors. An open scrutinee row instead absorbs an arm label it has not been seen to construct — that is principal row inference (§17.3), not a gap in the coverage proof. case applies the chosen arm to the payload, () for a nullary tag.

17.7. Scope signatures and stand-in arms

Let Error be the closed error record {cmd:String, status:Int, message:String, site:S}, where S = `some {script:String, line:Int, col:Int} | `none — the one vocabulary try, poll, and audit all report failure in. The scope forms take their bodies as thunks and are typed by them:

body : U (F_ε A)
------------------------------ Within / Grant
within opts body : F_ε A
grant  caps body : F_ε A

body    : U (F_ε A)
cleanup : U (F_ε' B)
------------------------------ Guard
guard body cleanup : F_ε A

body : U (F_ε A)
------------------------------ Audit
audit body : F_p {outcome : <`ok A | `err Error>, trail : List Observation}

title : String    body : U (F_ε A)
------------------------------ Spawn
spawn [title] body : F_p (Handle_ε A)

body : U (F_ε A)
------------------------------ Gather
gather body : F_ε A

spawn's title is optional; the handle keeps the body's grade (§11.2). gather passes its body's type through, grade included: joining its workers is a run-time effect that changes no type (§11.3).

try is §17.6's rule. within, grant and guard have their body's type, grade included. guard's cleanup runs for its effects and failures alone: its value is discarded, what it writes goes to the current output, and its type is not joined with the body's. The scope-body, handler-shape, and arm-result constraints retain ordinary diagnostic provenance and source spans.

within additionally types known option fields and installs schemes for literal handler entries while checking its body. grant types known capability fields. Unknown, spread, or dynamic option keys remain subject to runtime decoding.

An arm stands in for the head it names, and is checked against what that head is. standsFor(c) is the scheme of the base frame or arm already in force under c, and [String] -> F_w Unit for anything else:

standsFor(c) = σ                      c is a base frame, or already has an arm, of scheme σ
             = [String] -> F_w Unit   otherwise

Γ ⊢v V : U (standsFor(c))            Γ ⊢v V : U (String -> [String] -> F_w Unit)
------------------------ Arm        ------------------------------------------- Catch-all
Γ ⊢ [c : V] ok                       Γ ⊢ [handler : V] ok

So an arm for an external stands in at [String] -> Command, and its value is its output: curl: { |args| echo '{"ok":1}' } is an arm, and curl: { |args| '{"ok":1}' } is refused, since it returns a String where it should write. An arm that returns () is admitted as a ()-producer upcast to a command, and under let x = curl it captures "". An arm for echo, a base frame that writes, has echo's own scheme. An arm for detach, a base frame that returns, has detach's scheme and returns a receipt. A call through an arm types as its head does. The catch-all stands in for every command in its block, so { |name args| cd $name } is valid. An arm installed at run time, by alias or from a bundle, is checked by the same rule when it is installed.

17.8. Operational outcomes

Evaluation is a small-step machine, presented here at the level the rest of this section uses; the implementation is exactly this shape, one Rust match arm per rule. A state is a focus over a stack of frames, stepped against a store:

⟨M, Γ⟩ / κ, Σ  ⟶  ⟨N, Γ'⟩ / κ', Σ'
R / κ, Σ                          (κ nonempty: meets its top frame next step)
X / κ, Σ                          (climbs to the first frame whose rule catches it)
  • The focus is either a closure ⟨M, Γ⟩ — a computation paired with the environment its free variables read — a terminal R meeting the frame atop κ, or a break X climbing past it.
  • κ is a stack of frames, one per enclosing to, 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 Σ.
  • R has two shapes, value v or lambda ⟨M, Γ⟩ — a λ-closure standing for an unapplied function, since lambda p.M is never a value (§17.2). Only an Apply frame consumes a lambda by β; every other frame's hole wants a value, and meeting a lambda there is a case the checker excludes by η-expanding every arrow-typed computation into a thunked λ.
  • X has two shapes, error e and escape 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. try and ? 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.