Style guide

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

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

Section titled “1. Foundations”

The foundational axiom

Section titled “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

Section titled “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

Section titled “The memory model in one page”

This is the single most load-bearing page in the guide. A developer who gets every shape right and skips this page will still ship a leak — that is an observed outcome, not a hypothetical.

Arenas don’t free per-allocation. Every locus owns an arena (self.__arena). Everything anchored there — struct clones, String buffers, child structures — lives until the locus dissolves. There is no per-object free.

Method scratch reclaims at method exit. Every allocating method (and bus handler) gets a scratch region created at entry and destroyed at return. Transients — parse results, temporaries, locals — allocate into scratch and vanish per call. This is why ordinary code is allocation-bounded by construction: allocate freely in a method, and the method boundary cleans up.

Escaping a value costs an anchor. Storing a heap value into self copies it out of scratch into the locus arena (an “anchor”). The anchor is the transfer of lifetime: the value now lives as long as the locus.

Therefore: a long-lived locus means anything anchored per-iteration accumulates until dissolve — unless one of the substrate’s reclamation mechanisms applies. The mechanisms, in the order you should reach for them:

  1. Don’t escape. Keep transients in method scratch. Free by construction.
  2. Mutate in place. self.f.x = v, self.arr[i] = v, String/Bytes reassign that fits the existing buffer — all reuse existing storage, zero new bytes.
  3. Replace and let it retire. self.f = Struct { ... } whole-value replace memcpys the struct bytes in place, and — since v0.11.3 — retires the replaced String clones at the method’s activation boundary, recycling them on the next store. Steady-state replace of a scalar/String struct holds the arena flat. (Structs carrying Bytes / nested compound fields aren’t fully retired yet — see §7.)
  4. Bound the container. capacity slots, bounded[T; N], @form(ring_buffer) / @form(lru_cache) are cap-bounded by type.
  5. Give it to a child. An accept’d child locus owns its own arena, reclaimed when the child ends — the per-connection shape (see rule C4).
  6. Send it. Bus payloads ride a per-dispatch arena, reclaimed after delivery.

One structural consequence to internalize: a run() loop is a single never-ending activation. Nothing scratch-based reclaims inside it, and pending retires never flush. Hot per-iteration work belongs in a method called from the loop — the method boundary is the reclaim point. (This is why every churn example in this guide runs its body through a method.)


2. The shape catalog

Section titled “2. The shape catalog”

Seven idiomatic shapes. Every locus or free fn in a well-written program matches one; code that doesn’t should be reconsidered against the catalog before shipping.

2.1 App locus — outer encapsulation

Section titled “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.

locus Onboard {
params { dir: String = "fixture"; flavor: String = "go"; }
run() { drive(self.dir, self.flavor); }
}
fn main() {
let mut dir = "fixture";
if std::env::args_count() > 1 { dir = std::env::arg(1); }
Onboard { dir: dir };
}

2.2 Namespace locus — empty params, methods only

Section titled “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”.

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

Section titled “2.3 Service locus — long-lived, lifecycle + bus”

The full lifecycle for a thing that genuinely runs over time.

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); }
}

2.4 Spawned child locus — let-bound, scope-dissolves

Section titled “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).

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);
}

2.5 Data collection — @form locus with a domain facade

Section titled “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:

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:

2.5b Membership set — @form(set)

Section titled “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:

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

Section titled “2.6 Shape type — pure data, no flow”
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

Section titled “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.

@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:

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:

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

Section titled “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:

locus DbConnection {
params { conn_fd: Int = -1; last_error: String = ""; }
bus { subscribe ExecuteQuery as on_query; publish QueryResult; }
closure fatal_io { captures: last_error; epoch inline; }
fn handle_io(e: DbError) -> Row {
self.last_error = e.detail;
if e.kind == "send_failed" { violate fatal_io; }
return Row { data: "" };
}
fn on_query(q: Query) {
let r = send_query(self.conn_fd, q) or self.handle_io(err);
if !self.draining { QueryResult <- r; }
}
}

Naming conventions

Section titled “Naming conventions”
Construct Convention Example
Locus / type PascalCase Listener, Request
Method / field snake_case name_to_motion, listen_fd
Lifecycle decl drop the fn keyword run() { ... }
Free fn bare snake_case drive, handle_one_connection
Bus subject dot-separated, lowercase log.app.db
Constants UPPER_SNAKE_CASE STDLIB_AP_SOURCE

Library exports: pick short lowercase import aliases; name decls to read naturally under the alias (fin::Quote, not fin::FinQuote). See spec/projects.md for the seed model.

This document is tested

Section titled “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:

If you add a claim about the language here, add the test with it.

Canonical form — hale fmt is the arbiter

Section titled “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:

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

Section titled “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

Section titled “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

Section titled “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.<field> 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

Section titled “C2. Fd and resource ownership”

C3. Placement and starvation

Section titled “C3. Placement and starvation”

C4. accept / release — flows, not residents

Section titled “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.

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

Section titled “C5. Bus discipline”
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]

C6. Dispatch shape

Section titled “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:

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:

Section titled “C6b. Prefer only: to an enumerated none:”
@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)

Section titled “C7. @form constraints (the sharp edges of 2.5)”

4. Speed rules

Section titled “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

Section titled “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:

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

Section titled “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

Section titled “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

Section titled “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

Section titled “S5. Strings/Bytes that fit, stay in place”

self.f = <String/Bytes> 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

Section titled “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

Section titled “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

Section titled “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

Section titled “S9. Flat payloads; batch drains”

Transitively pointer-free payload types skip the serialize/ deserialize wire path entirely on cross-thread delivery — prefer scalar payloads for high-rate topics. For high-rate consumers, a Drain<T> handler runs once per queue drain with a zero-copy for over the batch instead of once per message. [convention]

S10. Certify the path

Section titled “S10. Certify the path”

When a hot path is clean, pin it:

@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

Section titled “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

Section titled “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:

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

Section titled “What’s already free — don’t optimize these”

Raw-fd free fns vs Stream

Section titled “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

Section titled “S13. Compose with chains, not hand-rolled loops”
// 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 filters 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:

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

Section titled “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 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 --strict-secret (the fail-closed @secret walk — loud by design, because one body’s reasoning is not a containment proof) opt-in reports

Where law lives

Section titled “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.

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 <toml>, --warn-resource-leak) gate thread / pool / subject / fd counts in CI — see spec/verification.md.


6. Anti-patterns

Section titled “6. Anti-patterns”

7. Boundaries — deliberate absences, open gaps, sharp edges

Section titled “7. Boundaries — deliberate absences, open gaps, sharp edges”

Three different kinds of “the language doesn’t do X” live here, and they call for three different behaviors from you: absences you should stop wanting (the design says no, and names the shape to use instead), gaps you can expect to close (write the workaround knowing it’s temporary), and sharp edges you step around (current limitations, no promise either way). Current as of v0.11.3 (2026-07-17); shipped-and-gone entries are removed on shipping — keyed String routing, match-as-expression, and whole-struct replace reclamation all lived here once.

Deliberate absences — the design says no

Section titled “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.

Open gaps — expect these to close

Section titled “Open gaps — expect these to close”

Write the workaround knowing it’s a placeholder.

Sharp edges — current limitations, step around them

Section titled “Sharp edges — current limitations, step around them”

Implementation constraints, not positions; no promise attached. One is soundness-adjacent and worth knowing cold.

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

Section titled “Appendix A. Leak-hunt diagnostics”

The workflow that pins a leak, in escalation order:

Terminal window
# 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=<grower>' → 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

Section titled “Cross-references”