ral — design rationale

Not part of the specification. The choices below explain the surface language; §20 of the spec fixes the underlying calculus.

Influences

The closest relatives are YSH/Oil and nushell; ral differs by not retaining POSIX compatibility and by not enforcing structured data on every pipeline stage.

Values and commands

The organising distinction is between values — inert data, named, passed, inspected — and commands — effectful processes that may read, emit bytes, return a value, or fail. Most shells collapse the two: every datum is a string, and every string is simultaneously data, a command name, and source text for further evaluation. The consequence is that captured output is re-lexed, split on whitespace, and glob-expanded. ral refuses this collapse and thereby avoids the class of bugs arising from it, without sacrificing first-class commands and pipes.

The formal account is call-by-push-value (Levy): values are inert, computations effectful, a thunk packages a computation as a value, and forcing runs it. The user need not know the theory to use the shell: {M} thunks, ! forces.

System calls are algebraic effects

The value/command distinction leaves one question open: what is a command? ral's answer is that running an external command is performing an algebraic effect operation. A name such as git, which the binding namespace declines, is an operation: it is performed, it returns once with a result or a failure, and its meaning is supplied separately by its interpretation — by default, the operating system carrying out the syscall. This separation of the operation from its interpretation is the defining move, and the rest of ral's dynamic design rests on it.

Not every kernel call is an effect in this sense. A structured-query builtin such as list-dir or file-info reaches the kernel yet is kept inside the pure value language, answering with records rather than bytes, precisely so the capture-then-reparse round-trip never arises. What makes a name an operation is being an open external name whose meaning comes from its interpretation, not merely touching the OS; ral draws the effect boundary at the external-process surface, the exec/wait family that defines a shell.

Two further choices are orthogonal to this principle. The first is whether a program may reinterpret an operation rather than defer to the OS; the second, should it reinterpret, is whether the reinterpreting handler may capture the continuation. Both belong to Effect handlers below, and ral takes the minimal setting on each. The principle stands without either: an external command is an effect whether or not any handler intercepts it, and performing one needs no captured continuation — the syscall is a single forward step and the continuation is the ordinary rest of the computation, never a reified value. From the identification alone follow scoped authority (a capability is permission over the effect set), the capability check at the point an effect is performed, audit as a record of the operations performed, and failure as an operation's exceptional outcome.

Blocks as the single abstraction mechanism

{M} stores a command; { |x| M } is a function; ! runs either. This replaces the several mechanisms of conventional shells — functions, aliases, eval, subshells, trap handlers — with one.

Forcing is always explicit, with a single exception: a bound name resolved in head position is forced (or applied) implicitly. This keeps ordinary calls natural (greet alice) while making the storage of commands visible (let plan = { make build }).

Two sigils

$ retrieves data; ! runs stored commands. !$b composes: dereference then force. A single sigil covering both would make "retrieve" and "run" indistinguishable at a glance, and the ambiguity would propagate into every expression that passes blocks as data.

Shadowing, not mutation

All let bindings are immutable; re-let shadows within the enclosing scope. Closures capture at definition time, so equational reasoning holds in the pure fragment. The cost is the absence of mutable accumulators; fold, reduce, and the streaming fold-lines replace them. The benefit is twofold: easier local reasoning, and a spawn that is safe without synchronisation — the isolated copy shares nothing that can be mutated concurrently.

External commands return strings

When an external command's stdout is captured in a let, the runtime decodes it as UTF-8, strips one trailing newline, and binds the result as String. Invalid UTF-8 fails with a message naming the command and suggesting | from-bytes; Bytes remains available via that terminator.

This trades a sliver of generality for a large reduction in ceremony: most command output is text, and demanding an explicit decode on every binding generates noise without adding protection beyond what a strict error already gives. The returned String is data, never re-lexed, split, or globbed; the classic capture-then-reparse chain does not arise.

Piping and failure

| and ? are deliberately separate: | moves data between stages; ? reacts to command failure. Exit status and data flow remain distinct concerns. if branches on Bool, not on command success; a predicate returning false still succeeds, so confusing "false" with "failed" is impossible. When success must be inspected as data, try is the mechanism. When a command dies from a signal or is stopped by terminal job control, ral keeps that distinction in the error it reports instead of collapsing everything to a numeric status too early.

