Split Shell by lifetime; make turn state a field
A turn installs one dynamic frame, so Shell should name that frame as one
field. eval_turn’s guard (core/src/turn.rs)
currently saves and restores a scattered set of fields: captured IO and
surface from Local, the foreground cancel, and part of
Location. The source registry half of Location is deliberately kept so both
hosts can render runtime errors after eval_turn returns
(ral/src/repl/exec.rs,
exarch/src/shell_eval.rs). The code works,
but the invariant is invisible: “the turn-local part” is encoded by parallel
assignments.
The better cut is not a small overlay. It is a Shell shape whose fields are the
lifetimes:
Shell
├─ mobile — persistable computation state
├─ turn — the installed top-level turn frame
├─ session — state that survives turn teardown
└─ local — host-local scratch with its own flow rulesThis is wider than the guard-only patch, but the final code has fewer and more
obvious invariants: eval_turn builds a TurnState, swaps shell.turn, runs,
publishes the cancel slots for the same extent, and restores the old turn on
drop.
Context
The current split crosses lifetimes:
Localmixes turn, session, and scratch.io,surface, andcancelare swapped for a turn;durable_rootandexit_hintslive for the session;auditandrepleach have their own inherit/return rules (core/src/types/shell/mod.rs).Locationmixes cursor and registry.script,line,col,source,current, andcall_siteare the active source cursor;db: SourceDbis the durable registry used at render time (core/src/types/audit.rs).FrameGuardknows the hidden membership.installswaps IO only underIoFrame::Capture, always swaps the foreground cancel, saves four location fields, callsinstall_root_context, and restores the saved fields inDrop(core/src/turn.rs).
The guard is a symptom. The missing type is the installed turn frame.
Decision
Split Shell by lifetime:
pub struct Shell {
mobile: Mobile, // lexical scope, control, dynamic context
turn: TurnState, // io, surface, foreground cancel, location cursor
session: SessionState, // durable root, source registry, exit hints
local: LocalState, // audit and REPL/editor scratch
}The field names are the invariants. A field either moves as a turn, survives a turn, crosses evaluation boundaries, or stays as host scratch.
TurnState
TurnState is the whole dynamic frame installed by a top-level turn:
struct TurnState {
io: Io,
surface: Option<SurfaceSink>,
cancel: ForegroundScope,
loc: LocationCursor,
}- IO is turn-local.
IoFrame::Captureseedsiowith buffers and an optional surface sink.IoFrame::Inheritseedsiofrom the ambientshell.turn.io/surface. The swap is total in both cases; only the seed differs. - The foreground scope is turn-local. The scope is installed as
turn.cancelfor the turn’s extent and published to the signal-reachable foreground slot for exactly that extent. - The location cursor is turn-local.
LocationCursorcontains every non-registry source-position field:script,line,col,source,current, and the fullcall_site.SourceLocconstruction reads the cursor; rendering later resolves the carriedFileIdagainst the durable registry.
The old SavedIo: Option disappears. Under Inherit, IO is still present in
the next TurnState; it is just inherited from the previous one.
SessionState
SessionState is the part that must outlive every turn:
struct SessionState {
root: DurableRoot,
sources: SourceDb,
exit_hints: ExitHints,
builtins: BuiltinTable,
}rootis the durable cancel root. Detached workers parent under it; the foreground scope is always a child of it (unify-turn-evaluation).sourcesis the durable source registry. A turn resets and seeds it at turn start, module loads append to it, and hosts read it after the turn returns to render runtime errors. It is durable across the teardown ofturn.loc, not across all future turns.exit_hintsis startup-loaded session data. It is not part of the dynamic frame and is not restored byeval_turn.builtinsis the host-installed command table. Builtin bodies are Rust function pointers or captured host closures; they are process-local dispatch state, not serialised ral values.
This removes the present Location half-split: the cursor is a turn field, the
registry is a session field.
Mobile
Mobile stays the persistable computation state:
struct Mobile {
scope: Env, // lexical value bindings
control: ControlState,
context: Context, // env overrides, cwd, grants, handlers, args,
// modules; no Location, no builtins
}Context no longer owns source position or builtin dispatch. The source cursor
is not mobile: it is installed by the current turn and discarded on teardown.
SourceDb is not mobile either: it is session render state and intentionally
skipped by IPC/serde. BuiltinTable is also not mobile: it is host/session
dispatch state, supplied by the receiving process rather than shipped in a
WireMobile.
IPC follows the same rule as today, but without a special case inside
WireContext: the wire mobile carries lexical scope, control, env/cwd/grants,
handlers, args, and modules. It does not carry builtin bodies. The receiver
starts from its own booted Shell, with core and host builtins already installed,
then installs the wire Mobile while preserving session.builtins. A
first-class builtin value, when one exists, crosses only as a synthesized ral
lambda/block that dispatches by bare name; the receiver resolves that name
through its own builtin table and handler stack.
LocalState
LocalState is deliberately small:
struct LocalState {
audit: Audit,
repl: ReplScratch,
}It is not a lifetime category. It is the residue whose members already carry their own flow manifests: audit has lexical ownership and capture policy; REPL scratch is host/editor state. Keeping only those two here avoids renaming the leftovers into a false “durable” bucket.
The turn guard
eval_turn becomes a one-field swap:
let next = frame.build_turn(shell)?;
let mut guard = shell.install_turn(next);
let _fg = publish_foreground(guard.shell().turn.cancel.as_scope());
let _rt = publish_durable_root(guard.shell().session.root.as_scope());
run;TurnGuard owns the previous TurnState and restores it in Drop. The
publication guards live inside the same dynamic extent, and must drop before the
old TurnState is restored so the signal slots never point at a scope whose
owning frame has been removed.
This must be a Drop guard, not the existing run_with_mobile shape:
Shell::run_with_mobile documents
that it does not restore on unwind. A turn guard must restore on unwind
because exarch catches worker panics and continues the session on the same
Shell (unify-turn-evaluation).
Child shells and IO flow
Moving IO into TurnState does not mean same-thread children share the same
object blindly. The existing IO flow is load-bearing:
- stdout/stderr are cloned where possible;
- stdin is read-once, moved into the child, and returned to the parent;
- capture depth, terminal state, and job-control eligibility follow the child.
So Io::inherit_from / Io::return_to move under a TurnState flow manifest:
TurnState::inherit_from(parent_turn)
TurnState::return_to(parent_turn)Only the turn-local pieces participate: IO and surface follow the same-thread
body, and the foreground cancel is shared by same-thread work. A spawned
worker does not inherit the foreground TurnState; it builds a fresh one under
the durable root with its own IO and worker scope.
Visibility
The split should not export new ways to violate the invariants. Visibility is part of the design, not cleanup.
- Raw fields are core-internal.
Shell::{turn, session, local}and the internals ofTurnState/SessionStateshould bepub(crate)or private insideral_core. Internal evaluator/runtime modules may write them directly; host crates should not. - The invariant-bearing handles are opaque.
DurableRootandForegroundScopeare public handle types with private fields. AForegroundScopeis constructed only from aDurableRoot, so an unrelated root cannot be installed as a turn’s foreground by accident. - Hosts get narrow accessors, not fields. The public surface should be the
operations hosts actually need:
- build a foreground scope for a frame, clone/cancel/inspect it for deadlines;
- read
SourceDbafter a turn for diagnostics; - load
exit_hintsand install host builtin sets during bootstrap; - snapshot and restore
Mobileat exarch’s clean tool-call boundary; - inspect terminal/interactivity state needed by frontends.
Mobileremains the intentional embedding seam. Exarch already snapshotsMobileand restores it after a caught tool panic. Keep that seam explicit with methods (mobile_snapshot,restore_mobile) rather than exposing the new turn/session fields just because the oldShellfields were public.
This keeps the low-LoC internal shape while preventing the host boundary from becoming another convention. The fields that encode turn safety are not a public API.
Consequences
-
The main invariant is one sentence. A turn replaces
shell.turn, runs, and restores the oldshell.turn. -
The source-location split is complete. There is no struct with both turn-local cursor fields and durable registry fields.
-
The foreground/root relation is typed. A foreground scope is minted from the durable root; detached workers use the root path, foreground work uses the turn path.
-
The broad rename is real. Existing call sites move roughly as follows:
shell.local.io → shell.turn.io shell.local.surface → shell.turn.surface shell.local.cancel → shell.turn.cancel shell.local.durable_root → shell.session.root shell.local.exit_hints → shell.session.exit_hints shell.mobile.context.location.db → shell.session.sources shell.mobile.context.location.* → shell.turn.loc.* shell.mobile.context.builtins → shell.session.builtins -
The public host API shrinks after the move. Some host code currently reaches directly into
shell.local/shell.mobile.context.location.db; the cutover should replace those reaches with named methods rather than recreate the same visibility leak under the new field names.
Alternatives considered
- Keep the field-by-field guard. Rejected: it preserves the hidden membership rule and makes every future turn-local field a manual save/restore hazard.
- Add only a small
TurnLocaloverlay. Rejected: it names the swapped bundle but leavesShellitself lying about lifetimes. The code still sayslocal.io,local.cancel, andmobile.context.location. - Move only IO/surface. Rejected: it removes
SavedIobut leaves foreground cancellation and source position as ad-hoc guard state. - Keep
Locationwhole underMobile. Rejected: it keeps durableSourceDband turn-local cursor fields in one struct, which is the core ambiguity this decision removes. - Make every field private immediately. Rejected as a migration tactic:
Mobileis already the embedding seam and many host/bootstrap paths configure it. Privacy should be tightened around the new invariant-bearing state first, then the older host conveniences can be narrowed separately.
Resolution
Implemented. The lifetime split, the turn guard, and the visibility discipline are all in the tree; both open questions are settled.
- The turn guard owns signal-slot publication.
TurnGuard::installswapsshell.turn, then publishes the foreground scope and durable root for the swap’s extent;Dropdrops the publications before restoring the previousTurnState, so a signal slot never points at a removed frame (core/src/turn.rs). Mobile’s broad field visibility is gone.Shell::mobileis nowpub(crate)alongsideturn/session/local; no host crate reads aShellfield directly. Every reach goes through an intent verb — the durability seam is the opaque-MobileSnapshotpairmobile_snapshot/restore_mobile(a host can rebind only a checkpoint it took, never a forged or wire-borne bundle), and the scope/context/control reads and writes have named accessors. The deferred embedding-API cleanup the “make every field private immediately” alternative set aside has thereby landed as its own step.
See also unify-turn-evaluation (the
turn lifted into ral_core), a-turn-end-to-end
(the turn flow), evaluator-machine (the
Mobile/Local split this supersedes), structural-bug-prevention
(the missing-type discipline), and host-embedding-api
(the embedding seam hosts cross).