The mode pass always runs; only value-type strictness is optional
Landed in three commits: 767a96bf makes the inference pass unconditional and
narrows --no-typecheck to a value-type-warning window; 602218d3 is the harvest
— the second engine and its residue come out, the annotation slots drop their
Option; 51419f6c closes the window — --no-typecheck retires with the
fragment/verdict apparatus that was its only consumer. The two latent bugs the
work surfaced and the loaders it newly checks are recorded below.
The deprecation window is now closed. Value-type errors are fatal on every path, exactly as mode errors are; the flag, the fragment classification, and the three-armed verdict that distinguished the two no longer exist. The sections below that describe the window’s behaviour are retired: they record what the window did while it was open, not live behaviour. See Closing the window at the end for what unwound.
The inference pass over each program is not advice the user may decline — it is compilation. After ir-pipespec-annotation the evaluator reads each stage’s modes off the IR, and the only thing that writes those annotations is the checker. The inference pass therefore always runs, because it produces the mode wires the evaluator cannot run without; what stays optional is solely how loudly the value-type fragment fails.
This is the fourth and last of four decisions whose end state is a single
mode-inference engine; the prior three have landed. The arc:
session-scheme-continuity (session-scheme-continuity) →
handler/alias mode preservation (handler-alias-mode-preservation) →
IR mode annotation (ir-pipespec-annotation) →
the mode pass made unconditional (here). The through-line is net code
removal: the prior three made the typed-IR rung of the
compilation-ladder real and proved the second
engine redundant. This page collects the corpses — it deletes the second engine
and its residue, narrows the --no-typecheck flag that was the engine’s last
caller to a value-type-warning window, and finally retires that window: the flag
and the fragment/verdict apparatus built to honour it come out together. One
judgment, one engine, one verdict.
Mode errors are always fatal — no expressiveness is lost
--no-typecheck today skips the static checker entirely (ral/src/main.rs,
render_type_errors gated by if !no_typecheck; boot.rs::source_config_inner,
the same gate). Once the checker is also the only source of the evaluator’s
mode wires, skipping it is no longer “trust me, run it anyway” — it is “run with
no modes”. So the mode pass separates from the value-type pass:
- A mode mismatch is fatal today anyway, just later and worse. A
∅-into-Bytesedge that the static checker would reject is, under--no-typecheck, caught at evaluation by the runtime engine’svalidate_pipeline→pipeline_mismatch(modes-equality-constrained-shared, T0012;core/src/evaluator/pipeline/analysis.rs). Check-time fatality is the same verdict, strictly earlier — before any side effect of the turn runs, with a source span instead of a stage index. Making it always-fatal removes no program that runs today; it only moves the rejection forward. - The annotation is what the evaluator consumes. With the runtime engine deleted, there is no fallback that re-derives modes from a live binding. An unannotated IR has no wires at all. “Skip the mode pass” is therefore not a policy the language can offer — it is a request to evaluate an incomplete program. The pass runs unconditionally on every evaluated IR: REPL turn, rc line, batch script, exarch turn.
Value-type errors: fatal by default, a deprecation window downgrades them
Retired. This section records the window while it was open; the flag and the
Verdict/ErrorFragmentapparatus it describes were removed in51419f6c. Value-type errors are now fatal on every path. See Closing the window.
The value-type fragment — the Hindley–Milner judgment over Str, Int, records,
lists, variants — is genuinely advice the user could decline in a way modes are
not: a value-type mismatch never feeds the evaluator a wire it needs, so
downgrading it to a warning leaves a runnable program. We commit to:
- Value-type errors are fatal by default. Every evaluated path reports them
and refuses to evaluate, matching today’s REPL and exarch behaviour (both route
through
compile_and_typecheck, which returnsCompileOutcome::Typesand skips eval —ral/src/repl/exec.rs,exarch/src/shell_eval.rs). - A deprecation-window flag downgrades them to warnings.
--no-typecheckkeeps its name for the window: zero churn for the users who already type it, and it is retired with the flag, so a rename would only add a second name to delete. Its meaning narrows from “skip the checker” to “downgrade value-type errors to warnings” — the inference pass still runs (it must, for the wires), the mode fragment is still fatal, and a value-type error prints as a warning and evaluation proceeds.
The split between the two judgments is a fact of the error type. TypeErrorKind
carries fn fragment() -> ErrorFragment { Mode, ValueType }
(core/src/typecheck/scheme.rs): a ModeMismatch is the mode fragment, a
CompTyMismatch is the mode fragment exactly when one of its component diffs is a
channel disagreement (CompDiff::Stdin / Stdout) — the two computations failed
to agree on a wire — and every other error is the value-type fragment.
typecheck_verdict (core/src/typecheck.rs) returns the three-armed
Verdict::{ Clean(Comp), ValueErrors { comp, errors }, ModeErrors(errors) }: a
clean check carries the comp annotated with schemes on the spine and wires
everywhere (annotate(_, true)); a ValueErrors carries a runnable
scheme-less-but-wired comp (annotate(_, false) — the wires were written, the
spine schemes withheld) beside its errors; a ModeErrors carries no comp, because
no consistent wire set exists. compile_to_verdict is the flag-honouring twin of
compile_and_typecheck; the latter stays intact and strict for exarch, --check,
and the build. --no-typecheck downgrades only the value-type fragment, rendered
through ariadne with Warning severity (core/src/diagnostic.rs); REPL, batch,
and rc all honour the flag, while --check stays strict regardless.
A turn whose check reports errors contributes no schemes. The window makes
reachable a turn that warns yet evaluates, so it forces a decision the total error
path of session-scheme-continuity
never had to make: what schemes seed turn N+1 off a turn whose check errored.
None. Schemes generalised out of a failed unification are not verdicts; seeding
the next turn with them would cascade one turn’s error into spurious errors on
every later use of the binding. Under the window a warned turn evaluates an IR
whose mode Wires are written as ever — the mode fragment checked clean, or the
turn would not evaluate at all — but whose Bind scheme slots stay empty: its
binds install values with no scheme, and turn N+1 checks those names as bare
names, exactly ADR 1’s rule that --no-typecheck bindings are scheme-less, with
a fresh type variable per use site. Mechanically this extends the withholding the
code already performs — typecheck’s error path returns no annotated comp
(Err(ctx.errors) before annotate_binds, core/src/typecheck.rs); the warn
path keeps that withholding for the scheme slots while still emitting the wires,
rather than annotate-and-downgrade. The symmetry with the fatal mode fragment closes it:
a turn that evaluates under the window has clean modes but possibly unsound value
types, so withholding its schemes keeps the cross-turn seed sound without blocking
the user.
Honest risk. Every checker over-constraint becomes a hard blocker the day
value-type strictness is the default. The 260601 ADR, the day mode-strictness was
first enabled, surfaced two genuine over-constraints — the higher-order
callback modes (map { echo $x }) and the try/guard/audit thunk bodies —
both fixed by making the rule mode-polymorphic rather than by relaxing it. More
are likely latent. The warnings window is the mitigation: a user blocked by a
spurious value-type error sets --no-typecheck, keeps working, and files the
case. A checker over-constraint is a checker bug to fix, not a reason to keep
the escape hatch — the window exists to buy time to fix those bugs, not to make
the checker permanently optional. The flag retires when the bug stream dries up.
rc files at boot
Today an rc file that fails its type check is skipped: source_config_inner
prints the errors and returns Err("skipped due to type errors"), which
source_config_file surfaces as one cmd_error and otherwise swallows — the boot
continues. This is exactly how the same function already handles an rc parse
error (compile(...).map_err, the same Err path), so the policy is uniform:
- An rc file is reported and skipped, never fatal to the boot. Under the
unconditional mode pass the offending rc file is reported and skipped while the
session still starts, aligned with the parse-error precedent. A broken rc must
not strand the user at no shell. This policy is live: a type error of either
kind in rc skips that file (it has no runnable annotation) but the boot survives.
(While the window was open, a value-type error in rc instead downgraded to a
warning and the file still applied — retired in
51419f6c.)
exarch needs nothing
exarch already runs mandatory checking every turn — run_shell calls
compile_and_typecheck unconditionally and has no --no-typecheck flag in its
surface at all (exarch/src/shell_eval.rs). This series merely brings the
REPL/scripts/rc world up to the bar the agent path already meets.
The harvest — what this page deletes
This is the series’ payoff. With the IR carrying ground wires and the pass
mandatory, the second engine and its scaffolding have no caller left. The harvest
landed in 602218d3 (with the three whole-file removals having physically ridden
into history alongside a concurrent session’s commits — see below):
core/src/ty.rsdeleted in full (616 lines,wc -latedf47cd9). The structural judgment, theModeUnifierunion-find,InferCtx, andCommandTypeResolution/command_is_internal(ADR 3 records theinternalfield is written at three sites and read nowhere — dead, deleted with the rest, not because it is inference but because what it classified into is unused). The static twins intypecheck/infer.rssurvive; the runtime copies go. The file grew from ADR 3’s predicted 506 lines while landingedf47cd9: the dual-run alignment added transitional code that kept the two engines’ verdicts identical until this file went —external_spec(an external head’s open input, mirroringexternal_exec_comp_ty),env_binding_type’sseen-set recursion break and lexical closure-body resolution (env_binding_type_seen), andnamed_head’s peering throughForce(Thunk(..))and aBind’srest. None of it survived the file; all of it existed only to hold the dual-run green, and died with the engine it duplicated.core/src/classify.rsdeleted in full (33 lines).prelude_command_spec/PRELUDE_BYTE_FNS— the hand-maintained["map-lines", "filter-lines", "each-line", "view"]byte-mode list, a third copy of mode knowledge the shell-free fallback consulted because it could not walk a prelude body. The baked prelude schemes and the IR annotation carry the real inferred modes; the residue is superseded.core/tests/type_system.rsremoved (65 lines). The engine-internal test target dies with the engine it exercised; the branch-join behaviour it pinned is ported to observablerhs_outputtests.validate_pipeline’s runtime unification and thepipeline_mismatchdiagnostic deleted (core/src/evaluator/pipeline/analysis.rs). They were already bypassed on annotated IR:specs_from_wiresreads ground, adjacency-consistent wires. Whatvalidate_pipelineenforced demotes to a plaindebug_assert!inspecs_from_wiresthat the adjacent wires agree (the checker unified the wires it emitted, so a debug build only asserts it).rg pipeline_mismatchhas no code hits; the static T0012 rendering is unaffected.- The
ModeStoretrait and the genericity ofunify_modefolded into the checker’s unifier (core/src/mode.rs). The trait existed for one reason: to drive the equality rule through both engines’ variable stores so they could not drift (260601’s “neither can drift again”). With one engine left the sharing mechanism has done its job —unify_modebecomes a plain method on the checker’sUnifier, andModeStore/ the runtimeModeSlot/ModeVarunion-find go with it. The lattice (PipeMode,PipeSpec,ByteMode,Wire,ModeMismatch) survives: it is what schemes serialise and what the annotation pass grounds into aWire. - ADR 3’s dual-run scaffolding deleted. The transitional
debug_assert!s that compared each annotation against the old runtime engine’s verdict —runtime_specs/stage_runtime_spec(analysis.rs, the debug-only stage oracle), thespecs_from_wirescross-check, and the cross-check arm ofrhs_is_byte_output(evaluator/comp.rs, inlined intoeval_bind_rhsonce the cross-check left it a bare read) — lost their right-hand side whenty.rswent and were removed with the engine. - The annotation slots lose their transitional optionality. With every
evaluated IR passing through annotation, ADR 3’s
Wireslots no longer need to model “checker has not run yet.” The elaborator emits a placeholder wire that the annotator overwrites; the slots are plain —Pipeline.wires: Vec<Wire>,Bind.rhs_output: ByteMode, notOption. This is the simpler shape and the right one: a read site that unwraps anOption<Wire>would be re-encoding “the pass might have been skipped” — a state this ADR makes unreachable. A non-optional slot makes the invariant a type fact, and the IR reads as inevitable rather than transitional. The elaborator’s placeholder isWire::EMPTY— the sameVar → Emptydefault the annotator writes for an unconstrained wire (ADR 3’s one defaulting rule), so an un-annotated-but-evaluated path — which this ADR forbids — would degrade to all-Emptyrather than panic, but no such path survives. - The
no_typecheckplumbing strip — DONE by51419f6c.RunOpts.no_typecheckand the clap--no-typecheckarg (ral/src/main.rs), the threading throughBatchOpts/InteractiveOptsandSession::boot→load_profiles→source_config_file→source_config_inner(ral/src/repl/session.rs,ral/src/repl/session/boot.rs), and the flag-honouring gates at the batch and rc check sites all come out. The Option-drop above did not wait on this: it required only the unconditional pass, not the window’s close. See Closing the window for the apparatus that came out with the flag.
Whole-file removals and the concurrent commit. core/src/ty.rs (616) and
core/src/classify.rs (33) were physically dropped from the tree by a concurrent
documentation session — 25bc13f5 carried both file deletions, and the
test-target removal core/tests/type_system.rs (65) rode 55ba22c3. 602218d3
completes the parcel: it lands the slot reshaping, the mode.rs fold, the
diagnostic demotion, the dual-run teardown, the loader checks, and the latent-bug
fixes that make the engine’s absence sound.
Net deletion. core/src/ty.rs (616), core/src/classify.rs (33), and the
type_system.rs target (65) come out whole. The ModeStore/unify_mode
genericity in mode.rs, validate_pipeline + pipeline_mismatch, and the
dual-run assertions are a further net reduction; the still-parked no_typecheck
plumbing across main.rs / session.rs / boot.rs is the last to go. The series
is net-negative by several hundred lines: one judgment, one engine.
Every evaluated path is now checked
The unconditional pass closed the two remaining holes where IR reached the evaluator unchecked:
- The
source/useloaders (core/src/builtins/modules.rs) loaded a file’s source and evaluated it without a check. A newcheck_sourceroutes them throughcompile_and_typecheckwith any type error fatal — a loader carries no deprecation flag (none ever reached it), so a mode or value-type error is surfaced as aBreak::Error, the file reported and not run. - Plugin files (
ral/src/repl/plugin/load.rs) likewise runcompile_and_typecheck, treating any type error as fatal.
Sandbox child bodies were already sub-nodes of annotated parents; the baked
prelude is annotated at build time. The synthetic single-stage uutils pipeline
(dispatch_uutils_via_pipeline, core/src/evaluator/command/uutils.rs) is an
external head with no upstream, so it synthesizes its own ground wire at
construction — a lone stage’s open external input grounds to ∅, its output is
bytes, mirroring PipeSpec::ext. This
was verified wire-for-wire by the dual-run asserts while the old engine still
existed, then the scaffolding went.
Two latent bugs surfaced by the work
Making the evaluator read modes off the node — rather than re-infer them at runtime — turned two previously-invisible mistakes fatal. Both are recorded here because the symptom (an empty annotation read where one was expected) is the kind a future node-reading consumer could reintroduce:
Ast::Backgroundhoisted its pipeline eagerly. The elaborator arm forcmd &lowered the inner pipeline withto_val, forcing it into a value instead of suspending it in a thunk theBackgroundnode spawns. This broke both fragments and the runtime spawn even under--no-typecheck. Fixed toBackground(Val::Thunk(body))(core/src/elaborator.rs), with three behavioural tests.eval_fuzz’s harness discarded the annotation. The test harness evaluated the bare elaborated comp, throwing away the annotation it had just computed. This was harmless while modes were re-inferred at runtime — the discarded wires were recomputed — and fatal the moment the evaluator reads wires off the node (it would read the elaborator’sWire::EMPTYplaceholder). The harness now runs the checked IR (core/tests/eval_fuzz.rs).
Closing the window
The window’s value was a deprecation buffer for checker over-constraints (see
Honest risk); with the bug stream dry, it retires. The whole fragment/verdict
apparatus 767a96bf built to honour the flag had no other consumer, so it unwinds
with the flag — a value-type error is now fatal exactly as a mode error is, which
is what the pre-existing strict path already expressed. The strip is a net
reduction of ~250 production lines:
- The flag and its plumbing.
RunOpts.no_typecheck, the clap--no-typecheckarg, and every thread —Session.no_typecheck, theno_typecheckparams onstep/execute_inputandload_profiles/source_config_file/source_config_inner— are gone (ral/src/main.rs,ral/src/repl/session.rs,ral/src/repl/exec.rs,ral/src/repl/session/boot.rs).--no-typecheckis now an unknown flag. - The three-armed verdict collapses to “clean comp / errors.”
typecheck_verdictfolds back intotypecheck— the directinfer + (errors ? Err : Ok(annotate(_, true)))it was before the window — andVerdictis deleted (core/src/typecheck.rs).CompileVerdictandcompile_to_verdictare deleted;compile_and_typecheck/CompileOutcomeis the single compile entry again (core/src/lib.rs), the path exarch,--check, and the loaders all already shared. - The fragment classification is deleted.
ErrorFragmentandTypeErrorKind::fragment()/TypeError::fragment()had no reader once the verdict split was gone (core/src/typecheck/scheme.rs). TheCompDiff::Stdin/Stdoutvariants stay — they are the unifier’s real per-channel diff structure (unify_comp_ty, rendering); only thefragment()reader went. - The warning renderer is deleted.
format_type_warning_ariadneand theReportKindparameter that distinguished it from the error render are gone (core/src/diagnostic.rs);render_ariadneandrender_messagelesshardcodeError, since every surviving site always rendered errors. The static T0012 / type-error rendering at the error sites is byte-for-byte unchanged.
The CLI-tier tests that pinned the window’s downgrade behaviour
(warn-and-evaluate, warn-and-apply-rc) retired with it; the tests that asserted
live behaviour — a value-type error blocks the binary, a mode error blocks, an rc
type error is skipped while the boot survives — were ported to the no-flag world
in ral/tests/type_errors_block.rs (renamed from no_typecheck.rs). The
in-process fragment() classification tests (core/tests/typecheck.rs) and the
warned-turn-installs-no-scheme tests (core/tests/session_schemes.rs) were deleted
with the code they exercised.
Sequencing
The planned order was independently verifiable steps; the implementation reversed
two of them. The mode pass went unconditional before the engine was deleted —
deleting the runtime fallback first would have left wireless --no-typecheck IR
unrunnable, since the unconditional pass is what guarantees every evaluated IR
already carries wires. So 767a96bf (steps 1+3) preceded 602218d3 (step 2 and
the Option-half of step 4).
- Flip authority — DONE by
edf47cd9(ADR 3’s third commit). The consumers read theWireoff the node when the checker has annotated it (resolve_pipeline→specs_from_wires→analyze_stage;eval_bind_rhs→rhs_is_byte_output), with the old engine surviving only as the--no-typecheckfallback (specs_from_runtime) and the dual-run cross-check. ADR 2’s install-timealias_arm_scheme— the single engine invoked at install, reading no IR node — survives unchanged. - Delete the engine + residue + runtime diagnostic — DONE by
602218d3(the whole-file removals having ridden25bc13f5/55ba22c3).core/src/ty.rsandcore/src/classify.rsremoved;validate_pipeline/pipeline_mismatchdemoted to the adjacent-wiredebug_assert!;ModeStore/unify_modefolded into the checker’sUnifier. The tree builds and the suite is green with those files absent. - Open the deprecation window — DONE by
767a96bf. Value-type errors became fatal by default; the flag downgraded them, the inference pass ran regardless. A value-type error warned-and-ran under the flag and blocked without it; a mode error never evaluated. The window then closed in51419f6c— the flag retired, value-type errors are fatal on every path. - Strip the flag plumbing — DONE. The
Option-drop on theWireslots landed in602218d3(it required only the unconditional pass, not the window’s close); theno_typecheckremoval fromRunOptsand every thread landed in51419f6c. Verified:rg no_typecheckempty,rg pipeline_mismatchempty.
Dependencies
- ADR 1 carries each session binding’s scheme forward, so a session-bound call site annotates with a real wire, not a defaulted one.
- ADR 2 forbids any handler or alias from changing a head’s modes after the check, so a compile-time annotation stays valid through dynamic rebinding.
- ADR 3 (landed
d4aa75be,e3c23b4b,edf47cd9) writes the wires and proves they agree with the old engine across the suite; this page is what let ADR 3’s deletion inventory actually land, by removing the unchecked fallback that was the old engine’s last caller.
Verified at the 602218d3 landing: the full test suite is green with
core/src/ty.rs and core/src/classify.rs absent — 33 test binaries, 0 failures
(the type_system target removed, the no_typecheck target added, against the
pre-series 33); -D warnings and clippy clean; rg pipeline_mismatch returns
nothing. Smokes: T0012 renders on echo foo | length; map-lines / uutils /
external pipelines run; a value-type error under the deprecation flag prints as a
warning and still evaluates, the same error without the flag blocks, and a mode
error never evaluates regardless of the flag.
Verified at the window-close landing (51419f6c): -D warnings build
and clippy clean across the workspace; the suite is green — still 33 test binaries
(the no_typecheck target renamed to type_errors_block, not dropped), 0
failures. rg finds no code reference to no_typecheck / ErrorFragment /
typecheck_verdict / CompileVerdict / compile_to_verdict /
format_type_warning (wiki hits are this retired-flag history). Net −249
production lines. Smokes: a value-type error now blocks (let x = hello; return $[$x + 1] exits nonzero with a T0010 Error — no flag to downgrade it); map-lines
and external pipelines run; --no-typecheck is an unknown flag.
See also session-scheme-continuity, handler-alias-mode-preservation, ir-pipespec-annotation, modes-equality-constrained-shared, compilation-ladder, startup, shell-eval.