Lexical scoping for data, dynamic scoping for authority
ral splits scoping by what is being scoped — lexical for data, dynamic for authority:
- Data is lexical.
letbindings capture at definition and are immutable, solet x = 1; { let x = 2 }; echo $xprints1and a closure observes the bindings in force where it was written. - Ambient authority is dynamic. The working directory, environment overlays,
capability restrictions, and effect handlers are inherited from the call site
and scoped by
withinandgrantover the body’s whole dynamic extent.
The duality matches the right model to each:
- data captured at definition is predictable — this is what buys equational
reasoning and safe
spawn; - what files you may touch and which commands exist must be scoped to the call site, so that a function defined in an unrestricted context can be invoked inside a restricted block and respect that restriction without code changes.
An environment variable follows the dynamic side of this split: it is read as
$env[KEY], never as a bare lexical name, and a within [env: …] overlay is
seen by $env, ~, child processes, and PATH resolution alike
(env-is-dynamic-only).
Each dynamic frame nests by its own algebra:
- capability frames by intersection — each layer narrows reachability (grant);
- environment overlays by shadowing — inner
KEY: VALoverrides outer; - handler frames by the deep self-masking discipline (effects-handlers).
A tail-recursive call inside a within or grant block stays under that scope
across every tail landing.
Two layers: lexical scope vs fork inheritance
The lexical scoping above is one mechanism; crossing a shell boundary is a different one, and they should not be conflated.
- Lexical scope within a shell is the
Envtype (core/src/types/env.rs): three tiers checked in order — natives, the frozen prelude, and a persistentimblmap of everything bound since.bindis an insert into the session tier that disturbs no environment a closure already captured, so extent is structural rather than a pushed-and-popped frame:M to x. NclosesNover the environment theToframe carries, extended withx, and nothing else whateverMdid along the way. Cloning anEnvis O(1) — the persistent map’s root is shared, not copied — which is the hot path for recursion and for every closure capture. - Fork inheritance is the flow matrix in
inherit.rs: when a genuine runtime fork (aspawnworker, a pipeline stage, a REPL aside, a sub-agent session) needs the parent’s lexical environment, it clones the wholeEnvinto the new shell, alongside the rest of the parent→child manifest (builtin table, dynamic context, cancel root).
A same-thread β-step bridges the two: applying a thunk puts its closure’s
Env in focus directly — force(thunk M) = M pushes nothing — while the
store (Shell minus its lexical scope) is shared by identity rather than
forked. A block and a lambda are told apart only by the body’s shape
(Comp::arrow), never by how force treats them: an unbracketed store write in
either body — cd, alias, a hook registration — persists past the force: a
plain block is not a boundary for the store. within [dir:] / within [handlers:] are the scoped forms
(the-evaluator-steps-closures,
superseding
same-thread-body-shares-the-session
as a description of the mechanism).
See also syscalls-are-effects (dynamic scope is for the authority over effects), cbpv, control-operators.
Realised in evaluator-machine (the dynamic frame stack).
Cite: RATIONALE §“Lexical data, dynamic authority”; docs/SPEC.md §5, §9.