Superseded by a fresh head defines its own modes.
2026-06-19. The bare-block handler and alias examples below (
{ return 3 },{ echo hi }) are no longer valid surface forms. A per-name handler and every alias must be a unary lambda{ |args| … }, and a catch-all must be a binary lambda{ |name args| … }; a bare block, a non-lambda, or a wrong-arity lambda is rejected at install time (docs/SPEC.md§3.2, handlers-and-aliases-are-lambdas). The mode-preservation analysis here is unaffected — read its examples with the argument-binding form supplied.
Handlers and aliases preserve a head’s pipe signature
A handler reinterprets an operation; it does not retype it. The
interpretation separation puts the
calling convention on the operation, so the OS reading and any within/alias
reading must agree on the same signature — exactly as a handler clause in the
algebraic-effects literature must match its operation’s signature. A handler
(or alias) for head h must preserve h’s PipeSpec: the mode pair [input, output] is pinned by unification against h’s known spec, while the arm’s
value type stays whatever scheme inference yields.
This is the second of four decisions whose end state is a single mode-inference engine. The arc: session-scheme-continuity (session-scheme-continuity) → handler/alias mode preservation (here) → IR PipeSpec annotation (ir-pipespec-annotation) → the mode pass made unconditional (unconditional-mode-pass). The through-line is net code removal: this page’s machinery is one extra unification per arm, and what it buys is the deletion of the runtime engine’s handler-first lookup in decision three.
Why the verdict must be transportable
For the checker’s mode verdict to reach the evaluator (ADR 3), no dynamic rebinding may change a head’s signature after the check. Today it can, and the runtime engine compensates:
core/src/ty.rs::env_binding_typeconsultsshell.lookup_handlerbefore scope lookup — it re-infers a head’s modes from the live handler arm body, because a handler can rebind a head to a body with different modes than the head’s declared signature.- The static checker reflects the same freedom. A handler arm’s scheme is
inferred from the arm body alone (
typecheck/infer.rs::infer_handler_comp,handler_comp_scheme), with no unification against the head’s spec. A byte-output arm gets a byte-output scheme; a value-output arm a value-output one. - Heads that take handlers are exactly the external operations
(effects-handlers: handlers intercept external
commands only; the install sites in
core/src/evaluator/scope.rs::WithinScope::parseandcore/src/types/shell/scope.rs::install_aliasboth reject any name that is a lexical binding or builtin). An external head’s declared spec isF[μ, Bytes](typecheck/infer.rs::external_exec_comp_ty); a session-bound head carries its harvested scheme (ADR 1).
The hazard this leaves in the language, independent of the series: a handler that flips a head from byte- to value-output silently re-wires pipelines compiled before the handler existed — spooky action at a distance through the dynamic context. The rule removes it.
The rule, by installation taxonomy
The two ways a name acquires a handler frame differ in lifetime, and the enforcement site follows the lifetime:
within [handlers: …]frames are dynamic-extent within one turn.Shell::with_handlerspushes a frame and removes it by handle on return (core/src/types/value.rs::HandlerStack::push/remove_by_handle); a frame never outlives theeval_top_levelturn that installed it. The checker sees the install lexically when the opts map is literal.- Aliases install persistent frames.
install_aliascallsHandlerStack::push_aliaswith no paired pop; the frame lives untilunalias(core/src/types/shell/scope.rs). An alias is cross-turn dynamic rebinding.
Literal within opts → statically
typecheck/scope.rs::collect_within_handler_bindings already walks a literal
handlers: [name: { comp }, …] map, infers each arm under the runtime calling
convention, and binds the generalised scheme for the body’s scope (with the
reject_handler_for_binding guard; a catch-all handler: { comp } is inferred
for side-effects only, since it names no specific head). Extend it to unify
the arm’s PipeSpec against h’s known spec before binding — the external
default F[μ, Bytes], or the session scheme for h (ADR 1). A
mode-changing arm fails that unification as a ModeMismatch
(modes-equality-constrained-shared:
modes are equality-constrained, none and bytes do not unify).
Alias statements → statically, same unification
A literal alias name { body } is already recognised at IR shape and bound as
a generalised handler scheme in the type environment
(typecheck/infer.rs::alias_statement_shape, infer_seq_with_alias_bindings).
The same PipeSpec unification applies: an alias body is an alternative
interpretation of the same head, so it preserves the head’s modes. Aliases
are mode-preserving by the same rule, not mode-changing. This is the simpler
choice and the right one: an alias whose value type is recorded by the existing
generalisation still typechecks, and ADR 1’s session-scheme mechanism is not
recruited to record a mode change that the rule forbids in the first place.
Computed opts and runtime-installed aliases → checked at install
A non-literal within [handlers: $h] opts value, and an alias installed by a
path the checker never witnesses (rc-config aliases: maps in
ral/src/repl/config.rs, plugin loads in ral/src/repl/plugin/load.rs), are
invisible to the static check. The design pressure says install-time
unification of the stored arm’s spec against the head’s spec; the simpler
choice — consistent with net code removal — is to reject a computed
handler/alias arm whose modes do not match the head, rather than grow a second
mode-inference path at install time.
- A repository search finds no computed
withinopts map: everywithin [handlers: …]in the prelude and tests is a literal map, andwithin_handler_non_literal_value_falls_through(core/tests/typecheck.rs) is the lone non-literal case — a value,$h, not a computed map. Computed opts maps are an empty set; rejecting a mode mismatch in them costs nothing real. - Runtime-installed aliases (rc, plugins) carry external-only bodies that
forward to a command and so are already byte-output
F[μ, Bytes]— the external default. The alias half needs no ADR 3 annotation: ADR 1 already computes the arm’s closed scheme at install (alias_arm_scheme, the static engine’s arm inference under the runtime handler calling convention, seeded from the live session) and stores it on the persistent frame’sHandlerEntry.schemefor all three install paths. A closed scheme carries the arm’sPipeSpec, so the mode-preservation check is one unification of the stored scheme’sPipeSpecagainst the head’s spec — implementable now. The one inference run at install is the static engine itself, invoked once to produce the frame scheme (ADR 1’s mechanism); no second engine and no mode-only re-derivation exists or is added.
Open point, resolved. The choice was whether computed within opts should
be rejected outright (no computed handler maps at all) versus mode-checked at
install. The maintainer chose the install-time mode check: a computed
within [handlers: $h] opts map STAYS LEGAL and is mode-checked at install,
not rejected outright. This is uniform with the runtime-alias case and does not
remove a feature that has no users yet but is cheap to keep honest. The computed-
within-opts arm is mode-checked by exactly ADR 1’s install-time engine, the
same one the alias half runs — WithinScope::parse calls the head-aware
alias_arm_scheme seeded from the live session and discards the computed scheme
(HandlerEntry.scheme stays None), where install_alias stores it. Both halves
read no IR node, so this consumer needs no IR annotation; ADR 3 carries no
thunk-root Wire slot. ir-pipespec-annotation
records the same conclusion from its side.
What this unlocks
With the rule in force, a head’s modes are its declared or scheme modes
regardless of any live handler — so env_binding_type’s lookup_handler arm
loses its reason to consult the handler stack first. That arm is the precondition
ADR 3 names for deleting env_binding_type outright. This page does not delete
it; it removes the invariant that justified it, leaving the deletion to land
where the IR carries modes the evaluator reads directly.
Verify: a mode-changing handler arm
(within [handlers: [foo: { return 3 }]] { foo | from-json }, a value-output
arm under a byte-output head) is rejected at typecheck for literal opts and at
install for a computed opts map or an rc/plugin alias; a mode-preserving handler
(within [handlers: [foo: { echo hi }]] { foo }) still typechecks and runs;
alias myecho { |a| /bin/echo ...$a } typechecks and installs (byte-output
forwarding preserves the external F[μ, Bytes] spec), while
alias foo { return 3 } used where foo is piped into a byte stage is the same
∅-into-Bytes mismatch reported statically.
See also session-scheme-continuity, modes-equality-constrained-shared, effects-handlers, syscalls-are-effects, handlers-deep-self-masking, handler-dispatch.