Design rationale

Reference material, synced from the compiler repo’s spec/. The guide is the gentler path in.

For each major syntactic construct in the Hale grammar, this document records:

  1. What the construct does.
  2. What the framework commits the design to. (The closed-graph evidence that constrains the design.)
  3. What the syntax commits to. (The specific surface choices and what they imply.)
  4. What was considered and rejected.

The goal is that a future reader understands not just what the grammar parses but why it parses that and not something else.


Foundational axiom: types are for shapes, loci are for flow

Section titled “Foundational axiom: types are for shapes, loci are for flow”

Hale commits to a clean two-primitive split at the declaration level:

If a thing has lifecycle, it is a locus. If it is pure data, it is a type. There is no third category at v0; the split is clean.

Recursive principle. Loci are the fundamental building block at every layer of an Hale program: an app is a locus; a library namespace is a locus (empty params { }, only methods — the namespace-locus pattern); a long-running service is a locus; a goroutine-equivalent is a locus; a bus subscriber is a locus; an HTTP-handler is a locus; a cache / pool / pipeline / queue is a locus. Inside any locus, behavior is itself a locus tower one layer down. The recursion bottoms at primitive operations (arithmetic, single field reads, primitive calls). Everything above the floor is loci nested in loci.

This axiom underlies most of the per-construct rationale that follows: every section answers some shape of “why is this piece of locus syntax in the language?” The answer, in every case, is that flow needs lifecycle / contracts / projection / recovery, and locus is the syntactic surface those four attach to. Type declarations need none of that — they are pure shape — so they have a separate, much simpler surface (see also spec/types.md).

Full design note: notes/hale-types-vs-loci.md.


Design discipline: substrate invariance

Section titled “Design discipline: substrate invariance”

Hale is written largely by agents and read largely by humans, which makes “who is at the keyboard?” an available argument in every design discussion. It is not a valid one, and this section exists so that it has a standing answer rather than being relitigated each time.

The rule. A language decision is justified by what it does for reasoning, never by who is expected to do the reasoning. “An agent will not mind the ceremony” and “a human needs something more familiar” are both out of order.

Why it holds. The founding claim — that systems outgrow working memory, so the language should hold the architecture — names no substrate. Locality, one-construct-one-meaning, and a diagnostic that reports the actual witness path all reduce what must be held in mind to be correct. Working memory is bounded for every reasoner; the bound differs, the shape of the constraint does not.

Where a real difference between agents and humans does exist, it is in acquisition cost — typing volume, memorising unfamiliar syntax, discovering an API without completion. Those are tooling concerns (hale fmt, the LSP, hale doc), and no language-level question turns on them. Capture semantics, sequence ownership and effect propagation are not decided by keystroke count. So the differential lives outside the region where design decisions are made, and ignoring it costs nothing.

Why the rule is load-bearing rather than merely tidy. Designing toward either substrate fails in a specific way. Assume agents absorb friction and the result is a language nobody can audit — which forfeits the entire point of making architecture checkable. Assume humans need familiarity and the surface converges on whatever is already popular, losing the model. Refusing to reason about the substrate forces every feature to justify itself on reasoning grounds, which is the only justification that survives contact with the next maintainer anyway.

The evidence is in the defect record. Every substantive correctness bug found in the effect system has been a reasoning-support failure that misleads any reader equally:

None of those are worse for one kind of reader. A false certificate is false to whoever relies on it.

Corollary. The same rule disposes of the inverse argument. Ergonomic work — better diagnostics, completion, fewer gratuitous surfaces — is not a concession to human users to be weighed against rigour. It is reasoning support, and it earns its place on the same grounds as everything else here.


0. Surface language: Go-shaped

Section titled “0. Surface language: Go-shaped”

Commits to. Familiar syntax for engineers; braces for blocks; semicolons as statement terminators; let for binding; fn for functions.

Why. The first authors of programs in this language are agents and humans collaborating, often already fluent in Go. Surface familiarity reduces cognitive cost and lets the genuinely-novel parts (lifecycle, contract, projection class, mode, closure) carry the unfamiliarity.

Considered and rejected.


1. ASCII-only source, names instead of Greek

