================================================================================
HALE CONTEXT PACK (slim)
================================================================================
The essentials plus the two highest-signal spec docs (style guide + lexical structure). Fits comfortably in a context window.
Generated 2026-07-20 from the Hale language specification. The authoritative source is
the compiler repository. When in doubt, run `hale check` — the compiler is the
oracle.
================================================================================
## Essentials & gotchas
## source: agent-assets/essentials.md
================================================================================
# Hale essentials & gotchas — write correct, idiomatic Hale
This is the distilled, load-bearing knowledge for writing Hale that compiles
and is idiomatic on the first try. It is the preamble to the full context pack;
the rest of the pack is the complete language specification for depth. Read this
first, then **let the compiler be the oracle** — `hale check
` accepts or
rejects with a precise diagnostic; iterate against it.
---
## 1. The axiom: everything is a locus
Hale has exactly two declaration primitives, on one gradient:
- **`type`** — pure data. Fields, layout, returnable by value. No lifecycle, no
flow. A record.
- **`locus`** — anything with *flow*: lifecycle (`birth`/`run`/`drain`/`dissolve`),
state, contracts, bus participation, supervision. An app is a locus; a service
is a locus; a namespace of helpers is a locus; a bus subscriber is a locus.
If a thing has lifecycle or mutable flow, it's a `locus`. If it's pure shape,
it's a `type`. There is no third category. There is no `class`, `module`,
`package`, or `namespace` keyword — a locus does all of it.
```hale
type Point { x: Int; y: Int = 0; } // data
locus Counter { // flow
params { n: Int = 0; } // params ARE the locus's mutable state
fn inc() { self.n = self.n + 1; }
}
```
`params` is both the construction-time defaults *and* the locus's runtime state
(mutated via `self.x = ...`). There is no separate `state {}` block.
---
## 2. The six-pattern catalog
Every locus/fn in idiomatic Hale matches one of these. If your code doesn't, reconsider.
1. **App locus** — `main.hl` defines a top-level locus owning the run; `fn main()`
reads argv, instantiates it. Lifecycle bodies can't `return`, so factor
short-circuit logic into a free fn.
2. **Namespace lotus** — empty/config-only `params {}`, methods only. The
replacement for "module of functions" / "static class".
3. **Service locus** — `birth`/`run`/`dissolve` (+ often `bus subscribe`) for
long-lived work.
4. **Spawned child** — a `let`-bound locus literal that dissolves at scope exit.
5. **Shape type** — a `type` record, pure data.
6. **Free fn** — top-level fn with no flow. When ≥3 cohere into a vocabulary,
promote to a namespace lotus.
---
## 3. Memory & lifetime — no GC, no borrow checker, no `free`
A locus owns a **region** (arena). When the locus dissolves, the region is freed
**wholesale**. The structure *is* the lifetime. There is no garbage collector,
no borrow checker, and no manual free.
Dissolve timing (the part people get wrong):
- **Statement-position literal** `Foo { };` — fire-and-forget; dissolves at the
statement boundary. Use for one-shot runs.
- **Let-bound** `let h = Foo { };` — stays valid for method calls; dissolves at
the enclosing fn's scope exit (deferred). Use when you need a usable handle.
- A locus with `bus subscribe` is long-lived regardless of binding shape.
Lifecycle methods: `birth` (setup) → `run` (steady-state) → `drain` (stop new
work) → `dissolve` (release). `accept(c: Child)` runs *before* a child's birth.
Parent/child: a parent that `accept`s children exposes `self.children`. Cross-locus
state goes over the **bus**, never lateral field reads (vertical-only flow).
---
## 4. Failure: two orthogonal channels
Hale has **no exceptions, no `panic`, no `assert`**. Failure is either:
**(a) Value channel — `fallible(E)`.** For recoverable, call-by-call errors.
```hale
fn parse_port(s: String) -> Int fallible(ParseError) {
return std::str::parse_int(s) or raise;
}
```
Every fallible call MUST be addressed at the immediate caller with an `or` clause:
- `or raise` — propagate one frame up (the enclosing fn must be `fallible`).
- `or ` — substitute a default; `err` is bound to the payload in scope.
- `or handler(err)` — hand off to a fn that returns the success type.
- `or fail E { ... }` — translate to the enclosing fn's error type.
- `or discard` — swallow; only when the success type is `()` (Unit).
`fail E { ... };` inside a fallible body exits via the error path (mirror of `return`).
**Where `fallible(E)` is allowed (v0.8.1 narrowing — important):** free fns,
`@form(...)` container methods, **and user-declared `fn` member methods on a
locus**. It is REJECTED on *substrate-facing* surfaces: lifecycle methods
(`birth`/`run`/`accept`/`drain`/`dissolve`/`on_failure`), mode methods, closure
assertions, and bus-subscribed handlers — those have no caller frame. (Older docs
say "no fallible on locus methods" — that blanket rule was narrowed; member fns CAN
be fallible now.)
**(b) Structural channel — closures + supervision.** For "a locus's invariant
broke." A parent's `on_failure(c, err)` decides policy: `restart(c)` /
`quarantine(c)` / `bubble(err)` / absorb. To escalate a caught value error into a
structural failure from inside a locus method (e.g. a lifecycle body, which can't
be fallible), use the error-check-fn pattern:
```hale
closure fatal_io { captures: last_error; epoch inline; }
fn handle_io(e: IoError) -> Row { // a member fn, returns the success type
self.last_error = e.kind;
if e.kind == "broken_pipe" { violate fatal_io; } // → structural escalation
return Row { data: "" };
}
// caller (e.g. in run()/birth()): let r = do_io() or self.handle_io(err);
```
`violate NAME;` is divergent (like `fail`/`bubble`). The parent reads frozen child
state in `on_failure` via the child handle (`c.last_error`).
---
## 5. The bus — and the one rule that trips everyone
Loci coordinate over a typed pub/sub bus. Declare a channel with `topic`, publish
with `<-`, subscribe in a `bus {}` block:
```hale
topic Tick { payload: TickData; subject: "ticks"; }
locus Producer {
bus { publish Tick; }
run() { Tick <- TickData { n: 1 }; } // identifier subject, not "Tick"
}
locus Consumer {
bus { subscribe Tick as on_tick; }
fn on_tick(t: TickData) { /* ... */ }
}
```
**THE GOTCHA (codegen-v0):** `bus { publish T; }` and `T <- v;` only resolve a
`topic T` declared in the **SAME `.hl` FILE** as the publishing locus.
Cross-file topic references fail with `publish references unknown topic`
*regardless of file order*. Two fixes:
- **Single publisher:** put the `topic` decl in the same file as the publishing locus.
- **≥2 files publish the same topic:** use the literal-subject form, which works
cross-file: `publish "wire.subject" of type T;` + `"wire.subject" <- v;`.
(Topic *payload-type* resolution IS seed-global/order-free — only the topic
*reference* in a bus block is file-local.)
**Placement & the deployment seam.** Thread placement and transport are
deployment seams, set ONLY in a `main locus`'s `placement {}` / `bindings {}`
blocks — never as per-locus annotations (the old `: schedule` annotation was
removed). With no `bindings`, loci run as one in-process binary; bind a topic to
a transport (`unix(...)`, etc.) and the same code runs cross-process.
```hale
main locus App {
params { gw: Gateway = Gateway { }; api: Server = Server { }; }
placement { gw: pinned(core = 1); api: cooperative(pool = io); }
bindings { Ticks: unix("/run/ticks.sock"); } // delete → monolith
}
```
A locus's methods may be invoked only on its placement pool's thread
(single-threaded-method invariant); cross-pool coordination goes over the bus.
---
## 6. Data: forms, not generics; Bytes vs BytesBuilder
Hale has **no parametric collection types** (`Vec`, `Map`). Use `@form`
annotations on a locus instead:
```hale
@form(vec)
locus Items { capacity { heap data of Int; } } // push/get/set/pop/len/sort_*
@form(hashmap)
locus Reg { capacity { pool entries of Entry indexed_by name; } } // get/set/has/remove
```
`@form(vec | hashmap | ring_buffer)`. Synthesized fallible methods (`get`/`pop`/
`remove`) return injected error types (`IndexError`/`KeyError`/`EmptyError`).
For "list of things" returns without a form, use the **row-string / index-API**
idiom (tab-separated columns, newline-separated rows), per stdlib precedent —
not an invented parametric collection.
**`Bytes` vs `BytesBuilder` are distinct types.** `Bytes` is an immutable
length-prefixed blob (`at`/`slice`/`len`/`concat`). `std::bytes::BytesBuilder` is
a growable accumulator locus (`append`/`len`/`shift_front`/`clear`/`snapshot`/
`view`). They don't coerce. The recv-loop idiom writes into a builder
(`recv_into`) and `snapshot()`/`view()` to hand off a `Bytes`. Prefer `Bytes` for
binary I/O, `String` for human-readable text.
**Structural interfaces** (Go-shaped): a locus satisfies an `interface` by having
the methods — no `impl`. Interface methods can't be `fallible`; carry errors in
result structs (`ok`/`err` fields), the Go `(result, error)` shape.
---
## 7. Numbers, strings, naming
- **`Decimal` for money, `Float` for math.** `Decimal` (suffix `d`: `1.50d`)
never participates in implicit conversion. `Int → Float` widens implicitly
(one-way); `Float → Int` needs explicit `Int(x)`.
- Strings are NUL-terminated (`String`); use `Bytes` for binary. f-strings:
`f"hi {name}"`. ASCII-only source (names, not Greek: `sigma`, `phi`).
- **Naming:** PascalCase for loci/types/interfaces; snake_case for fns/fields/
params; UPPER_SNAKE for consts; dot-lowercase bus subjects (`log.app.db`).
Lifecycle methods drop `fn` (`run() { }`, not `fn run()`).
---
## 8. Projects: seeds, imports, vendoring
- **A directory is one seed** (F.19): every `.hl` file in a dir shares one
top-level scope, order-free. No `pub`/visibility. Decompose by concern, not
visibility. Subdirectories are *separate* seeds.
- **Cross-seed import** (F.25): `import "path" as alias;` (the alias is
REQUIRED). References read `alias::Name`. The path resolves entry-relative
then workspace-root.
- **Vendoring is the dependency model** (F.26): `hale fetch` clones git deps into
`vendor/`; no registry, no transitive resolution — vendor everything you use.
---
## 9. Hard gotchas (codegen-v0 limitations — don't fight these)
- **CQRS / no-locus-return:** a `fn` member of a locus may NOT return a
user-declared locus type (`fn get() -> SomeLocus` is rejected). Use
parent-child + contract, a bus topic, or delegation. **Free fns CAN return
loci** (constructors like `std::io::file::open`) — so factory functions are
free fns, not methods. (This is why `@form(vec)` factories and matrix/metrics
factories are free fns.)
- **Topic file-locality** (§5 above) — the most common first-try failure.
- **Strict field access:** a typo'd field (`self.greting`) is a hard error, not a
silent `Unknown`. Good — fix the name.
- **No `panic`/`assert`** — failure is structural or `fallible` (§4).
- **Empty `if` bodies parse-fail** — put a `// note` comment inside or invert the
condition.
- **Fn-pointer callbacks can't capture** surrounding state — route state through
the bus, reconstruct it inside, or use a locus method with its own `self`.
- **`hale run` rejects qualified-name struct/locus literals** (`std::http::Request
{ }`) — use `hale build` + run the binary for programs using path-qualified
stdlib types.
- **Two-hop `_util` import** (pond-specific): `_util/*` libs aren't importable from
inside tier libs (a two-hop codegen break) — keep local copies there.
---
## 10. The workflow that makes this work
1. **Read this pack** (and the relevant spec section) before writing a locus.
2. **Write**, matching one of the six patterns.
3. **`hale check `** — the compiler is the oracle. Read the diagnostic; it
names the rule and usually the fix.
4. **Fix and re-check** until `ok: N file(s) typechecked`.
5. **Maintain a `FRICTION.md`** in the project: when you hit a wall, append what
you learned (the cause + the working shape). Read it before the next task.
This "friction discipline" is how large Hale systems stay buildable by agents.
Don't reach for foreign patterns (TOML-in-a-locus, fluent builders, singletons,
decorators). When something doesn't fit the six patterns, log it as friction
rather than coding around it.
================================================================================
## Style guide
## source: spec/styleguide.md
================================================================================
# Style guide
This document specifies idiomatic Hale in four layers: the
**shapes** a program is composed of, the **correctness rules**
that keep a long-lived program sound, the **speed rules** that
make a hot path allocation-free, and the **enforcement ladder**
the compiler backs them with. Where `grammar.ebnf` says what's
syntactically valid and `types.md` / `semantics.md` say what's
*meaningfully* valid, this document says what's *idiomatically*
valid — what a program should look like when an author has
applied the framework's primitives coherently rather than fought
them.
Every rule here is grounded in production Hale code — the
`pond` contrib libraries, the `causality` game engine, a
production market-data connector fleet, the `bench` performance
corpus, and a downstream market-data service whose leak hunts
drove much of the substrate's reclamation machinery. Rules cite
the shape that earned them.
The styleguide is normative for new code in `apps/`, the bundled
stdlib seed (`crates/hale-codegen/runtime/stdlib/`), and
`examples/`. It is descriptive for older code that predates a
given rule; refactors apply the rules opportunistically.
How to read it: **Foundations** and the **shape catalog** get a
program structured right. **Correctness** keeps it right over
days of uptime. **Fast** applies only where a path is genuinely
hot — the section says so explicitly. **Enforcement** is the
one-table summary of what the compiler checks for you.
---
## 1. Foundations
### The foundational axiom
> **Every named structural thing is a locus.** Types are
> loci-in-waiting — the smallest growth stage on the locus
> gradient.
If a thing has lifecycle, contracts, bus participation, modes,
closures, capacity slots, or projection class, it is a
fully-grown **locus**. If it is pure data (record, returnable by
value, no flow), it is a **type**. The two are points on one
gradient, not separate categories. There is no third primitive.
See `notes/hale-types-vs-loci.md` for the source axiom.
Loci recur at every layer: an app is a locus, a service is a
locus, a spawned worker is a locus, a cache is a locus. There is
no `module` / `class` / `package` keyword because none is needed
— anything one of those would do, a locus does, and the locus
carries lifecycle and contracts the other forms don't.
### Roles, not keywords
Only two declaration forms exist: `locus` and `main locus`.
"App", "service", "namespace", "child", "collection" are **roles**
a locus plays, realized through which members it declares —
`params` / lifecycle / `bus` / `accept` / `capacity` — not
through separate keywords. Don't hunt the grammar for a `service`
or `module` keyword; pick the role from the catalog below and
declare the members that realize it. (Production confirmation:
the connector fleet and `causality` together define every role
in the catalog using exactly the two forms.)
### The memory model in one page
This is the single most load-bearing page in the guide. A
developer who gets every *shape* right and skips this page will
still ship a leak — that is an observed outcome, not a
hypothetical.
**Arenas don't free per-allocation.** Every locus owns an arena
(`self.__arena`). Everything anchored there — struct clones,
String buffers, child structures — lives until the locus
*dissolves*. There is no per-object free.
**Method scratch reclaims at method exit.** Every allocating
method (and bus handler) gets a scratch region created at entry
and destroyed at return. Transients — parse results, temporaries,
locals — allocate into scratch and vanish per call. This is why
ordinary code is allocation-bounded *by construction*: allocate
freely in a method, and the method boundary cleans up.
**Escaping a value costs an anchor.** Storing a heap value into
`self` copies it out of scratch into the locus arena (an
"anchor"). The anchor is the *transfer of lifetime*: the value
now lives as long as the locus.
**Therefore: a long-lived locus means anything anchored
per-iteration accumulates until dissolve** — unless one of the
substrate's reclamation mechanisms applies. The mechanisms, in
the order you should reach for them:
1. **Don't escape.** Keep transients in method scratch. Free by
construction.
2. **Mutate in place.** `self.f.x = v`, `self.arr[i] = v`,
String/Bytes reassign that fits the existing buffer — all
reuse existing storage, zero new bytes.
3. **Replace and let it retire.** `self.f = Struct { ... }`
whole-value replace memcpys the struct bytes in place, and —
since v0.11.3 — *retires* the replaced String clones at the
method's activation boundary, recycling them on the next
store. Steady-state replace of a scalar/String struct holds
the arena flat. (Structs carrying `Bytes` / nested compound
fields aren't fully retired yet — see §7.)
4. **Bound the container.** `capacity` slots, `bounded[T; N]`,
`@form(ring_buffer)` / `@form(lru_cache)` are cap-bounded by
type.
5. **Give it to a child.** An `accept`'d child locus owns its own
arena, reclaimed when the *child* ends — the per-connection
shape (see rule C4).
6. **Send it.** Bus payloads ride a per-dispatch arena, reclaimed
after delivery.
One structural consequence to internalize: **a `run()` loop is a
single never-ending activation.** Nothing scratch-based reclaims
inside it, and pending retires never flush. Hot per-iteration
work belongs in a *method called from* the loop — the method
boundary is the reclaim point. (This is why every churn example
in this guide runs its body through a method.)
---
## 2. The shape catalog
Seven idiomatic shapes. Every locus or free fn in a well-written
program matches one; code that doesn't should be reconsidered
against the catalog before shipping.
### 2.1 App locus — outer encapsulation
Every app's `main.hl` defines a top-level locus that owns the
whole run; `fn main()` reads argv, instantiates it, exits.
```hale
locus Onboard {
params { dir: String = "fixture"; flavor: String = "go"; }
run() { drive(self.dir, self.flavor); }
}
fn main() {
let mut dir = "fixture";
if std::env::args_count() > 1 { dir = std::env::arg(1); }
Onboard { dir: dir };
}
```
- `params` holds argv-derived configuration with defaults (the
app self-demos with no flags).
- Statement-position literals fire-and-forget: the locus runs and
dissolves at fn-return.
- Lifecycle bodies reject `return` — factor short-circuit logic
into a free helper called from `run()`.
### 2.2 Namespace locus — empty params, methods only
A coherent vocabulary of pure helpers wrapped in a locus with
empty (or config-only) `params { }`. The language's substitute
for "module of functions" / "static class".
```hale
locus Morpheme {
params { flavor: String = "go"; }
fn lookup_morpheme(m: String) -> String { ... }
fn name_to_motion(name: String) -> String {
let hit = self.lookup_morpheme(name);
...
}
}
```
Config-only params are fine; the point is **no lifecycle state**.
Promote three-plus related free fns into one of these when the
vocabulary becomes visible; leave unrelated helpers as free fns
(a "util" namespace of strangers is an anti-pattern).
### 2.3 Service locus — long-lived, lifecycle + bus
The full lifecycle for a thing that genuinely runs over time.
```hale
locus Listener {
params {
host: String = "127.0.0.1";
port: Int = 0;
listen_fd: Int = -1;
on_connection: fn(std::io::tcp::Stream) = default_on_connection;
}
birth() {
self.listen_fd =
std::io::tcp::listen_socket(self.host, self.port) or raise;
}
run() { ...accept loop... }
dissolve() { std::io::tcp::close_fd(self.listen_fd); }
}
```
- `birth()` acquires, `dissolve()` releases, `run()` loops.
- Sentinel params (`-1` for "not yet bound") let `dissolve()`
no-op safely on partially-constructed loci; make close
idempotent.
- A service that accepts children long-term needs the
accept/release pairing — rule C4.
### 2.4 Spawned child locus — let-bound, scope-dissolves
A let-bound locus literal lives for the fn body and dissolves at
scope exit (the m82 dissolve-timing rule).
```hale
fn handle_one_connection(conn_fd: Int, on_conn: fn(std::io::tcp::Stream)) {
let s = std::io::tcp::Stream { conn_fd: conn_fd, owns_fd: false };
on_conn(s);
}
```
- Let-bind when the locus should live for the fn's duration;
statement-position literals dissolve at end of expression.
- **Mind fd ownership**: `Stream` defaults `owns_fd: true` and
closes its fd at dissolve. A transient Stream wrapped around a
fd someone else owns (a pooled connection, an accepted raw fd)
must say `owns_fd: false`, or the wrapper's scope exit closes
the connection out from under the owner — a bug found in
production twice (`pond` pq and keepalive both hit it).
### 2.5 Data collection — `@form` locus with a domain facade
Keyed or growable data lives in a `@form(hashmap)` /
`@form(vec)` locus. The idiomatic wrapper is thin **domain
methods over the form's synthesized surface** — a facade that
names the operations in the domain's vocabulary:
```hale
type Sig { key: Int = 0; px: Decimal = 0.0d; seq: Int = 0; }
@form(vec)
locus SigRows { capacity { heap rows of Sig; } }
locus SigList {
params { rows: SigRows = SigRows { }; }
fn append(s: Sig) { self.rows.push(s); }
fn count() -> Int { return self.rows.len(); }
fn at(i: Int) -> Sig { return self.rows.get(i) or Sig { }; }
}
```
The form locus carries the synthesized surface (`push` / `get` /
`set` / `len` / …); the facade holds it as a field and names the
domain operations. (Domain methods directly on the form locus
also work; the two-locus split keeps the form swappable and the
facade's methods flush-friendly.)
This shape recurs across every production codebase surveyed (a
downstream service defines it four times; `pond` and `bench`
pervasively). Conventions learned there:
- Cell types must be **unqualified in-seed structs** — not loci,
not qualified paths (F.1 constraint; forces cell decls into
the same seed).
- `@form(hashmap)` iterates in **bucket order** — add a
`seq: Int` field if consumers need insertion order.
- `@form(hashmap)` has no delete — model removal with a
tombstone field (`present: Bool`), the production idiom.
- Scalar `[T; N]` fixed arrays are the zero-alloc alternative
when the population is fixed: `causality` runs its whole game
state on SoA fixed arrays and uses **zero** `@form` loci.
### 2.6 Shape type — pure data, no flow
```hale
type Request { method: String; path: String; body: String; }
```
Returnable by value; no lifecycle. Types may hold `fn(...)`
fields. If methods accumulate on a concept, it has flow — it
wanted to be a locus.
### 2.7 Error-check fn — value error → structural failure
A locus method that catches a `fallible` call's error and decides
between recovery (substitute a value) and escalation (drain and
notify the parent) pairs an `or self.method(err)` clause with an
`epoch inline` closure:
```hale
locus DbConnection {
params { conn_fd: Int = -1; last_error: String = ""; }
bus { subscribe ExecuteQuery as on_query; publish QueryResult; }
closure fatal_io { captures: last_error; epoch inline; }
fn handle_io(e: DbError) -> Row {
self.last_error = e.detail;
if e.kind == "send_failed" { violate fatal_io; }
return Row { data: "" };
}
fn on_query(q: Query) {
let r = send_query(self.conn_fd, q) or self.handle_io(err);
if !self.draining { QueryResult <- r; }
}
}
```
- The error-check fn is the **one named site** owning both the
audit-state update and the escalation choice.
- `violate NAME;` diverges: the runtime synthesizes a
`ClosureViolation`, sets `draining`, routes to the parent's
`on_failure`. Guard downstream sends with `if !self.draining`.
- The two-channel rule: substrate-facing surfaces (lifecycle,
modes, closure assertions, bus handlers) cannot declare
`fallible(E)`; user-declared `fn` members and free fns can.
See `spec/decisions.md` F.27.
- Loci whose methods can't return through the value channel use
the **`last_error` scratch convention**: `last_error` /
`last_kind` / `last_errno` fields the caller reads after a
sentinel return (`pond` logfmt's file sink is the reference
shape).
### Naming conventions
| Construct | Convention | Example |
|---|---|---|
| Locus / type | PascalCase | `Listener`, `Request` |
| Method / field | snake_case | `name_to_motion`, `listen_fd` |
| Lifecycle decl | drop the `fn` keyword | `run() { ... }` |
| Free fn | bare snake_case | `drive`, `handle_one_connection` |
| Bus subject | dot-separated, lowercase | `log.app.db` |
| Constants | UPPER_SNAKE_CASE | `STDLIB_AP_SOURCE` |
Library exports: pick short lowercase import aliases; name decls
to read naturally under the alias (`fin::Quote`, not
`fin::FinQuote`). See `spec/projects.md` for the seed model.
### Canonical form — `hale fmt` is the arbiter
Mechanical style is not a judgment call: `hale fmt` (v0.11.9,
spec/testing.md) defines the canonical form, zero config, and
`hale fmt --check` gates it in CI. Don't hand-enforce anything in
this list — run the tool:
- 4-space indentation, bracket-driven; a closer returns to its
opener's line indent; continuation lines (a leading `&&`/`.`, a
trailing operator) get one level.
- Binary operators spaced; unary `-`/`!` tight; `.`/`::`/`..`
tight; nothing inside `(` `)` `[` `]`; literal braces spaced
(`Rec { key: 1 }`, `{ }`); `:` tight-left except the spaced
`locus X : serves P` conformance colon; generics tight
(`Holder`); lifecycle parens tight (`run()`).
- Blank lines collapse to at most one; no alignment padding
(`let x = 1;` becomes single-spaced — don't build alignment
columns, they churn every diff); exactly one trailing newline.
- Comments stay where you put them: own-line comments indent with
the code, trailing comments sit one space after it.
What fmt deliberately does **not** decide stays yours, and is
where the rest of this guide applies: *where lines break* (fmt
preserves your line structure, gofmt-style — there is no
max-line-length rewrap), naming, comment content, and blank-line
placement (it collapses runs, it never inserts or removes the
single separators you chose). Write the shape; let the tool make
it canonical. For agents this is load-bearing: canonical form
means a model-written diff touches only the lines it changed.
### Rolling the design
The catalog is small on purpose. A new primitive must **roll
into** the seed: mirror an existing shape (a reader who knows one
knows it at a glance) *and* interlock in composition (its outputs
are valid inputs to existing consumers). A primitive that needs a
paragraph of "this one works differently" is a new category, not
a rolled one — log the friction, don't ship the invention.
---
## 3. Correctness rules
Each rule is tagged with its enforcement status:
**[error]** the compiler rejects it, **[warn]** the compiler
warns by default, **[@hot]** checked only inside `@hot` fns,
**[convention]** nothing checks it — the guide is the enforcement.
### C1. Value ownership on assignment — trust it, know the edges
Assignment of heap values into self-storage has **value
semantics with single ownership**: every slot exclusively owns
its blobs. `self.g = self.f` copies; a struct literal embedding a
`self.` read copies; replacing the source never corrupts
the destination. You don't defend against aliasing — the
compiler/runtime maintain the invariant (and anchor retirement
depends on it). **[structural — the substrate handles it]**
The edge that remains yours: **views**. `.view()` /
`.text_view()` are non-owning windows over a builder's buffer,
valid only until the next overwrite/recv. Persist one with
`std::str::clone` / `std::bytes::clone`; on reuse-buffer
protocols adopt the *deferred-clear discipline* — clear the
builder at the *start* of the next cycle, not the end of this
one, so views handed out stay valid between frames (`pond`
websocket's client is the reference). **[convention]**
### C2. Fd and resource ownership
- One owner per fd. Wrappers over borrowed fds declare
`owns_fd: false` (see 2.4). **[convention]**
- `birth()` acquires / `dissolve()` releases, with sentinel
defaults so partially-built loci dissolve safely; closes are
idempotent. **[convention]**
- Injected loci (a locus passed in via params) are
**caller-owned**: don't dissolve what you didn't instantiate.
**[convention]**
### C3. Placement and starvation
- Blocking I/O belongs on `pinned` (its own thread) or a
`where async_io` pool (parks on readiness). A blocking call on
a classic cooperative pool stalls every co-scheduled locus —
the compiler traces this **interprocedurally** and warns; a
blocking cooperative *subscriber* is a dead receiver and is an
error. **[warn / error]**
- **One non-returning `run()` per classic cooperative pool** —
a second never starts. Give daemons their own pool or pin
them. **[convention]**
- A single-writer state locus read from other pools: pin it, and
poll scalar fields across pools rather than sharing heap
values. **[convention]**
- TLS I/O never goes on an async_io pool (its recv blocks the
worker; no park integration yet — see §7). **[convention]**
### C4. accept / release — flows, not residents
Declaring `accept(c: Child)` without `release(c: Child)` makes
every accepted child a **resident**: it lives until the accepting
locus dissolves. On a daemon that's O(accepted-children) growth
until OOM — the canonical accept-loop leak. Declare
`release(c: Child)` to make children **flows**, reclaimed when
their `run()` completes; or `terminate` them from a handler.
```hale
locus Conn {
params { fd: Int = -1; }
run() { while true { let f = recv(...); if f.closed { return; } ... } }
}
locus Server {
accept(c: Conn) { }
release(c: Conn) { } // ← Conn is a flow: reclaim on run() return
}
```
The compiler warns on accept-without-release when the accepting
locus's `run()` loops forever; run-to-exit programs accepting a
bounded batch stay silent. **[warn]**
### C5. Bus discipline
- Declare topics next to their publisher; `hale check` verifies
the pub/sub graph — a subscription nothing publishes to warns
(its handler can never fire), and a cooperative subscriber
whose `run()` blocks is a dead receiver **error** (the
blocking call starves the dispatch that would deliver to it).
**[warn / error]**
- Payloads are declared types, not raw Strings — the payload
type is the contract. **[convention]**
- Delivery is synchronous at the publish site (match + enqueue);
handlers run when the target's queue drains. Don't assume a
handler ran because the publish returned. **[convention]**
- **Single-writer collectors**: fan-in by having N publishers and
one subscriber append to its own private field — never share a
mutable collection across loci. **[convention]**
- **Keyed routing replaces handler-side filtering.** When N
instances each want their own slice of a topic's traffic, key
the topic and filter at the subscription:
```hale
topic Posted { payload: Msg; keyed_by room; }
locus Room {
params { name: String = "lobby"; }
bus { subscribe Posted as on_post where key == self.name; }
fn on_post(m: Msg) { ... } // only this room's traffic arrives
}
```
Keys may be `Int` / `Decimal` / `Time` / `Duration` / `Bool` /
no-payload enum / `String` (String keys are hash-gated —
non-matching traffic costs one integer compare per entry). An
`if m.room == self.name` in the handler is the anti-pattern
this exists to delete: it makes every instance pay for every
message. Keys are **captured by value at registration** —
re-keying means dissolve + re-instantiate. `on_unmatched:`
picks the no-match policy (swallow / fail / fallback).
**[convention; the routing itself is checked]**
- Inline the payload at the `<-` send site (`T <- Msg { ... }`
publishes from a stack slot). Routing a payload through a free
fn first anchors it in the caller's region — a per-publish leak
`causality` hit in production. **[convention]**
### C6. Dispatch shape
`else if` chains and `match` (statements *and* expressions, with
String / enum-payload / guard arms) are all first-class. A
command router over a String field is a `match`:
```hale
fn on_command(f: Frame) {
match std::json::find_string_field(f.json, "type") {
"hello" -> self.on_hello(f),
"join" -> self.on_join(f),
"research" -> self.on_research(f),
_ -> println("unknown command"),
}
}
```
Deep `} else { if ... } }` ladders (a 16-deep one shipped in
`causality` before String-match landed) are a legacy shape —
rewrite on touch. F.18 exhaustiveness is checked in both
positions; expression-position arms must agree on one type.
**[error for non-exhaustive / mismatched arms]**
### C7. `@form` constraints (the sharp edges of 2.5)
- Cell types: unqualified in-seed structs only. **[error]**
- Hashmap iteration is bucket-order → `seq` field for order;
no delete → tombstone. **[convention]**
- A fixed-array *field* (`[Int; N]` on a struct) is out-of-line
storage — it dangles across a zero-copy SHM boundary even
though typecheck accepts it; SHM payloads need scalar fields.
**[convention — see §7]**
---
## 4. Speed rules
**First ask: is this path hot?** Hot means a per-frame/per-message
handler, a tight parse loop, an ingest path — hundreds per second
and up. If not, stop here: the defaults are correct *and* fast
(method scratch reclaims per call; the compiler elides scratch
for non-allocating helpers entirely). Gold-plating a cold path
buys nothing and costs shape.
For genuinely hot paths, the idioms below are the difference
between flat RSS at 100k msg/s and a daemon that dies nightly.
Each is production-derived. Certify the path when done — S10.
### S1. Reuse buffers via params fields
The single highest-value idiom. A locus that processes frames
holds its buffers as fields and reuses them every frame:
```hale
locus WsConn {
params {
rx_buf: std::bytes::BytesBuilder = ...; // frame reassembly
tx_buf: std::bytes::BytesBuilder = ...; // send assembly
scratch: std::bytes::BytesBuilder = ...; // unmask/inflate
}
}
```
A locus or `BytesBuilder` instantiated *inside* a handler or loop
allocates a fresh arena/buffer every message (~4.5 KB/frame
measured in production) that reclaims only at method return — and
its chunk only at dissolve. The compiler warns on this shape by
default. `pond` websocket (three reused builders), the downstream
service's conn writer, and `causality`'s runner all converged on
the fields shape independently. **[warn; error under @hot]**
### S2. `recv_into`, not allocating recv
`recv_into(fd, buf, max)` appends into a reused builder's tail —
zero-alloc. The allocating `recv` family returns a fresh buffer
per call; in a loop that accumulates until the method returns.
**[warn in loops]**
### S3. Zero-copy views with the deferred-clear discipline
Read assembled data with `.view()` / `.text_view()` instead of
`snapshot()` / `finish()` — the latter copy the builder's whole
contents per call. Views are valid until the next overwrite;
C1's deferred-clear discipline makes them safe across frames.
**[@hot: snapshot-in-loop is flagged]**
### S4. Mutate scalars in place; replace structs judiciously
In-place scalar mutation (`self.state.px = v`) is
allocation-free. Whole-struct replace (`self.state = State{...}`)
is *correct* — the struct bytes memcpy in place and replaced
String clones retire (v0.11.3) — but each store still pays an
anchor-clone + retire per String field. On a certified-hot path
prefer in-place writes for the fields that changed; keep the
replace when most fields genuinely change (construction-position
init is legitimate now that retirement reclaims it).
**[@hot hint]**
One shape to know: re-anchoring a field only on *transition*
(e.g. a websocket message's `kind` re-anchors only on a
text/binary flip, not per frame) keeps replace costs off the
per-frame path entirely — the `pond` client does this.
### S5. Strings/Bytes that fit, stay in place
`self.f = ` reuses the existing buffer when the new
value fits. Bounded-variance fields (timestamps, fixed headers,
checksums) hit the memcpy path forever. Grow-paths clone (the
abandoned String buffer retires; Bytes doesn't yet — §7), and
oscillating lengths degrade capacity — genuinely variable-length
hot fields belong in a reused `BytesBuilder` + view instead.
**[convention]**
### S6. Pre-render once, fan out many
When one event fans out to N consumers, render the common
payload once at ingest and let each consumer add only its
per-consumer delta (a sequence prefix, a connection id). N× the
delta beats N× the render — the downstream service renders its
update JSON once and its N connections each prepend a `seq`.
**[convention]**
### S7. Two-flavor emitters
Build human-path output with `std::json::Builder` (a locus —
fine in cold paths), and hot-path output with raw concat into a
reused buffer. Keeping both flavors side by side (`render` /
`render_pre`) documents which callers are hot. **[convention]**
### S8. Counted loops unlock BCE; helpers are free
`for j in 0..coll.len()` (exclusive, literal-0 lower bound, no
mutation of `coll` in the body) lets codegen elide the per-`get`
bounds check and vectorize — measured in `bench`'s form-vec
microbenches. And factor freely: non-allocating fns/methods cost
nothing (interprocedural scratch elision since 2026-06-28) — hot
code does not need to be monolithic code. **[convention]**
### S9. Flat payloads; batch drains
Transitively pointer-free payload types skip the serialize/
deserialize wire path entirely on cross-thread delivery — prefer
scalar payloads for high-rate topics. For high-rate consumers,
a `Drain` handler runs once per queue drain with a zero-copy
`for` over the batch instead of once per message. **[convention]**
### S10. Certify the path
When a hot path is clean, pin it:
```hale
@hot @budget(alloc_per_call = 0) fn on_frame(m: Frame) { ... }
```
`@hot` turns this guide's warnings into hard errors inside the fn
and enables the stricter hints (S3, S4). `@budget(alloc_per_call
= N)` is the counted contract — the compiler proves the ceiling
transitively through callees and fails the build on violation;
`N = 0` is the zero-alloc certificate. The downstream service
carries `@budget(alloc_per_call = 0)` on its send path, its
gateway ingest, and its state facade — the contracts are also
the documentation. **[opt-in errors]**
### S11. Event-driven ingest, not polling
One reader locus per source, on a `where async_io` pool, parked
on readiness (`udp::Reader`, tcp recv) — no `set_recv_timeout`
poll loops. Measured: ~4 µs p50 wake latency, stable over hours.
Poll-scanner sleeps accumulate tail latency debt that looks like
a runtime problem but isn't. **[convention]**
### S12. Cache cross-locus handles at boot
A hot path that looks something up by string key in another locus
(a metrics counter, a symbol table) resolves the handle **once at
boot** and stores it as a field:
```hale
params { c_ticks: metrics::Counter; } // resolved in main(), threaded in
fn dispatch(m: Msg) { self.c_ticks.inc(); }
```
Per-call keyed lookups walk a hashmap and allocate a search
temp in scratch — fine cold, waste hot. (Same idiom as Go/Rust
metrics code; not Hale-specific.) **[convention]**
### What's already free — don't optimize these
- Scalar assigns (`Int`/`Float`/`Bool`/`Decimal`/`Time`/
`Duration`), `LocusRef` stores, view assigns, `Cell` fields.
- String fields holding **static literals** — `.rodata` pointers
short-circuit every clone.
- The metrics RMW shape (`e = store.get(k); store.set(E { key:
e.key, ... })`) — same-arena skips make it zero-alloc.
- Fresh literals in `return` position (sret routes them straight
into the caller's arena). Returning via a `let` binding first
forfeits that routing — inline the literal on hot paths.
- `hashmap.set` replacing an existing key — anchored in place,
replaced clones retire.
### Raw-fd free fns vs `Stream`
Both work. The `Stream` locus methods are the default idiom —
they carry `owns_fd` semantics and fallible returns
(`fallible(IoError)`); the `__`-prefixed free fns
(`__send_bytes` / `__recv_bytes` / …) are the raw escape hatch
for pooled/borrowed fds where locus lifecycle would fight the
ownership (note their return convention: `0` = success, `-1` =
error — not a byte count). Historical reports of the free fns
crashing natively do not reproduce on ≥ v0.10.0 and the surface
is regression-tested. **[convention]**
---
## 5. The enforcement ladder
The compiler backs this guide in four tiers. Everything default
is advisory or genuinely-broken-only; strictness is opt-in where
you certify a path.
| Tier | Check | Status |
|---|---|---|
| **error** (default) | blocking `run()` on a cooperative *subscriber* (dead receiver); unowned subscriber in a handler; non-exhaustive / type-mismatched match; `@form` cell constraints | build fails |
| **warn** (default) | unbounded-alloc survey (self-escaping allocs in unbounded contexts; retirement-aware since v0.11.3); subscription nothing publishes to; locus/builder in a loop **or bus handler**; allocating recv in a loop; blocking call on a cooperative pool (interprocedural); accept-without-release on a daemon | advisory |
| **@hot** (opt-in) | all of the above as errors within the fn; `snapshot()`/`finish()` in a loop; whole-struct self-field replace | errors in certified fns |
| **@budget** (opt-in) | `alloc_per_call = N` counted transitively; `N=0` zero-alloc certificate | build fails on violation |
| **fmt** (CI gate) | `hale fmt --check` — canonical mechanical form (§2, "Canonical form"); exit 1 lists offenders | gate in CI; `hale fmt` fixes |
| escape hatches | `@unbounded` (fn or lifecycle hook) acknowledges intentional accumulation; `--allow-unowned-subscriber`; `--no-warn-unbounded-alloc` | |
Resource budgets (`--dump-resource-budget`,
`--check-resource-budget `, `--warn-resource-leak`) gate
thread / pool / subject / fd counts in CI — see
`spec/verification.md`.
---
## 6. Anti-patterns
- **Bare `fn main()` with helpers and no outer locus.** Apps are
loci.
- **Handler-side topic filtering** (`if m.room == self.name`) on
a fan-out topic — use `keyed_by` + `where key ==` (C5).
- **Loci or builders instantiated per-message** in a handler —
hoist to fields (S1). The compiler warns.
- **`type` for things that have flow**; methods accumulating on a
type — it wanted to be a locus.
- **"Util" namespaces of unrelated helpers** — group by
vocabulary.
- **Fluent builders that mutate self, decorators, TOML/JSON
config in a locus, singletons in disguise** — foreign patterns;
find the seed shape that fits.
- **Spawning a bus subscriber unowned inside a bus handler** —
it dissolves when the handler returns and can never fire.
Compiler error; `accept` it instead. (In `run()` is fine.)
- **Churning heap state directly in a `run()` loop** — a run loop
is one never-ending activation; nothing reclaims. Route
per-iteration work through a method.
- **Deep `} else { if` ladders** — `else if` and `match` are
first-class; String-match the command routers.
- **Floating quantities** — every named quantity has one locus
owner. State that "lives between loci" is a modeling error.
---
## 7. Boundaries — deliberate absences, open gaps, sharp edges
Three different kinds of "the language doesn't do X" live here,
and they call for three different behaviors from you: absences
you should stop wanting (the design says no, and names the shape
to use instead), gaps you can expect to close (write the
workaround knowing it's temporary), and sharp edges you step
around (current limitations, no promise either way). Current as
of v0.11.3 (2026-07-17); shipped-and-gone entries are removed on
shipping — keyed String routing, match-as-expression, and
whole-struct replace reclamation all lived here once.
### Deliberate absences — the design says no
Not on any roadmap. Each has a blessed shape; reaching for the
absent thing means fighting the design, not waiting on it.
- **No parametric collection types** (`List` / `Map`).
Collections are loci: `@form(vec)` / `@form(hashmap)` /
`bounded[T; N]` with the facade shape (2.5). A builtin generic
container would be a second primitive; the locus axiom says
there is one.
- **No stdlib `Option`.** The mechanism isn't missing —
generic enums work, and `type Option = enum { Some(T),
None };` compiles, constructs, and matches today. What's
deliberate is the *idiom*: the blessed "couldn't compute"
shapes are a sentinel + sibling predicate, or `fallible(E)`
when diagnostic context matters (free fns, stdlib wrappers,
and user-declared `fn` members all support it). An
Option-threading style imports another language's error
culture; the two-channel design is the native one.
- **Fn-pointer callbacks don't capture state.** Loci are the
language's closures — state lives in a locus with its own
`self`, or routes through bus subjects. A capturing lambda
would be an anonymous locus without lifecycle or contracts;
name it instead.
### Open gaps — expect these to close
Write the workaround knowing it's a placeholder.
- **Bytes / nested-compound fields of a replaced struct don't
retire** (String leaves do, since v0.11.3). Until then:
genuinely-churning Bytes fields in a reused `BytesBuilder`;
nested compound fields in their own locus or flattened.
- **Synced (cross-pool) `@form` maps don't retire** replaced
cells (needs an epoch scheme). Churned shared maps on hot
paths stay single-pool for now.
- **Dynamic per-instance bus subjects** (the conversation-per-
topic shape). Keyed routing covers the *bounded/known* key-set
case — including String keys — but an unbounded,
runtime-created subject set still has no shape.
- **TLS has no async_io integration** — its recv blocks the
thread, not the coro. Keep TLS off async_io pools (C3) until
non-blocking TLS reads land.
- **`or fail E { ... }` payloads can't reference `err`.** Wrap
the fallible call in a helper that catches-and-rebuilds the
error (the `pond` subprocess wrapper is the reference).
- **Duration arithmetic in expression position** is limited —
hold clock readings as Int ns from the start (`monotonic_ns`),
which is also the fast path (no ASCII round-trip).
### Sharp edges — current limitations, step around them
Implementation constraints, not positions; no promise attached.
One is soundness-adjacent and worth knowing cold.
- **Fixed-array struct fields are out-of-line pointers, and
typecheck accepts them in zero-copy SHM payloads** — where
they dangle cross-process. This is the sharpest edge in the
list: the compiler does not stop you. SHM payloads need scalar
fields (the 512-hand-spelled-fields workaround in `bench`
marks the pain; a flattening form is future work).
- **`@form` cell types can't be loci or qualified paths** — keep
cell structs in-seed (C7).
- **Lifecycle bodies reject `return`** — factor short-circuit
logic into a free helper. Related paper cuts: `-> ()` on a
non-fallible method fails codegen (omit the return type);
empty `if` bodies parse-fail (add a comment or invert the
condition).
- **No char-level `s[i]`** — use `s[i..i+1]` slices or
`std::str::index_of`.
If the catalog seems to be missing a pattern, log a friction
entry with the smallest reproducible example — the catalog grows
from real friction, not speculation. (That's also how entries
move between the buckets above: friction against a *deliberate
absence* is a design conversation, not a feature request.)
---
## Appendix A. Leak-hunt diagnostics
The workflow that pins a leak, in escalation order:
```bash
# 1. Confirm + identify the growing arena (1 Hz heartbeat dump)
LOTUS_ARENA_RESIDENCY=1 ./binary
# + call std::process::dump_arena_residency() from a hot path
# 2. Log every chunk attach with arena labels
LOTUS_ARENA_LOG_CHUNK_ATTACH=4096 LOTUS_ARENA_LOG_BIG_MAX_EVENTS=0 \
LOTUS_ARENA_RESIDENCY=1 ./binary
# grep 'kind=root label=' → the allocating call site
# 3. Pool stats at thread exit (atexit-only — long-running daemons
# must exit early to capture; add a fixed-duration run mode)
LOTUS_CHUNK_POOL_STATS=1 ./binary
# 4. Static sweep — the model-side view of the same question
hale check app.hl # unbounded-alloc survey is default-on
hale check app.hl --dump-alloc-summary
```
Compose 1 → 2 → source grep; that exact chain pinned the May-2026
per-instance residuals. Full env-var reference:
`spec/runtime.md` § "Diagnostic env vars". The compiler-side
ladder (§5) is the static complement — prefer fixing the shape it
names over chasing the trace.
## Cross-references
- `spec/design-rationale.md` — the *why* behind shape decisions.
- `spec/semantics.md` — dissolve timing, capacity slots, fallible
semantics, routing keys, failure traversal.
- `spec/memory.md` — the substrate reclamation contract (the
normative version of §1's one-page model).
- `spec/verification.md` — the checks behind §5's ladder.
- `spec/forms.md` — the `@form(...)` library.
- `docs/src/systems/performance.md` — the pedagogical companion
to §4.
- `AGENTS.md` — the agent workflow; `agents/compiler-dev.md` for
compiler sessions.
- `notes/hale-types-vs-loci.md` — the source axiom.
================================================================================
## Lexical structure
## source: spec/tokens.md
================================================================================
# Lexical structure
This document specifies the lexical layer of Hale: the set of
tokens the lexer produces and feeds to the parser. The formal
grammar in `grammar.ebnf` is defined over these tokens.
## Source encoding
Source files are UTF-8 encoded. File extension: `.hl`.
Outside string literals and comments, only ASCII characters are
permitted. The framework's mathematical primitives are spelled
out as ASCII names; the renderer can produce Unicode for human
display, but the source is ASCII-only. This commitment serves the
agent-first authorship principle (no symbol-input friction) and
simplifies tooling.
## Whitespace
- Spaces, tabs, carriage returns, and newlines are whitespace.
- Whitespace separates tokens but is otherwise insignificant.
- No off-side rule (no Python-style indentation).
- Newlines are not statement terminators; semicolons are required.
## Comments
- Line comment: `// ...` to end of line.
- Block comment: `/* ... */`. Block comments do not nest in v0.
- Doc comment: `///` lines directly above a declaration attach to
it (decorator lines like `@hot` may sit between). Rendered by
`hale doc` into the seed's API reference (spec/testing.md).
Tooling recovers doc text positionally from the source — the
lexer itself skips all comments. `/** */` stays reserved for a
future block form; `hale doc` v1 reads `///` only.
## Identifiers
- Match: `[a-zA-Z_][a-zA-Z0-9_]*`.
- Case conventions are enforced by lints, not the lexer:
- `PascalCase` for type names, locus names, perspective names.
- `snake_case` for variables, fields, function names.
- `SCREAMING_SNAKE_CASE` for constants.
- Reserved words (see below) are not legal identifiers.
## Reserved words (keywords)
### Declaration keywords
```
locus perspective type const
fn import export module
interface
```
`interface` (F.20) declares a structural interface — a named set
of method signatures. Any locus whose method set is a superset
structurally satisfies the interface (no `impl I for L`
declaration). Phase A (typecheck) and Phase B (codegen vtable
dispatch) both shipped 2026-05-11; interface values are usable as
fn params, fn returns, locus param/field values, and
`@form(vec)` cell elements. Interface elements in tuples / fixed
arrays remain gated on the broader composite-construction
coercion design (same gap as tuple-of-`LocusRef` escape).
### Locus member keywords
```
params contract bus capacity
as_parent_for indexed_by
```
`capacity` introduces an F.22 `capacity { ... }` block carrying
zero or more `pool X of T;` / `heap Y of T;` slot declarations.
`as_parent_for` and `indexed_by` are **hard keywords** used only
in slot-clause position inside a capacity block: `pool P of T
as_parent_for ChildL;` (v1.x-4 — share the slot's allocator with
a child locus's same-named slot at accept time) and `pool P of T
indexed_by field;` (v1.x-FORM-4 — names the hashmap key field on
a `@form(hashmap)` cell type).
The slot-kind words `pool` and `heap` are **contextual idents** —
they lex as ordinary Idents and the parser recognizes them only
in slot-decl head position inside a capacity block. So
`fn pool_alloc(...)`, `let heap = ...`, and `type Heap { ... }`
all stay admissible outside capacity blocks. Same F.10-style
narrowing the closure-keyword family uses for `approx` / `within`.
### Lifecycle keywords
```
birth accept release run
drain dissolve on_failure
```
### Mode keywords
```
bulk harmonic resolution
```
`mode` is a **contextual keyword**: recognized only
at locus-member position (as the leading token of a `mode_decl`
production in `grammar.ebnf` § 11). Outside that position it
lexes as an ordinary Ident, so `cam.mode: Int` and similar
param/field uses are admissible — relevant for raylib-style
bindings where `mode` is a natural API name. Same F.10-style
narrowing as `bindings`, `birth_check`, `pool`, `heap`.
`bulk` / `harmonic` / `resolution` remain reserved (they sit in
member-name position via `try_keyword_as_name`).
### Projection-class keywords
```
projection rich chunked recognition
```
### Placement keywords (F.31)
```
placement cooperative pinned pool core cores
topology node l3 reserve replicas
```
`placement` introduces the `placement { }` block on `main
locus` (F.31); `topology` introduces the `topology { }` block
(topology Phase 1b). `cooperative` / `pinned` are
placement-spec keywords inside `placement { }`. `pool`, `core`,
and `cores` are **contextual idents** — recognized only as
kwarg names inside `cooperative(pool = X)` /
`pinned(core = N)` / `pinned(cores = A..B | A..=B | {a, b, c})`
placement specs (topology Phase 1a). Phase 1b adds
`pinned(node = N)` / `pinned(l3 = name)`, plus the `topology`,
`node`, `l3`, and `reserve` block keywords. Phase 1c adds
`replicas` (a second `pinned(...)` kwarg, e.g. `pinned(cores =
A..B, replicas = K)`). All are contextual idents — outside their
positions they lex as ordinary Idents
and can name fns / vars / fields (same F.10-style narrowing the
closure / mode keyword families use). Note L3-domain names go
through the identifier rule, so a **hard** keyword (e.g. `bulk`)
can't name a domain — use a plain identifier. `cores` range
bounds and set elements are integer literals; the range reuses
the expression tokens `..` (exclusive) / `..=` (inclusive).
The pre-F.31 `schedule` keyword is gone — the `: schedule
cooperative | pinned` per-locus annotation no longer exists.
Placement is a `main`-only deployment seam (`bindings`'s
sibling), not a per-locus annotation.
### Closure keywords
```
closure epoch persists_through resets_on
```
`approx` and `within` are **contextual keywords**, recognized
only inside a `closure { ... }` block body (and only in the
specific positions the closure-assertion grammar admits them).
They lex as ordinary Idents elsewhere, so `fn approx(...)` and
`let within = ...` are admissible outside closure bodies. Same
F.10-style narrowing the mode-keyword family uses
post-dot. Shipped 2026-05-11; resolves
`notes/hale-friction.md` 2026-05-10
`closure-keyword-shadows-helper-ident`.
`captures` and `inline` (v1.x-VIOLATE, F.27) are
**contextual keywords**. `captures` is recognized only as a
clause keyword inside `closure { ... }` body, in the
`captures: f1, f2 ... ;` production. `inline` is recognized
only as an `epoch_spec` variant (immediately after `epoch` in
a closure clause). Both lex as ordinary Idents elsewhere — so
`let captures = ...` and `fn inline(...)` stay admissible
outside closure bodies. Same F.10-style narrowing.
### Recovery primitives
```
restart restart_in_place quarantine reorganize
bubble
```
`violate` (v1.x-VIOLATE, F.27) is a **contextual keyword**:
recognized only as the leading token of a statement inside a
locus method body (the `violate_stmt` production in
`grammar.ebnf` § 14). Outside that position it lexes as an
ordinary Ident, so `let violate = ...` and `fn violate(...)`
stay admissible. The optional trailing `with` in
`violate NAME with EXPR;` is the previously-reserved keyword
`with` (no longer reserved-for-future-use); recognized only as
the separator before the payload expression in this production.
### Contract keywords
```
expose consume inferred
```
### Bus keywords
```
subscribe publish on of
```
### Perspective keywords
```
stable_when serialize_as serves reperspective
```
`serves` (Phase 2a) is a **contextual keyword** —
recognized only in a locus header's post-`:` list, as the
`serves P` conformance clause (`locus RouterV1 : serves Router`).
`reperspective` (Phase 2b) is a **contextual keyword**
recognized only as a statement head followed by `self`
(`reperspective self. as ;`). Outside those positions
both lex as ordinary Idents, so `fn serves(...)` /
`let reperspective = ...` stay admissible. Same F.10-style
narrowing the placement / closure keyword families use.
`perspective` (a **hard** declaration keyword) doubles as a type
constructor: `perspective(P)` in type position is a handle to the
contract `P`. The parser recognizes it by the `perspective`
token followed by `(`.
### Statement / expression keywords
```
let mut if else
match for in while
return break continue true
false nil tier self
```
Bindings are immutable by default. `let mut x = ...` declares a
mutable binding; reassignment via `x = ...` is permitted. Without
`mut`, reassignment is a compile-time error.
`self` is meaningful only inside a lifecycle block, mode block,
or closure block. It refers to the enclosing locus's own params
and contract-exposed state. Outside such a block, `self` is a
parse error.
### Predefined type names (NOT keywords)
```
Int Uint Float Decimal
String Bool Time Duration
Bytes BytesView StringView BytesMut
```
`BytesView` / `StringView` (F.30) are non-owning views over a
`BytesBuilder`'s buffer; `BytesMut` (#3) is a raw `{ptr, len}`
writable/readable window (a `Topic.write` ring slot or a
`MirrorRing` window). See `spec/types.md`.
PascalCase per the type-name convention. The lexer emits these
as `Ident` tokens; the parser recognizes them by name in **type
position only**. In expression / namespace position, these names
are unreserved — `time::sleep` is a regular path because `time`
(lowercase) is an ordinary identifier. This eliminates the
lexical collision between primitive type names and stdlib
namespace names that would otherwise occur.
Shadowing a predefined type name with a user-defined type
(`type Int = ...`) is permitted by the grammar but produces a
compiler warning.
### Reserved for future use (not yet legal)
```
trait impl async await
macro
```
(`with` is no longer in this list — v1.x-VIOLATE recognizes it
as a contextual keyword inside the `violate_stmt` production.
`where` is no longer in this list either — Form K
recognizes it as the suffix keyword on `binding_entry` carrying
operational constraints.)
`yield` is a real statement keyword (m26b) — explicit
cooperative yield point; lowers to a bus-queue drain in
codegen. Listed under cooperative-scheduler keywords.
`terminate` is a statement keyword — ends the
current locus's lifecycle from inside one of its own methods
(the locus analogue of `return`). Only valid inside a locus
method body. See spec/semantics.md § "terminate".
### Cooperative-scheduler keywords
```
yield
terminate
```
### Fallible / error-addressing keywords (v1.x-FORM-1)
```
fallible fail or raise
```
All four are **contextual keywords**, recognized only in the
positions described below. Outside those positions they lex as
ordinary `Ident` tokens, so existing code that uses
`let fail = ...`, `fn or_else(...)`, or `let raise = ...` stays
admissible.
- **`fallible`** — recognized only immediately after a function
declaration's return type and before its body, in the
`fallible_marker = "fallible" "(" type_expr ")"` production.
Marks the function as one whose call sites MUST address the
error. *Permitted at declaration sites: free fns + stdlib-
synthesized methods over `@form(...)` containers; rejected at
typecheck on user-declared locus methods per the two-channel
rule (see `spec/semantics.md` § "Fallible call semantics"
§ "Where each channel lives").*
- **`fail`** — recognized only as the leading token of a
statement inside a fallible-fn body. Exits via the error
path, attaching the expression as the typed payload.
Symmetric to `return`.
- **`or`** — recognized as a postfix on any expression of
fallible type (the `or_clause` production). Four RHS forms:
`or raise` (propagate one frame up the call stack),
`or discard` (swallow the error and substitute Unit —
rejected at typecheck on calls whose success type is non-Unit;
sugar for the old `or noop(err)`), `or fail ` (B3 / G6 —
diverge like `raise` but with a fresh payload of the enclosing
fallible fn's declared error type), `or `
(substitute), `or ` (hand off).
- **`raise`** — recognized only as the immediate RHS of `or`.
Diverges the expression by re-entering the fallible-return
shape of the enclosing `fallible(E)` fn, with the payload
carried into the enclosing fn's error sret slot. Past every
enclosing fallible frame — at the implicit main locus's
root boundary — the runtime panics via `lotus_root_panic`.
The value-error channel is orthogonal to the closure-
violation channel; `or raise` does **not** enter the
`bubble` / `on_failure` machinery by default. See
`spec/semantics.md` § "Fallible call semantics" for the
full two-channel semantics.
The implicit binding `err` is in scope on the RHS of an `or`
clause and resolves to the fallible call's payload value
(typed as the fn's `fallible(T)` marker). This is a typecheck
rule, not a lexer rule — `err` lexes as an ordinary identifier
everywhere; the typechecker just introduces it as a binding in
the `or` RHS scope.
Rule of thumb for use:
- A stdlib fn whose failure carries useful diagnostic context
returns `fallible(T)` with a named payload type T.
- A stdlib fn whose "failure" is a benign not-found case (and
whose successful value has a natural default like 0 or "")
uses the sentinel-with-discriminator idiom instead
(`parse_int` / `can_parse_int`). Not every stdlib fn is
fallible — the marker is reserved for true error paths.
## Operators
### Arithmetic
```
+ - * / %
```
### Comparison
```
== != < > <= >=
```
### Logical
```
&& || !
```
### Bitwise
```
& | ^ << >> ~
```
### Assignment
```
= += -= *= /= %= &= |= ^=
```
### Closure / approximation
```
~~ equivalent to `approx`; tests value approximate-equal
within a stated tolerance band. Used in closure tests.
```
### Bus send
```
<- Send a typed message on a declared bus subject:
`"subject" <- value;`. The left side names a subject
declared in the locus's `bus { publish ... }`; the
right side is the typed payload. Same Erlang-shape
as `Pid ! Msg`; one-direction (subscribe is
declarative, not an operator).
```
### Member access / call / index
```
. :: ( ) [ ]
```
### Type / generic
```
< > -> => : ::
```
(Note: `<` and `>` are overloaded between comparison and generic
arguments. The parser disambiguates contextually.)
### Punctuation
```
; , { }
```
### Annotation prefix (v1.x-FORM-1)
```
@
```
`@` introduces a decorator-shaped annotation that decorates the
declaration immediately following it. v1 recognizes one such
annotation: `@form(, ...)`, which sits above a
`locus` declaration and picks an efficient lowering (see
`spec/forms.md`).
Lexically, `@` is a single-character punctuation token. The
parser routes the following `form` ident as a contextual
keyword in this position only.
Reserved for future annotation surfaces; user-defined
annotations are not in v1.
### Reserved (no v0 meaning)
```
# $ ? ?? ?:
```
## Literals
### Integer literals
- Decimal: `0`, `42`, `1_000_000` (underscores permitted as digit
separators).
- Hexadecimal: `0xFF`, `0x1A_2B`.
- Octal: `0o755`.
- Binary: `0b1010_1010`.
- Optional type suffix: `42i32`, `0xFFu64`. Default: `int`.
### Float literals
- Decimal: `3.14`, `1.0e-3`, `2.5E+10`.
- Optional type suffix: `3.14f32`, `2.5f64`. Default: `float`.
### Decimal literals
- Suffix `d`: `1.50d`, `0.05d`. Used for the built-in `decimal`
type (fixed-precision, no float artifacts; same semantics as
the `shopspring/decimal` Go library).
### Time / duration literals
- Duration suffixes: `ns`, `us`, `ms`, `s`, `m`, `h`, `d`.
Examples: `100ms`, `5s`, `1h30m`. Compound forms permitted.
- Time literals: ISO-8601 between backticks: `` `2026-05-08T12:00:00Z` ``.
### String literals
- Double-quoted: `"hello"`. Standard escape sequences:
`\n`, `\t`, `\r`, `\\`, `\"`, `\'`, `\0`, and `\xNN` for
ASCII bytes 0x00..=0x7f (added 2026-05-16; useful for
separators like `"a\x01b"`). High bytes (`\x80`+) are
rejected with a message pointing at `std::bytes::*` — the
Rust String invariant would UTF-8-encode them as two bytes
and surprise the caller. No `\u{NNNN}` Unicode escape at
v1; agents needing non-ASCII drop to byte literals.
- Raw strings: `r"..."` — no escape processing.
- Multi-line strings: `"""..."""`.
- F-strings (v1.x-10): `f"hello {name}"` — interpolates Hale
expressions inside `{...}`, each rendered via the same
formatter `println` uses. `{{` and `}}` are literal braces.
Plain `"..."` strings keep `{` and `}` as ordinary characters
for back-compat (no breaking change). Interpolation accepts
any expression; types are converted with `to_string`.
### Boolean literals
- `true`, `false`.
### Nil literal
- `nil`. Represents the absent value of an option type. Distinct
from numeric zero or empty string.
### Bytes literals
- `b"..."` — byte-string literal. Same escape table as `STRING_LIT`
(`\n`, `\t`, `\r`, `\\`, `\"`, `\0`, `\xNN`), but the body is
raw bytes — **no UTF-8 promotion**, so `\xNN` accepts the full
`0x00..0xFF` range (unlike the string literal which clamps at
`\x7f`). Embedded NULs are preserved; the value's length is
carried alongside the byte buffer, so `len(b)` and
`std::bytes::at(b, i)` work over the entire literal. Codegen
lowers via `lotus_bytes_from_buf(arena, src, len)`, which
copies the source bytes into the caller's arena.
Use cases: binary protocol headers / framing, magic bytes,
embedded fixtures. Earlier code reached for
`std::bytes::from_string("...")`, which only worked for ASCII;
the `b"..."` form replaces that workaround end-to-end.
## Built-in identifiers (not keywords)
These identifiers have semantic meaning in the standard library
and are conventionally reserved, but are not parser-reserved
keywords:
```
B c sigma phi
k_max span_max
sum prod min max
length empty
print println
to_string len abs
Int Float
```
`Int(x)` (v1.x-11) is a built-in cast — explicit Float → Int
narrowing via `fptosi` (truncate toward zero). Int arg is the
identity; other types reject. There is no implicit Float → Int
conversion; the user must commit via this constructor-shaped
call. `to_string(x)`, `len(x)`, `abs(x)`, `min(a, b)`, `max(a, b)`
are similarly bare-name builtins.
`print` and `println` are built-in functions, always in scope
without an `import`. They write to stdout. `print` does not
emit a trailing newline; `println` does. They accept any number
of arguments of any displayable type and concatenate.
`publish` is a built-in function in scope inside any locus
that declares matching `bus { publish SUBJECT of type T; }`.
The compiler verifies the subject and type at each call site.
Out of scope in loci with no publish declaration.
The framework names `(B, c, σ, φ)` use ASCII spellings in source:
`B`, `c`, `sigma`, `phi`. The framework's `k_max` is `k_max` or
its named alias `span_max`.
## Tokenization rules
- Longest match: `==` is one token, not `=` followed by `=`.
- Keywords win over identifiers: `run` is the lifecycle keyword,
not an identifier.
- Numeric literals are recognized greedily up to the first
non-numeric character (after handling `0x`, `0o`, `0b`
prefixes, decimal point, exponent, and type suffix).
- Comments are stripped before parsing (except doc comments,
which are attached to the following declaration as metadata).
## Reserved character classes
The following characters are not part of any token in v0 and are
lexer errors if encountered outside a string or comment:
```
` (outside time literal) \ (vertical bar mid-token, not || or |=)
```
(Backticks are reserved for time literals only; bare backslash
outside a string literal is illegal.)