No command-level ||

try { a } { |_| b } replaces a || b in command context. A binary || on pipelines would force precedence rules relative to ? and |, adding grammar for a case try already handles. The || operator that does exist is the Boolean connective inside expression blocks $[a || b] (§2, SPEC) — it operates on Bool values, not on command success.

Expression blocks

$[...] is one expression language spanning arithmetic (+ - * / %), comparison (== != < > <= >=), and logic (&& || not). Bash partitions these into (( )) and [[ ]] because its history forced separate lexers; ral has no such history. Comparisons already cross the numeric/Boolean boundary by returning Bool, so the simplest and most honest grammar is one. && and || short-circuit; operands are strict Bool$[1 && true] is a type error, not truthy. The not keyword carries unary negation because ! is already force (!{...}) and ~ is tilde expansion; context-dependent symbol overloading would be the worse trade.

Data-last argument order

map f items, fold f init items, filter p items. Piping and partial application then align: items | map $f | filter $p reads left-to-right, and map $f is a function waiting for its list.

No context-dependent lexer rules

: and = are in BARE but not IDENT. Names (IDENT) therefore terminate naturally on these characters, while in command arguments (BARE) expressions such as -DFOO=bar and http://host:port remain single tokens. The lexer is single-pass and modeless.

Scoped execution contexts

within and grant are properties of the execution context, not of source text: a function defined in one module and called inside a restricted block runs under that restriction. within [env: [KEY: VAL]] { body } overrides environment variables; within [dir: PATH] { body } overrides the working directory. Both are facets of a single scoping primitive. Lexical capture is the right model for data; dynamic inheritance is the right model for ambient authority.

grant is a capability wrapper around a block: it narrows the active authority for the duration of body and otherwise composes like any other block-bodied builtin. An fs/net-restricting grant on a platform with OS sandboxing additionally re-execs ral inside the platform sandbox to run the block — but that re-exec is a transport detail (§ Implementation: "Mobile-state eval transport"), not part of grant's semantics. The block returns to the caller the same way whether it ran locally or in an OS-sandbox child; the caller observes only the outcome and the captured audit/byte observations.

Effect handlers: deep with self-masking

Handlers are the orthogonal reinterpretation layer of System calls are algebraic effects above: they let a program supply its own interpretation of an operation in place of the OS default. They are additive — the effect identity holds whether or not any handler intercepts a command — and ral's are a deliberately small fragment, tail-resumptive with no first-class resume, less than algebraic-effect handlers usually offer.

within [handlers: …, handler: …] installs effect handlers on a dynamic stack. Two independent design questions arise, often conflated under one "deep vs shallow" heading; ral commits to a definite answer to each.

Handlers interpret open operation names after the language binding namespace has declined the name. Lexical bindings, prelude names, and builtins are not shell aliases: a handler cannot replace length, and a local let foo = ... beats an active handler for foo. This keeps ordinary language names stable while preserving command mocking for open external names such as cat or git.

Deep vs shallow is the question of whether a handler H persists across the continuation of the operation it handles. After within [handlers: [git: H]] { git a; git b }, both git a and git b trigger H; the installation is not consumed by the first call. By the standard criterion (Plotkin–Pretnar deep handlers re-wrap H around the continuation; Hillerström–Lindley shallow handlers do not), ral's handlers are deep.

Self-masking vs self-transparent is a separate question: during the evaluation of H's body, is H itself still in scope? In ral, the matched frame is lifted off the dynamic stack for the dynamic extent of the handler body — so a call to the same name from inside H reaches the next outer frame, or the OS, never H itself. ral's handlers are self-masking.

Without resume, deep handlers can re-trigger themselves only through a raw recursive call from inside the handler body. The Plotkin–Pretnar calculus avoids this issue because all re-entry happens through resume k, and k evaluates under H by construction; the continuation discipline does the work. ral has no resume — handlers receive the command name and arguments and return a value or fail — so self-masking is the operational rule that keeps the dominant idiom (within [handlers: [git: { |args| my-git ...$args }]]) free of infinite recursion without requiring ^git inside every handler body.

