================================================================================ HALE CONTEXT PACK (slim) ================================================================================ The canonical authoring rules plus the two highest-signal spec docs (style guide + lexical structure). Fits comfortably in a context window. Generated 2026-10-12 from the Hale language specification. The authoritative source is the compiler repository. When in doubt, run `hale check` — the compiler is the oracle. ================================================================================ ## Authoring rules (canonical AGENTS.md) ## source: hale/AGENTS.md ================================================================================ # AGENTS.md — Hale for agents writing `.hl` programs You are writing programs in Hale. This file is the load-bearing prompt: encoding first, then operational. Read it once before you write any `.hl` code. ## The Design (recursive hypergraph) Hale operationalizes **The Design** — a recursive hypergraph — at the language substrate. Every rule about how Hale code is shaped traces back here. Quote node / hyperedge / invariant IDs when citing a rule (e.g. `H8`, `I4`). ``` %DESIGN v1 nodes: α axiom ; declared, irreducible Σ system ; α with 1→N decomposition Δ dimension ; projection of Σ's 1→N Π perspective ; choice of Δ K capacity ; bound on Σ D displacement ; K-full + new → drop-least ↑ failure-up ; D insufficient → Σ.parent ⊥ root-as-boundary ; recursion stops at horizon ∥ vertical-only ; edges ⊆ {parent↔child} ⋈ multi-DAG-projection ; substrate-DAGs join via form hyperedges: ; arity ≥ 2 H1 unfold (α, Σ, [α₁..αₙ]) ; α-as-Σ has children H2 recurse (Σ, αᵢ) ; each child read-as-α H3 compose-Δ (Δ₁, Δ₂) → Δ₃ ; ⊗-closed H4 observe (Π, Δ) ; perspective picks Δ H5 depth-Δ (Π, Δd) ; cross-depth Π IS a Δ H6 bound (Σ, K) H7 displace (Σ, K-full, new) → kept ; drop reveals priority H8 bubble (Σ, ↑) → Σ.parent.on_fail H9 vertical ∀ edge ∈ Σ-tree : edge ∈ ∥ H10 lateral-Δ compose-Δ is licit-lateral-at-Δ-layer only H11 root (Σ.root, ⊥) = current-observable-horizon H12 DAG-join (⋈, DAGᵢ) via form invariants: I1 form : portable across deployment substrates I2 params : substrate-local I3 pyramid : ∀ d ∈ depths(Σ-tree), ∃ stability-tuple_d I4 MS2 : ∀ q ∈ model, q ∈ exactly-one-Σ-tower floating-q ⟹ modeling-error I5 form-content : form claims are perspective-invariant; content reduction claims are perspective-conditioned ; I6 is reserved for the unit graph (GH #1076) I7 lower-reads : lowering consumes rows, never derives them I8 dialect : every dialect's graph is closed at a named horizon; past it is a hole, never an assumption ; I7 holds for the tables lowering reads today; spec/registry.md's ; legacy rows name the residue (F.40 phase 2) hale ≜ operationalization(DESIGN, substrate=language) map: ; ⊥ = where the construct's graph closes (I8) locus ↔ Σ ; ⊥ the entry's root type ↔ Σ-proto (no lifecycle/flow) ; ⊥ its declaration contract ↔ Π@depth-edge ; ⊥ the contract's edge expose | consume ↔ Π↑ | Π↓ ; ⊥ the contract's edge capacity{pool,heap} ↔ K-tuple, slot-0 implicit Arena ; ⊥ the locus on_failure ↔ ↑ ; ⊥ the owner's root drain (cascade) ↔ ∥ depth-first ; ⊥ the locus's subtree projection class ↔ K-conditioned Π-resolution (rich|chunked|recognition) ; ⊥ the locus bus ↔ Δ-composed channel, ⋈ when bound to transport ; ⊥ the seed (a library seed closes nothing) closure-test ↔ I3 local check at Σ ; ⊥ the locus perspective T ↔ Π serialized across processes ; ⊥ the process edge fallible(E) ↔ value-channel Π↑ (orthogonal to ↑) ; ⊥ the call @form(...) ↔ K-discipline lowering, application-layer Σ ; ⊥ the locus (the slot's operation set) claims | constitution ↔ laws over the derived graph ; ⊥ the adopting main placement | bindings ↔ a partition of Σ-instances, a labelling of ⋈ ; ⊥ main hale.root: ⊥(language-graph) = DESIGN itself ``` ## The locus axiom Everything named and structural is a **locus** (Σ). If it 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**, a locus still in proto-form. There is no third primitive at the structural layer. By `I4` (MS2), every named quantity must be assignable to exactly one locus in one locus tower; floating quantities signal modeling error, not framework gap. ## The pattern catalog Seven shapes. Every well-written `.hl` program matches one. If something doesn't fit, reconsider against the catalog before inventing. (`spec/styleguide.md` is the unified guide — shapes, correctness rules, hot-path speed rules, and the compiler's enforcement ladder in one place; this section is the working summary.) 1. **App locus** — outer encapsulation; one per app. PascalCase name; `params` from argv defaults; `run()` delegates to a free helper; `main()` reads argv and statement-instantiates the locus. 2. **Namespace locus** — empty (or config-only) `params { }`, methods only. The language's substitute for "module of functions" / "static class". Instantiate once, dispatch through it. 3. **Service locus** — full lifecycle (`birth → run → drain → dissolve`) for things that genuinely run over time. Sentinel params (e.g. `-1` for "not yet bound") let `dissolve` no-op on partially-constructed loci. 4. **Spawned child** — `let s = SomeLocus { ... };` defers dissolve to the enclosing fn's scope exit. The binding stays valid for method calls between construction and dissolve. Statement-position literals (`SomeLocus { };` with no `let`) fire-and-forget at the end of the statement. - **Accept'd flow child** (connection / per-request shape): a long-lived parent `accept(c: Conn)`s one child per connection; the child's `run()` IS its lifetime (a recv loop that returns on close). Declare `release(c: Conn)` on the parent to mark `Conn` a *flow* — then run-completion reclaims the child (its arena freed as the connection ends, not at parent dissolve). Or end it explicitly with `terminate;`. Without `release`/`terminate` an accept'd child is a *resident* and lives until the parent dissolves — on a daemon, forever (unbounded growth). See `spec/semantics.md § release(c)`. 5. **Data collection** — a `@form(vec)` / `@form(hashmap)` locus holding the storage, wrapped by a thin facade locus whose methods name the domain operations (`append` / `count` / `at` over `push` / `len` / `get`). Cell types must be unqualified in-seed structs; hashmap iteration is bucket-order (add a `seq` field for order) and has no delete (tombstone with a `present: Bool`). Fixed scalar `[T; N]` arrays are the zero-alloc alternative for fixed populations. 6. **Shape type** — `type Foo { a: Int; b: String; }`. Pure data, no flow. PascalCase, snake_case fields. Construct via struct literal. 7. **Free fn** — first-class seed member. Use when the operation has no flow and isn't naturally a method on an existing locus. When 3+ free fns form a coherent vocabulary, promote them to a namespace lotus (pattern 2). **Composition patterns** (one layer up — how the shapes combine in real services; full treatment in `docs/src/services/patterns.md`): - **Three-locus gateway** — pinned reader → cooperative manager that `accept()`s → keyed per-entity children. The topic declares `keyed_by ` and each child subscribes `where key == self.` (scalar or String keys), so the bus delivers only that entity's traffic — never filter in the handler. The answer to "N dynamic keyed children with lifecycle"; the bus *is* the keyed routing table, so you never need a map of loci. Declare `release(c)` so children reclaim. For a FIXED worker fleet (parallelism, not per-entity state) use the replica variant instead: `pinned(..., replicas = K)` + `subscribe T as h where key == replica` — K shards, one subscribe line, K spelled once. - **Demand-driven discovery** — a subscription triggers the `accept()`; topology grows from the data, zero hardcoded children. - **Hot-path counters/gauges** — locus methods returning locus values are rejected (GH #18.6 "CQRS"). Migration: pre-allocate the counter/gauge loci as `params` at boot and mutate fields in place, or publish `MetricUpdate` to a single-writer collector. Never a method that returns a locus. - **Publish-policy gate** — accumulate in place, publish behind a `tick()` with a time/volume trigger, so publish volume is bounded independently of input volume. - **View lifetime** — `StringView` / `BytesView` / `*_span` results are valid only until the next recv/overwrite; `std::str::clone` out to persist. Stale access is now panic-guarded. ## What's NOT in the language Filter these reflexes before they cost you time. - **No `import` / `use` syntax for stdlib.** Stdlib is called inline through `std::*` paths (`std::io::fs::read_file(p)`). Cross-seed user libraries use `import "lib/x" as alias;`. - **No visibility modifiers** (`pub`, `private`). Every top-level decl in a seed (one directory) is visible to every file in that seed. Decompose by concern, not visibility. - **No `async` / `await`.** Concurrency comes from loci + the bus + per-locus thread *placement* declared in `main`'s `placement { }` block (F.31): `placement { gateway: pinned(core = 1); metrics: cooperative(pool = io); }`. Placement is a deployment seam on `main` only — never on the locus declaration. Unspecified main-locus params default to `cooperative(pool = main)`. - **No `trait` / `impl` blocks.** There's `interface I { ... }` with structural satisfaction — any locus whose method set is a superset satisfies `I`. No `impl I for L`. - **No parametric collection types** — no `Vec` / `Map` / `Option` / `Result`. Use `@form(vec)` / `@form(hashmap)` / `@form(ring_buffer)` on a locus. Errors flow through `fallible(E)` with required addressing at the call site. - **No closures-as-values.** Function pointers exist (typed `fn(T) -> U`); inline closure-with-capture does not. Reconstruct context in the callee or route through the bus. - **No method syntax on builtins.** `len(s)`, `to_string(n)` — not `s.len()`. User-defined locus / type methods use `obj.method()` normally. - **No printf-style format strings.** `println(a, b, c)` concatenates its args. F-strings `f"hello {name}"` interpolate. - **No `return` inside `birth` / `run` / `dissolve` bodies.** Factor short-circuit logic into helper free fns. - **`fallible(E)` is rejected on substrate-facing surfaces:** lifecycle methods (`birth` / `run` / `dissolve`), mode bodies (`bulk` / `harmonic` / `resolution`), closure-assertion bodies, and bus-subscribed handlers. Those have no caller frame to address the error channel, so a `fallible(E)` declaration would describe a contract that can't be satisfied. User-declared `fn` members on a locus and free fns DO carry `fallible(E)` — they have a real caller. The narrowed two-channel rule (2026-05-25) keeps `↑` and `fallible` separate at the substrate boundary; everywhere else they compose. See `spec/semantics.md § fallible-on-locus`. - **No `panic(msg)` / `assert(cond)`.** Failure is structural, routed through closure-tests + `on_failure` (the `↑` channel) or value-level via `fallible(E)`. ## Operational rules - File extension `.hl`. ASCII-only outside string literals and comments. Statements end with `;`. Newlines are not terminators. - `let x = 1;` is immutable; `let mut x = 1;` is reassignable. - Bare struct/locus literals at statement position run birth-through-dissolve immediately. `let`-bound literals defer dissolve to the binding's scope exit. - Bus send: `Foo <- payload;`. Subscribe is declarative (`subscribe Foo as handler;`). Subscribers must be born before publishers fire — instantiate them first in `main`. - `self` is valid only inside lifecycle / mode / closure / `fn` member bodies of a locus. - Build a directory: `hale build mydir/` bundles every `.hl` in the directory as one program; binary lands at `mydir/mydir`. Inside one seed, top-level scope is shared and resolution is order-free. - Don't edit `crates/`. That's compiler territory. If a primitive you need is missing, work within the existing surface; don't reach into the compiler. ## Naming - Locus, type, perspective, interface: `PascalCase`. - Method, field, param, free fn: `snake_case`. - Constant: `SCREAMING_SNAKE_CASE`. - Bus subject (literal form): dot-separated lowercase (`log.app.db`). Topic name (preferred form): PascalCase identifier. ## Reading errors Diagnostics cite the rule that fired. Read verbatim. Common surprises: - Bus subscriber declared after publisher fired → instantiate subscribers first. - Process boots, then idles — a handler "never fires", a socket "never opened" → check params order. An inline-on-main child whose `run()` never returns blocks every LATER param from being born (its `birth()` never runs). Declare the keep-alive child last, or place it `pinned` / on a non-`main` pool. `hale check` warns on the provable shape. - Topic ref used as expression value → topics aren't values; they address bus channels only. - `self` outside a method body → you're in a free fn or top level; no enclosing Σ. - Lifecycle / mode / closure-assertion / bus-handler method declared `fallible(E)` → the substrate orchestrates these, so the error channel has no caller to address. Drop the `fallible(E)` and route failure through `↑` (closure-test + `on_failure`), OR factor the body into a user-declared `fn` member that the lifecycle method calls with `or` to bridge the channels. - "Error not addressed" on a `fallible` call → add `or raise` / `or default` / `or handler(err)`. ## The loop: the compiler is the oracle Write, then ask the compiler. Don't reason your way to certainty about a rule — `hale check` will tell you in under a second, and its diagnostics name the rule and usually the fix. ```sh hale check # the oracle: accepts, or says exactly why (a dir = the whole seed) hale build # compile to a binary hale run # compile + exec hale test # run *_test.hl files hale fmt # canonical form; zero config hale verify # like check, but ANY finding fails (CI gate) hale doc --stdlib # every stdlib fn, signature + effect classes ``` Iterate against `hale check` until it prints `ok: N file(s) typechecked`. A rejected program is not a setback; it is the fastest feedback in the toolchain. **Keep a `FRICTION.md`** in the project. When you hit a wall, append what you learned — the cause and the working shape — and read it before the next task. Large Hale systems stay buildable by agents because that file exists, not because anyone remembers. When something genuinely doesn't fit the patterns above, log it as friction rather than coding around it. Foreign shapes (TOML-in-a-locus, fluent builders, singletons, decorators) are the usual sign you're fighting the language instead of using it. ## First step 1. Skim `spec/styleguide.md` if you haven't (the seven patterns above are condensed from it — its §1 memory model and §4 speed rules are the parts most sessions under-read). 2. Pick the smallest target. State it out loud: app name, stdlib paths you'll need, what you're not sure about. 3. Read 2-3 programs close to your target shape. The richest in-tree sources are in `crates/hale-codegen/tests/fixtures/examples/` (small per-feature anchors, numbered — the broadest acceptance surface). 4. Write the smallest program that gets one thing working. `hale run ` for fast feedback; `hale build` for the native binary. 5. Iterate. Before you finish, run `hale fmt ` — canonical form is tool-defined (zero config), so your diff touches only the lines you actually changed. Never hand-align columns; fmt collapses them. ## Hot-path memory patterns Before writing code that runs many times per second (per-frame handlers, tight loops, bus dispatch hot paths), read [`spec/styleguide.md`](./spec/styleguide.md) §1 "The memory model in one page" and §4 "Speed rules" (which absorbed the old `agents/memory-patterns.md`). They catalog which assignment / return / lookup shapes the substrate makes allocation-free automatically and which require care from the author. The arena allocator doesn't free per-allocation, so patterns that look innocent at the call site can leak into a locus's lifetime arena — but the substrate closes more of those shapes than you'd expect, and the "What's already free" list preempts overcautious code. The compiler backs the rules with a layered enforcement ladder (default warnings → `@hot` → `@budget` → `@effects`); see styleguide §5. `@effects(none: {…})` — with the `@no_block` / `@no_syscall` / `@deterministic` / `@no_publish` / `@no_spawn` / `@no_ffi` / `@no_recursion` shorthands — asserts what a fn may *reach*, checked transitively with the offending call chain in the diagnostic. And a handler on a `where async_io` locus is checked for blocking calls with no annotation at all: the placement is the contract. ## Binding an external C library Hale binds to non-stdlib C libraries (raylib, sqlite, curl, ...) via user-extensible `@ffi("c")` declarations — no stdlib expansion or compiler change needed. If you're writing a binding library (typically landing in pond), read [`agents/binding-packages.md`](./agents/binding-packages.md) for the recommended file layout, the wrap-vs-leave-bare conventions, the C-glue skeleton, and the testing pattern. The substrate contract for the FFI surface itself is in [`spec/ffi.md`](./spec/ffi.md). ## Inline structural failure For "catch a value error in a locus method and shut this locus down," use the four-piece pattern. Spec reference: `spec/design-rationale.md` § F.27. - **`closure NAME { captures: f1, f2; epoch inline; }`** — assertion-less closure shape that fires only via explicit `violate`. Snapshots the listed fields into the violation payload. - **`violate NAME;` / `violate NAME with expr;`** — statement-level, divergent (`Never` type, same shape as `fail` in fallible fns and `bubble` in `on_failure`). Valid only inside a locus member fn; resolves NAME against the current locus's `epoch inline` closures. - **Error-check fn pattern** — a locus member fn with signature `fn(ErrType) -> SuccessType`, used as `or self.handler(err)` at fallible call sites. Body either returns the substitute value or `violate`s. The bridge between the value channel and the structural channel. - **`self.draining`** — synthetic Bool field on every locus, readable inside method bodies. True from the moment `violate` (or any drain trigger) fires until dissolve completes. Useful for "don't publish further after we decided to wind down." The canonical pattern for "catch error and shut this locus down" is a closure declaration + a member fn + one `violate` statement. Don't reach for a `should_exit: Bool` flag plus a `while !should_exit { yield; }` loop — the primitives above are the supported form. ## Other contracts worth knowing Beyond the effect assertions above, all opt-in: - `@phase_effects(birth: {alloc}, run: {})` on a **locus** — what each lifecycle phase may do. `{}` forbids everything; a phase you don't mention is unconstrained. That one line is "no dynamic memory after initialization". - `@sealed` on a **locus** — its `params` are readable and writable only from inside its own methods. Others may still CALL it; they may not touch its state. Loci are otherwise NOT field-encapsulated (`self.child.key` typechecks), so this is the confinement primitive secrets rest on. Opt-in. `hale check --sealable` reports which loci could take it today and what it would cost. - `@secret name: T` on a **parameter** — a LINT. It flags a secret reaching a publish or log in the same body; it follows no calls and tracks no aliases, so it is not a containment proof. `hale check --strict-secret` widens the walk and reports `uncertified` where it cannot follow. For a guarantee, confine with `@sealed` and claim over an effect class. - `@supervised` on a **locus** — every locus in its subtree must have a failure policy in scope. - `@unbounded` on a fn or hook — acknowledges an intentional accumulation and silences the unbounded-allocation advisory; the growth is not yet understood. `@bounded_by()` silences the same and names the input that bounds it (`@bounded_by(record)`, `@bounded_by(open_requests)`); prefer it when you know. One or the other, not both. - `@form(lru_cache)` joins `vec` / `hashmap` / `ring_buffer` as a cap-bounded collection shape. ## Architecture as law (claims, constitutions, fleets) A `main locus` may carry a `claims { }` block: named sentences over the assembled program graph, checked by `hale check` as ERRORS, zero runtime cost. Groups are checked vocabulary (misspelling = error; empty group = error unless `may_be_empty`). ```hale group workers = { delta::* }; main locus App { claims { isolated: forbid reaches(workers, gamma_wing); consumed: require subscribes(some workers, topic t::Tasks); one_writer: count publishers(topic t::Tasks) == 1; } } ``` When a claim fails, the compiler returns a WITNESS — the exact path, in your spelling. Treat it as the next task: remove the edge, route through a gate, or change the law (a categorically different edit). Unknown means violation: an unresolvable call on a forbidden path refuses certification rather than counting as nothing. Two verbs quantify over the whole closed world rather than a path: ```hale claims { confined: require sealed(all vaults); attributed: require attributed(all syscall); } ``` `require sealed(all G)` — every locus in `G` is `@sealed`, so an unsealed member is a build error. `require attributed(all C)` — every fn that DIRECTLY performs built-in class `C` carries a user-declared class, so every boundary crossing names a purpose. That is orthogonal to `forbid reaches(app, effects(syscall)) avoiding gate`, which constrains WHERE the crossing happens and says nothing about what it is for. Neither implies the other. **Secrets** are the worked case, and they need no new analysis: confine the material to a `@sealed` locus that loads it in `birth`, classify the one privileged method, state the law. ```hale @sealed locus Signer { params { key: Bytes; } @effects(is: { secret_use }) fn sign(m: Bytes) -> Signature { … } } claims { no_plugin_secrets: forbid reaches(plugins, effects(secret_use)); one_op_per_request: bound secret_use <= 1 on paths from handlers; } ``` `secret_use` is a compiler built-in; every built-in effect name is reserved, so `effect secret_use;` is an error. `std::secret::Signer` and `Credential` ship this shape: they take the NAME of a source (`env_var:` / `key_file:`), never the bytes, so no line of your code holds the material. That is CONFINEMENT, not information flow — a value derived from the key is not tracked. Shared law goes in a `constitution NAME { … }` (top level), adopted per entrypoint with `adopt NAME;` inside `claims { }`. Composition is union only — a stricter rule is a second named claim; weakening is unexpressible. `[environments]` in `hale.toml` binds constitutions per deployment target; `hale check --matrix` proves every (entrypoint × environment) pair. Multi-binary law lives one tier up: `hale check --dump-topology=` emits a byte-reproducible artifact per application; a fleet plan (JSON) names deployed INSTANCES and explicit routes; `hale fleet check ` composes the artifacts and proves cross-binary claims. Only an explicit route creates a fleet edge — matching topic declarations alone connect nothing. `hale fleet sign/keygen/attest` + `[fleet_trust]` pin the artifacts and binaries a deployment admits (strict when declared). ## Matching `match` is an expression as well as a statement, so it can produce a value directly rather than through a mutable temporary: ```hale let label = match code { 200 -> "ok", 404 -> "missing", _ -> "other", }; ``` Arms use `->`, not `=>`. Arms may be blocks. `_` is the catch-all and is required when the arms don't cover the domain. ## Hard gotchas (codegen-v0 limits — don't fight these) These are the ones that bite on a first attempt. Each is a real restriction, not a style preference. - **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`), which is why factories are free fns rather than methods. - **Strict field access.** A typo'd field (`self.greting`) is a hard error, not a silent unknown. Fix the name. - **Empty `if` bodies parse-fail.** Put a `// note` 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` and run the binary for programs using path-qualified stdlib types. - **No `panic` / `assert`.** Failure is structural (`violate`) or value-level (`fallible`); see above. ## Pointers - Spec (canonical contract): `spec/`. Start with `spec/styleguide.md`, then `spec/semantics.md`, then `spec/grammar.ebnf`. - Stdlib surface: `spec/stdlib.md`. - Form library (`@form(vec)`, `@form(hashmap)`, `@form(ring_buffer)`): `spec/forms.md`. - Memory / capacity slots / projection classes: `spec/memory.md`. - Per-feature anchor programs (in-tree): `crates/hale-codegen/tests/fixtures/examples/`. - Contrib libraries — protocols / parsers / shapes that don't belong in stdlib but are too useful to rewrite per-project: . Vendor via `hale.toml` → `hale fetch`; import as `import "vendor/pond/" as ;`. - Sibling repos: , . --- **Hale** is the language. **lotus** is the runtime substrate. C-runtime symbols stay `lotus_*` by design. ================================================================================ ## 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, and so does a struct carrying `Bytes`. (Nested compound fields aren't 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 Eight idiomatic shapes. Every locus, type 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 drive(dir: String, flavor: String) { println("onboarding ", dir, " ", 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. - A lifecycle body short-circuits with `return;` as a method does (`birth`, `run` and `dissolve` alike). ### 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,fragment 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,fragment 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 are **data, never loci** — a locus cell is refused (§7, deliberate absences). A struct from an imported seed or the stdlib is a cell type like an in-seed one (`shared::Quote`, `std::http::Request`). - `@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.5b Membership set — `@form(set)` When the question is *is this present* rather than *what is stored here*, use `@form(set)`. It is the hashmap slot and runtime with a different surface: ```hale type Item { key: String; } @form(set) locus Seen { capacity { pool items of Item indexed_by key; } } ``` `insert(v)` / `contains(k) -> Bool` / `remove(k)` / `len()` / `is_empty()`. The reason not to reach for `@form(hashmap)` with a throwaway value: membership then reads `get(k) or false` at every call site — the value plumbing leaking back out of a container whose value nobody wants. Sync disciplines work identically (`@form(set, sync = striped)`). ### 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.6b Secret-owning locus — `@sealed`, one classified operation Key material, a token, anything the rest of the program must never hold. The shape is not an analysis; it is the ownership model doing its job with one word added. ```hale,fragment @sealed locus Signer { params { env_var: String = "SIGNING_KEY"; key: Bytes = b""; } birth() { self.key = std::bytes::from_string(std::env::var(self.env_var)); } fn ready() -> Bool { return len(self.key) > 0; } @effects(is: { secret_use }) fn sign(m: Bytes) -> Bytes { if !self.ready() { return b""; } return std::crypto::hmac_sha256(self.key, m); } } ``` Four rules, each load-bearing: - **`@sealed`.** Loci are otherwise *not* field-encapsulated — `self.signer.key` typechecks from anywhere holding the locus — so this is what makes "the key never leaves its owner" a fact the compiler knows rather than one you verify by reading. It covers reads *and* writes: a locus whose state can be replaced from outside lets a caller choose the key. - **Take the NAME of a source, not the bytes.** `env_var:` / `key_file:`, loaded in `birth`. Param initialization is deliberately unrestricted — a parent writing `Signer { key: … }` already holds what it passes — so material arriving as a constructor argument means some line of the application held it. Make `birth` the only writer, including the no-source case; otherwise a caller can seed it regardless. - **Refuse when unavailable.** Every privileged method must consult `ready()`. A signer that computes under an empty key returns a MAC anyone can forge. - **Classify the one privileged method** with `secret_use`, so the law can find it. A sealed locus may not also carry `contract { expose … }`: sealing denies every external read, so the expose grants nothing, and the pair is rejected rather than left to read as permission. Callers use the locus's methods. Then the application states its own rule with claim forms that already exist: ```hale,fragment claims { no_plugin_secrets: forbid reaches(plugins, effects(secret_use)); one_op_per_request: bound secret_use <= 1 on paths from handlers; vault_confined: require sealed(all vaults); } ``` Prefer `std::secret::Signer` / `Credential` to writing your own. Write your own when the operation is neither HMAC nor credential comparison, and keep it small enough to read: **the sealed locus's own body is trusted**, which is the assumption the shape rests on. This is confinement, not information flow. A signature *derived* from the key is not tracked, and a constant-time compare still lets the verdict be published. ### 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,fragment 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 fallible(ClosureViolation) { 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) or { return; }); 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`. - A method that returns a value and may violate is `fallible(ClosureViolation)` (the checker refuses it otherwise), so the violation is the call's failure and the caller's `or` says what happens next: here, nothing is published. - 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). ### 2.8 Quantity — a counted unit, not a bare Int Declare a quantity when a number has a unit a reader could get wrong: money, time, bytes, ticks. The value is still the `Int` it counts, but the compiler now refuses the mix-ups (`5ms + 4KiB`, a bare count where a duration goes) and makes every exact conversion for you. ```hale unit cent; unit USD = 100 cent; unit B; unit KiB = 1024 B; unit mK; type Money = quantity Int in cent; type ByteCount = quantity Int in B; type TempDelta = quantity Int in mK; type Celsius = point TempDelta { origin: 273_150 mK; } type Line { qty: Int; price: Money; } fn total(l: Line) -> Money { return l.qty * l.price; } fn main() { let l = Line { qty: 3, price: 12USD }; let cap: ByteCount = 4KiB; let room = Celsius(21_000mK); let started = std::time::monotonic(); if std::time::monotonic() - started > 50ms { println("slow"); } println(total(l), " ", cap, " ", room); } ``` - **Name the catalogue once.** A unit is one node of the whole program, so it is declared once, in the seed that owns its domain; an importer writes `12USD` with no prefix, and a second `unit cent;` anywhere in the program is an error. Time is declared already: `ns` to `day`, `Duration` and `Time` are the stdlib's. - **A point for a reading whose zero is a convention** — a temperature, a price, an instant (`Time` is one). Two points differ by a quantity and never add; `origin:` says where the point's zero sits. - **Hold clock readings as `Time` and `Duration`**, not as `Int` nanoseconds: every sum, difference, scaling and comparison of them is typed, and the representation is the same `i64`. - **A bare `Int` is right for a count of things** — an index, a length, a number of items (`qty` above). `qty * price` is `Money`; the count has no unit to get wrong. Narrowing is C8. The contract is `spec/units.md`, the walk-through the book's "Units, end to end". ### 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` | | Unit | its symbol: short, in the case its domain writes it | `ms`, `bp`, `cent`, `KiB`, `USD` | A unit name is written against a number with no space (`500ms`, `3bp`), so it reads like the stdlib's time units: short and mostly lower-case, with an established symbol keeping its case (`KiB`, `mK`). Two names are refused outright: one a literal already owns as a suffix (`d` is the Decimal literal's, `e5` a Float exponent — "unit `d`: a unit named `d` collides with the Decimal literal's suffix: `3d` is the Decimal `3`; give it another name"), and a builtin type's (`Bytes`, `String`), which a type position reads before any declaration. Hence `ByteCount`, not `Bytes`, for a count of `B`. 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. ### This document is tested `spec/styleguide.md` is the most prescriptive file in the repo, and until 2026-08-03 nothing checked a line of it. That is how §7 came to assert that a generic payload enum "compiles, constructs, and matches today" when it does not construct — the claim was written once and never executed again. Two gates now run in the normal suite: - **Snippets.** Every ```hale block must parse *and typecheck*. Partial sketches opt out with ```hale,fragment; deliberately-wrong anti-patterns use ```hale,counter, so a reader of the source can tell "incomplete" from "wrong on purpose". (`hale-types/tests/styleguide_snippets.rs`) - **Claims.** §7's assertions about what the language does *not* do are pinned as tests. Absence is the thing nobody re-tests, so when a gap closes the prose keeps saying it is open. If one of these changes, the test fails and names the entry to update. (same file, plus `hale-cli/tests/styleguide_claims.rs` for the claims that are codegen limits rather than typecheck limits — a split worth knowing, because those are invisible to `hale check` — and for the retirement gaps, which only a running program's resident set shows.) If you add a claim about the language here, add the test with it. ### 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, **[@effects]** checked when the fn declares the matching effect assertion (`@effects(none: {…})` or its `@no_*` sugar), **[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]** - **Declare non-returning inline children last.** Params birth in declaration order and an inline-on-main child's `run()` runs synchronously during its own birth — so a keep-alive child declared early means every later param is never **born**: its `birth()` never runs, its subscriptions and sockets never exist, and the process looks like it booted and idles. Declare the blocker last, or move it off the main thread (`pinned` / a non-`main` pool) — only the blocker's placement matters. The compiler warns where it can prove the shape; absence of the warning is not a guarantee. **[warn]** - A single-writer state locus read from other pools: pin it, and poll scalar fields across pools rather than sharing heap values. **[convention]** - TLS on an async_io pool reads with `recv_into` / `recv_stamped_into`, which park the coroutine. The handshake (`connect`, `upgrade`) and `recv_bytes` still block the worker (§7), and the compiler warns where a handler on such a pool reaches them — so does `std::http`'s https client, which uses both. **[warn]** ### 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,fragment 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,fragment 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,fragment 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]** ### C6b. Prefer `only:` to an enumerated `none:` ```hale,fragment @effects(only: { alloc }) // closed — future-proof @effects(none: { syscall, block, publish, time, entropy, env, ffi, spawn, recursion }) // open — rots ``` `none:` lists what is forbidden and permits everything else, so a contract meaning "this handler only allocates" has to enumerate every other class — and the list is a snapshot. Add a class to the language, or declare one of your own with `effect NAME;`, and every such contract silently permits it. The annotation still reads "only alloc" and no longer means it. Nothing fails; the certificate quietly weakens. `only:` states the permitted set and is checked against the complement computed at check time from the classes that actually exist. Nothing is written down that can go stale. **[convention — reach for `none:` only when you genuinely care about one or two classes]** ### C7. `@form` constraints (the sharp edges of 2.5) - Cell types: data, never a locus. A struct from another seed or the stdlib qualifies; spell the stdlib's time types bare, `Time` and `Duration` (§7). **[error]** - Hashmap iteration is bucket-order → `seq` field for order; no delete → tombstone. **[convention]** ### C8. Narrow with a policy, never by hand A conversion that divides can lose a remainder: `/` by a literal, a quantity into a coarser denomination (`.in(USD)`, a binding or a field of a coarser type). Each says what becomes of the remainder — `or floor`, `or ceil`, `or trunc`, `or half_up`, `or half_even`, `or `, `or raise`, `or fail E { … }` — or the target type says it once with `{ round: …; }`: ```hale unit cent; unit USD = 100 cent; type Money = quantity Int in cent; fn main() { let fee: Money = 250cent; let share = fee / 3 or half_even; let dollars = fee.in(USD) or floor; let count: Int = (fee.in(USD) or half_even) / 1USD; let wait = 1500ms.in(s) or ceil; println(share, " ", dollars, " ", count, " ", wait); } ``` Two reasons. The compiler refuses the bare form, at the site: ```text type error: `Money` divided by 2 leaves a remainder: say what happens to the remainder: `or floor`, `or `, `or raise`, or give `Money` a `round:` policy ``` And the policy is the reader's record of the rounding: a ledger that rounds half-even says so where it does, instead of in an `Int` division three calls away. Don't route around it by dividing `Int`s you pulled out of a quantity — that is the hand-rolled rounding the rule exists to name. The one division that takes no policy is a quantity's count in a unit, `fee / 1USD`: an `Int` quotient that truncates toward zero, as `Int`'s `/` does, and a runtime divisor (`d / n`) is the same integer division. When the count must round another way, narrow first, as `count` above does. **[error for an unnamed narrowing]** --- ## 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,fragment 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. A `where zero_copy` binding holds its payload to that shape at check: scalar fields, nested flat structs, and fixed arrays of `Int` / `Float` / `Bool` / `Decimal` / `Duration`, which are laid out inline; an array of structs or of `Time`, a slice, a `String` or `Bytes` is refused ("payload type … is not flat-shapeable"). 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,fragment @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,fragment 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]** --- ### S13. Compose with chains, not hand-rolled loops ```hale,fragment // idiomatic let n = self.users.filter(it.active).count(); self.users.filter(it.active).into(self.actives); let total = self.users.map(it.age).sum(); let oldest = self.users.max(it.age) or raise; self.users.filter(it.active).each { self.greet(it); } let page = self.users.skip(20).take(10).count(); self.users.filter(it.active).sort_into(self.ranked, older_first); self.orders.group_count_into(self.by_desk, it.desk); // not this let mut i = 0; let mut n = 0; while i < self.users.len() { let u = self.users.get(i) or { break; }; if u.active { n = n + 1; } i = i + 1; } ``` A chain is not a value being built — it is rewritten to exactly the loop on the right, so the two have identical cost. Prefer the chain because it is shorter to read, and because stages fuse: two `filter`s are one pass, where the hand-rolled version invites an intermediate. A chain runs over a `@form(vec)` source directly (`self.users`), and over a `@form(hashmap)` source anchored on `.entries` (`self.book.entries.filter(...).count()`) — the same pseudo-field a `for e in m.entries` loop iterates, walking the hashmap's occupied slots. `.items` is the explicit vec anchor. The element `it` is the stored value in both. Three properties make chains safe on a hot path, and they are worth knowing rather than assuming: - **Zero allocation.** A chain is legal under `@budget(alloc_per_call = 0)` and `@hot`. Nothing is materialized until a terminal that says so (`into`). - **Eager.** A predicate's effects are attributed to the predicate's own line, not the terminal's. There is no deferred execution to mislead a witness path. - **No lambdas.** `it` is bound by the rewrite; the predicate is syntax, not a value. Nothing captures, so nothing escapes. Whole-set operations (`sort_into`, `reverse_into`, `group_count_into`) cannot fuse — they need every element before producing any — so they materialize into caller storage: the chain fills (or bumps) a collection you declared, then reorders it in place. That cost is visible in the budget, which is the point. Positional selection (`take(n)` / `skip(n)`) and the element's ordinal (`enumerate()`, binding `idx`) DO fuse — they are counters on the one loop, not materializations. --- ## 5. The enforcement ladder The compiler backs this guide in tiers. Everything default is advisory or genuinely-broken-only; strictness is opt-in where you certify a path — plus one tier you get for free because your *placement* already declared the intent. | 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; the unit laws, and a narrowing with no policy (C8) | 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 | | **@effects** (opt-in) | `@effects(none: {syscall, block, time, entropy, env, ffi, publish, spawn, recursion})` / `@effects(publish: {T})`, and the `@no_*` / `@deterministic` sugar — checked transitively; diagnostics carry the call chain to the offending leaf | build fails on violation | | **@sealed** (opt-in) | a sealed locus's `params` are reachable only from inside its own methods — reads and writes both. The tier that makes state confinement structural; loci are otherwise not field-encapsulated. `hale check --sealable` reports what taking it would cost | build fails on violation | | **placement-implied** (automatic) | a handler on a `cooperative(pool = X) where async_io` locus that reaches a blocking call — the placement *is* the assertion, so no annotation is needed; writing `@no_block` upgrades it to an enforced error | advisory | | **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` | | | advisory tools | `hale check --sealable` (which loci could seal today); `hale check --flows` (which `release` clause makes each type a flow); `hale check --strict-secret` (the fail-closed `@secret` walk — loud by design, because one body's reasoning is not a containment proof); `hale check --units` (every narrowing by site, with its factor and the policy that discharged it and where that policy came from, and each quantity's headroom — record it and review its diff) | opt-in reports | ### Where law lives The tiers above certify a *function*. Claims certify a *system*, and the only real style question is which closed world owns each sentence. Two claim verbs quantify over the whole closed world rather than over a path, which makes them the right shape for a baseline a team adopts once: `require sealed(all G)` (every locus in the group keeps its state to itself) and `require attributed(all C)` (every fn that directly performs built-in class `C` names a user-declared purpose). Both cover code nobody has written yet, where a group-scoped claim does not. Prefer them for policy; keep `forbid reaches` for the specific edges a design forbids. - **In the `main locus`** — law about this application. A claim is evaluated in a closed world, so it belongs where the world closes. Name it for the requirement (`orders_pass_risk`), never for the mechanism (`no_call_from_a_to_b`): the name is what a reviewer reads in a diff when someone weakens it. - **In a `constitution`** — the same sentence needed by several entrypoints. Authorship moves; evaluation does not. Each adopting main still proves it in its own world, so `adopt Core;` is not a shortcut around closure. Composition is union-only: a stricter rule is a second clause, never a replacement, so a derived constitution can only add law. - **In a library's `claims { }`** — obligations about the surface the library exports, re-evaluated in every importer. - **In a fleet plan** — law no single binary can state, because it is about the arrangement. `count publishers == 1` inside one program is a different sentence from "one deployed publisher"; write the second where instances exist. Two habits worth keeping. Declare the vocabulary a claim quantifies over — an undeclared group is an error, and a group that is legitimately empty says so with `may_be_empty` rather than being allowed to hold vacuously. And read `uncertified` as work to do, not as a pass: it means the model has a hole where the claim needed to look. 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 state** — every named piece of state 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.22.0 (2026-10-07); shipped-and-gone entries are removed on shipping — keyed String routing, match-as-expression, whole-struct replace reclamation, `err` in an `or fail` payload, `Duration` arithmetic, `return` in a lifecycle body, inline fixed-array payload fields, qualified `@form` cell types, an `Int` returned from a `-> Float` fn and an `Int` into a `Float` let or field 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`.** Two separate reasons, previously conflated here. The *idiom* is deliberate: 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. The *mechanism*, however, IS missing, and this section previously claimed otherwise. A generic payload enum declares and compiles, but does not construct: `type Opt = enum { Some(T), None };` builds, and then `Opt::Some(1)` fails with "path call `Opt::Some` in expression position". Non-generic payload enums construct and match normally — genericity is the whole difference, and it is the same generic-instantiation gap behind `Vec`. So `Option` is not merely unblessed; it is not currently expressible. - **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. - **A locus is never a `@form` cell.** Cells are data a form copies, recycles and frees; a locus has a lifecycle that would be orphaned by it. The compiler says so: "cell type cannot be a locus. Cells are data; loci are managed entities … Route locus membership through `accept(c: Worker)` instead". Accept the children, and keep a `@form(hashmap)` of their keys beside them if you need lookup by name. ### Open gaps — expect these to close Write the workaround knowing it's a placeholder. - **Nested-compound fields of a replaced struct don't retire**: each replace leaves the old one in the arena (measured on v0.22.0, ~6 MiB of resident growth over 200 000 replaces, where a String-only struct holds flat). String leaves retire since v0.11.3; Bytes fields of a self-field struct store and of a vec element since GH #1033 / #1037; a replaced `@form(vec)` or `@form(hashmap)` cell retires its whole value, nested records, tuples, arrays and Bytes included (spec/forms.md, "Cell reclamation"). Until then: nested compound fields of a self-field struct in their own locus or flattened. - **`striped` / `lockfree` `@form` maps don't retire** replaced cells — a churned String-bearing cell on those modes still accumulates (~2.3 MiB over 200 000 replaces of 16 keys, on each). (`sync = serialized` retires since 2026-08-03: reads are owned snapshots cloned into the caller's arena, so churned shared maps no longer need to stay single-pool. The clone is per String field per read — a read-heavy hot path on a String-bearing cell now pays it, so weigh serialized against single-pool on read-dominated shapes.) - **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 parks only its reads into a builder.** On a `where async_io` pool `recv_into` / `recv_stamped_into` park the coroutine; the handshake (`connect`, `upgrade`) and `recv_bytes` block the thread, and `send_bytes` is a plain `SSL_write` on it. Read with `recv_into`, and keep the handshake — and the `std::http` https client, which uses `connect` and `recv_bytes` — off async_io pools (C3). ### Sharp edges — current limitations, step around them Implementation constraints, not positions; no promise attached. Two of them pass `hale check` and fail only at `hale build`: know those cold. - **No char-level `s[i]`** — and the checker doesn't say so: `s[1]` on a `String` typechecks, then the build fails with "indexing a non-array value (type String)". Use `s[i..i + 1]` slices, `std::str::index_of`, or the UTF-8 accessors `std::str::cp_at` / `cp_size` / `cp_count` when you need code points rather than bytes. There is still no `Char` type; `cp_at` yields the code point as an `Int`. - **Write the stdlib's time types bare: `Time` and `Duration`, not `std::time::Time` or `std::time::Duration`.** The qualified spelling typechecks as a field, a `@form` cell type or a fn parameter and fails at build ("qualified type `std::time::Duration` not in stdlib path-renames table"). A qualified `let` annotation builds, but the binding is not the quantity (`d / 1ms` is refused as "`Duration` is not an `Int`"), so write it bare there too. 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. There is **no escaped-identifier form**: a reserved word cannot be spelled as a name by quoting, prefixing or backslashing it. The only fix for a collision is a different name. A reserved word written where a name belongs — a `params` field, a local, a `fn` / method / parameter, a struct field, a locus or type name — is a parse error reported **once, at the word**, naming the word and the declaration it was written in: ```text work.hl:8:9: parse error: `epoch` is a reserved word and cannot name a params field; rename it (Hale has no escaped-identifier form — spec/tokens.md lists every reserved word) ``` The parser then resumes as if the declaration had been named, so the rest of the file still parses and the one error is not followed by the consequences of the abandoned declaration (a later `}` reported as a stray token, a literal of the type reported as a block, the same word reported again at every use site). Every use of the SAME word as a name after the first is part of that one mistake and is not reported again. This section is the canonical list the diagnostic points at. ## 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 resets_per_epoch ``` `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`. `Time` and `Duration` are declared by the stdlib's seed (`type Duration = quantity Int in ns;`, `type Time = point Duration;`, spec/units.md § The stdlib's time catalogue); a type position reads either name as that declaration, whose values keep the primitive's representation. 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. A scalar declaration (a quantity, a point, an identity, a range) or a `unit` of such a name is an error (spec/units.md § The laws, law 12). ### 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. ### Unit dialect words (GH #1076) ``` unit quantity point distinct ``` All four are **contextual keywords** (spec/units.md § Declarations), each recognized in one position and an ordinary `Ident` everywhere else, so a local, field, parameter, `fn` or method may still be named by any of them: - **`unit`** — at top level, when a name and then `=` or `;` follow it: `unit ms = 1_000 us;`, `unit tick;`. - **`quantity`**, **`point`**, **`distinct`** — right after a `type` declaration's `=`, when a type expression follows: `type Money = quantity Int in cent;`. `type P = point;` still aliases a type named `point`. `range`, `round` and `origin` name the clauses of a scalar type's `{ }` block (`type Byte = Int { range: 0..256; }`) and are not words anywhere else. `in` stays a hard keyword; after `.` it is a member name, so `d.in(s)` is an ordinary method call. ### Surface words (GH #1417) ``` api rpc requires ``` All three are **contextual keywords** (spec/api.md § Surfaces and their rows), each recognized in its positions and an ordinary `Ident` everywhere else: - **`api`** — at top level, when a name and then `{` follow it: `api Public { … }` declares a surface. It stays an ordinary identifier elsewhere, as the `api::serve` path head (the `bindings { api: … }` entry head of the retired structural path is refused, spec/semantics.md § The api binding). - **`rpc`** — at the head of a row inside an `api` block (`rpc Orders::place;`), and as the annotation `@rpc` on a locus fn. - **`requires`** — after a row's handler, inside `@rpc( … )`, and among a hub binding's clauses: `requires: [trader]`. `api::serve( … )` is a call whose arguments after the surface and the transport are named (`as:`, `receivers:`, `bound:`, `on_full:`); no other call takes named arguments (spec/api.md § Serving). ## 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`. - There is no type suffix. A decimal integer written against a name is a decimal literal (`d`) or a quantity literal (any other name, the time units among them), below; a radix integer followed by a name is a parse error. ### Float literals - Decimal: `3.14`, `1.0e-3`, `2.5E+10`. An `e` or `E` is an exponent only when a digit, or a sign and a digit, follows it. - There is no type suffix: but for the decimal literal's `d` (below), a float written against a name is a parse error. ### Quantity literals (GH #1076) - A decimal integer immediately followed, with no space, by a name: `500ms`, `3bp`, `5kg`, `2min`, `1_250_000USD`, `2EUR`. One token, carrying the magnitude and the unit's name as written. Integer magnitudes only. The lexer knows no unit: which one the name is, a unit of the stdlib's time catalogue or one a program declares, is the catalogue's to say where the literal is checked (U4). - The unit is named in the units' own namespace, never a local's: a quantity literal is its unit's component's quantity, at the quantity's denomination when it is a whole count of it (`1000mg` of a quantity in grams is 1) and else at its unit (`5mg`), converted at compile time into the denomination it flows into (spec/units.md § Literals). A unit no `unit` declares is refused at the literal. ### 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 - A duration literal is a quantity literal of a unit of the stdlib's time catalogue (`std::time`, spec/units.md § The stdlib's time catalogue): `ns`, `us`, `ms`, `s`, `min`, `h`, `day`. Examples: `100ms`, `5s`, `2min`, `1day`. It is a `Duration`, its value its length in nanoseconds (`100ms` is 100,000,000), which the catalogue computes; a program's own unit of the time component (`unit tick = 10 ms;`) writes `5tick` the same way. One magnitude, one unit: a span of mixed units is a sum (`1h + 30min`). - `d` is the Decimal literal's and `day` is the day: `3d` is the Decimal `3`, as it always was, and no unit may be named `d` (spec/units.md, law 11). `m` is no unit: `5m` is refused as a unit no catalogue declares, the message saying minutes are `min`. - A compound such as `1h30m` is one magnitude and the unit `h30m`, which no catalogue declares: the check refuses it as a literal of several units and says the sum to write (`1h + 30min`). - A unit declaration's factor and a denomination read the magnitude and the unit as written (spec/units.md § Declarations): `unit tick = 100ms;` parses as `unit tick = 100 ms;`. - Time literals: ISO-8601 UTC between backticks: `` `2026-05-08T12:00:00Z` ``, `` `2026-09-14T08:30:15.25Z` `` (a fraction of one to nine digits; the `Z` is optional; an offset is a compile error). Parsed at check time into an instant (GH #607). ### 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"..."`) and multi-line strings (`"""..."""`) are NOT implemented — the lexer rejects both (verified 2026-07-22, GH #228 docs-drift audit; both were previously listed here aspirationally). Byte literals + f-strings cover the practical cases; add these forms to the lexer before re-documenting them. - 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`. Whitespace immediately inside the braces is trimmed, so `f"{ x }"` and `f"{x}"` are the same. An empty interpolation (`f"{}"`) is a lex error: there is no positional-argument form for it to refer to. A string literal may appear inside an interpolation (`f"{lower("ADA")}"`) — the lexer tracks quote state while scanning for the closing brace, so braces inside that string do not close the interpolation. **Format specs** (GH #469): an interpolation may end with `:spec`, giving ```text spec := [[fill]align] [width] ["." precision] [kind] align := "<" | "^" | ">" kind := "x" | "X" ``` The `:` that opens a spec is the **last top-level one** that is not part of a `::` path separator and not inside brackets or a string — so `f"{std::str::lower(s):>8}"` splits once, correctly. - `width` pads to at least that many **bytes** and never truncates. Absent an explicit alignment, numbers pad on the left and everything else pads on the right. - `fill` is only recognised when an alignment follows it, which is what keeps `{x:8}` a width rather than a fill of `8`. - `precision` applies to `Float` and `Decimal` only; `Decimal` is rendered through the exact fixed-point path, not via `f64`. Maximum 17. - `kind` renders an `Int` in hexadecimal, from the unsigned bit pattern (so `-1` is `ffffffffffffffff`). A spec that is ungrammatical is a **parse** error; a spec that is grammatical but does not apply to the value's type (hex of a `String`, a precision on an `Int`) is a **type** error. **Lint — a plain string that looks interpolated.** Because a plain `"..."` keeps `{` and `}` as ordinary characters, writing `println("x={x}")` compiles, runs, and prints the braces. The checker emits a **warning** naming the f-string form when a plain string literal passed to `print` / `println` contains `{name}` **and** `name` resolves to a binding in scope. The in-scope condition is what makes it a lint rather than a heuristic: `println("{}")`, `println("{\"a\": 1}")` and prose about a template placeholder name nothing and stay quiet. It is a warning, never an error — printing braces around a word that happens to also be a local is unusual, not illegal. `{{` anywhere in the literal suppresses it, since that spelling signals the author knows the syntax. ### 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 eprint eprintln to_string len abs starts_with contains check_closures 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. `Float(x)` is its widening twin — Int → Float via `sitofp`, Float arg the identity, other types reject. These two are the only casts; the remaining primitive-type names are types and nothing else. `to_string(x)`, `len(x)`, `abs(x)`, `min(a, b)`, `max(a, b)`, `starts_with(s, p)` and `contains(s, p)` are similarly bare-name builtins, as is `check_closures()` at statement position. **A bare builtin call is the same call in either position.** In statement position the expression is evaluated and its value discarded, so `len(s);`, `Int(3);` and `min(1, 2);` are legal (if pointless) statements rather than a narrower vocabulary: a name the compiler answers in expression position it answers here too. The four printers and `check_closures()` return no value and so are statement-position-only; the `bounded[T; N]` intrinsics keep their own statement spellings, with `push` / `at` / `set` carrying an `or` disposition because they are fallible. #### Names a free `fn` may not take (GH #863, GH #880) These names are **claimed at the call site**, ahead of any user declaration: ``` sum prod min max abs to_string Int Float len starts_with contains print println eprint eprintln check_closures __fmt ``` `sum(` and `prod(` have their own production at expression head (the closure-assertion accumulators — see [`semantics.md` § Closure-test evaluation](semantics.md)). `min`, `max`, `abs`, `to_string`, `Int`, `Float`, `len`, `starts_with` and `contains` are answered by a call-expression arm that matches the callee name before it consults the program's free fns. `check_closures` is answered the same way at statement position. `__fmt` is what an f-string interpolation desugars into. A declaration of one of those names could therefore never be reached, so a **free `fn` may not take it** — the parser refuses the declaration, at the name: `abs` is a built-in call form and cannot name a fn; rename it (every `abs(...)` call site lowers to the builtin, so the declaration could never be reached …) The four printers are claimed for the opposite reason, and it is the stronger one: they resolve through the program's fns *first*, and the Hale-source standard library is merged into the **same global fn namespace**. A `fn print(a: Int)` therefore captures the library's own `print("…")` calls, and a program that never mentions `print` again fails to build with ``fn `print` arg 0 type mismatch: expected Int, got String``. Whether a given printer is reachable that way is an accident of what the library happens to call today, so all four are claimed. The rule is on the **declaration**, not the name. A locus method, an `interface` method and a `perspective` contract `fn` may all still be called `sum`, `len` or `contains` — they are reached through a receiver (`self.sum()`, `b.len()`), which no builtin claims, and the standard library's own `mirror_ring.hl` and `bytes_builder.hl` declare `fn len()`. A `module { }` item is a free fn (its items resolve into the same global fn namespace) and is refused. Everything else in this section's table stays available to a free `fn`. `B`, `c`, `sigma`, `phi`, `k_max`, `span_max`, `length` and `empty` are conventionally reserved but no call site dispatches on them. The `bounded[T; N]` intrinsics (`count`, `clear`, `truncate`, `push`, `at`, `set`) and the accumulator vocabulary (`count`, `mean`) are recognized only when the argument IS a `bounded` receiver, or inside a closure assertion. The rest of the element-chain vocabulary (`map`, `filter`, `into`, `any`, `all`, `first`, `find`, `each`, `take`, `skip`, `enumerate`, `sort_into`, `reverse_into`, `group_count_into` — [`semantics.md` § Element chains](semantics.md)) is recognized only after a `.`. #### A declaration shadows a bounded intrinsic (GH #892) The six `bounded[T; N]` intrinsics dispatch on the type of their first argument, so where a program *declares* one of those names the two readings can be told apart — and the program's own declaration wins: > A free `fn` whose **first parameter is a `bounded[T; N]`** > answers every call of that name whose first argument is a > receiver of **that same** `bounded[T; N]`, at any arity the > declaration accepts. The intrinsic answers every other call. ```hale type Window { id: String; samples: bounded[Int; 8]; } fn count(xs: bounded[Int; 8]) -> Int { return 8801; } fn main() { let mut w = Window { id: "w1" }; push(w.samples, 1) or raise; println(count(w.samples)); // 8801 — the declaration } ``` The rule is held by the typechecker and by codegen alike, so `hale check` and `hale build` resolve such a call to the same `fn`. It is a *shadow*, not a refusal: no declaration is rejected, and a program that declares none of the names is unaffected. Element type and capacity are part of the match, and that is load-bearing rather than fussy. The Hale-source standard library is merged into the same global `fn` namespace and calls these intrinsics on buffers of its own, so a shadow keyed on the name alone would retarget the *library's* calls at the user's declaration — the capture that makes the four printers claimed names. A declaration over some other `bounded` is simply not the receiver's, and the intrinsic answers. Method position is untouched: `f.get(i)` and the element-chain terminals (`f.filter(…).count()`) are reached through a receiver, which no free `fn` claims. Until GH #863 / GH #880 the declaration was accepted and the divergence appeared later, in one of two shapes. `hale check` passed and `hale build` refused the program with an unlocated `unsupported in codegen v0` (`len`, `starts_with`, `contains`, `print`, `eprintln`, `check_closures`, `__fmt`); or the program BUILT and silently ran the builtin instead of the body — a wrong answer with no diagnostic anywhere (`abs`, `to_string`, `Int`, `Float`, and a two-arg `min` / `max`). `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.) ## Annotation inventory Annotations are `@`-prefixed contextual idents (never reserved words), so a program may still use these names as identifiers. They attach to the declaration that follows. | annotation | attaches to | meaning | |---|---|---| | `@ffi("c")` | free fn | external C-ABI binding | | `@export` | fn / locus | WASM module export; persistent singleton | | `@form(kind)` | locus | storage form (vec / hashmap / ring_buffer / lru_cache) | | `@locality(tier)` | locus | placement locality tier | | `@bounded` | locus | opt into the memory-bound proof | | `@unbounded` | fn | acknowledge intentional unbounded allocation | | `@hot` | fn | certify a hot path (promotes advisories to errors) | | `@budget(alloc_per_call = N, stack_bytes = N, block_points = N, publish = N, fanout = N)` | fn | quantitative budgets, comma-separated | | `@effects(none: {…})` | fn | forbid effect classes, checked transitively | | `@phase_effects(phase: {…}, …)` | locus | per-lifecycle-phase effect contract | | `@effects(causes: {…})` | fn | effect classes this fn may cause via bus edges | | `@supervised` | locus | every locus in the subtree has a failure policy | | `@sealed` | locus | this locus's `params` are readable only from inside it | | `@secret` | fn param | **lint**: flags a value reaching a publish or log/file sink in the same body | | `@effects(publish: {…})` | fn | the allowed publish set | | `@effects(depends: {…})` | locus | complete set of subjects that may transitively reach any handler | | `@effects(is: {…})` | fn | classify this fn as a source of the named effect classes | | `@effects(only: {…})` | fn | CLOSED contract — the inferred set must be a subset of these | | `@no_syscall` `@no_block` `@no_ffi` `@no_publish` `@no_spawn` `@no_recursion` `@deterministic` | fn | sugar for the `@effects(none: …)` forms | | `@no_panic` | fn | no reachable trap (disposition coverage — a different analysis) | | `@rpc` / `@rpc(requires: [ROLE, …])` | locus fn | a row of the seed's default surface (spec/api.md) | The fn CONTRACT annotations — `@unbounded`, `@hot`, `@budget(...)` and the effect assertions — stack on one fn in any order; `@ffi` and `@export` say what a declaration IS and take the slot alone. A stack that contradicts itself (the same annotation twice, or `@unbounded` with a contract that forbids allocation) is a check-time error at the annotation. See `spec/verification.md` § Decorator stacks. `effect NAME = { A, B };` optionally DEFINES a class as the union of others. A composed class owns no bit of its own, so forbidding it forbids every member, and a fn reaching a member carries it. Members may be built-ins or other declared classes; a definition cycle is rejected. The built-in classes are `syscall`, `block`, `time`, `entropy`, `env`, `ffi`, `publish`, `spawn`, `recursion`, `alloc`, and `secret_use` (GH #436 — a privileged operation over confined secret material, carried by `std::secret`'s methods). **Every built-in name is reserved**: `effect secret_use;` — or `effect syscall;` — is an error, because the built-in wins at every use site and the declaration would be a silent no-op. `effect NAME;` is a top-level **declaration**, not an annotation: it introduces a user effect class that `is:` / `none:` / `causes:` then name like a built-in. `effect` is a **contextual keyword** — recognized only at the start of a top-level item followed by an identifier and `;`, so an existing `effect` binding is unaffected. Declared classes are single-seed (see `spec/verification.md`). Effect annotations stack with each other and with `@hot` / `@budget(...)`; see `spec/verification.md` for what each class means and where its truth comes from. `group NAME = { … } [may_be_empty];` is a top-level **declaration** of claim vocabulary, `domain NAME = { … };` declares a closed index domain for effect families (`effect knowledge(wing);`), and `claims { … }` is a main-locus member holding named bundle-level claims (GH #382). The claim verbs are `forbid reaches` (with `via` / `during` / `avoiding` modifiers), `only edges` (grant enumeration), `bound` (path budgets over user classes, plus the built-in `secret_use` — the one counted built-in with no `@budget` spelling), `require subscribes|publishes` (existence), `require sealed(all G)` and `require attributed(all C)` (GH #436 — universals over the closed world rather than over a path), `cover`, and `count`. All introducers — `group`, `claims`, `domain`, `forbid`, `reaches`, `via`, `may_be_empty`, `edges`, `bound`, `require`, `sealed`, `attributed`, `all`, `cover`, `count`, `during`, `avoiding`, `seed` — are **contextual keywords**, recognized in their positions only, so existing identifiers with those names are unaffected. `@budget( = N)` bounds calls to declared carriers of the class. See `spec/verification.md § Claims` and `spec/grammar.ebnf`.