The first thing I deleted today was the send operator.
Hew has carried <- since the early actor work, copied straight out of muscle memory — pid <- Msg(x), the Erlang send arrow. It reads well in a paper. It reads badly in a language where everything else you do to an actor is a method call. You call counter.total() to ask, you call counter.stop() to stop, and then for the one operation that defines the whole model you reach for a glyph that looks like nothing else in the grammar. It was the last place the actor model wasn’t honest with itself.
So <- is gone. The lexer no longer has a LeftArrow token at all, and BinaryOp::Send and Expr::Send came out of the AST with it. That part was easy. The interesting part was deciding what happens when someone types <- anyway, out of the same habit I had.
Removing the token doesn’t make the input disappear — the lexer just produces a < immediately followed by a -. So the Pratt parser now watches for exactly that: a Less whose end span touches a Minus’s start span, with nothing between them. When it sees the two tokens kissing, it doesn’t try to parse a comparison-then-negation; it stops and says what actually happened:
E_OPERATOR_REMOVED: the `<-` send operator has been removed;
use `handle(msg)` call syntax instead (HEW-SPEC-2026 §4.x)
= help: replace `target <- msg` with `target(msg)`The span-adjacency check matters. x < -1 is still a perfectly good comparison, and it has a space in it, so the spans don’t touch and the parser leaves it alone. Only the glued-together target<-msg shape trips the diagnostic. (I wrote x<-1 with no spaces into a test on purpose, just to watch it get caught, and then felt slightly bad for the future person who writes a tight comparison and gets told the send operator is gone.)
What replaces it isn’t new syntax — it’s the absence of syntax. An actor send is a method call, and it was already a method call for everything except the send itself:
// Before v0.5: the removed send glyph was used here.
// After v0.5: just a call
actor Counter {
var count: i64;
receive fn increment(n: i64) {
count = count + n;
}
receive fn total() -> i64 {
count
}
}
fn main() -> i64 {
let counter = spawn Counter(count: 0);
counter.increment(5);
counter.increment(32);
match await counter.total() {
.Ok(v) => v,
.Err(_e) => 0,
}
}The send glyph is gone but nothing underneath it changed. counter.increment(32) still posts a message to a mailbox, still returns immediately, still gives you no reply. await counter.total() still does the ask-and-wait. The OTP semantics — the mailbox, the one-message-at-a-time discipline, the supervised lifecycle — are exactly what they were. I only changed how you spell the most common thing you do. This one runs end to end today; main returns 37.
The lambda actor
The other half of the spawn cleanup is the inline actor. Hew used to have a spawn (params) => body lambda form, which had the same problem as <-: it was a special shape that didn’t look like anything else. The parser now rejects it with a fixit and points at the replacement, which borrows the closure’s own parameter syntax:
// A lambda actor: an inline body over a typed mailbox.
let doubler = actor |x: i64| {
println(x * 2);
};
doubler.send(21);actor |x: i64| { ... } is a closure that happens to own a mailbox. The parameter list is the message type, the body runs once per message, and the whole thing is a handle you can .send to. Under the surface it composes over a Duplex<S, R> — a channel with two independently bounded queues, one each direction — so the same machinery carries both a fire-and-forget tell and a request/reply ask.
The detail I care most about is the self-reference. A lambda actor’s body can send to itself, and the obvious implementation — capture a strong handle to the actor inside its own body — is a reference cycle that never drops. So the body captures itself weakly. A self-send upgrades the weak handle just long enough to dispatch; if the upgrade fails because the last external handle is already gone, the send surfaces as ActorStopped rather than resurrecting a dead actor. The actor’s liveness is decided by who holds it from outside, never by the work it has queued for itself.
Here is the honest part: that snippet parses and type-checks today, and it does not yet run. The runtime carrier exists — a dual-queue Duplex with refcounted send and receive capabilities, and a lambda-actor instance wrapped around it with the strong/weak split wired in. The type checker knows the method shapes: .send, .recv, .send_half(), .recv_half(), .close(). What’s missing is the lowering that connects the surface to the carrier, and rather than guess, the compiler stops at the boundary:
E_CODEGEN_FRONT: codegen-front validation failed: fail-closed:
SendHalf / RecvHalf / LambdaActorHandle Place lowering is not yet wiredThat’s the right failure. The alternative — emit something, lower it to some call, ship a binary that may or may not do what the source says — is exactly the kind of quiet wrongness this whole rewrite is trying to delete. A surface that parses but can’t lower yet should say so, loudly, at compile time. It should never fabricate a binary.
(Update, June 2026: the lowering landed. That exact snippet now compiles and runs — prints 42, exits 0. The diagnostic above is what you got in May, and it held the line until the wiring was real.)
The one piece of that boundary I did get to run is a real bug check. When you call .send_half() on a Duplex, you hand one direction’s capability to a new binding, and the unified handle must not also close that direction when it drops. The structural checker already caught that collision inside a single drop list, but it couldn’t see the case where the split happens in one block and the stale drop fires in a later one. So there’s now a forward dataflow over the MIR that tracks each Duplex local as Live, Consumed, or MaybeConsumed across the whole control-flow graph, and rejects a unified-handle drop that fires after the handle was split on any path. It’s a no-op for code that never touches a duplex, so the plain integer programs pay nothing for it.
Machines get a front end
The actor work was the planned headline, but the machine surface needed the same treatment, so it got the rest of the day. A machine is a state machine the compiler actually understands — states, events, transitions — and today it grew entry and exit hooks, an emit action, and the keyword form that makes the whole thing check:
machine Signal {
events {
Start;
Stop;
Ready;
}
emits {
Ready;
}
state Idle;
state Active;
on Start: Idle => Active {
emit Ready {}; // declared in `emits`, checked at compile time
Active
}
on Stop: Active => Idle {
Idle
}
on Start: Active => Active reenter {
Active
}
on Stop: Idle => Idle reenter {
Idle
}
on Ready: Idle => Idle reenter {
Idle
}
on Ready: Active => Active reenter {
Active
}
}A few things are being checked here that you can’t see in the code. The match over state-by-event has to be exhaustive — every event has to have a transition from every state, which is why the reenter arms exist for the events that just hold their ground. Anything you emit has to be declared in the emits block — the manifest is optional, but once you write one the compiler holds you to it — and the emit graph is checked for cycles, so a transition can’t kick off an event that loops straight back into itself through emits. And a state can’t nest another state inside its body — the parser rejects that with a hierarchical-states diagnostic, because nested states are a different feature with different semantics and I don’t want a half-version sneaking in through a parse arm. Unlike the lambda actor, this one lowers: it type-checks and it runs.
(Update, June 2026: composite states have since landed as the real feature — a state body can nest one level of substates now, with deeper nesting still reserved, and the checker demands exactly one initial state. The rejection above did its job: it kept the half-version out until the whole version arrived.)
Scope becomes a block
The last cutover of the day wasn’t machines at all. I also moved structured concurrency to a block. The old form was scope |s| { ... }, threading an explicit scope object you had to name and pass around; the new form is just scope { ... }, a lexical region that owns whatever you start inside it and won’t return until they’ve all finished. The verb for starting a child stays fork:
scope {
fork worker(1);
fork worker(2);
}
// the scope does not return until both forks completescope { } lowers today. fork parses today and is honest that it doesn’t lower yet — the diagnostic literally says type checking lands in a follow-up — which is the correct state for it to be in on day one. The shape is decided, the keyword is real, the lowering is queued.
(Update, June 2026: “queued” was optimistic, but part of the picture landed. scope { fork { zero_arg_call(); } } lowers in v0.5.0 — zero-argument fork and the join-on-exit machinery both work. However, scopes and forks only run in suspendable contexts: actor handlers, closures, and task entries. A scope in main or a free function is refused with a diagnostic. The name-bound form fork name = call(...), argument-bearing forks, value await on a fork, and sibling cancellation are specified but refuse — they land in a follow-up release.)
That’s most of what today was: not building the v0.5 actor runtime, but deciding what the language says before any of it turns into a binary. <- is gone, spawn (...) => ... is gone, scope |s| is gone, and in their place is a surface where sending to an actor looks like calling it, an inline actor looks like a closure, a machine declares what it can emit, and a scope is a block. The parts that lower, lower. The parts that don’t, say so.