The shell intuition is the same as a POSIX function named git whose body wants to call the real git: the function shadows the name only outside itself. ral generalises this to the whole handler stack, typed and lexically scoped, with ^name available as an explicit bypass of the lexical/prelude/builtin binding chain (handlers still apply, because ^name is a syntactic flag on the lookup, not a frame-unwind).

This combination is the practical content of ral's effect-handler design: deep, so within covers its dynamic extent the way the user expects; self-masking, so the wrap-and-forward idiom is the natural reading and not a recursion trap.

Path construction uses interpolation

Outside quotes, $name is a separate atom — $dir/file is two arguments. Paths are built by interpolation: "$dir/file.txt". This inverts the bash convention, where quoting suppresses word-splitting; in ral the unquoted form is already safe (there is no splitting), and quoting performs concatenation.

Not POSIX

POSIX shell compatibility requires word-splitting, glob expansion on unquoted variables, $IFS, and context-dependent quoting. ral eliminates exactly these. Compatibility is therefore a non-goal.

Control flow is library; five control operators are syntax

The grammar knows about exactly five control operators — within, grant, try, guard, audit — plus the two purely-syntactic forms if and case and the chain operator ?. Everything else that smells like control flow (for, while, map, each, the prelude's attempt and retry) is an ordinary parameterised block in the prelude. The split is principled, not pragmatic.

A construct earns a grammar arm when both of the following hold: its typing rule is not derivable from ordinary Hindley–Milner over the builtin signature, and its runtime semantics cannot be expressed as a function taking thunks without lying about that signature. The five operators meet both conditions:

Surfacing these as keywords also shrinks the surface elsewhere. The five names are reserved in let-binding position and in bare-head command position; ^try keeps PATH-lookup semantics; $try and the other four in value position are compile-time errors with a targeted diagnostic. The IR carries a Within/Grant/Try/Guard/Audit node per operator with named structural fields, and a Redirect wrapper for the trailing-redirect case — none of the five appear as string-keyed builtins anywhere in the typechecker or evaluator.

if, case, and ? remain in the grammar for a different reason: they take an arbitrary number of arms and need parser support to keep the surface readable, but their typing is ordinary. Everything else stays in the prelude. The parser does not grow when a user defines retry; it grows only when a new wrapper needs handler-frame manipulation, audit-tree ownership, or a typing rule outside HM.

Aliases are semantic, not syntactic

Aliases live in the interactive command namespace, resolved at evaluation time after value-head lookup, active only in interactive mode. Scripts never see them, so script behaviour cannot depend on the user's interactive configuration.

guard, not on EXIT

guard wraps a body, runs cleanup regardless of outcome, and propagates the original failure unchanged: scoped and lexically apparent. Registration-based cleanup (on EXIT) is mutable global state whose ordering follows execution flow rather than source structure, and composes poorly with nested error handling.

Termination: return, fail, exit

Scripts end at the last statement. Three primitives end them earlier, each with its own scope:

Errors are values, not numbers. The record produced by try { ... } { |e| ... } is the input shape fail accepts, so wrap-and-rethrow composes without dropping fields. - exit N (alias quit) terminates the whole shell process with status N. Reserved for top-level use; scripts that want to halt cleanly should prefer return.

try and audit are separate operators

try traps failure and dispatches to a handler that receives a small structural record (status, cmd, message, line, col); it is otherwise transparent, in that it does not redirect fd 1 or fd 2, so side-effects inside the body remain observable as they happen. audit builds the full execution tree, recording per-command bytes regardless of outcome. Separating them keeps the common case (catch-and-handle) from paying for the uncommon one (full tracing), and lets the two compose: audit { try { … } { … } } traps errors and records bytes. Both are control-operator keywords (§ "Control flow is library; five control operators are syntax"), so the typing rules are dedicated rather than shoehorned through a builtin scheme.

Audit is one mechanism

Every audit-producing site goes through the same lexical scope: the scope-introducing operator (grant, within, guard, try, audit) owns the nodes its body produces. Process boundaries — the OS-sandbox child ral re-execs for an fs/net-restricting grant, each pipeline stage helper — only transport audit fragments; they never decide tree shape. The wrapping scope merges incoming fragments into its own child trail, so reports stay readable: a sandboxed grant { … } shows its body's nodes as direct children of the grant node, not loose at the root.

source is kept for configuration

~/.ralrc and interactive configuration need scope merging, which use (returning a module map) does not do. source exists for this; use remains the default for library code.

Concurrency: isolation, not shared state

spawn creates an isolated copy of the evaluator; there is no shared mutable state and no synchronisation. await is the only channel. A second await on the same handle returns the cached result, avoiding the need for affine types or runtime traps on aliased handles.

A spawned handle buffers its output and replays it on await; a watched handle (watch "label" P) streams each line live to the caller's stdout, prefixed [label] (stdout) or [label:err] (stderr). watch is a one-line prelude alias over the _watch builtin, not a keyword. The framing lives in a single Sink variant — LineFramed — that buffers bytes until \n and emits prefix + line + '\n' as one write to the caller's stdout; sibling watchers serialise through the OS stdout lock (or, under the interactive REPL, rustyline's external printer) so each line is atomic even when several watchers run concurrently. Live watching hides the usual cmd > /tmp/log &; tail -f scaffolding behind a library function. Ral deliberately does not ship a read-API on handles (read-line $h, select-line [h₁,h₂]): value builtins like each are value-complete, so a handle-as-pipe-source would require a streaming-internals refactor, whereas line-framed watching satisfies the observed motivating use case at a much smaller surface.

Paths are strings

No Path type. UTF-8 for textual values, and the absence of word splitting removes the historical reason shells needed path-specific quoting.

let unifies binding, capture, and storage

The let RHS is a command context, and a single mechanism covers three operations:

Bare words run commands; quoted words are data; value forms (literals, blocks, lists, maps, derefs, arithmetic) receive an implicit return in command context. The shell convention (unquoted words run commands) is preserved without collapsing the language into strings.

Three layers, one asymmetry

The filesystem surface is split into three layers:

  1. Structured queries — primitives that return values: list-dir, file-info, file-empty, path-*, temp-dir, temp-file. These are what drives a structured pipeline; they have no shell-tool analogue worth bothering with.
  2. Bytes I/O — codecs (from-string, to-json, …) plus redirects. to-json $v > $path replaces the old write-json; from-string < $path replaces a read-file primitive. Atomic-rename-on-write is built into > for regular files.
  3. Filesystem effects — bundled coreutils (cp, mv, rm, mkdir, ln -s, chmod, …). Effects don't return structured values, so giving them ral-native primitives buys nothing the shell form doesn't already give. The old copy-file, move-file, make-dir, remove-file wrappers are gone for this reason.

The asymmetry is the design: structured returns earn a primitive; effects don't.

Core keeps the universal part of that surface. Exarch adds its agent-facing search and edit tools (grep-files, hash-lines, hash-replace, explore-dir, and small source helpers over them) as host extensions, because they are a model workflow rather than a shell language requirement.

remove-file was a footgun

The dropped remove-file did rm -rf if you pointed it at a directory. That is the kind of behaviour ral exists to abolish. The dangerous verb wears its name — rm -r (or rm -rf) — and the caller writes it on purpose. Effects are bundled coreutils now; the trap goes away.

Bundled coreutils are mandatory in exarch, optional in ral

A sealed exarch profile that depends on host coreutils isn't sealed — it's reproducible only modulo whatever cp or mv the host happens to ship (BSD vs GNU drift, version skew, locale defaults). Exarch therefore bundles a curated coreutils set and pins behaviour. The binary-size cost is paid once per profile build and is the price of "I know exactly what's in this".

The bare ral binary keeps coreutils behind a feature flag. An interactive shell on a developer machine has system coreutils already; no reason to ship 30+MB of duplicate tools.

Capability-checked dispatch for bundled tools

Every uutils invocation goes through a wrapper that consults the tool's own clap parser to find the path-argv positions, then calls the same check_fs_read / check_fs_write that the structured primitives use. Bypassing the sandbox by reaching for cp instead of a primitive is therefore not possible — both paths land at the same chokepoint. within [dir: ...] scope propagates by chdir under a per-call lock, so relative paths resolve against ral's scoped CWD, not the host process CWD.

Syscall bridge, not text parsing

The structured query builtins — list-dir, file-info, the is-* predicates (is-file, is-dir, exists, …), resolve-path, and glob — replace shelling out to stat, ls, or realpath and parsing their text. Platform differences and the perpetual bytes–text–structured round-trip disappear. Effects are not in the bridge — they are bundled commands invoked through the capability-checked dispatch.

Record types and scoped labels

The checker infers per-field types for map literals with static keys. Representation is a row: a list of (label, type) pairs with an optional tail variable standing for unknown fields. Field access unifies the target with [label:α | ρ] and returns α. The unifier is Rémy (1989): mismatched head labels permute past each other into a shared fresh tail.

The spread [...$base, port: 9090] raises the question of duplicate labels: if $base already has port, the result has two. Rémy's original system assumes uniqueness and would require absence markers (Pre(T) / Abs) and a restriction operator ρ ∖ port. Introducing them means new row constructors and changes to unifier, generaliser, and display.

ral instead adopts the scoped-label row types of Leijen (2005). Duplicates are permitted in rows; selection always takes the first; extension prepends, shadowing the prior entry rather than removing it. The key observation is that the Rémy rewrite rule already treats duplicates correctly — it swaps only different labels past each other, so same-label entries keep their relative order. No changes to unifier, generaliser, or occurs check are required.

Effect: [...$base, port: 9090] with $base : [host: String | ρ] infers as [port: Int, host: String | ρ]. The explicit field prepends over the spread's row variable, which becomes the result's open tail. Shadowed duplicates are invisible to selection and suppressed in display. With multiple spreads the result is open but imprecise — chaining two arbitrary rows needs row concatenation, which is not part of Leijen's system and is not included.

Plugins are modules

A plugin is an ordinary ral module (§8) whose return value is either a manifest map or a block that takes an options map and returns a manifest map. There is no plugin DSL, no separate loader language, and no magic $config binding: a plugin's knobs are fields on the options map it receives. _plugin 'load' 'fzf-files' [key: 'ctrl-t'] evaluates the file, applies the options map to the returned block, and reads name, capabilities, hooks, keybindings off the resulting record. Record destructuring, row polymorphism, and grant already exist for other reasons; the plugin system is a thin composition of them.

return { |options|
    let key = get $options key 'ctrl-t'
    return [
        name: 'fzf-files',
        capabilities: [
            exec: [fzf: []],
            fs: [read: ['.']],
            editor: [read: true, write: true, tui: true],
        ],
        keybindings: [[key: $key, handler: $_handler]],
    ]
}

The shell wraps each handler in grant $capabilities { … } before installing it, so a plugin runs with exactly the authority it declared. A handler that tries to do more fails at the capability check, not on trust.

_ed-tui captures stdout

Interactive plugins invoke fuzzy finders (fzf, sk, …) that draw on the terminal via /dev/tty and print the user's selection on stdout. A plugin needs that selection as a value. If the body's stdout went to the terminal, the selection would appear above the prompt and the handler would get nothing back.

_ed-tui therefore opts into byte capture for the duration of its body, analogous to what let x = !{ … } does at a binding site. When the body returns Unit, the captured bytes (trimmed of one trailing newline) become the return value; when it returns something non-Unit, that wins. This is the same "last command's bytes are the value" rule as let, applied inside a higher-order builtin.

let dir = ed-tui { fzf --walker dir +m }

Byte pipelines are processes; value pipelines are folds

Pipelines have two distinct execution models, and the type of the adjacent edges decides which one runs.

A pipeline whose every stage operates on values is just typed data-last composition: x | f reduces to f !{x}, evaluated sequentially in the parent. No process is spawned, no pipe exists. This is the path range 1 21 | filter $even | sum takes — three function calls threaded by the value channel.

A pipeline that touches bytes — at least one external command, or any byte edge — runs as a Unix-style process pipeline. Every stage, including ral-implemented ones, executes in a subprocess; all subprocesses share one process group; the parent ral process is not a member of that group. This shape is what makes cat README.md | glow -p work: the kernel sees one foreground process group containing every stage that can touch the terminal, regardless of whether cat is /bin/cat, an alias, a handler, or a ral block that wraps bat.

External stages in foreground pipelines are not spawned directly. Each one is wrapped in a small exec trampoline: a re-exec of ral that joins the pipeline pgid, resets signal dispositions to default, and then waits for the parent to release a one-frame gate. The parent claims the foreground once — tcsetpgrp on the pipeline pgid — and then writes every gate frame at once, which lets every stage start its real work knowing the foreground is correct. Without the trampoline, a fast external could start (and call tcsetattr) before the parent finished tcsetpgrp, and the kernel would deliver SIGTTOU to a process the user never asked to background. The trampoline is an implementation detail: spawn-failure diagnostics still mention the user's command, not the helper.

In non-interactive contexts (scripts, captured pipelines) the parent never calls tcsetpgrp, so there is no foreground race to lose; the trampoline is skipped and externals are spawned directly through the same build_command funnel. Ral helper stages always run through their own job-frame protocol regardless of foreground state, because the frame carries the work to evaluate, not just a release signal.

Windows pipelines do not need a trampoline at all. Windows has no tcsetpgrp: the console is shared by attached processes and there is no per-stage "is this in the foreground" signal to race against. External stages on Windows direct-spawn into a per-pipeline Job Object (with CREATE_NEW_PROCESS_GROUP so each stage is individually addressable for Ctrl-Break fan-out), and TerminateJobObject / KILL_ON_JOB_CLOSE makes the abort path plain RAII. The trampoline exists only to win a Unix-specific race, so it stays Unix-specific.

Mixed and ral-helper pipelines on Windows use the same gate / report / value-edge protocol as Unix, with two changes: the channel is an anonymous OS pipe pair (os_pipe::pipe()) wrapped in a Reader/Writer enum, and inheritance happens through SetHandleInformation(HANDLE_FLAG_INHERIT) plus a numeric handle value stashed in an env var. Helper subprocesses parse that value as a Win32 HANDLE, wrap it via from_raw_handle, and clear HANDLE_FLAG_INHERIT so the handle does not leak into nested children. The exec trampoline path remains Unix-only — its sole purpose is to win the tcsetpgrp race that does not exist here.

Job control on Windows is intentionally narrower than on Unix. There is no SIGTSTP analogue: kernel-induced stops are unreachable, so a pipeline cannot be parked in the job table. fg <id> blocks on the group's Job Object until JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO arrives; bg only validates pre-existing stopped-state bookkeeping (which on Windows is always empty); disown clears JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE on the group's Job and drops ral's bookkeeping, leaving the children running unsupervised. Ctrl-C escalates: the first delivery sends CTRL_BREAK_EVENT to every active member, the second runs TerminateJobObject on every live group, and the third forces ral to exit.

The cost of running ral stages out-of-process is that they become subshells with respect to mutation. A helper stage's cd, env-set, alias / module / registry updates, or REPL changes do not flow back to the parent — only the pipeline's pipe contents and final value cross the boundary. This matches the way every traditional shell treats process pipeline stages, and it is what lets job control remain coherent: a shell process that participates in its own foreground pipeline cannot consistently both own the terminal and not own it.

Job control is narrower than bash, on purpose

Ctrl-Z parks the foreground command's process group as a numbered job; fg [N] resumes it; bg [N] resumes it in the background; jobs lists what is parked. Beyond that, ral does not reproduce the bash machinery that exists to compensate for the shell having no persistent UI for parked work.

There is no exit-time refusal ("There are stopped jobs"), no asynchronous [N] Done <cmd> print stream interleaved with the next prompt, and no %1 / %+ / %- short-form addressing. Job state is observed by typing jobs, not by being interrupted at the prompt. The bash exit-time guardrail and the async notification stream both exist because the shell has no other UI for jobs; the trade — printf-into-readline that occasionally collides with what the user is typing — is the cost of that compensation. Modern multiplexers (tmux, zellij) cover the "I want a second running thing" use case better than juggling jobs in one shell, so ral's narrower surface is sufficient for the case Ctrl-Z genuinely needs to handle: vim's drop-to-shell idiom and the same pattern in less, man, and top.

The kernel mechanism — SIGTSTP delivery to the foreground process group, tcsetpgrp for terminal handoff, waitpid(..., WUNTRACED), SIGCONT to resume — is unchanged from the canonical Unix implementation. The narrowing is in the prompt-side bookkeeping, not the OS-side machinery.

Pure-value pipelines (§4.3) are sequential folds in the parent evaluator. No process exists for SIGTSTP to suspend, so they are outside this discussion entirely.

Keybinding dispatch is handler composition

Multiple plugins can bind the same key. Dispatch walks handlers in reverse load order; a handler returning true consumes the keystroke, a handler returning false falls through to the next, and if every plugin handler declines the shell runs its built-in binding for the key.

This is the same shape as the ? fallback chain for commands: a stack of alternatives where each one decides whether to handle or pass. Load order controls precedence, the same way use order does for bindings, so the user's last-loaded plugin wins by default and earlier plugins remain reachable.

# if autosuggest's CTRL-F doesn't apply, the built-in binding still runs
load-plugin 'autosuggest' [:]

Plugin code runs on the real env with host authority

Hooks (buffer-change, pre-exec, post-exec, chpwd, prompt) fire on the live evaluator, not a clone. They need to observe and sometimes alter shell state — the prompt hook returns the prompt segment, chpwd may update state cells.

The shell does not push a capability frame around handler, keybinding, or plugin-alias dispatch: plugins run with whatever capabilities the caller's stack already grants. The manifest's capabilities: key is advisory documentation only.

This is a deliberate trade. Plugin manifests are self-declared, so in-process attenuation gives zero defence against an adversarial plugin author and only catches honest-but-buggy plugins. Paying the conceptual cost — with_capabilities carrying two distinct meanings (user-syntactic grant vs in-process plugin attenuation), bleeding into the eval-boundary's transport dispatch — was disproportionate. Users who want to confine a plugin call should wrap the call site in grant { … }; that is what grant is for, and it composes with plugin code the same way it composes with any other code.

Handlers for an event run in load order; a failing handler's error is logged but does not cancel siblings. buffer-change runs under a soft deadline (default 16ms) so a slow plugin cannot make typing feel laggy; stale handlers are re-run at the next input idle.

Shadowing looks like mutation

let count = 0
for [1, 2, 3] { |x|
    let count = $[$count + 1]   # shadows in the loop body
}
echo $count                      # 0 — outer binding untouched

Idiomatic replacement:

let count = fold { |acc x| return $[$acc + $x] } 0 [1, 2, 3]

A block returns its last result, not its full stdout

let b = { echo one; echo two }
let x = !$b                    # "two", not "one\ntwo\n"

In a let RHS only the last command's byte output is captured as the bound value. Earlier commands run for effect: their bytes are not captured but are still forwarded to the terminal.

let x = !{ echo "log: starting"; hostname }
# "log: starting" appears on the terminal; x is the hostname

Return values do not enter pipes

let helper = { |x|
    echo "log: $x"
    return [result: $x]
}
let r = helper foo        # captures the map
helper foo | grep log     # pipes stdout; the map is discarded

let binds command results; | carries the byte stream (or structured values on a value edge). These are separate channels.

Early exit from for

for stops on failure; there is no break. For early-termination logic use a dedicated combinator: take-while / drop-while for pure predicates, first for "find the first item matching a predicate" (it fails if no item matches), or an explicit fold that threads a decision through the accumulator.

Inline function application needs braces

_if !$pred $head { … } { … }     # wrong — 4 atoms to _if
_if !{$pred $head} { … } { … }   # right — 3 atoms, middle one evaluates the call

!$pred is one atom (force of the pred value); $head is another. Writing them side-by-side passes both to the surrounding command as separate arguments. To inline a call, wrap the application in braces and force the block: !{$pred $head} evaluates to the call's return value and occupies one atom position.