Section titled “1. ASCII-only source, names instead of Greek”

Commits to. phi, sigma, B, c, k_max, sum, prod, approx / ~~. No Unicode operators in source. Renderer can produce Greek for human display; the source is ASCII.

Why. The ancient texts’ named-concept registry already commits to: source uses names, renderer produces symbols. Hale inherits this. Agent-first authorship benefits from no symbol-input friction. Tooling is simpler.

Considered and rejected.


2. Locus declaration

Section titled “2. Locus declaration”
locus Fitter : tier 4, projection chunked {
params { ... }
contract { ... }
bus { ... }
birth(...) { ... }
accept(child: Strategy) { ... }
run() { ... }
drain() { ... }
dissolve() { ... }
on_failure(c: Strategy, err: Error) { ... }
mode bulk(...) -> ... { ... }
mode harmonic(...) -> ... { ... }
mode resolution(...) -> ... { ... }
closure pnl_attribution { ... }
}

Commits to. A locus is the unit of declaration. Annotations (tier, projection class) are optional; default tier is inferred from nesting; default projection class is chunked if the locus declares accept and the compiler cannot statically determine N.

Lifecycle members, mode declarations, failure handlers, and closure tests all live as members of the locus body, not as separate top-level declarations. This keeps the framework’s “every locus is its own substrate-cell” commitment syntactic: everything about a locus is in its block.

Why. Every locus needs a stable identity for the compiler to reason about. The locus is the unit of memory region, of lifecycle, of closure-test scoping, of contract — making it the syntactic block keeps these aligned. Tier and projection-class annotations are optional because the framework’s own discipline permits inference (multi-perspective stability doesn’t require hand-declaration; learned values are commit-after-N=3).

Considered and rejected.


3. params block (also: locus state)

Section titled “3. params block (also: locus state)”
params {
B: int = 1_000_000;
c: int = 1000;
sigma: int = 10;
phi: float = 1.0;
capital_usd: decimal = 1_000_000.00d;
running_sum: int = 0;
inferred_param: int : inferred;
}

Commits to. Each param has a name, a type, and either a value (compile-time-evaluable expression) or : inferred. The compiler treats hand-declared values as priors and inferred values as to-be-determined (statically by the compiler if possible, otherwise at runtime via the lotus runtime’s perspective-stability machinery).

params is also the locus’s state. Following Ruby’s @foo pattern, Hale collapses the params-vs-state distinction. The declared params are simultaneously:

  1. Birth-time defaults: overridable at instantiation (Aggregator { running_sum: 100 }).
  2. Runtime mutable state: accessible and reassignable via self.foo throughout the locus’s lifetime, from any lifecycle method, mode block, closure, or member function.

There is no separate state { ... } block. A locus’s state is its params; its params are its state.

Why. Multi-perspective stability is the framework’s commit discipline. Hand-declared values are perspectives the author provides; inferred is “no perspective yet, system finds one.” Collapsing params and state means the same surface is both the declared-perspective surface and the running-state surface; no artificial barrier between them. Aligned with how Erlang processes hold state (one mutable bundle per process) and how Ruby instance variables work (@foo is both a parameter and an instance variable).

Considered and rejected.


4. contract block

Section titled “4. contract block”
contract {
expose position_size: decimal;
expose pnl: decimal;
consume book: Resolution<MarketBook>;
consume volume: Bulk<VolumeEvent>;
}
contract: inferred ;
contract {
expose explicit_field: int;
expose inferred ;
consume inferred ;
}

Commits to. expose declares fields visible to coordinators above; consume declares typed dependencies on coordinatees below. Either may be inferred. A contract may be entirely explicit, entirely inferred, or mixed.

Why. This is the contract-graded visibility commitment from the design conversation. The contract is what mediates access between L’s region and C’s sub-region — physical layout is hierarchical, logical access is contract-mediated. Making this syntactic keeps the visibility rule auditable: a reader knows exactly what a locus exposes and what it depends on without having to read the whole body.

inferred lets the compiler synthesize the contract from the locus body (compile-time inference) or learn it from runtime observation (NN-style inference). The framework’s discipline guards against bad inference: a learned contract must satisfy closure tests and respect substrate-derivation anchoring.

