Three Spec Revisions in Three Days

· 5 min read
Building Hew: Part 4 of 16

I was writing a counter actor — about as simple as concurrent programs get — and the code looked like this:

counter.send(Increment { n: 10 });
let count = counter.ask(GetCount {}).await;

Increment is a wrapper struct that exists only to carry the number 10. GetCount is an empty struct whose only purpose is to ask a question. The compiler was happy and the program ran, but every line felt like paperwork.

(This is how you end up with three spec revisions in three days.)

Dropping the Borrow Checker

Most systems languages reach for a borrow checker, and for good reason. But the biggest decision in v0.4 (February 12) was removing it entirely from actor internals.

“The Rust borrow checker exists to prevent data races in concurrent code. In Hew, actors are single-threaded and process one message at a time. Mutable aliasing cannot cause data races within an actor.”

If you’ve ever fought a borrow checker for code that can’t possibly race — shuffling lifetimes around a loop body, restructuring perfectly clear logic to satisfy the checker — the appeal is immediate. Actors are already isolated. Each one owns its own heap, runs single-threaded, and communicates only by deep-copying messages across boundaries. The guarantee a borrow checker provides is already there, enforced architecturally.

So: safety at the boundary, freedom within it. Inside an actor, you get normal mutable variables without lifetime annotations. Types crossing actor boundaries must be Send (deep-copied). Per-actor RAII handles cleanup. Whether this holds as programs scale, I honestly don’t know yet.

Same revision also changed generics from Vec[T] to Vec<T>. Most developers already read angle brackets as “parameterized by,” and the familiarity made code noticeably easier to scan. The spec grew from 1,992 to 3,018 lines.

A single commit then added 4,206 lines: closures with environment structs, Vec and HashMap method dispatch, Option/Result constructors, the ? error propagation operator, and 12 string operations. Tests jumped to 396 passing, 44 examples compiling. Before that commit, you could define types and send messages. After it — iterating collections, handling errors, manipulating strings — Hew crossed the line from prototype to something that felt like a real language.

Which made the remaining rough edges impossible to ignore.

The Messaging Overhaul

Remember the counter example? That was real v0.4 syntax. On February 13, the whole messaging model got overhauled:

Before (v0.4):

counter.send(Increment { n: 10 });
let count = counter.ask(GetCount {}).await;

After (v0.5):

counter.increment(10);           // fire-and-forget
let count = counter.get().await; // request-response

The wrapper structs are gone. The send/ask distinction is gone. Interacting with an actor looks like calling a method — the compiler figures out intent from the return type. If a receive function returns void, the call is fire-and-forget. If it returns a value, the caller gets a future.

The <- operator came in for lambda actors:

actor <- message;

And new concurrency primitives replaced patterns that had required manual future management:

select { a.get() => ..., b.get() => ..., after 5s => ... }
race { fetch_from_a(), fetch_from_b() }
join { task_a(), task_b(), task_c() }

select waits on the first actor to respond. race runs concurrent tasks and returns the first result. join waits for all to complete.

(A later revision, v0.6.0, consolidated race into select — having two ways to await the first result was exactly the kind of ambiguity the rest of this process was trying to eliminate. More on that in Part 7.)

What Broke

After v0.5, I ran a simulated “first-time user” exercise — 15 programs, written from scratch. The results were humbling.

The good parts were genuinely good. Structs, enums, pattern matching, actors all worked as expected. let counter = spawn Counter(0); counter.increment(10); read well and behaved correctly.

And then there were the other parts. The type checker flagged errors but programs ran anyway. (Is the type checker wrong? Is the runtime too permissive? Both, it turned out.) The lambda syntax in the spec didn’t match what the compiler accepted. Without else if chains, conditional logic turned into deeply nested blocks. Some error messages exposed raw MLIR details instead of Hew-level diagnostics — nothing says “not ready” quite like an SSA value showing up in an error meant for end users.

Every one of those failures was specific and actionable. They fed directly into v0.6 (February 15): six syntax consolidations, three grammar simplifications, five semantic clarifications. No new features — just removing the rough edges testing had exposed. Else-if chains added. Pipe lambda syntax removed, because there were too many ways to express the same thing.

The spec started at 394 lines and ended at 3,018+.