================================================================================ 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.)