An external is a byte operation
An external command is an operation whose signature the operating system
fixes at List String → Bytes; a handler interprets that operation and
cannot re-declare its type. The checker read a bare head it could not
resolve as this external, cashed the byte route into the tree at that point
(a Capture node, a PipeYield), and the machine later resolved the same
name against a different environment — one where a sourced file, a
reinterpreting arm, or a literal catch-all had made it something else. Where
the two disagreed, WF-2’s promise (ρ = Bytes implies the value is Unit)
broke, and only a runtime check caught it — c6077c72 replaced a silent
assert! with the diagnostic this decision keeps (types
narrates WF-2 and this check). This decision closes the disagreement at its
source: it
forces the byte route uniformly on every reinterpretation of an unresolved
name, makes ^name a uniform escape to the path-head class, and deletes the
one binder that could install a name behind the checker’s back. What the
runtime check now guards is no longer a known user mistake — it is an
invariant the statics are supposed to hold everywhere.
Context
Four things could make the checker’s route and the machine’s outcome for one
bare head disagree: head_pipe_route answering a fresh route for a name
that turned out to be a native or a binding; the literal catch-all
reinterpreting every external in its extent without its type being checked; a
per-name or catch-all arm returning a value where the checker had cashed
Bytes; and source, which installs a binding while the run is already
under way, long after the whole unit was checked, so a name the checker read
as external could resolve to a block returning an Int. The user’s own
diagnosis was blunt: “handlers mock binaries. binaries return bytes. so they
should return bytes.” Closing three of these four statically and deleting the
fourth, the un-checkable source, is this decision.
A second ruling landed alongside the first and reverses part of
a-name-is-a-value-or-it-is-handled:
^name was defined there as an escape hatch that “skips the env by
definition” while still reaching an installed handler. The user’s ruling —
”^ should reach external” — makes ^name skip the handler stack too, base
frames included.
Decision
The route is forced uniformly, at install
head_pipe_route (core/src/typecheck/infer.rs) answers PayloadRoute::Bytes
for every name with no handler scheme in scope, unconditionally — no carve-out
for a name that happens to be a native or a binding, because under ruling 2
(below) no arm installed under such a spelling is reached by any bare head at
all, so there is nothing for it to define a route for. An arm that
reinterprets an unhandled name inherits the byte route rather than declaring
one, and is refused at install (pin_arm_to_head) if its body is not
Unit-valued — the same refusal echo itself has always enforced. The
literal catch-all is pinned the same way, since it stands in for every
external in its extent; a computed catch-all is vetted at its runtime install,
as a computed per-name arm already was. Base frames’ own routes (echo
Bytes, detach Value α) and alias-over-alias pinning are unchanged: an arm
over a base frame is still reached at the bare head, and still pins to that
frame’s own scheme.
^name joins the path-head class
^name now means exactly what ./tool and /usr/bin/tool mean: it skips the
value world and the handler stack — run frames and base frames alike — and
executes the external of that name (SPEC §9.5). This reverses
a-name-is-a-value-or-it-is-handled
§4’s “^detach and ^echo reach their base frames rather than a PATH
binary of that name — accepted”: under this ruling ^echo is /bin/echo
and ^detach is not found, everywhere. §4’s first leg is untouched and
restated here: resolution order is the only arbiter of interception, no
install path admits or refuses a name, and this does not resurrect T0043
(CannotRedefineBuiltin) or T0044 (HandlerShadowedByBinding) — a handler or
alias under any taken name still installs; whether it is ever reached is
answered by the same order both sides already share, not by a second opinion
at install. The checker’s Exec arm and the runtime’s resolve_command_word
must move in lockstep for this: both now send every non-bare head —
CommandWord::External(_) and every path head alike — to the one path-head
arm, external_exec_comp_ty on the checker’s side and
CommandIdentity::resolve on the runtime’s; changing one side without the
other would reopen exactly the check/run divergence this whole decision
exists to close, so both land in one commit with a test pinning agreement.
An arm installed under a native’s or a session/local binding’s spelling is
therefore unreachable from every bare head — globally for a native, and
exactly where the binding is in scope for a binding, since a closure that does
not capture it never sees it shadowed. This is not a new refusal: explain
already reports the winner at a bare head and lists a shadowed arm under
shadows:, so explain length inside an arm over the native length reads
length: builtin / shadows: handler — the discoverability a dead arm needs,
in the tool already built for “what runs here.” No install-time gate, and no
new non-fatal diagnostic class: ral has none, and inventing one here would be
scope creep the runtime’s own resolution order already makes unnecessary.
One type for every user arm
A handler substitutes a new body for an external’s operation, never a new
type: every user handler arm — per-name or catch-all — now has exactly one
type, U(List String → F[Bytes] Unit) (an arm installed over the name
detach itself is the one exception, below). A value-returning command of
variable arity is therefore not expressible. alias three { |args| return 3 } is refused at install ([T0012]), and a binding of the same shape is
refused too, for the unrelated reason that a binding is fixed-arity
([T0011]). This is the accepted cost of ruling 1: bindings are the value
world — fixed arity, typed values; handlers are the command world — argv in,
bytes out, the OS’s own contract. A name is a value or it is handled; if it is
handled, it is a command, and a command writes.
The replacement spellings all still work: a value function taking a list,
let three = { |xs| … }; three [a, b, c]; a fixed-arity value function,
unaffected; a variadic command — forwarder, byte mock, fail-bodied arm —
unaffected. If a bash-style f a b c calling convention for value functions is
ever wanted, the route back is recorded rather than built here: alias as a
lexical argv-convention binding, distinct from alias-as-handler, which
functions-and-handlers already
wants to retire — out of scope for this decision.
detach’s two identities
Two things named detach must not be conflated, and this decision changes
only one of them. An arm installed over the spelling detach
(within [handlers: [detach: …]]) pins to detach’s own scheme,
∀α. [Str] → F α (typecheck/builtins.rs:757), and may return anything — it
is reached at the bare head like any base-frame arm, unaffected by ruling 1.
Separately, detach intercepting another head (detach "d" my-server …)
runs that head’s arm through apply_handler under the run’s own sinks, with
no Capture, and reports the arm’s value as its own — and that arm is now
uniformly List String → F[Bytes] Unit, so it contributes only Unit to
detach’s α. detach never forwards an intercepted arm’s value; it dies
with the capability ruling 1 accepts, not as a separate decision. What now
inhabits detach’s α: the {pid, desc} receipt on a real birth; Unit when
the head it ran is intercepted by a user arm or is itself a base frame
(detach "d" echo hi); and whatever an arm installed over detach itself
returns.
source is deleted; use is the one loader
source ran a file’s phrases into the caller’s own environment while the run
was already under way, installing names the checker had no way to see. It
could not be fixed by checking harder: a source path is a runtime value
resolved against the runtime cwd and the runtime grant, so a static reading of
what it installs would have to read the filesystem in the checker — the same
divergence a PATH oracle would reintroduce, against within [dir:] and
grant [fs:]. The verb was intrinsically un-checkable and, by the time of this
decision, vestigial: two call sites, both inside its own test. Its whole
phrase spine — Phrase::Source, CompKind::Source, Frame::Source, the
registry entry, the elaborator’s source arms — is deleted; use is the one
loader left in the language, typed String → Map a, and every host loading
door — evaluate_source, evaluate_checked, compile_toplevel,
module_phrases, their cycle and depth guards — is untouched, still serving
rc, plugins, exarch, and the capability files.
seed_env binds scheme-less names monomorphically
A binding installed with no scheme — a host seed var, an rc env:/prompt:
key — used to be dropped from the type environment entirely, which made an
existing name of that kind read as unbound, and so, at a bare head, as an
external. seed_env now binds it at one fresh monomorphic type variable
instead. Generalising it to ∀a. a would be unsound — a wildcard that could
stand in for any type at all — where a single fresh variable merely says “one
value, one type not yet observed,” which is what it is.
The lease ledger loses a seam
Exarch’s binding-lease ledger
(agent-binding-reaping) renewed a
name’s lease at three seams sharing one exhaustive walker over referenced
names: the turn’s own compiled program; runtime-compiled loads through
check_source/compile_toplevel; and a dispatch-time touch in
classify_command’s Resolution::Env arm, for a bare head that resolved to a
binding the elaborator’s bound set never saw. Only source could produce that
last case — a name the elaborator does see compiles its reference as an
App, never an Exec, so it never reaches that arm at all. With source
gone, the seam has no argument left to renew: classify_command’s
dispatch-time touch and BindingLedger::renew_one, which existed only to
serve it, are deleted together. Two seams remain, not three.
Consequences
- The runtime backstop,
machine::bytes_promise_broken(core/src/evaluator/machine.rs), stays at its two cash sites,Frame::CaptureandPipeYield::Unit— the check was always correct, only its hint was wrong. It namedsourceas the cause and claimedusehands a file’s names back “where the checker can see them” (false:useisString → Map a, dynamically typed, each projection instantiating a fresh variable, so a bad key or a mismatched use surfaces as a runtime[R0001], never aT-code, and never through this check). The hint now says what is true: every producer this decision knows of is refused statically, so a script reaching this check has found a checker/runtime divergence with no known cause, and should be reported rather than diagnosed as a mistake in the script. explainreads in bare-head order unconditionally: the comment that justified probing handlers before the manifest by “^namereaches it” is deleted along with the reason, since^namereaches nothing installed under a native’s or a binding’s name any more.- Grants are unaffected:
classify_commandadmits theExternalarm exactly as it did before ruling 2, whether the head arrived bare, by path, or by^name. - Self-masking (
Frame::Unmask) is unaffected: a forwarder inside an arm still reaches its own outer frame by writing the bare name, never^name, which no longer forwards to anything installed. - Every test that observed the old reversed route rule, or observed
^namereaching an installed arm, is converted to observe the ruled semantics instead, none deleted as coverage: what such a test showed before — “which arm won” — is now read off a captured value rather than off the type the checker gave the winning arm, since that type no longer varies by winner. - Windows.
echois not a bundled coreutil, so^echoresolves to/bin/echoon Unix and to nothing at all on Windows, which has no standaloneecho.exe. No in-repo program needed the old reading once its four^echo-in-an-arm tests were rewritten to bareecho(self-masking already reaches the frame from inside the arm), so this is a platform gap noted, not one anything here papers over.
Alternatives considered
- Narrow the fresh-route rule to a
within’s dynamic extent, or to a name that is not a native or binding. Rejected: a frame’s lifetime does not change what it interprets, and under ruling 2 an arm over a native or binding spelling is reached by no bare head at all, so there is nothing left for a narrowing to except. - A
PATHprobe at handler-install time, refusing an arm whose name resolves to nothing onPATH. Rejected: a dead-PATH oracle, and a-name-is-a-value-or-it-is-handled already rejected static admission gates on the same grounds — resolution order is the one arbiter, and a second static opinion can only disagree with it. - A base-frame carve-out keeping
^detach/^echoat their frames. Rejected: it would restore exactly the asymmetry ruling 2 removes, and reads incoherently beside “^nameis the binary” —^echowould name ral’sechowhile^catnames the OS’scat. Nothing in the repository needed the old reading; every site that spelled^echoinside anechoarm did so incidentally and reaches the frame through self-masking regardless. - Finish
sourceas a checked binder (staging its target as a typed unit, or weakening the whole-program check to admit it). Rejected on both counts: the first is unsound for the same reason the runtime check exists — the checker’s filesystem/cwd/grant view cannot agree with the machine’s without becoming aPATH-oracle in another shape; the second weakens WF-2’s own guarantee for every caller to rescue one vestigial verb. - A non-fatal diagnostic class for a dead arm under a native or binding
name. Rejected as scope creep: ral has no such class, one was floated
before and never built, and
explain’s existingshadows:line already answers the discoverability question a warning would exist for.
Known limitation
use’s typing, String → Map a, is dynamic: a module’s names are not typed
individually, each projection instantiates a fresh variable, and a bad key or
a type mismatch through it is a runtime error, not a static one. A typed
module signature — checking a used file’s exports against a declared shape
— is a separate, larger design and explicitly out of scope here.
See also
a-name-is-a-value-or-it-is-handled
(superseded in part: §4’s “^detach/^echo reach their base frames” reverses;
its first leg — resolution order as the only arbiter, no name-admission gate —
stands),
no-value-has-an-optional-argument
(superseded in part: every “^cd still reaches an installed arm” reading
reverses — ^cd now reaches the PATH binary or nothing),
agent-binding-reaping (superseded in
part: the binding-lease ledger’s three renewal seams narrow to two, source’s
seam having no producer left),
functions-and-handlers (the
alias-as-lexical-argv-binding route back, still open there),
pipes-are-positional-byte-wires
(the byte-route encoding this decision leans on but does not change),
types (WF-2 and the payload route),
effects-handlers,
name-resolution,
builtins,
handler-dispatch,
binding-leases,
fixed-arity,
runtime.
Cite: RATIONALE §“Values and commands”, §“Effect handlers reinterpret external
names”; docs/SPEC.md §6.3, §9.1, §9.5, §17.