Considered and rejected.


5. bus block

Section titled “5. bus block”
bus {
subscribe "fitter.observation" as on_observation of type Observation;
subscribe "fitter.kernel.updates" as on_kernel of type KernelUpdate;
publish "fitter.drift" of type DriftReport;
}

Commits to. External typed message bus is a first-class declarative surface. The grammar names subscriptions and publications without committing to a specific bus implementation (NATS, Unix sockets, shared memory, UDP multicast). The runtime binds the bus block to the actual transport at link / startup time.

Why. The running example needs UDP multicast input. Future programs will need NATS, Kafka, or other transports. Declaring the bus interface in source means the language can typecheck the messages flowing in/out without committing to a specific runtime. This also enables the perspective-shipping contract between fitter and applier binaries — both compile from the same Hale source, both have type-level agreement.

Considered and rejected.


6. Lifecycle blocks

Section titled “6. Lifecycle blocks”
birth() { ... }
accept(child: Strategy) { ... }
run() { ... }
drain() { ... }
dissolve() { ... }

Commits to. Five named lifecycle states, declared as parameterized blocks within a locus. birth is invoked once at locus instantiation; accept is invoked on each coordinatee attachment; run is the steady-state loop; drain halts new work but lets in-flight finish; dissolve frees the region.

Why. Failure-recovery is parent-policy-driven, and recovery primitives (restart, quarantine, etc.) need named states to operate over. The compiler enforces the state machine. Missing transitions get compiler-supplied defaults (e.g., default dissolve frees the region; default drain waits for inflight messages).

Considered and rejected.


7. mode declarations

Section titled “7. mode declarations”
mode bulk(input: [Book]) -> VolumeProfile { ... }
mode harmonic(input: [Book]) -> StrategyProfile { ... }
mode resolution(input: [Book]) -> SingleDecision { ... }

Commits to. Three modes (bulk / harmonic / resolution) are language-native; user defines any subset; compiler emits one implementation per declared mode.

Why. Modes are a substrate primitive from the ancient texts — the commitment that one kernel has three projections. Making them syntactic means the compiler can:

Considered and rejected.


8. on_failure handler

Section titled “8. on_failure handler”
on_failure(c: Strategy, err: Error) {
match err {
Error::Timeout(_) -> restart_in_place(c);
Error::Corruption(_) -> quarantine(c) for 30s;
Error::Capital(_) -> bubble(err);
_ -> dissolve(c);
}
}

Commits to. A locus declares one on_failure handler that the runtime invokes when a coordinatee fails. The handler receives the failed coordinatee and the error, and chooses among recovery primitives.

Why. The framework’s failure-traversal commitment is vertical-only (failures flow up to the parent), and the parent makes the policy decision. Locus-attached on_failure is the syntactic home for that policy. Per-coordinatee-class overrides live in the contract (consume member with a per-class on_failure); not yet in v0 grammar but reserved.

Considered and rejected.


9. closure blocks

Section titled “9. closure blocks”
closure pnl_attribution {
sum(intent.pnl) ~~ sum(book.realized_pnl) within 0.05d;
epoch tick;
persists_through(restart_in_place, quarantine);
resets_on(dissolve, replace);
}

Commits to. A closure test is a structural audit declared at a locus. The first non-clause in the body is the assertion: two expressions and a tolerance. Subsequent clauses control epoch boundaries and recovery interaction.

Why. Cyclic-closure is a substrate primitive from the ancient texts. Making it syntactic enables:

The ~~ operator is reserved for closure assertions only (per precedence.md); using it elsewhere is a parse error.

Cycle-existence rule. A closure assertion must observe at least one runtime-varying value. An assertion whose left and right are both pure literals (no identifiers, no self, no calls) is a compile error: the result is fixed at compile time and the closure can’t audit anything. This is the first narrowing of the cycle-existence rule; the deeper version (left and right reach a common producer through some causal chain) requires the typechecker to track param-to-param dataflow and lands in a later iteration.

Field references inside closure assertions resolve through the strict locus surface (params + methods + self.children

Considered and rejected.


10. perspective declarations

Section titled “10. perspective declarations”
perspective Kernel<T> {
params {
scale_row: [decimal; 8];
sigma_factor: decimal;
regime_id: int;
}
stable_when {
// Held to be stable when ≥3 perspectives have validated
// and the closure tests at the producing locus pass.
return num_validated >= 3 && closure_status == ok;
}
serialize_as KernelV1;
}

Commits to. In the transport-driven hot-load model (the aspirational path — see semantics.md § “Perspective hot-load”; the shipped perspective is the in-process contract + slot), a perspective is a serializable parameter bundle within a shared compiled-in schema. Both producer (fitter) and consumer (applier) compile from the same Hale source, so the type is the contract; the bus carries only parameter values.

Why. This is the fitter/applier split: one process fits parameters from observations; another applies them at high frequency. The serialization format isn’t a separate concern — it’s the perspective type. Compile-time type agreement between binaries means no protocol-versioning handshake; the schema version is the source-code version they both compile from.

stable_when is a function-level boolean expression that the runtime evaluates to decide whether a perspective is ready to ship. This puts multi-perspective-stability into the source.

Considered and rejected.


11. Region-based memory, no GC

Section titled “11. Region-based memory, no GC”

(No grammar surface — this is semantic. Documented for posterity.)

Commits to. Each locus has a private memory region. C’s region is a sub-region of L’s region. Allocation within a region is locus-scoped; dissolution frees the region wholesale. No garbage collector; no borrow checker.

Why. The framework’s recursion property gives the hierarchy for free. Hale’s locus-lifecycle methods give the deterministic free-points. The contract block gives the access discipline. Together they give region-based memory management without the inference problems that have historically made region-based MM hard (Tofte-Talpin region inference is hard; here, the hierarchy is explicit in the source).

The projection class is a perspective-resolution commitment — a declaration of what observation granularity the locus serves to perspectives one tower up. Storage strategy is downstream of that commitment, not the commitment itself:

The compiler picks the allocator based on the locus’s declared resolution. The resolution choice is what’s load-bearing; the allocator is its implementation.

Considered and rejected.


12. Sum / product reductions in the grammar

Section titled “12. Sum / product reductions in the grammar”

sum(expr), prod(expr) are language-native primary expressions rather than stdlib functions.

Why. Closure assertions reference sum constantly. Putting the reduction operators in the grammar means:

Other reductions (min, max, count) are stdlib. sum and prod are special because they appear in framework-primitive expressions.


13. Time and duration as language-native types

Section titled “13. Time and duration as language-native types”
let t: time = `2026-05-08T12:00:00Z`;
let d: duration = 5s;
let timeout: duration = 100ms + 50us;

Why. Trading and any closure-test-with-band system needs time and duration as first-class. Making them lexical avoids the “is 5s a string or a duration” ambiguity and prevents unit-confusion bugs.


14. decimal as a primitive type

Section titled “14. decimal as a primitive type”

Why. Floating-point arithmetic is wrong for money and any other fixed-precision domain. Hale makes decimal a primitive distinct from float, with semantics matching the shopspring/decimal Go library. Decimal literals use the d suffix (1.50d).


15. Generics and projection-class generics

Section titled “15. Generics and projection-class generics”

Rich<T>, Chunked<T>, Recognition<T> are not stdlib types; they’re language-native generic constructors. The compiler recognizes them and selects the appropriate allocator / implementation based on which projection-class wrapper a value carries.

Commits to. A user can write code parametric in projection class:

fn process<P: ProjectionClass, T>(input: P<T>) -> P<Result> { ... }

The compiler picks the body specialization based on P’s concrete instantiation.

Why. This is the “same source, different generated allocator” commitment. Without language-level projection-class generics, the user would have to write three nearly-identical functions for Rich<T>, Chunked<T>, Recognition<T>. With them, one function compiles to three.


A. Locus instantiation and handles

Section titled “A. Locus instantiation and handles”

(Added in v0.1.1, after the hello-world example surfaced this.)

A locus is instantiated using struct-literal syntax:

let h = Hello { greeting: "hi" };
Hello { }; // unbound; locus dissolves at statement end

The compiler distinguishes locus instantiation from struct construction by what Hello is declared as. The semantic difference is significant:

birth() runs to completion before the instantiation expression returns. If birth() panics, the runtime emits a failure event that the parent’s on_failure handles (or defaults to process exit at the runtime root).

When the handle is bound (let h = ...), the locus lives until h goes out of scope (at which point default drain and dissolve are invoked).

When the handle is unbound (Hello { }; as a statement- expression), the rule depends on whether the locus has any ongoing-work surface beyond birth:

The rule generalizes: a locus is long-lived iff it can do something after birth completes. If birth is all there is, it dissolves at the statement that birthed it.

This means every function scope is itself an implicit locus (see §D below). Anonymous children of a scope dissolve before the scope returns — same rule as bound handles, just without a name.

Multiple bindings of a handle are not yet specified. v0 punts; expected: handles are move-only (Rust-shaped), so let h2 = h; transfers ownership and h is no longer usable. Reference counting is rejected (no GC, no ARC).

B. The self keyword

Section titled “B. The self keyword”

(Added in v0.1.1.)

Inside a lifecycle block (birth, accept, run, drain, dissolve), a mode block (mode bulk, etc.), or a closure block, the keyword self refers to the enclosing locus. self.greeting accesses the greeting param; self.position accesses a contract-exposed field; etc.

Outside these contexts (in free fn bodies, in const decls, in top-level expressions), self is a parse error.

Considered and rejected:

D. Function scope as implicit locus (and lifecycle methods are not)

Section titled “D. Function scope as implicit locus (and lifecycle methods are not)”

(Added in v0.1.2 from 01-locus-with-run; refined in v0.1.3 from 02-parent-child.)

Free fn functions have implicit loci. Every fn main(), fn helper(), etc. has an implicit locus at its scope. Locally-bound handles and anonymous children of the function body are children of this implicit locus. The function returns when:

For fn main() { Ticker { ... }; }, the implicit main locus has one anonymous child (the ticker). main cannot return until the ticker’s run() has completed (or been drained). This makes “main returns when its work is done” the natural semantics without requiring explicit wait() or join() calls.

Lifecycle methods do not have their own implicit locus. birth, accept, run, drain, dissolve, on_failure are not regular functions — they run as the locus. Children instantiated inside a lifecycle method attach to the enclosing locus, not to a fresh implicit scope.

locus Coordinator {
accept(g: Greeter) {
println(g.greeting); // reads child's exposed state
}
run() {
// Greeter { ... } here: child of Coordinator,
// NOT of run()'s scope. accept() will be invoked.
Greeter { greeting: "hi" };
}
}

This distinction matters because the framework’s “locus is the unit of region” commitment means lifecycle methods can’t have their own region — they ARE the locus.

The implicit-function-locus model also underwrites SIGINT handling: SIGINT triggers drain() on the runtime root locus (which contains main); the drain cascades to main’s implicit locus, which cascades to its children. See F.4 (drain cascade).

C. Default lifecycle methods

Section titled “C. Default lifecycle methods”

(Added in v0.1.1.)

When a locus omits a lifecycle keyword, the compiler supplies a default:

A locus with only params and birth (like the hello-world program) is fully valid; the compiler fills in the rest.

E. mut keyword and immutable-by-default bindings

Section titled “E. mut keyword and immutable-by-default bindings”

(Added in v0.1.2.)

Bindings are immutable by default. let x = 0; produces an immutable binding; reassignment x = 1; is a compile-time error. let mut x = 0; produces a mutable binding; reassignment is permitted.

This matches Rust. Considered and rejected:

Mutability is a per-binding property, not a per-type property. A let mut x: int is mutable; the int type itself is not “mutable” or “immutable.” This avoids the type-level mutability machinery seen in some languages.


The locked design commitments have moved

Section titled “The locked design commitments have moved”

The F-series — the locked design commitments (what each construct commits the design to, what was considered and rejected, and which are superseded or still sketches) — is an append-only decision log, now in its own file: decisions.md. This file keeps the current conceptual rationale; decisions.md is the decision/history layer.