================================================================================ HALE CONTEXT PACK (full) ================================================================================ Everything an LLM needs to write correct, idiomatic Hale: the canonical authoring rules, the full language specification, and the real-world gotchas. 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. ================================================================================ ## Operational semantics ## source: spec/semantics.md ================================================================================ # Operational semantics This document specifies what Hale programs *do* when they run. Where `grammar.ebnf` says what's syntactically valid and `types.md` says what's meaningfully valid, this document says what happens at runtime. The semantics are described informally (engineering-grade prose), not as formal small-step rules. Formal operational semantics are deferred to v1+; the framework's substrate-invariance claim is not blocked on full formalization. ## Program startup 1. The runtime initializes: - Region allocator, schedulers (one per CPU core; cooperative within each), bus router, lifecycle dispatcher. - Establishes the **runtime root locus** as the implicit parent of `main`'s implicit locus. 2. Bus topics with `bindings { Topic: ...; }` entries in the `main` locus are registered against their declared transports. Topics without a binding stay same-process via the cooperative queue. 3. `fn main()` is invoked. ## Function call A `fn name(args) -> ret { body }` invocation: 1. Caller passes args by value. 2. Implicit locus is allocated for the function's scope. 3. Body executes statement-by-statement. 4. Locally bound handles are bound to local names. 5. Anonymous-child loci with ongoing-work surface attach to the implicit locus. 6. On `return value;`, control flows back to caller. Before return, the implicit locus drains and dissolves (waits for all children to finish; depth-first cascade per F.4). ### Implicit Int → Float widening at call sites When a function parameter has type `Float` and the call-site argument has type `Int`, codegen inserts an implicit `sitofp` widening at the call site. The same rule fires at let-binding type ascriptions: `let nf: Float = self.n;` where `self.n: Int` succeeds. The widening is **one-way only** — `Float → Int` narrowing remains explicit, and `Decimal` never participates in implicit cross-type conversion. Phase 2c. See F.23 in `spec/design-rationale.md` and the Phase 2c entry in `spec/stdlib.md`. ## Expressions — `if` and block tails A `{ ... }` block whose last item is an expression *without* a trailing `;` carries that expression as its **value**. In expression position (let-RHS, fn-call argument, if-arm body) the value is consumed; in statement position (loop body, `Stmt::If` / `Stmt::Match` block) the trailing expression is evaluated for side effects and the value is discarded — semantically equivalent to having added the `;`. A **fn-shaped body** (free fn, locus method, mode) with a declared return type treats its trailing expression as an implicit `return`: `fn double(n: Int) -> Int { let d = n * 2; d }` returns `d` (2026-08-11 — previously the typechecker accepted this form and codegen rejected it as a fall-through). A body with *no* declared return type keeps statement-position semantics: the tail is evaluated for side effects. `if cond { ... } else { ... }` is dual-position: - **As statement** (`if c { foo(); }` — no `else`, or any arm whose block has no trailing expression): no value; trailing expressions in either arm are evaluated for side effects. - **As expression** (e.g., `let x = if cond { i } else { j };`): the then- and else-arms' trailing expressions are phi-merged at the join basic block. The else branch is **required**; arm trailing-expression types must match; arms may carry their own let-bindings before the tail (the bindings are scoped to the arm). The two positions are distinguished by **shape, not syntactic context** (WS3.2). A trailing `if` — the last item of a block, with no `;` — becomes that block's tail expression when it is **value-producing**: every arm (then, else, and each `else if`) must end in a trailing expression. This is what lets an `if` nest as a block value: ```hale let x = if a { if b { p } else { q } } else { r }; // \__ then-arm block whose tail is the inner if __/ ``` The inner `if` is the then-arm block's tail, so the outer `if`'s then-value is the inner `if`'s value. A trailing `if` that is **not** value-producing (no `else`, or an arm with no tail) stays a statement — it has no value to carry, and forcing it into the value path would leave a tail-less arm with nothing to yield. `else if` chains carry through the value path — `ElseBranch::ElseIf` recurses and the innermost arm's tail feeds the phi at the outermost merge. Phase 2b introduced `if`-as-expression; WS3.2 (2026-06-11) made a value-producing trailing `if` compose as a block tail. See F.24 in `spec/decisions.md` and the Phase 2b entry in `spec/stdlib.md`. `match` is dual-position too (Gap C, 2026-07-17): - **As statement**: arm bodies are evaluated for side effects; heterogeneous arm-body types are legal (values discarded). - **As expression** (`let x = match n { 0 -> 10, _ -> 20, };`): every arm body's value is phi-merged at the join block, so all value-producing arms must agree on one type (checked at typecheck with the match's span; `match` expression works with every pattern form the statement supports — literal / binding / wildcard / tuple / enum-constructor patterns, guards, and block arm bodies with trailing expressions). F.18 exhaustiveness applies in both positions. The only reachable no-arm-matched case in expression position is a match whose arms are all guarded and every guard is false at runtime; it yields the zero value of the result type (`0` / `0.0` / `false` / empty for pointer-shaped types), mirroring the statement form's silent-no-op fallthrough. **Scrutinee-less form.** `match { cond -> body, else -> body }` omits the scrutinee and tests guards directly: ```hale let tier = match { n < 10 -> "small", n < 100 -> "medium", else -> "large", }; ``` Arms are tried in written order and the first whose condition is true wins. `else` is the catch-all and carries no condition. This is **sugar**, desugared at parse into the guarded form it replaces — `match { c -> b, else -> d }` is `match true { _ if c -> b, _ -> d }`. Everything downstream sees an ordinary match, so both positions, block arm bodies, the trailing comma rule, and the all-guards-false fallthrough above behave identically. The form exists because first-match-wins over conditions is common where dispatch is a ladder of tests rather than a shape match, and the scrutinee in that case is a value the author invents only to ignore. `match {` is not ambiguous with matching on a block expression: that shape is written `match (…) { … }`. ## Binary data — Bytes and conversion `Bytes` is the binary-safe sibling of `String`. Same single-pointer ABI; the underlying blob is `[i64 len][u8 data[len]]`. The `i64 len` prefix means embedded NUL bytes survive, unlike `String`'s strlen-based view. Producing a `Bytes`: - `std::io::fs::read_bytes(path) -> Bytes fallible(IoError)` (m89; IoError flip 2026-05-16). Caller addresses with `or raise` / `or fallback(err)`. - `Stream.recv_bytes(max: Int) -> Bytes fallible(IoError)` — binary-safe TCP receive (Phase 2g; fallible since #209 — EOF/timeout return empty, only genuine errors fail). - `std::bytes::from_string(s: String) -> Bytes` — copies the strlen-measured body into a length-prefixed blob (Phase 2g). - `std::bytes::slice(b, lo, hi) -> Bytes` — half-open range copy with bound clamping (Phase 2g). Consuming a `Bytes`: - `len(b) -> Int` reads the length prefix. - `std::bytes::at(b, i) -> Int fallible(IndexError)` — byte-as-Int (0..255). Address out-of-bounds via `or` clause (Phase 2g; IoError flip 2026-05-16 swapped the pre-flip `-1` sentinel for the fallible channel). - `Stream.send_bytes(b)` — length-preserving TCP send (m89; `fallible(IoError)` with Unit success since #209). - `std::str::from_bytes(b) -> String` — copies into a NUL-terminated buffer; embedded NULs persist but downstream strlen-based String operations truncate at the first (Phase 2g). All returned `Bytes` values from the path-call surface are anchored in the lazy global payload arena, so callers can stash the pointer past the call site without m49 deep-copy plumbing. ## Rendering values as text `println`, `print`, `to_string`, `String + x`, and f-string interpolation all render through **one** rule. The set of types that rule accepts is called the *printable* set. A type is printable when it is: - a scalar primitive — `Int`, `Float`, `Bool`, `Decimal`, `Duration`, `Time` — or `String` / `StringView`; - an enum (rendered `Enum::Variant`, or `Enum::Variant(a, b)` when the variant carries a payload). `Enum` is the **declaration's own name**, whether the value's type was declared in this seed or reached through an `import`: an imported `Color::Red` renders `Color::Red`, not the import alias and never the mangled symbol the declaration was renamed to (2026-09-20, GH #885; before it, every print path rendered `__lib___Color::Red` — a name that appears nowhere in the author's program). The alias is deliberately absent: it is per-importing-file (`spec/projects.md` § *Scoped imports*), and the rendering is a property of the value, not of the file that printed it; - a `type` record whose fields are **all** printable, rendered `Name { field: v, other: w }` in declaration order; - a tuple of printable elements, rendered `(a, b)`; - a `[T; N]` or `bounded[T; N]` whose element type is `Int`, `Float`, `Bool`, `Decimal` or `Duration`, rendered `[a, b, c]`. Three exclusions are load-bearing rather than incidental: - **A locus is never printable.** A locus is flow, not shape; and since a `@sealed` locus confines its `params` (see GH #436), a printable locus would be a way to read the confined state back out. There is no printable path to a sealed value. - **`Bytes` is not printable.** The useful rendering — hex, a length, a UTF-8 attempt — is a choice the author must make. - **An unsized `[T]` is not printable.** There is no length to walk at the render site. Rendering is defined recursively, with two rules about the nesting: - A `String` **inside** a composite is quoted with `"`; a `String` rendered on its own is not. `to_string(s)` is identity, and it stays identity — but `User { name: a, b }` is ambiguous about its arity in a way `User { name: "a, b" }` is not. - A composite longer than **32** elements is truncated with a trailing `…` inside the brackets. For `bounded`, the marker depends on the live count, not the declared capacity. Rendering has no user-visible ordering or allocation guarantees beyond producing the text above; results are owned by the caller's arena like any other constructed String. A `StringView` renders as its text, copied as `std::str::clone` copies it, so the String that `to_string(view)`, `"x=" + view` or `f"{view}"` produces does not follow the builder the view reads (2026-10-05, F.40 phase 4: before it, only `println` and `print` rendered a view, and the other three passed the check and were refused at build). The checker and lowering read one definition of this rule (`hale_types::printable`): the checker refuses a value outside the set at its span, and lowering renders a record only when every field is printable, as the checker does. ### Format specs `f"{expr:spec}"` renders `expr` under `spec` instead of under the default rule. The grammar is in `spec/tokens.md`. Ordering is fixed: the value is **rendered first** (honouring `precision` or a hexadecimal `kind`, both of which need the value) and the text is **padded second** (`width`, `fill`, `align`). An absent alignment resolves from the value's type — numeric values pad on the left, everything else pads on the right — so a column of figures lines up on the ones place and a column of names lines up on the first letter without either being asked for. Grammatical errors in a spec are reported by the parser; spec/value mismatches (hexadecimal of a `String`, a precision on an `Int`) are reported by the typechecker. Neither reaches codegen. ## Locus instantiation `LocusName { params }`: 1. Compute params (overrides applied to declared defaults). `self` resolves **lexically**: inside a *default* expression — including the field inits of a nested locus literal written in that default — `self.X` reads the locus being instantiated (earlier-declared siblings only; a default that reads a later-declared sibling is a compile error, since defaults run in declaration order). Inside an *override* expression, `self` belongs to the code that wrote the literal — the enclosing method's locus, or the enclosing params block when the literal itself sits in a default (F.4 call-site rule). This holds regardless of where the instantiation executes (fn main, a params-init, or another locus's method body — 2026-07-14 fix). For a locus placed `pinned`, the defaults, and every locus nested under it with its whole instantiation, are computed on the locus's own thread, and the literal completes once they are; an override is still evaluated where the literal is, except a locus it builds as the field's value, which is part of the pinned subtree (`runtime.md` § "Placement classes", m27 + m28a). For a field placed on `cooperative(pool = X)` the same holds on X's worker, as the first job of that field: its defaults and every locus nested under it are computed there, and the literal completes once they are (the pool side of m27 + m28a); the field's own `birth()` still runs where the literal is. 2. The nearest enclosing ancestor that declares `accept(c: I)` for the child's interface is the **owner** (innermost-wins — interest-based ownership / accept bubbling; see below and `runtime.md`). Its `accept(c)` runs once the child's region exists and its params are built, before the child's `birth()`, and it cannot reject (`runtime.md` § "Lifecycle obligations", line 5). With no accepting ancestor the child is a transient throwaway (no owner). 3. Region allocated as a sub-region of the **owner's** region (the accepting ancestor — not necessarily the direct parent); size determined by projection class. 4. Bus subscriptions wire up, before `birth()`, so that `birth()` may publish to its own subscriptions; delivery to the locus becomes eligible once `birth()` (and its `birth_check`) has completed, and what was published to it before waits, in order (`runtime.md` § "Lifecycle obligations", line 6). 5. `birth(args)` runs synchronously. 6. Modes are reachable for invocation. 7. If `run` declared, scheduled to run on the locus's scheduler. For a locus placed `pinned` or on a cooperative pool other than `main`, "scheduled" means handed to that thread or worker and the instantiation returns immediately. For a locus that runs inline on the main thread — the default placement, and an explicit `cooperative(pool = main)` — `run()` executes **synchronously here**, and step 8 does not happen until it returns. See § Birth order is load-bearing. 8. Expression returns the locus handle. **The instance before its params.** Step 1 can already reach the locus being built: a default's child is instantiated inside the literal (its `run()` inline, step 7), and an `I{}` it births bubbles to this locus when this locus is the accepting ancestor. So the locus's own state is initialized before step 1, as soon as its region exists, before anything can reach it: its accepted children (`self.children`, empty), its restart, quarantine and drain state, its failure route and its owner link. Nothing later in the instantiation resets it. A child accepted during step 1 stays in `self.children` and is torn down with the locus, like any other accepted child. The params themselves follow step 1's order: a field is its value once its turn comes, whatever an `accept` wrote to it before. **Accept bubbling.** The owner in step 2 need not be the direct parent. An `I{}` instantiated anywhere in a subtree bubbles to the nearest enclosing ancestor that declares `accept(I)` (innermost-wins); resolution is entirely static (the closed-world instantiation graph fixes every owner edge at compile time). The owner may live in a different tower or on a different pool: a cross-pool owner is served by an async handoff over the bus, so a cross-pool `I{}` is **fire-and-forget** — it may only appear as a bare statement, and using the instance as a value is rejected at compile time. The rule holds at every expansion of a default, since a default is lowered where it is expanded, not where it is written. The typechecker rejects it at the literal: in a member body (a closure's assertion included), under the locus whose body it is; in a `params` default, under each locus that builds the defaulting locus and leaves that field to its default (the default is expanded there, and a default is a field's value, never a bare statement). A fn's or a locus method's argument default is judged per invocation: each call that leaves the argument out expands the default in the caller, under the caller's locus, and is rejected at the call when the default builds a cross-pool `I{}` there; a call that supplies the argument expands nothing, and a caller on the owner's thread is not rejected. Chains of defaults of either kind are followed. A `const`'s value and a type's field default are expanded at every use, under whichever locus uses them, so one that builds an `I{}` some locus would post is rejected at the position. See `runtime.md` "Interest-based ownership (accept bubbling)." Whether the owner is on another pool is a fact of each *instance* of the enclosing locus, not of its type: a locus nested under a root field placed off main runs on that field's thread, and one type can have instances on several threads. The owner is resolved once, for every instance; the delivery is chosen per instance. When some instances of the enclosing locus run on the owner's thread and others do not, and the owner is a `main locus`, a bare `I{};` is born in the owner's region where the enclosing instance runs on the owner's thread, and handed off where it does not. Where that choice cannot be made — a value use of the literal, or an owner with more than one instance, which has no single thread to hand off to — the literal is refused at compile time, naming every instance of the enclosing locus and the thread it runs on. A resolved owner is never dropped for a transient birth. ### Birth order is load-bearing A parent births its `params` fields **in declaration order**, one at a time, and step 7 above runs an inline locus's `run()` *synchronously*. Composing those two rules: **a params field whose `run()` does not return prevents every field declared after it from being born at all.** Not merely from running — from being *born*. The later locus's `birth()` never executes, so the subscriptions it registers, the sockets it binds, and the children it accepts never come into existence. Nothing fails and nothing is logged; the process completes what looks like a normal boot and then sits idle. This is the single most expensive way to misread a Hale program, and the symptom points nowhere near the cause — it typically presents as "my handler never fires", which sends you looking at the bus. ```hale main locus App { params { server: Server = Server { }; // run() { while true { ... } } metrics: Metrics = Metrics { }; // NEVER BORN } } ``` Only the **blocker's** placement matters. Moving the *later* field off the main thread does not rescue it — the instantiation itself runs inline on main, so even a `pinned` sibling declared after a blocking field waits. The four placements behave as: | Placement of the blocking field | Blocks later births? | |---|---| | default (no placement entry) | **yes** | | `cooperative(pool = main)` | **yes** | | `cooperative(pool = io)` (any non-`main` pool) | no — `run()` is posted to a worker | | `pinned` | no — `run()` gets its own thread | Two remedies, both sound: declare the non-returning field **last**, or place it off the main thread. The compiler warns whenever it can *prove* the shape — a terminal `while` loop with no `break`/`return`/`terminate`, in a field that runs inline on main, with at least one field declared after it. The proof is deliberately conservative, so a loop the compiler cannot prove non-terminating is not reported: absence of the warning is not a guarantee of correct ordering. This rule is why a child's `run()` is the wrong place to put work that must happen after its siblings exist. The **main locus's** `run()` is the right place — it begins only after params-init completes, so by then every child has been born. ### Dissolve timing rules Five shapes, three timings (m82 — "locus all the way down"; expression position generalized from receivers to every position by GH #711 / #812). **Who reclaims a locus is decided before any code is emitted.** One pass over the program gives every locus-producing expression — a literal, a proven-fresh factory call, and each branch, arm and element of an `or`, a carrier or a composite separately — the owner the rules below name, and lowering reads that decision rather than making one as it goes. An instantiation the pass left undecided is refused, so the rules here hold by construction rather than by the order things happen to be lowered in (`spec/decisions.md` F.39). - **Statement-position literal** (`LocusName { ... };`, no binding and nothing done with the value): birth → run → drain → dissolve all fire at the statement boundary. Fire-and-forget. The handle is discarded. - **Let-bound literal** (`let h = LocusName { ... };`): birth + run fire at the construction site. Drain and dissolve are **deferred to the enclosing fn's scope-exit flush** (`runtime.md` § "Lifecycle obligations", line 11). The user-visible binding `h` is the handle; the locus instance lives until `h` goes out of scope. This is what makes `let s = Stream { conn_fd: fd }; s.send(msg) or raise;` work — `s` stays valid for the method call because dissolve hasn't fired yet. - **Expression-position literal** (a literal used as a value: a method receiver `LocusName { ... }.method()`, a call argument `serve(LocusName { ... })`, a field read `LocusName { ... }.field`, an operand): the expression that consumes it is the handle, so the literal has **the same timing as a let-bound one** — dissolve is deferred to the enclosing fn's scope-exit flush. Naming the literal with `let` first and using it inline are the same program, in every position. In particular the literal is alive for the entire call it is the receiver or argument of, including any allocation churn or drain point inside it, and for as long as a callee that retains it holds on to it. This holds in statement position too (`LocusName { ... }.method();`): only a literal whose value is *discarded* is torn down at the literal's own boundary. - **Factory result** (`let h = make(...);`, or the call used directly as a value — `serve(make(...))`): the locus is built inside the callee and handed back, so the expression that consumes the handle is its owner, with **the same timing as a let-bound literal**. A binding owns what it names; a call result nothing names is owned by the enclosing fn's scope. What a binding names is the expression written **at that position**, not a factory call nested inside it: in `let x = combine(a, make());` — and in `return combine(a, make());`, where the caller is the owner — the binding (or the caller) owns `combine`'s result, and `make`'s is a result nothing names, owned by the enclosing fn's scope and reclaimed at its exit (GH #837). An `if`, a `match` or a block written **as a value** is not the value either: it hands back one arm's, and exactly one arm runs — so the rule applies **per path**, to each arm's tail expression, which is what is written at that position on the path that produces it. `return if c { make(1) } else { make(2) };` hands the caller whichever arm ran, and the frame that built it reclaims neither; `let x = if c { make(1) } else { make(2) };` reclaims whichever arm ran exactly once, at the enclosing fn's scope exit. A factory call anywhere else inside such a carrier — in the condition, in the scrutinee, in a statement before a block's tail — is a result nothing names, reclaimed at that scope's exit like any other. An **ascribed array or tuple** literal is likewise what the site names, never one of its elements: `let xs: [Thing; 2] = [make(1), make(2)];` names the array, and each element's result is one nothing names, reclaimed at the enclosing fn's scope exit (GH #883). This is the same rule in the **fallible** spelling, where the call is reached through `or` — `let c = std::process::spawn(argv) or raise;` is reclaimed exactly as `let h = make(argv);` is, and so is `or `, where whichever branch produced the value is the branch that gives it an owner (GH #793). Two positions are excluded, because there the handle is *handed on* rather than consumed: a result written directly as a locus- or interface-typed **field of a locus literal**, which the literal owns (F.17), and a binding the enclosing fn **returns**, which the caller owns — dissolving either would free a value another owner still holds. The first of those two is an ownership *transfer*, not an exemption: `Router { quick: make(5) }` is the same program as `Router { quick: Quick { } }` — the field's value is reclaimed by the owner's teardown cascade, at the owner's timing, whether the field is written at the call site or as the param's **default**, and whether the call is bare or reached through a *diverging* `or` (`or raise`, `or fail`), where the factory's result is the only value the field can hold (GH #836). `or ` transfers on **both branches**: the field holds whichever branch ran and owns that value, so `Router { quick: make_f(5) or make2() }` reclaims exactly the locus that was built, once, from the owner's cascade — the substitute is not *also* a temporary of the frame that built the owner, which would flush it at that frame's exit with the field still pointing at it (GH #853). The claim needs every branch to transfer: a proven-fresh factory call of the field's own locus, a locus literal, or a nested `or` of those. A call in that position that transfers nothing is excluded exactly as an external handle is — one that returns a locus it did *not* build (one of its arguments, a handle it was given), and an `or` whose ok value or substitute is such a call — and the value is left to its real owner. - **Long-lived** (locus has `bus subscribe`): always deferred, irrespective of binding shape — the locus must stay alive to receive published events between birth and the enclosing scope's exit. Multiple deferred dissolves in the same scope fire in **reverse instantiation order** at scope exit (LIFO), matching the F.4 depth-first cascade. The reason: a later-created locus may depend on an earlier-created one, so the later one must dissolve first. Whichever of the four shapes applies, the timing is the timing of the **whole owned tree**, not of one level of it. A locus held as another locus's param field never has a teardown of its own — its instantiation is parent-owned, and the owner's teardown cascades into it (F.29). That cascade runs to the leaves: a grandchild's `drain()`, its `dissolve()` body, its capacity slots and **its arena** are the owner's responsibility just as a child's are, at exactly the moment the owner's timing fires. Every level's arena is destroyed, none outlives the owner, and the count of live arenas a finished program leaves behind is zero. "Constructed by the owner" covers both spellings of construction: a nested literal (`Mid { leaf: Leaf { } }`) and a factory call whose result the field takes (`Mid { leaf: make_leaf() }`, in the diverging-`or` spelling too, and as a param default). A field the owner did NOT construct (`Mid { leaf: shared }`, an external handle passed in; or a call that hands back a locus somebody else built) is excluded at whatever depth it appears, and is torn down once by its real owner, at its owner's timing. **What the field is DECLARED as does not change any of this.** A param typed by a *contract* — an `interface` the child satisfies (`params { j: Counter = Churner { } }`) or a `perspective(P)` the child serves (`params { router: perspective(Router) = RouterV1 { } }`) — holds a parent-owned child exactly as a locus-typed param does, and the cascade reaches it and everything under it. Which locus satisfies the contract is a per-instantiation choice: a designation written at the literal (`Gateway { router: RouterV2 { } }`) overrides one written as the param's default, and the child torn down is the one that was actually constructed. Both spellings of construction reach a param typed by a contract, whichever contract it is: `Queries { j: make_churner() }` is the same transfer as `Queries { j: Churner { } }`, and so is `Gateway { router: make_router_v1() }` into a `perspective(P)` param. The child reclaimed is the impl that was actually built rather than the one the param's default names (GH #895), and the `or` spellings transfer here exactly as they do into a locus-typed param — the diverging ones, where the factory's result is the only value the field can hold, and `or `, where the field owns whichever branch ran. A `reperspective` swap does not change it either — the swap replaces code and keeps state, so the holder still owns the impl its designation built. A locus literal written inside the initializer of a param that **cannot hold a locus** is not an ownership transfer, because there is no field for the owner to cascade from. In `Lonely { n: Queries { }.total() }` the `Queries` literal is an ordinary expression-position literal, owned by the enclosing fn's scope and reclaimed by its scope-exit flush, exactly as it would be written on a line of its own. A deferred dissolve is scoped to the enclosing **fn**, not to the enclosing block — the slot a `let` binds lives until the fn exits, including after the loop that bound it, while the *name* is the block's: a `let` is visible from its statement to the end of its block, and a `let` that reuses a parameter's or an outer `let`'s name inside a block shadows it there and there only (GH #1132) — but a locus created in a **loop** is reclaimed when its slot is reused, and only the last one at scope exit. Control arriving at the same instantiation a second time is the end of the previous instance's life: it is torn down there, with the same `drain → dissolve → arena reclaim` the scope-exit flush runs, before the new one takes its place. This holds for every spelling — `let h = L { };`, a fresh literal used as a value, a `let` bound to a locus-returning factory, and a `let` whose RHS is an `if` / `match` / block or an ascribed array or tuple, where the reclaim is per ARM and per ELEMENT because that is where the value is built — so a loop's residency is one instance per site, not one per iteration. Ownership follows the binding, not the name: a `let` that shadows a binding the fn hands back is a binding of its own, reclaimed like any other, and only the binding a `return` resolves to where it is spelled — or a binding whose value flows into it — passes to the caller (GH #1140). A locus that ESCAPES the iteration is unaffected, because it never had a slot here to begin with: a literal written directly as another locus's param field is parent-owned, and so are an `accept`'d child and a locus the fn returns. A handle the loop passes *in* to an owner (`Keeper { held: h }`) keeps its own slot and is reclaimed here — the owner's cascade steps over a field it did not construct, as it always did (F.29). Each instantiation reclaims its own previous instance, where it stands, so within one iteration the reclaims run in instantiation order — the reverse of the LIFO order the scope-exit flush uses. Two loci bound in the same iteration are independent; a `dissolve()` body that reads a sibling handle it does not own sees the sibling's next instance. **Every exit path flushes independently, and a `return` takes nothing away from the others.** A fn — `fn main` included — may leave through an early `return`, through a later `return`, or by falling off the end. Each of those paths dissolves everything the fn owns *at that point*, in reverse instantiation order, and the paths do not interact: a `return` in the middle of a body does not shorten the set the fall-through exit dissolves. So in ```hale fn main() { let a = Noisy { tag: "a" }; if bad_usage { return 2; } // dissolves `a`, then exits 2 let b = Noisy { tag: "b" }; } // dissolves `b`, then `a` ``` the guarded exit dissolves `a`, and the ordinary exit — the one taken when the guard does *not* fire — dissolves `b` and then `a`. A locus bound only inside the branch that returns is listed on the other paths too but was never instantiated there, so those paths skip it: it dissolves exactly once, on the one path that built it. `return f()` evaluates `f` before any teardown begins, and a locus `f` itself bound is dissolved by `f`'s own exit (GH #789). ### `terminate` `terminate;` ends the current locus's lifecycle from inside one of its own methods — the locus analogue of `return` (which ends a fn). It is only valid inside a locus method body. It does **not** free anything directly: it sets the locus's `__drain_requested` latch and exits the current method like `return;`. When the method's `run()` coro completes with the latch set, the runtime runs the locus's normal `drain → dissolve → arena reclaim` — i.e. `terminate` *invokes* the declarative teardown early; it is never a manual free. Its purpose is **per-child reclamation on completion** for an `accept`'d child whose lifetime is its own flow rather than its parent's. An accept'd child on a daemon parent is otherwise reclaimed only when the parent dissolves (never, for a daemon), so per-connection children accumulate. A connection child whose `run()` is a recv/park loop ending on EOF can `terminate;` (or just `return;` once run-completion-reclaim for declared flows lands) so its arena is reclaimed the moment its flow ends, while the parent and the rest of the program keep running. Reclamation is idempotent (the arena-destroy latch), so a `terminate` that races the parent's eventual dissolve is torn down exactly once. The reclaim runs on the coro's own pool worker, after `run()` returns, while the locus's arena is still valid — never seizing a still-executing frame. (A child that `terminate`s mid-`run()` exits `run()` immediately, like `return`; code after `terminate` in the same method does not execute.) **Validity (typecheck).** `terminate;` in a free function is a typecheck error — there is no enclosing locus whose lifecycle to end. It is accepted in any locus method body (lifecycle method or member `fn`). **From a bus handler.** `terminate;` is no longer limited to `run()`. A subscriber can end its own life from inside a bus handler (e.g. `on_close` receives a shutdown message and calls `terminate;`). The reclaim runs when the handler returns — the dispatch path checks the `__drain_requested` latch after each handler and runs the spine on the handler's own worker. This is the resident-subscriber analogue of the connection child that `terminate`s from its `run()` recv loop. A handler that `violate`s sets the same latch and is reclaimed the same way — unless its owner still holds it (below). A child reclaimed this way may still be named by its owner — a param field (`c: Child = Child { }`) keeps its handle. Both halves of the owner's teardown cascade — the drain walk and the dissolve walk — test the child's arena-destroy latch (`__arena`, slot 0 of a struct that lives in the owner's arena) **before** the per-child body, and step over a child already reclaimed: its `drain()` and `dissolve()` do not run twice, and its own children, which lived in its freed arena, are not visited again (GH #1036). **A failed child its owner holds is kept (GH #1069).** `terminate` is a child ending its own life; a failure is not. A child whose `run()` or handler failed — a `violate`, a failed `birth_check` — and that its supervisor did not restart (it absorbed the failure, quarantined the child, or spent `restart(c) for N`) **stops**: its `run()` has returned, and on the wrapper paths that would have reclaimed it its bus subscriptions are dropped. But while anything still holds it — a param field, a binding, a value the enclosing expression uses — its memory stays until that owner's teardown, where its `drain()` and `dissolve()` run once. The owner's handle therefore always names a live child: `self.c.why` after an absorbed failure reads what the child last stored. Only a child nothing else reclaims tears itself down when it fails: an `accept`'d child (its owner's tracker expects it gone) and a bare statement literal (its statement's own teardown then steps over it, on the same latch). A handler of the failed child can return before the supervisor has decided — the failure is delivered on the owner's thread, and the child's thread runs its other work while it waits — and the child stops there, its subscriptions dropped. A restart the supervisor then decides takes that stop back: the restarted child has every subscription it had before it failed, so a bound receiver restarted after the call it was executing returned is served again (spec/api.md § Receiver failure and generations). ### `release(c)` and flow children `release(c: Child) { ... }` is the death-side bookend, symmetric to `accept(c: Child)`. Declaring it on a parent has two effects: 1. **It marks `Child` a *flow*.** A flow child is reclaimed when its `run()` *completes* — a plain `return` (or running off the end of `run()`), no explicit `terminate;` required. This is the connection model: `run()` is the connection's flow (a recv/park loop that returns on EOF), and the child's arena is reclaimed the moment that flow ends. A child whose type is NOT declared in any parent's `release` is a *resident*: its `run()` returning means "ready" (it lives on as a subscriber), and it is reclaimed only when the parent dissolves. The same `run()`-returns event thus means "reclaim me" for a flow and "ready" for a resident — disambiguated by the parent's declaration, never guessed. 2. **It fires on each completion.** When a flow child completes (via run-completion OR `terminate;`), the runtime calls `parent.release(owner, child)` — **after** the child drains, **before** it dissolves — so the parent observes the completion and reads the child's final settled state (the mirror of `accept(c)`, which reads it fresh). `release` is policy only: it does not free. The owner is the accept'ing parent, recorded at accept time; `release` does not fire if a flow-typed locus is instantiated outside an accept context (no owner). Several parent types may accept the same child type; each child carries its accept'ing owner AND that owner type's `release` fn (stored at accept dispatch), so the body that fires is the actual owner's own, never another parent type's (2026-09-05, GH #526 F.6: the reclaim spine used to pick the first declared `release(c: T)` program-wide). Whether `T` is a flow remains a type-wide fact: if any parent declares `release(c: T)`, every `T` reclaims on run-completion, and an owner that declares no `release` simply has no bookend called. Whether `T` is a flow is decided over the whole program, imported seeds and the standard library's own loci included (a stdlib owner's `release`, the TCP substrate's connections on an `async_io` pool, reclaims its children as a user owner's does), and a clause on a locus that is never instantiated counts all the same. `hale check --flows` reports every flow type with each `release(c: T)` clause that makes it one, by its declaring locus, file and line, as the declaring seed spells it, never a mangled symbol (GH #736); a qualified type is the whole path, so a local `Stream` is never mistaken for `std::io::tcp::Stream`. It is an opt-in report, never a diagnostic, since a release declaration is legitimate. It lists the program's own clauses: a stdlib child type is internal to the stdlib locus that owns it, and is not listed. **Three ends, kept apart.** A *flow* ends by completing: its `run()` returns (or it `terminate`s), and it is reclaimed on the spot, its owner's `release` firing between its drain and its dissolve. A *resident* never completes that way — its `run()` returning means "ready" — and ends one of two other ways: by its own `terminate;`, typically from a bus handler (the handler returns, then it drains and dissolves; no `release` fires, for none names its type), or in its owner's dissolve cascade, which tears it down before the owner's slots and arena are freed. A resident that disappears when its `run()` returns is a flow by some clause elsewhere; the report above names it. **A resident and what a handler hands it.** A payload delivered to a handler, and any container the handler builds while it runs, belong to that dispatch and are reclaimed when the handler returns. A child born from the handler that must keep them copies them in its own `birth()` — cloning Strings, rebuilding rows — into storage it owns; holding the handler's pointers past the dispatch is a use after free. The transfer rule is: **a value is copied at the store; a locus is borrowed, and a borrow must outlive its holder.** A String, a row or a payload's field handed to a resident is copied into the resident's own storage as it is stored, so the resident reads it after the handler has returned. A container that is a locus (a `@form(vec)` the handler built) handed to a resident by name is a borrow, and the checker refuses it at the argument, in `check`, `build`, `run` and `test` alike, naming this rule (GH #712, decided by the table under "A borrow outlives its holder"); the working shape is the copy in `birth()` above, which is sound because a borrow read only in `birth()` lives no longer than the instantiation. `release` has the same shape as `accept` — one typed child param — and the same fn signature `(parent_self, child_self)`. **Validity (typecheck).** A `release(c: T)` with no matching `accept(c: T)` on the same locus is a typecheck error: a locus that never accepts a `T` child can never release one, so the declaration is dead (almost always a wrong child type or a forgotten `accept`). **Parent-dissolve reclaim.** When a parent that `accept`s children dissolves, it reclaims each accept'd child it still tracks — running the child's full teardown (drain → dissolve → arena reclaim) before the parent's own arena (which backs the children's subregions) is freed. A parent that accepts tracks every child it accepted, whether or not any of its methods iterates `self.children`, because this cascade is what the tracker exists for; and the cascade runs on every path that dissolves the parent — graceful shutdown, a reclaimed flow, and a mid-life reassignment of the field that holds it — so a child's bus subscriptions are deregistered before the memory they point at is freed, never left to receive into whatever reuses it. The order within the parent's teardown is children first, then the parent's own capacity slots, then its arena: a child may hold a slot borrowed from the parent (`as_parent_for`), and a borrowed slot is never destroyed by the child. This is what makes the "resident reclaimed only when the parent dissolves" rule above *observable*: a resident's `dissolve()` body (fd close, flush) runs at the parent's graceful shutdown, rather than the child being silently swallowed by the parent's wholesale arena free. Flow children that already self-reclaimed mid-life are no longer tracked, so they are not torn down twice; the per-child teardown is idempotent regardless (an `__arena`-null latch). The cascade is single-threaded-safe: cooperative pool workers are joined before the dissolve cascade runs at program exit, so no worker can be mid-dispatch to a child being reclaimed. (A parent reclaims only children it *tracks* — i.e. one whose body iterates `self.children`; a dispatcher that accepts but never iterates holds no per-child handle and relies on flow/`terminate` reclaim.) ### Locus method dispatch **Methods on loci may not return locus values.** This is the load-bearing rule for locus method dispatch in Hale. The compiler rejects any `fn` member of a locus whose declared return type (or fallible-payload type) names a user-declared locus. #### Why this rule Five design principles converge on the same constraint, which is why the rule is shaped this narrowly: - **CQRS** — queries return data; entities (loci) are managed structurally, not returned. A method returning a locus mixes command and query semantics in one call. - **Law of Demeter** — a method that returns an entity puts that entity into a stranger position at every call site. The only ways to use it are LoD violations (call methods on a stranger) or pass-through (forward to another callee). Both shapes signal the method shouldn't have existed. - **Dependency Inversion** — depending on a returned entity is depending on a concretion. The bus and `contract`-exposed fields are the abstraction surfaces for cross-locus coordination. - **Single Responsibility** — a locus whose only purpose is to be the return value of a factory method has no responsibility of its own; it's a method-dispatch wrapper around state that lives elsewhere. - **Mechanical sympathy** — every "method returns locus" call site triggers per-call allocation through the m90 payload-arena routing (program-lifetime, never freed). The pattern leaks by construction. Removing the shape removes the allocation. These aren't five rules layered on top of each other — they're five lenses pointing at one structural error. The compiler enforces it once. #### The factory train wreck The motivating violation is the cross-tower factory pattern: ```hale locus Counter { store: Store; // borrowed reference back to caller's state key: String; fn inc() { self.store.touch(self.key); } } locus Registry { fn counter(name: String) -> Counter { // ← rejected return Counter { store: self.store, key: name }; } } // caller: self.reg.counter("ticks").inc(); // would leak per call ``` The `counter()` method declaration is the rejection site. The diagnostic names three canonical alternatives: **Mode keywords in contract names:** mode names (`bulk` / `harmonic` / `resolution`) are admitted in expose-entry position — `expose bulk: Float;` — making the exposed-mode pull rule below expressible (it was a parse error before). The exposed type is checked against the mode's declared return; expose entries in general must bind a real params field, mode, or fn member at a matching type (M3 stage 4). 1. **Parent-child + contract reads.** `Counter` becomes an accepted child of `Registry`; `Registry` reads counter state through the contract: ```hale locus Counter { params { name: String; value: Int = 0; } contract { expose value: Int; } fn inc() { self.value = self.value + 1; } } locus Registry { accept(c: Counter) { /* default registration */ } fn inc(name: String) { // iterate self.children, find the matching counter, // call c.inc(). Vertical method dispatch on owned // child — friend access, no LoD violation. } } ``` 2. **Bus topic (mediator).** Counters publish events; Registry subscribes: ```hale topic Inc { name: String }; Counter::Inc { name: "ticks" } -> Inc; ``` Closed-world rewrite (when its preconditions hold) collapses the bus round-trip to a direct dispatch — same cost as delegation, without the typed-handle loss. 3. **Delegation.** `Registry` exposes the operation directly: ```hale self.reg.inc("ticks"); ``` Loses the typed handle but doesn't allocate. Acceptable when the caller has only a few counters to touch. #### Owned-child + contract is the canonical "B's data feeds A" shape When locus A needs to read and update derived state computed from its own input, the canonical pattern is **B as an owned-child field of A**, with the update going through a vertical command (`self.b.compute(...)`) and reads going through a vertical contract exposure or method call on the child: ```hale locus Segment { params { /* accumulator state */ } fn clear() { /* reset */ } fn push(t: Float, v: Float) { /* update */ } fn slope() -> Float { /* compute */ } fn intercept() -> Float { /* compute */ } } locus LeadingEdge { params { // ring buffer fields seg: Segment = Segment { }; // owned-child field } fn fit() { // command, returns nothing self.seg.clear(); // replay ring contents into self.seg } fn slope() -> Float { // query, returns data self.fit(); return self.seg.slope(); // vertical method on owned child } } ``` The `54-geom-leading-edge` example fixture demonstrates this shape end-to-end. The earlier "factory return" form (`fn fit() -> Segment`) was the pattern this rule rejects. #### What's not rejected The rule fires only on `fn` members of a locus. It does not catch: - **Free fns returning loci** — entity creation patterns like `std::io::file::open(path: String) -> File fallible(IoError)` are constructors, not factory methods on existing loci. - **Methods returning primitives, records, or fallible-of-those.** `BytesBuilder.finish() -> Bytes fallible(ClosureViolation)` is fine; `LeadingEdge.slope() -> Float` is fine. - **Methods returning nothing.** Commands stay commands. - **Namespace-lotus pattern.** `__StdLangLang.parse(src) -> Int` is fine — the locus's methods return data, not loci. - **Lifecycle / mode / failure handler bodies.** These don't have value-bearing return types. #### Migration There is no opt-out annotation. The rule is the language's structural axiom for locus methods — programs that violate it are mis-designed, and the diagnostic names the canonical alternatives. Migrating from the factory shape to one of the three alternatives is a refactor, not a switch flip. The runtime m90 routing (see § Method-returning-locus heap allocation below) survives only to cover the few remaining shapes where a locus value transits through the m90 path indirectly (e.g., interface returns from free fns). With factory methods stopped at the declaration site, the dominant trigger of the m90 leak goes away by construction. ### Method-returning-locus heap allocation (m90) When a method declares `-> Some` and instantiates a `Some` in its body, the instance is allocated via the lazy global payload arena (program-lifetime), **not** the caller's stack or the callee's arena. Both the eager dissolve and the deferred-frame push are suppressed at the instantiation site; the locus semantically "moves" to the caller and lives for the program. This is the codegen-side fix for "second method call on a returned locus reads stale state" — the first read sees still-valid memory, the second sees overwritten state. Heap allocation gives the returned handle program-lifetime safety at the cost of leaking the locus instance + its arena until process exit. Acceptable trade-off for v1 (matches `Bytes` lifetime semantics). A return-slot ABI (caller passes a struct out-pointer + adopts the locus into its own deferred-dissolves frame) would tighten this without leaking — deferred to v1.x. Covers both `return Some { ... };` and `let s = Some { }; ...; return s;` because `current_user_fn_ret` is set during either literal's lowering. ## `@sealed` and a revealed secret A `@sealed` locus confines its `params`: only its own methods read them (`spec/verification.md` § "Secrets", GH #436). A parameter's default reads them as the caller that leaves it does, since that is where it is evaluated: a default that reaches a sealed field is refused unless every call that leaves it is inside the sealed locus. A value of a sealed generic locus's specialization (`Box`) is that locus: its `params` are confined the same. A generic body reads a field of a parameter type as each specialization binds it: `self.inner.key` with `inner: T` is refused when a specialization binds `T` to a sealed locus, and so is a chain of fields of any depth reaching one, a field typed as another generic applied to the parameters included (`self.h.s.key` with `h: Holder` and `Holder`'s `s: A`). `std::secret::Credential` is sealed, and its `reveal()` / `reveal_text()` are the one named way its material leaves as a `Bytes` or `String` — a raw token or password has to leave as text to be put on a wire at all. The checker holds every reveal to that one use, on the path `hale check`, `build`, `run` and `test` share. **A revealed secret is consumed in the statement that reveals it:** - A reveal is called in a **locus method** (a fn, lifecycle method, mode or `on_failure` of a locus), never a free fn, a constant, a param default or any other place. - Its value reaches a **consumer** within the same statement: - the **payload** of a wire write: the data argument of `std::io::tcp::send_fd`, `std::io::tls::send_bytes`, `std::io::udp::send`, `std::io::tcp::Stream.send` / `send_bytes`; the body or content type of `std::http::post` / `std::http::Client.post`; the `headers` or `body` of a `std::http::ClientRequest { … }` literal handed to `std::http::request` / `Client.request`. A descriptor, host, port, URL or method is not the wire's: an `HttpError` repeats the host back, so a secret there is refused; - a **comparison of the whole value**: `==`, `!=`, a `match` whose patterns bind nothing, `Credential.matches`. Taken apart or combined first (`t[0..1] == c`, `t < "m"`), a comparison answers a question about part of the secret, one call at a time, and is refused. `Credential.matches` compares in constant time; `==` does not; - a parameter declared **`@secret`**. - On the way to a wire write it passes only through composition that cannot keep it: `+` and the other operators, an `if` or `match` arm, a record, tuple or array literal that is itself handed on, and a call whose result carries it on — a stdlib fn on the checker's list of value fns (a positive list: a `PURE` effect class says nothing of an out-parameter, and `std::str::builder_append` is pure and keeps what it is handed), `len` or `to_string`, or a free fn the checker proves transparent (every call it makes is to such a fn, or writes a builder it made itself; it calls no method or fn value, builds no locus, assigns only its own locals, and sends, stores and prints nothing). - It is **never** bound to a `let` or a `match` pattern, assigned, stored in a field, a param default or a locus literal, returned, raised, published, printed, iterated, used to decide a branch, or handed to anything in `std::process` (an argv, an environment, a child's stdin). The check fails closed: a receiver whose type it cannot see (a pattern binding, a call's result, an alias) is taken to be a `Credential`, a call it cannot follow keeps what it is handed, and a reveal outside the bodies it walks (a contract, a closure, a claims block) is refused. ```hale // the header line is the wire write: one statement return std::http::request(std::http::ClientRequest { method: "POST", url: u, headers: "Authorization: Bearer " + std::secret::Credential { vault: "api-key" }.reveal_text(), body: b }) or raise; ``` A **`@secret` parameter is a declared consumer**, so the rule holds inside its fn as well: the parameter may reach only a wire write, a comparison, or another `@secret` parameter, through the same composition, and the checker verifies that body. A chain of them ends at the wire — `write_str(@secret s)` → `write(@secret b)` → `std::io::tls::send_bytes(fd, b)` — each hop one statement. A one-way **derivation is not a consumer**: a hash, an HMAC, a PBKDF2 of a password is password-equivalent — a SCRAM client key authenticates exactly as the password does — so a derived value carries the secret on like any other composition, and binding it is refused the same way. For a tool that reads a secret from a file rather than a wire, `Credential.write_private(path)` writes it on the sealed side, with nothing revealed: `path` is a new file, created mode 0600 by `openat(2)` itself, in a directory the effective user owns with no group or world access, outside any git work tree (`spec/stdlib.md` § `std::secret`). The rule follows a value, not a file: once written, the file is the tool's to read, and a program that reads it back itself holds the secret in the open. Making the private directory, handing the path to the tool and removing both afterwards are the caller's. Four sites wait on one change, `pq` taking a `Credential` for the Postgres DSN and its SCRAM exchange: `dna::role_password`, `dna::ReferenceInfrastructure.knowledge_database`, `pq::salted_password` and `pq::compute_client_final` are allowed by qualified name **and by the body that was reviewed** — the checker pins each declaration's text, wherever the seed is and under whatever alias it is imported — each finding in them a warning naming that deferral. A fifth site, or one of the four with anything changed in its body, is refused. ## Capacity slot lifecycle and dispatch (F.22) A locus's `capacity { pool X of T; heap Y of T; ... }` block declares **slots 1..N** — additional storage disciplines beyond slot 0 (the locus's own Arena, implicit). Slot order in the declaration is significant. ### Slot lifetime ordering Slot init runs at instantiation, in declaration order, **after slot 0 (arena) is set and before the locus's own field initializers run**: 1. Slot 0 (arena) — fresh `lotus_arena_create()`, or a sub-region of the parent's arena if the parent's projection class is Chunked / Recognition and accepts this locus. 2. For each declared slot in declaration order: call `lotus_pool_create(size_of(T), 8)` or `lotus_heap_create( size_of(T), 8)`. Store the returned allocator pointer in the slot's `__slot_: ptr` field. 3. Locus's user fields (params + their defaults / overrides). 4. Synthetic flags (`__restart_count`, `__quarantined`, etc.). Slot destroy runs at dissolve, in **reverse declaration order**, **before slot 0**: 1. Drain + dissolve closures + user `drain()` / `dissolve()`. 2. For each slot in reverse declaration order: call `lotus_pool_destroy(allocator)` or `lotus_heap_destroy( allocator)`. 3. Slot 0 arena destroyed via `lotus_arena_destroy(arena)`. Reverse-order destroy matches F.4's reverse-instantiation cascade rule for let-bound loci; the same principle applies to slots within a locus. ### Slot restrictions (v1) 1. **Slot element type must be a value-shape, not a LocusRef.** Loci have lifecycle; cell recycling (Pool.release) or individual free (Heap.free) would orphan the locus. Use `accept(c: Child)` for locus membership; slots are for value-shaped types. Enforced at typecheck (with a span-targeted diagnostic) and again at codegen as defense in depth. 2. **Slot pointers don't cross the bus.** Structurally enforced: slot names aren't typeable identifiers, so they cannot appear as bus payload struct fields. No runtime check is needed; the type system makes the case unreachable. 3. **Duplicate slot names rejected.** Two slots sharing a name (even across separate `capacity { ... }` blocks on the same locus, though v1 grammar admits only one block per locus in practice) fail at both typecheck and codegen. ### Method-shaped slot dispatch The user-facing surface is `self..(args)`. The parser and typechecker both recognize `self.` as a slot reference rather than a missing field; the codegen intercepts the method-call shape and routes directly to the matching C primitive: | Slot kind | acquire / borrow | release / return | |---|---|---| | `pool X of T` | `self.X.acquire() -> Cell` (no args) | `self.X.release(c)` (one Cell arg) | | `heap Y of T` | `self.Y.alloc() -> Cell` (no args) | `self.Y.free(c)` (one Cell arg) | Calling a pool method on a heap slot (or vice versa) is a build-time diagnostic that names the right method for the slot kind. The `Cell` cell type is documented in `types.md`; struct cells support `cell.field` read/write (v1.x-2) and `Cell` carries slot-of-origin so cross-slot release is a hard error (v1.x-5). Primitive cells (`Cell` etc.) still reject field access with a focused diagnostic — direct load/store through a primitive Cell handle (e.g. `*cell`) is the natural next surface but no current workload demands it. Slot access outside a method-call receiver position (e.g., `let x = self.entries;` to hold a slot handle as a value) is not supported at v1 — slots have no value-level CodegenTy that survives outside the dispatch path. Codegen errors with "no field on locus self" if the standalone access slips past typecheck. v1.x can lift this if a workload demands first- class slot-handle values. ### Slot 0 parent-override When a locus is accepted by an owner (the accepting ancestor — see "Accept bubbling," not necessarily the direct parent) whose projection class is **Chunked** or **Recognition**, the child's slot 0 (arena) is allocated either as a sub-region of the owner's arena (Chunked, via `lotus_arena_create_subregion`) or out of the owner's recpool (Recognition with the matching sub-mode, via `lotus_recpool_fixed_acquire` / `lotus_recpool_slab_acquire`). The child is freed wholesale when the owner dissolves. **Rich**-class owners do not sub-region-allocate; accepted children get their own top-level arenas. When bubbling crosses a pool, the child is born in — and reclaimed by — the owner's thread via an async bus handoff (`runtime.md`). See `memory.md` Per-projection-class allocation table. F.22 names this as "projection class governs parent-override of slot 0." **Slot 1..N parent-override** (`pool entries of Int as_parent_for Child;`) shipped via v1.x-4 (surface) + v1.x-4b (runtime mechanic, commit `d50ab79`): the borrow-mask `__slot_borrowed_mask` field carries one bit per slot, set when the slot was borrowed from a parent's matching slot at accept time; the dissolve pass skips destroy on borrowed slots so the parent retains ownership of the underlying allocator. ## Lifecycle method invocation ### `birth()` Runs once, synchronously, after region allocation and before the locus is "live" for any other purpose. A failure during birth (a birth-epoch closure or `birth_check`) is a `ClosureViolation`: the region is kept, and the parent's `on_failure(c, ClosureViolation { ... })` runs at that epoch, held while the parent's params are open, so the handler can read the child and restart it (`runtime.md` § "Lifecycle obligations", line 8). There is no `StructuralFailure`. ### `accept(c)` Runs after child c's region is allocated and its params are built, and before its `birth()`. Receives c with its params set (not its running state). Its result is not read: `accept` admits the child and cannot reject it; an admission interface is separate work (`runtime.md` § "Lifecycle obligations", line 5). After accept returns, the child registers in `self.children` (per F.11). A locus declares **at most one** `accept` (single-accept-type per parent, `types.md` F.11). A second `accept` clause is a typecheck error naming both clauses; the first clause remains the locus's accept type. A parent that must own two child types gives one of them a different owner. (2026-09-04, GH #525: a second clause used to overwrite the first silently.) ### `run()` Runs continuously until drain is requested or run returns naturally. Cooperative — yields at every bus dispatch, every `time::sleep`, every explicit yield point. The scheduler may run other loci while this run is yielded. If run() returns naturally, the locus exits run-state and proceeds to drain. A locus that declares no `run()` has an empty one: the two spellings are the same program (GH #735, 2026-09-22). In particular a flow child — a type some parent `release`s — is reclaimed when its empty run completes, right after its birth, whether the empty `run() { }` is written or omitted; it does not live until its owner's exit because the hook was left out. A resident is unaffected: an empty run means "ready" either way. A `violate` in run(), or a closure that fails there, is a `ClosureViolation`, delivered to the parent's `on_failure(c, ClosureViolation { ... })` at that point (`runtime.md` § "Lifecycle obligations", lines 8 and 9). run() has no panic. ### `drain()` Runs once, when the locus is asked to drain. Drain *cascades depth-first* (per F.4): drain runs on all children first, synchronously; then runs on self. During drain: - New child accepts are refused. - In-flight handler invocations complete. - New bus messages are not accepted; in-flight messages on bus subscriptions are delivered. - Closure tests at `tick` epoch may fire (if not already fired). Default drain: no-op (just transitions state from running to drained). ### `dissolve()` Runs once, after drain completes, in this order (`runtime.md` § "Lifecycle obligations", line 10): - Closure tests at `dissolve` epoch fire (per F.9). A failure is delivered then, to the parent's `on_failure(c, ClosureViolation { ... })`. - The user-supplied cleanup code runs, if any. - Region freed wholesale. - Without a failure, the parent sees normal child-dissolution. Default dissolve: free region. ### `on_failure(c, err)` Runs when a child of self fails: a `ClosureViolation`, or, for main's bindings, a transport's loss. Receives the child handle and the typed error. The handler may: - Return normally (absorb): treat as collapsed — the child stops, and a child the parent holds stays readable until the parent's teardown (§ `terminate`, "A failed child its owner holds is kept"). - Call `restart(c)`: run the child again — `birth()`, then `run()` — on the same instance (see § Recovery primitives). - Call `restart_in_place(c)`: re-init in place (preserve arena). - Call `quarantine(c)`: keep child in a halted state with arena preserved; future inspection possible. - Call `bubble(err)`: pass the failure to self's parent. - Call `dissolve(c)` explicitly: free child's region. Default on_failure: `bubble(err)`. The runtime root's default is process exit with stack trace. **Which handler runs.** A locus may declare one `on_failure` per child type — `on_failure(a: Alpha, err: ClosureViolation)` beside `on_failure(b: Beta, err: ClosureViolation)`, a stdlib locus such as `std::http::Server` included. Every handler takes the same error type, so the failing child's own locus type is what selects one: an `Alpha`'s failure runs the `Alpha` handler and only it, whatever order the handlers are declared in. A child whose type has no handler on its parent gets the default. A second handler for a child type that already has one is an error: the first declared is the one a failing child reaches, so the second could never run, and `hale check` refuses it where it stands, pointing at the first. **Which locus is the parent.** A child locus held in a param field is supervised by the locus that holds it, whichever way the literal is written: as the field's default in the parent's `params { }`, or at the call site in the parent's literal (`App { b: Boom { why: w } }`), in any branch of an `if` or `match` that the field's value names, and one level at a time through nesting (`App { w: Wrap { b: Boom { } } }` — `Boom`'s parent is `Wrap`). Where the holder itself is built does not matter: `let s = Sup { };` inside another locus's method gives `Sup`'s default child to `Sup`, not to the method's locus (GH #1074). An override expression is otherwise lowered in the caller's context (`self.x` in it reads the caller), but the literal it builds belongs to the parent. A locus built elsewhere and passed in by name — a `let` binding, a factory's return — keeps the route of the place it was built: the locus whose method body built it, or none from a free fn or `fn main`. **When it runs: never before the parent's params are settled.** A child can fail while its parent is still setting params — a cooperative child's `run()` executes inside that loop (§ Birth order is load-bearing) and a pinned child's thread starts in it. The failure is **held** and delivered once the parent's last param is stored, before the parent's `birth()`; several are delivered in the order they arrived, on the thread settling the parent. So a handler always reads params that hold their values, and nothing it writes is overwritten by a default stored after it. The failing child outlives its held failure: a child whose `run()` has already ended — a cooperative child's returns right after its `violate` — is reclaimed only after its handler has run, so the handler's `c` is always a live child. The rule holds the *handler* back, not the children. The other reading — every param settled before any child runs — would undo § Birth order is load-bearing: a later param's default may read what an earlier child computed (`total: Int = self.loader.count` after `loader`'s inline `run()` filled it), and that only works because the children are born, and inline ones run, one at a time in declaration order. **A restart requested from a held handler takes effect when the handler returns**, in the same delivery step: on the parent's thread for a cooperative child (the one settling the parent, which the child was running on), on the child's own thread for a pinned one — that thread waits for the handler's decision rather than deciding by timing — and on its pool for a child placed on a worker pool, whose birth ran on the pool's worker: the restart is posted there as the child's run, so the re-run `birth()` and the `run()` after it execute where the first ones did, never on the thread settling the parent (whose own `run()` would otherwise wait behind a child `run()` that never returns). The child the handler restarts is the one it was handed, kept alive for it. A birth-epoch failure is held like any other: a child whose birth failed does not start `run()` until its handler has returned, and then starts it, restarts, or stays quarantined as the handler decided. **A handler may replace a sibling whose own failure is still outstanding** (`self.b = Kid { … }` in the handler for `a`, with `b`'s failure not yet delivered): the field holds the new child at once, the replaced child's failure is still delivered to the owner, after the running handler returns (handlers never run inside one another), and the replaced child is kept for that handler and reclaimed right after it. **The reclaim wins.** A decision about a child the handler has replaced, or whose owner's reclaim reached it while its failure was outstanding, is not performed: `restart(c)` and `restart_in_place(c)` on such a child leave it as if the handler had returned without asking, so it is reclaimed once, after its handler, and no `birth()` or `run()` of it starts again, while the new child in the field is untouched. (An owner's teardown that reaches the child through a pool join before its reclaim does not refuse it yet: `spec/runtime.md` § "Lifecycle obligations", "Restart during drain".) One failure cannot wait and is delivered at once: a **dissolve-epoch closure's**, because the child's region is released right after. The bracket costs a locus nothing unless it declares `on_failure` and holds a locus-typed field or computes a default; one that does pays two uncontended lock round trips per instantiation. ### Reassigning a locus-typed field (WS1#4) Assigning a fresh locus literal to a locus-typed field — `self. = SomeLocus { … };` — is a **lifecycle transition**, not a value store. It is lowered **break-before-make**: 1. The instance currently in the field runs its teardown spine: drain → dissolve, including its child cascade. Cleanup such as closing `@ffi` handles happens before the replacement's birth. Its storage is released once started runs have given up their holds, as in the teardown when its parent dissolves. 2. A new instance is constructed from the literal **into self's own arena**, owned by the field (not scope-bound) — so it outlives the enclosing method and is reclaimed through the field when self later dissolves, exactly like a field-default child from params-init. 3. The field is repointed at the live new instance. The old instance's drain and dissolve finish before the new instance is constructed, unless its own failure is still outstanding: then its whole teardown follows its handler, and a restart that handler asks for about it is not performed (§ "on_failure(c, err)", "The reclaim wins"). There is one storage-retention exception: when a queued main-thread handler performs the replacement, waiting for an old run inside that handler could deadlock a reply queued behind it. Physical release is then deferred until the handler returns. The old run retains its arena, forms and owned descendants until it finishes; their storage is released before the containing owner's storage. This does not postpone `dissolve()` or keep resources closed by that method open. Handler bodies keep their completion order. Outside a handler, replacement waits for the old run while servicing the queue. For "same instance, reconfigure," use **in-place mutation** (`self.. = v;`), which stays the cheap path and triggers no teardown. v1 scope: the new instance inherits the parent's pool; reassigning a *pinned*-placed field does not re-apply the pinned placement. **The right-hand side must be a locus LITERAL** (error). Assigning a locus value produced somewhere else — `self.held = make_row(…);`, or `let c = Conn { … }; self.conn = c;` — is rejected, because ownership would be ambiguous: the field claims the instance (its teardown reclaims it when self dissolves) and so does the frame that produced the value (its scope exit reclaims what it built). Nothing in the language decides between them. That ambiguity is not hypothetical. Until this rule, a locus returned from a free fn was routed to a program-lifetime arena and never reclaimed, which is what made such a store *appear* to work: the field's pointer stayed valid because nothing ever freed it — the leak was the safety mechanism. Every attempt to give those loci a real lifetime produced either a use-after-free or silently wrong values. Three remedies, all existing shapes: - **Assign a literal** — construction in place, the lifecycle transition above, unambiguously owned by the field. - **Route membership through `accept(c: L)`** — parent/child ownership, which is the language's answer for "this locus holds that one" whenever the child is not a `params` field. - **Transfer the STATE, not the instance** — where a locus is a *handle* over an external resource, the module that owns the resource may publish a transfer operation that writes the handle's state into the instance the field already holds and leaves the source at its not-acquired sentinels. That is not a locus store at all: no second instance enters the field, and the disarmed source's own teardown releases nothing. `std::process::adopt(dest, src)` (GH #716) is the shipped instance — the pid and three pipe fds of a spawned `Child` move into the field, whatever the field held is released first, and the source is disarmed. The first two remedies cannot reach that case: the resource is acquired by a syscall inside a factory, so there is no literal to write and `accept()` cannot adopt what a free fn built. This is a **module-provided** operation, not a language feature. The language does not gain move semantics: the transfer is ordinary field assignment written inside the module that defines the handle, and it is sound only because that module knows what "one owner" means for its resource (which descriptors, whose teardown, in which order). A locus with no external resource has nothing to transfer and needs none of this. **Contract-typed fields hold a borrow when assigned a handle (GH #967).** An `interface`- or `perspective(P)`-typed field cannot be assigned a locus literal (the literal's type is not the field's), and the only values that reach `self. = v` are interface VALUES — a parameter, another field: a handle somebody else owns. That store is a **borrow**, the same decision F.39 makes for a name in a field initialiser (`Owner::Borrowed`), applied to assignment: 1. The instance the field owned until then — its default, or a literal it was built with — is reclaimed, break-before-make as above. 2. The field is marked borrowed: the holder's cascade never reclaims what it now points at. The handle's own owner does, at its own time. 3. The handle is stored. A field that already held a borrow releases nothing when assigned again. What the rule does not decide is lifetime: the borrowed instance has to outlive the holder, exactly as one passed by name at construction has to — that is the contract of every handle, and the checker's question in GH #730. Before this rule the store was a plain value store: the field kept describing the default it was built with, so the holder's teardown reclaimed the handed-in instance (its owner then read freed memory, or reclaimed it a second time) and the default leaked. **A borrow outlives its holder (GH #730, 2026-09-22).** A handle stored by name into a locus-carrying param field — `LocusRef`, `interface`, `perspective(P)` — is borrowed, never the holder's to reclaim, so what owns it must outlive the holder. Ownership is structural and reclamation a tree cascade, so the checker decides this from position, with no annotation — on the path `hale check`, `build`, `run` and `test` share, so a program `check` refuses never builds: | the handle comes from | holder owned by the frame | by `self` (a field, an accepted child) | by the caller (returned) | |---|---|---|---| | a field of `self` | sound | sound | refused | | a `let` of this frame | sound while the binding is in scope | refused | refused | | a bus handler's payload | sound | refused (GH #712) | refused | | a parameter | sound | every caller is asked | left alone | For a parameter, every call site of the method is classified by the same table; the first caller that hands a `let` of its own frame or a handler payload is the witness the refusal names, and a parameter at the caller recurses to a bounded depth. Nothing else is refused: a chain the walk cannot follow is left alone, never guessed at. A borrow the holder reads only in `birth()` is birth-scoped and sound — the instantiation runs inside the frame that owns the handle — which is the shape a resident uses to copy what it was handed before the dispatch ends. Not decided here: a borrow across thread domains (GH #730). A container a handler built and handed to a resident's form-typed field is the `let` row of this table (GH #712); the data it holds is copied at the store and is not a borrow. **A handle a method keeps is the same borrow (GH #1048).** A method *keeps* a parameter of a locus-carrying type when its body stores it into `self`: assigns it to a field, hands it (alone, or inside a record, a tuple or an array) to a mutator of a `@form` container of `self`'s (`push`, `set`, `insert`, `put`, `append`, …), or passes it to a method of a place under `self` that keeps it in turn. The checker reads that from the body — the stdlib's and an imported seed's included: `std::http::Router.add` keeps its handler and `use` its middleware. The argument at a call to such a method is a borrow the **receiver** holds, decided by the table above with the receiver as the holder, by the receiver's root binding: - a place under `self` (`self.router`) is `self`'s, and so is a place under the child an `accept` receives; - a parameter is the caller's, and so is a binding the body hands back — by `return r;`, a tail, or a record, tuple or array literal carrying it out — resolved by binding, not by name, through `let` aliases (`let r2 = r;`) either way; - any other binding is the frame's. A receiver's locus is known when its root binding holds a locus literal, a call to a free fn declared to return a locus, or an alias of either, and through the declared field types below it (`a.router`). A locus literal written as the argument is a temporary of the frame that builds it, so it counts as that frame's `let` — also where a parameter carries it into a keeping method, when the caller is asked: ```hale fn build(dir: String) -> std::http::Router { let r = std::http::Router { }; r.add("GET", "/x", Echo { s: dir }); // refused: r is returned return r; } ``` The router would dispatch into a handler its building frame reclaimed. Within one frame the holder and the argument end together, except in a loop: a locus built in a loop body is reclaimed when the next iteration reuses its slot, so a literal or `let` inside a loop kept by a router declared outside it is refused; one inside an `if` block lives to the frame's end and is sound. A handler that is a field of the locus owning the router (`self.router.add("GET", "/x", self.echo)`) lives as long as the router and is sound. The birth-scoped exemption above does not apply to a kept handle: `birth()` can hand the router on to something that outlives it. A keeping method called on `self` itself (`self.observe(j)`) is a store into `self` spelled as a call, left where an assignment `self.observed = j` is left (GH #967). A call through a value this walk cannot type (an interface, a call's result used in place) is left alone. This is the same principle as the no-locus-return rule on methods (`fn get() -> SomeLocus` is rejected): **a locus is structure, not a value to hand around.** Ordinary `let`-bound loci — including factory results — are unaffected; they are owned by the binding that names them. A factory result written as a locus- or interface-typed **field of a locus literal** — `Router { quick: make("q") }`, in a body, a default, or another factory — is owned by that literal: the literal is the construction site, so this is not the ambiguous store above, and the frame that made the call does not reclaim the result at its exit (dna/FRICTION.md F.17). ## Mode invocation `self.bulk()` / `self.harmonic()` / `self.resolution()` invoke mode declarations. Modes are: - Synchronous functions taking the receiver as implicit argument. - Read/write the locus's arena directly (no copies). - Compiled to per-projection-class implementations. Mode invocation from outside the locus (e.g., `child.bulk()` from a parent) is permitted iff `bulk` is contract-exposed on the child; goes through the contract's typed surface (per F.14). ## Topic declarations A `topic Foo { payload: T; }` declaration names a typed pub/sub channel at top level. Subscribers, publishers, and send sites reference the topic by name; the payload type travels with the declaration instead of being repeated at every `subscribe ... of type T` site. ```hale type Tick { n: Int; } topic Ticks { payload: Tick; } locus Counter { params { count: Int = 0; } bus { subscribe Ticks as on_tick; } // no `of type T` fn on_tick(t: Tick) { self.count = self.count + 1; } } locus Pub { bus { publish Ticks; } // no `of type T` run() { Ticks <- Tick { n: 1 }; // identifier subject, not "Ticks" } } ``` Type-check rules: 1. Every subscriber's handler signature must match `Topic.payload` exactly — a static error cites both sites if they diverge. (Enforced 2026-08-11 — downstream handoff; this rule was written but never implemented, and the reinterpreted payload surfaced a String field's heap pointer through an Int parameter from safe code. The check covers both subject forms — declared topics and string subjects with `of type` — plus arity, accepts `Drain` batch handlers by their element type, and names the topic-as-parameter-type mistake (`fn on_h(msg: Hello)` where `Hello` is the topic) specifically. Payloads that resolve `Unknown` — cross-seed topics — stay permissive by the milestone-2 rule; stdlib paths resolve to their real nominal types since GH #470 and are checked like any user type.) 2. The send-expression's type at a topic-ref `<-` site must match `Topic.payload`. 3. The `of type T` clause is forbidden on topic-ref subscribe / publish; the topic carries the payload type. 4. A topic identifier outside subscribe / publish / send-subject position (e.g. `let x = Foo;`) is a type error — topics are not values, they only address bus channels. `topic` is a contextual keyword: lexes as `IDENTIFIER` except in top-level declaration position, so existing names (struct fields called `topic`, local variables named `topic`) continue to work. Lowering: codegen and runtime work against the legacy string-subject form. A desugaring pass between typecheck and codegen rewrites `BusSubject::Topic(Foo)` → `BusSubject::Literal { subject: "Foo" }` and fills in the elided payload type, so the downstream pipeline (cooperative queue, mailbox post, transport fanout) is unchanged from the string-subject path. The wire-format subject for a topic named `Foo` is the bare string `"Foo"`. Coexistence: the literal-string form (`subscribe "S" as h of type T;`) is still accepted because the log namespace lotus relies on wildcard publish (`publish "log.**" of type LogEvent;`) + runtime-computed subject strings (`subj <- LogEvent { ... }` where `subj` is `"log." + self.full_path`), and the topic-decl form has no equivalent at v1. The two forms can be mixed within one program; they only collide if a topic name and a literal subject share the same wire-format string, which the type checker catches via the standard duplicate-symbol diagnostic. **Canonical form for new code:** prefer the topic-decl form (`topic Foo { payload: T; subject: "wire.subject"; }` + `subscribe Foo as h;`). Reach for the literal-string form only when you need a wildcard subscription or a runtime-computed publish subject — those are the cases the topic system doesn't cover at v1. #### Computed publish subjects are confined to their declaration A send whose subject is not a literal (`subj <- v`) requires the enclosing locus to declare a wildcard `publish` whose payload accepts `v`. That declaration is an **authorization**, and it is enforced — not merely required at the declaration site: 1. **Pattern.** At the send, the computed subject must lie under one of the locus's declared publish patterns. A locus declaring `publish "io.tcp.**"` cannot publish `"app.order"`. Violating this raises `BusPublishUnauthorized`. 2. **Payload.** The computed subject must not reach a subscription declared for a different payload type. Violating this raises `BusPayloadMismatch`. Both are runtime checks on the computed path only; a literal subject is bound to its declaration at compile time and pays nothing for either. The payload check reads the subjects every subscription recorded when it registered, which may be on any thread (a pinned or pool anchor initializes its subtree on its own). A send reads them with no lock while another thread may be recording more; a subscription recorded while the read is under way is not checked by that send. (The runtime's subject-to-payload table has the registration table's publication scheme: rows that never move, behind an array published as a pointer and a count, appended under a lock, a replaced array kept until teardown.) They are not stylistic. Without them the computed string reached dispatch verbatim, so a subject outside the declared pattern was delivered to whatever subscribed to it and the payload was reinterpreted as that subscriber's type — a two-field `LogEv { a, b }` published on `"app.order"` arrived at an `Order` handler as `id=a qty=b`, with `hale check` reporting `ok`. Statically, a subscription that sits under another locus's declared wildcard pattern while expecting a different payload is a **warning**: whether the hazard is live depends on whether that locus ever publishes a subject reaching it (a `std::log::Logger` declares all of `log.**` but publishes only under its own path), so it names the risk without refusing the program. **The pattern also bounds analysis.** Because a computed publish cannot escape its declaration, an unresolved publish inside a locus declaring `"io.tcp.**"` provably cannot produce a publisher of an application topic. Subject-specific questions — how many publishers a topic has, whether `@effects(depends:)` names the complete inbound surface — are answered against the patterns rather than against one program-global "some publish is unknown" bit. Residue that carries no pattern (an unfollowable call, a truncated frontier, a publish whose subject expression resolves to nothing) stays unbounded and still withdraws every subject. ### Phase 2: hierarchy, subjects, bindings, closed-world optimization Phase 2 extends topic declarations with three orthogonal pieces: **1. Hierarchical topics + wire subject.** A topic may declare a parent and an own-subject segment. The materialized "wire subject" is the dot-joined chain of segments root-to-leaf: ```hale topic Events { payload: Event; subject: "events"; } topic Login : Events { payload: Login; subject: "login"; } // Login's wire subject is "events.login". ``` Defaults: own-subject defaults to the topic's name (verbatim), so top-level `topic Ticks { payload: Tick; }` keeps Phase-1's behavior of wire subject `"Ticks"`. Parent must reference a declared topic; cycles + missing parents are typecheck errors. Two distinct topics that produce the same wire subject are also errors — path-shaped routing would be ambiguous. The desugar pass rewrites `BusSubject::Topic(Login)` to `BusSubject::Literal { subject: "events.login" }` so codegen and the bus runtime see only the wire form. **2. `main` locus + `bindings { }` block.** A locus prefixed with `main` is the binary's entry-point holder and is the only place a `bindings { }` member is legal. Bindings choose a transport per topic; the same library compiles to in-process or external in different binaries by varying the main locus. An entry may name an imported topic — `dna::ReviewVerdict: unix(...)` binds the declaration the library owns, never a re-declaration of it — and resolves exactly as a qualified bus subject does (GH #527 B6). ```hale main locus App { bindings { // Beat: not bound — same-binary cooperative queue (default). Login: unix("/tmp/login.sock"); // role inferred Events: unix("/tmp/events.sock", role: listen); // explicit override Remote: MyNatsAdapter { url: "nats://..." }; // adapter locus } } ``` **The publish contract (GH #227, 2026-07-22).** `T <- value` succeeding means *the broker accepted the message*; what "accepted" obligates the broker to is defined per binding, and the broker may never accept a message it already knows it cannot handle under that binding's guarantee. Per binding: | Binding | "Accepted" means | |--------------------|-------------------------------------------------------------------------| | none (in-process) | dispatched to every born subscriber in this binary | | `unix(...)` | handed to the peer connection, message boundaries preserved | | `udp://...` | handed to the local IP stack; lossy from there **by declaration** | | `shm_ring(...)` | slot claimed + committed, under the declared `on_overflow` policy | | adapter locus | whatever the adapter's own contract says (it owns delivery) | | hub (`Topic: self.hub`) | a stream: handed to every admitted subscriber's queue under the row's `bound` and `on_full` (`spec/api.md` § Streams) This contract is what lets `<-` be an infallible statement: the error channel isn't missing, it's relocated to where it can be acted on. Consequences, all normative: - **Binding realization failure is a birth failure of the declaring locus.** A `bindings { }` entry (or `LOTUS_BUS_CONFIG` route) whose transport cannot be opened — socket/bind/listen/addr failure, connect-retry timeout, unparseable route — refuses the boot. There is no code path where a binding fails to open and subsequent publishes report success. Bindings live on the `main` locus, whose parent is the root, so the unhandled default is the root failure shape: the structural diagnostic on stderr + non-zero exit (the same seat `lotus_root_panic` occupies; routing through a main-locus `on_failure` is the planned extension of that seat). The runtime primitive is `lotus_bus_binding_fail(subject, url)`. Listener-side realization is synchronous at registration (socket + bind + listen; UDP: parse + bind + group join) — only the blocking accept / recv loop runs on the reader thread, so a dead binding can never fail invisibly on a detached thread. - **Per-send transient errors on a lossy transport are not structural.** A UDP `sendto` failure is logged (once per errno class), not fatal: the binding's guarantee is best-effort by declaration, so downstream loss is within contract. - **Peer EOF on a listen binding is not loss.** The listener is still bound; the serve loop re-arms and accepts the next peer (GH #233 step 2), so rolling restarts of connect-side binaries work without policy. - **Listen-side ingress the kernel accepted is delivered (GH #468).** Three normative pieces, closing what used to be silent loss at the registry's edges: 1. *Binding readers imply the locked queue.* Any `bindings { }` block makes the program off-thread for queue purposes (compile-time, same flag as pinned placement), and the runtime re-asserts it whenever it spawns a reader thread (covers `LOTUS_BUS_CONFIG`) — a concurrent reader enqueue can never race the owner's drain on the unlocked single-threaded path. This was the actual mechanism behind "a loaded run occasionally loses a mid-stream wire message": two binding readers and the drain on an unlocked queue. 2. *The boot window buffers.* A message received between transport realization and the same birth's (later) subscriber registrations is buffered — bounded, per binding: 64 messages / 1 MiB, oldest-first eviction, counted (`buffered_early` / `dropped_early` in the counters dump) — and flushed FIFO the moment a matching registration lands. A relay-shaped program with no local subscriber degrades to the old drop behavior at the cap, counted instead of silent. Post-boot, a message with no registered deserializer remains the documented relay-shaped skip. 3. *Exit quiesces before teardown.* At every main-exit point, before pools join and loci dissolve, listen fds half-close (new connections are refused; queued data and already-accepted backlog connections stay readable — the kernel returns queued AF_UNIX data before EOF even after `shutdown`), readers drain to true EOF through the still-intact registry, buffered boot-window residue is flushed, and one final local drain delivers to still-alive handlers. Bounded: `LOTUS_BUS_QUIESCE_MS` (default 500, `0` disables), loud if exceeded — a silent peer holding a connection open cannot stall exit. Wire data arriving after the half-close arrived after exit and is outside the contract. - **Connect-side loss is structural, with reconnection as supervision policy (GH #233 steps 3–4).** A send failure on a source-declared connect binding marks the entry *lost* (fanout skips it — publishes during the window are dropped, never falsely "delivered") and queues a loss event dispatched on the owner thread at the next queue drain. Default: the structural exit, diagnostic naming the subject. Handled: the main locus declares `on_failure(t: std::bus::UnixTransport, err: ClosureViolation)` — the handler receives a synthetic `link_lost` violation, and `restart (t);` re-runs the connect-with-retry; on success the binding resumes, on failure (or a handler that declines) the structural exit fires. Reconnection is a supervision decision, not a transport feature. `LOTUS_BUS_CONFIG` connect routes sit outside the supervision tree (no locus to route through) and keep logged-only send failures. - **Malformed `LOTUS_BUS_CONFIG` lines stay warn-and-skip** (the file is an operator-layered override and the diagnostic names the line); a well-formed line whose route cannot be *opened* fails the boot like a `bindings { }` entry. #### The api binding (retired) A program's API is no longer an entry in the main locus's `bindings { }`. `bindings { api: … }`, `@gated`, `serve:` and an `expose` member read as an API are refused with one diagnostic that names the replacement, and the compiler carries no code to accept them. `spec/api.md` is the contract: - what the program offers is a **surface**: an `api NAME { rpc Locus::handler requires: [role]; … }` block, or `@rpc` on a handler (§ Surfaces and their rows). A call is a call of the handler, authorized against the row's `requires`; a read is an `rpc` whose handler returns the value; - where and how it is offered is a **serve site**, `api::serve(Surface, unix::Rpc { … } | http::Rpc { … } | mcp::Rpc { … }, as: "name", bound: N, on_full: refuse)`, in the main locus (§ Serving), over a transport that is a locus with lifecycle (§ The `Rpc` interface), under a role source and a bearer source the program supplies (§ Identity sources); - a stream is a **topic bound to a hub**, `bindings { Topic: self.hub requires: [role], bound: N, on_full: drop_old; }` over a `ws::Hub` or a `udp::Hub` (§ Streams); - what a client reads is the **description** of one exposure for the caller (§ The description): `{"describe": true}` over the socket, `GET /.description` over HTTP, `hale check --api` from the rows. `hale describe`, `call`, `watch`, `admin` and `mcp --app` are clients of it. What stays here, because it is not the API's: the publish contract (§ "The publish contract", above) and the bus codec clause `codec(…)` of a binding (F.36) apply to every `bindings { }` entry, a topic bound to a hub among them. A message that did not come through a transport is not a call: `std::api::Context` (`spec/stdlib.md` § `std::api`) is built by the exposure for a handler that declares it, and a handler called any other way gets the caller it names. Transport surface: - `unix("/path")` or `unix("/path", role: connect|listen)` — AF_UNIX framed-byte transport. Substrate-provided as a **locus** (GH #233 / F.37): the entry is sugar that instantiates `__StdBusUnixListenTransport` / `__StdBusUnixConnectTransport` (stdlib `bus.hl`) as a cooperative child of the main locus, converging with the adapter path. `birth()` realizes the transport synchronously on the boot path (so a dead binding fails the boot, per the publish contract above); the listen side's birth spawns the C serve thread, whose loop accepts a peer, fans recv'd payloads into the local handler set, and **re-arms on peer EOF** (closes the dead connection and accepts the next peer — a restarted connect-side binary reconnects without the subscriber noticing); `dissolve()` interrupts + joins the serve thread and destroys the transport. `role: connect` opens a write-side transport that publish-site dispatch sends to. When `role:` is omitted, the typechecker infers it from the bus block (`publish` only → connect, `subscribe` only → listen); if both publish and subscribe touch the topic, the binding is rejected with a "specify `role:`" diagnostic. - `LocusName { field: value, ... }` — user-supplied protocol-layer adapter. Any locus that declares `fn send(subject: String, bytes: Bytes)` satisfies the `__StdBusAdapter` contract and may appear on the right-hand side of a binding. The bus router dispatches outbound payloads for the bound topic through the adapter's `send` method; framing, retry, ordering, and connection management are the adapter body's concern. The adapter's own `params` block carries protocol configuration (broker URL, credentials, timeouts, point-to-point role for p2p shapes). The grammar distinguishes substrate vs adapter by the head's case (lowercase keyword `unix` vs capitalized locus name). An adapter declared by an imported library is named through the import alias, `alias::Adapter { ... }`, and binds exactly as one declared in the program's own seed; the case rule reads the last segment. The same holds for a `codec(alias::Codec { ... })` clause (GH #1034). Inbound dispatch from an adapter into the local handler set is handled by `std::bus::__local_dispatch(subject, bytes)` (m105): it reconstructs the payload against the subject's registered deserialize fn and fans into local subscribers via `lotus_bus_dispatch_wire`. **Placement.** A bound adapter is pinned by construction (F.31): its `run()` gets a thread of its own, and every subscription the adapter declares is delivered on that thread, at its `run()`'s next yield (a `sleep`, a park) or after `run()` returns. `send` is the exception: the runtime calls it on the PUBLISHER's thread. A publish from inside `send` onto a topic the adapter itself subscribes therefore goes through the bus to the adapter's thread — never the closed-world direct call (§ "Closed-world topology optimization") — so one locus can own a socket and be both its `send` and its receive loop, with every write on its own thread (GH #1032). The `bytes` a `send` call receives are valid for that call: the runtime builds them in an arena of the call's own, freed when `send` returns, so publishing through an adapter holds memory only for the sends in flight — none per message, whether `send` returns at once or parks (a `sleep` or a `recv` on an `async_io` pool) while other publishes overlap it (GH #1038). A `send` body that keeps the bytes — stores them in a field, publishes them onward — keeps a copy, as it would any stored value. Whatever `send` does, every other route of the same subject — an env-routed `unix://` or `udp://` connect (`LOTUS_BUS_CONFIG`) — sends the bytes the program published: a `send` that publishes, or parks while another publish runs on its thread, cannot change what the routes after it see (GH #1058). - `shm_ring("/name", slot_count: N, on_overflow: )` — POSIX SHM ring substrate backing the zero-copy route. Name is the shm_open object name; slot_count defaults to 128 when not specified. `on_overflow` is REQUIRED — see "Back-pressure" below. Satisfies `intra_machine` and `zero_copy` constraints intrinsically. Slot size is derived at codegen from the topic's payload type (which must satisfy `is_flat_shapeable` — variadic fields rejected). Substrate-provided: the runtime's `lotus_shm_ring_*` primitives in `runtime/lotus_shm_ring.c` own the lifecycle. At codegen, each shm_ring binding emits a `lotus_bus_register_shm_ring(subject, slot_size, slot_count, name)` call into main's prelude (alongside the existing `lotus_bus_register_remote` for unix bindings). Subsequent `Topic <- value` (Send) statements on the bound topic short-circuit to `lotus_bus_publish_shm_ring(subject, &value, sizeof(value))` — the C runtime owns claim + memcpy + commit. This is the one-memcpy path: 1.6x faster than the m28b two-memcpy baseline per `experiments/k2-zero-copy/bench.c`. Explicit `let slot = topic.claim(); slot.field = ...; slot.commit();` surface (the slot-as-locus design in [[slot-locus-design]]) for the zero-memcpy path is post-v1; the implicit `<-` path covers the common case. **Subscribers (Form K6b).** Hale-side `bus subscribe` for shm_ring-bound topics is wired. Codegen emits a `lotus_bus_register_subscriber_shm_ring(...)` call at the subscriber locus's birth lifecycle; the C runtime opens the ring, spawns a dedicated reader thread per binding, and dispatches each newly-committed slot to the user's `fn on_foo(p: T)` handler with `p` pointing directly into the ring slot (no memcpy on the subscriber side). **Batch / drain dispatch (`Drain`).** The dispatch mode is selected by the handler's PARAMETER TYPE, using the *same* `subscribe Topic as on_x;` keyword: - `fn on_x(t: T)` — per-record (above). The reader thread calls the handler once per committed slot. - `fn on_x(feed: Drain)` — BATCH. The reader thread calls the handler ONCE per available batch, passing a `Drain` handle. The handler consumes the batch with an inline `for t in feed { ... }` loop — there is NO per-record function call, and no per-call handler arena scratch. This is the throughput path for high-rate cross-process feeds, where the per-record call + scratch overhead is what loses to a bare consumer loop. `Drain` is a built-in 1-arg type constructor (not a user generic). It is only spellable as a batch handler's single param and as the iterable of `for t in feed`; the loop binds `t` to each record read zero-copy through the ring slot — `t.field` accesses GEP directly into the mapped slot, exactly like the per-record handler's payload param. A batch handler registers through `lotus_bus_register_subscriber_shm_ring_batch(...)` (which spawns `shm_ring_batch_reader_thread`) instead of the per-record registration; the handle's runtime ABI is `{ void* ring, int64_t start_seqno, int64_t end_seqno }`. The consumer cursor is release-stored once per batch (not per record). Batch handlers on a `layout:`-bound (foreign) ring are not supported yet — use a per-record handler there. **Threading constraint.** The handler runs on the reader thread, NOT the cooperative scheduler. Handlers must be thread-safe and avoid touching shared scheduler state. Users who need cooperative dispatch should use `unix(...)` instead. Future versions may add an optional cooperative-queue routing mode at the binding level. **Staleness.** v1 ships without a stamped-epoch read guard — handlers must finish fast enough that the ring doesn't wrap past the slot they're reading. If a slot has wrapped at the moment the reader thread fetches it, the slot is skipped silently (lotus_shm_ring_read_slot returns NULL). Post-v1 work will generalize F.30b's stamped-epoch guard for per-field read checks. **Back-pressure (Form K7).** `on_overflow:` is required on every shm_ring binding — there's intentionally no default. Three policies: - `block` — publisher's `claim()` spins with 100µs nanosleeps until the consumer's release-stored `consumer_seqno` advances enough for a free slot. No timeout in v1; deadlocks if the consumer dies. Right for control-plane topics where latency tolerates backpressure but data must not be lost. - `drop` — publisher's `claim()` returns the next slot unconditionally (pre-K7 behavior). Slow consumers silently miss messages. Right for stale-is-worthless feeds (market data tickers, telemetry). - `fail` — publisher's `claim()` returns NULL when the ring is full; the `publish_shm_ring` wrapper panics with a clear stderr diagnostic and `_exit(1)`. Process-level visibility into back-pressure events. Graceful caller-side handling via fallible-`<-` is a K7b follow-up; today, fail = process exits. The consumer's reader thread release-stores the cursor after each batch of dispatches; the cursor lives on its own cache line (separate from the producer's `seqno`) so the two sides don't pingpong each other's writes. **Birth-order trap (single-binary + `block`).** Hale births child loci in `params`-declaration order. In a single-binary deployment where a Producer's `birth()` immediately publishes onto a `block`-policy ring, the Consumer locus MUST be declared *before* the Producer in the parent's `params` block — otherwise the Producer's first overflow blocks on a `consumer_seqno` that no live reader will ever advance, and the process hangs. Order the consumer first, or move the publishing into the **main locus's** `run()`, which does begin after all child births. Moving it into a *child's* `run()` does NOT help: a cooperative child's `run()` runs inline during its own birth, before its later siblings are born — see [§ Birth order is load-bearing](#birth-order-is-load-bearing). (Cross-binary deployments aren't affected — the subscriber lives in a different process and exists before the publisher process starts.) **Foreign rings via `ring_layout` (Proposal B).** The shm_ring transport above reads/writes the *native* Lotus ring (the `LRSRNG1` header + equal-sized slots). To read a ring defined by *another* program — an externally-defined binary broadcast ring — a `ring_layout` declaration describes that ring's binary shape, and a binding references it with the `layout:` kwarg: ```hale ring_layout ForeignRing { magic 0x52494E47464D5431; // expected header magic at offset 0 version 1 at 8 : u32; // header field `version`: expect 1 buffer_size at 12 : u32; // ring data capacity, read from header data_at 128; // first-record byte offset cursor published { // the published byte cursor at 64; repr atomic_u64; load acquire; unit bytes; } framing byte_records { // records are [u32 len][payload] len_prefix u32; align 8; pad_sentinel 0xFFFFFFFF; } overflow lap_detect; } main locus App { bindings { Ticks: shm_ring("/foreign.ticks", on_overflow: drop, layout: ForeignRing) where zero_copy; } } ``` The `layout:` reference must resolve to a declared `ring_layout` (else a typecheck diagnostic). A binding with no `layout:` is the native ring, unchanged. *Record headers (`record_header_bytes`).* The default `byte_records` shape is `[len_prefix][payload]` — the prefix is the whole per-record overhead. A real foreign producer often prepends a fixed header (sequence number, kernel timestamps, opcode) before the payload. Set `record_header_bytes N` on the `byte_records` framing to describe it: the payload then starts `N` bytes into the record and the stride is `N + align(len)` (the `len` field is still read at record offset 0 with `len_prefix`). `N` must be a multiple of `align`. A producer that marks a tail pad with a header *field* rather than a `len` sentinel (e.g. a `kind` byte where `1` means padding) declares `pad_field_offset` / `pad_field_width` / `pad_field_value`; a record whose field equals that value is skipped to the wrap. The in-band header scalars are surfaced to the handler by declaring their offsets (`seq_offset` / `seq_width`, `kernel_ns_offset` / `kernel_ns_width`, `user_ns_offset` / `user_ns_width`): the reader decodes them per record into thread-locals the subscribe handler reads via `std::shm::last_record_{seq, kernel_ns, user_ns}()` — the errno-style idiom of `recv_stamped`'s `last_recv_*_ns`. The payload itself is still delivered as the `BytesView` / typed value. `recheck post_copy` adds a torn-read guard: each record is copied out, an acquire fence taken, and the cursor re-read; if a free-running producer lapped the record during the copy it is discarded rather than handed to the handler. *Slot rings (`framing slots`).* The example above is a variable-length `byte_records` ring. A `slots` framing describes a fixed-stride slot ring instead — the shape of the native Lotus ring itself. The geometry (`slot_size`, `slot_count`) is read from the foreign header rather than fixed in the layout, the cursor is the published seqno (1-based; `unit slots`), and slot *S* lives at `data_at + (S mod slot_count) × slot_size`. A consumer skips a seqno the producer has already lapped (`published − S ≥ slot_count`) rather than read a torn slot — matching the native reader. This makes the native `LRSRNG1` ring expressible as a `ring_layout`, read through the same abstraction as a foreign one: ```hale ring_layout LotusRing { magic 0x4C5253524E4731; // "LRSRNG1" slot_size at 8 : u64; // geometry read from the header slot_count at 16 : u64; data_at 128; // first slot (after the 2-cache-line header) cursor published { at 24; repr atomic_u64; load acquire; unit slots; } framing slots { } overflow lap_detect; } ``` The producer side for a foreign `slots` ring (a Hale writer) is not yet offered; `slots` is a consumer framing at this version. *The layout contract.* A `ring_layout` is validated at typecheck (`hale-types::check`), so a malformed layout fails the build, not the read. The rules: - Each scalar `repr` must be a known fixed width — `u8`/`u16`/ `u32`/`u64`, `i8`/`i16`/`i32`/`i64`, `f32`/`f64`. - A `cursor` block needs an `at` offset, a known `repr` (`atomic_u64`), a known `load` memory ordering (`relaxed`/ `acquire`/`release`/`acq_rel`/`seq_cst`), and a `unit` of `bytes` or `slots`. At least one cursor is required. - `framing` kind is `byte_records` or `slots`. `byte_records` requires a `len_prefix` (and reads capacity from a `buffer_size` scalar); `slots` requires `slot_size` and `slot_count` scalars (the consumer reads the slot geometry from the foreign header, and derives capacity as their product). - All offsets are non-negative. - A `ring_layout` is a declaration, not a value — referencing its name in expression position is an error. *Cross-field conformance.* Because the foreign format is fixed and unchangeable, a layout that mis-transcribes it is the program's own bug — and several of these fields silently corrupt the reader if wrong, so they are caught at compile time: - Every header scalar and the cursor (an 8-byte atomic) must lie *before* `data_at` — a field whose `[at, at+width)` overruns the data region is rejected. - No two header fields (or a field and the cursor) may overlap. - `byte_records` `align` must be a power of two — it is the record-stride alignment the reader masks with. - `pad_sentinel` must fit in the `len_prefix` width; otherwise wrap detection reads a truncated value and never fires. - `len_prefix` width must be `<= align`, and a producer's compile-time `buffer_size:` must be a multiple of `align` — else a record header could land in `(cap - len_prefix_width, cap)` and read or write past the data region. - An `atomic_u64` cursor's `at` must be 8-byte aligned (an unaligned atomic load is undefined); `magic`, `data_at` (for `byte_records`), and a `buffer_size` scalar must all be present. *Payload conformance at the binding.* A `layout:`-bound topic's payload picks the consumer mode: - A **flat-shapeable struct** → *typed mode*: the record is read by a direct pointer-cast (and, on the producer side, written by a `memcpy` of the payload struct — the foreign record bytes *are* the Hale struct, bindgen-style). The framed `len` must equal the struct's fixed size or the record is resynced (the OOB guard above). Enforced whether or not the binding also asserts `where zero_copy`. - A **`BytesView`** → *raw-frame mode*: for heterogeneous / variable-length rings (e.g. a discriminated-union feed). The consumer can't assume a fixed size, so `value_size` is 0 (the size gate is off) and the handler receives a bounded `BytesView` over each record — it decodes with `std::bytes::read_*` + a discriminator branch. The framed-size bounds checks against the ring still apply; the record payload is copied into a Bytes-shaped scratch blob (the pack readers need that prefix, and the mapping is read-only), so raw-frame mode is not zero-copy. The producer side is symmetric: `Recs <- bytes` (a `Bytes` or `BytesView` value, e.g. built with a `BytesBuilder`) frames `[len_prefix len][bytes]` where `len` is the value's actual byte length, so a producer can emit heterogeneous / variable-width records — the runtime publish path is size-generic. For a *zero-copy* write, `Topic.write(max) { w => ... ; len }` reserves up to `max` bytes, binds a writable `BytesMut` view `w` over the slot (written with the `std::bytes::write_*` family, the bounds-checked mirror of the readers), and commits the byte count the body's tail yields — the producer writes record fields directly into the mapped ring with no intermediate buffer. The reserve and commit are scoped to the block, so the view can't escape and the commit can't be forgotten. - A struct field may carry a Go-style backtick metadata tag after its type (`price: Int `repr:"u32_le"`;`) — free-form `key:"value"` metadata stored on the field. A `repr:""` key makes the struct a binary layout: `Type::field(v)` reads that field from a `Bytes` / `BytesView` and `Type::set_field(w, x)` writes it into a `BytesMut`, at the field's offset (computed in declaration order over the tagged fields, or pinned with `,at=N`). These desugar to the matching `std::bytes::read_*` / `write_*` call, so they share the primitives' bounds-checking and cost. - A `json:""` tag is the second tag consumer: a struct with at least one `json:` tag gets a generated `Type::from_json(s) -> Type fallible(JsonError)` that parses the object in a single pass (driving the `std::json` object cursor), dispatching each key to the matching field and reading the value by the field's declared scalar type (`Int` / `Float` / `Bool` / `String`). The key is the tag value, else the field name; unmatched keys (and nested objects/arrays under them) are skipped. A missing field raises `JsonError { kind, field }` unless the field declares a literal default (`= "USD"`), which fills it. `from_json` is `fallible`, so callers must address it. The same tags drive emit: `Type::to_json(v) -> String` serializes a value back (bare numbers/bools, escaped strings, nested structs recursed), round-tripping with `from_json`; it is not fallible. A field whose type is another generated JSON struct is parsed recursively (the nested object's raw text is handed to that type's parser; a nested failure propagates). Array fields are **not** supported by design — Hale sequences are locus-owned (there is no heap-owning value collection), so a JSON array is read by walking the array cursor and pushing into a `@form(vec)` locus cell, not parsed into a struct value field (see `notes/value-collections.md`). Further tag keys remain reserved for future consumers (validation, db mapping). Any other payload (with `String`, `Bytes`, or variable-size fields and not itself `BytesView`) is rejected. *Out-of-bounds safety.* The guarantee is that a wrong layout — or a non-conforming / hostile foreign producer — yields **wrong values, never an out-of-bounds access**. It holds given the checks above plus the runtime's boundary defenses: the consumer rejects, at attach, a foreign `buffer_size` that isn't a multiple of `align`; each record's len-prefix read is clamped within the data region; and the framed `len` must equal the bound payload's fixed size before the handler is invoked (a short record is resynced, never dispatched), so the handler cannot read past a record near the wrap. The bound checks are overflow-safe against a hostile `len` or offset. The member token positions (`acquire`, `atomic_u64`, `bytes`, and words that collide with keywords like `release`) are layout *words*, not Hale type expressions — bare identifiers (or keyword-spelled words) checked against the sets above, never resolved as types. *Consumer (read).* A subscriber on a layout-bound topic registers via `lotus_bus_register_subscriber_shm_ring_layout(subject, name, desc, self, handler)`, where `desc` is a flat 16-entry descriptor codegen builds from the resolved layout. The runtime attaches the foreign segment read-only (it never creates it — the foreign producer owns the ring), validates the magic and `version`, reads `buffer_size` for the data-region capacity, then runs a `byte_records` reader thread: acquire-load the published byte cursor, and for each record walk `data_at + local % capacity`, read the `len_prefix`, skip a `pad_sentinel` tail-pad to the wrap, hand the payload view to the handler, and advance by `align_up(len_prefix + len, align)`. Field *roles* are read by convention from the layout — a scalar named `version` (with an expected value) is the version check; one named `buffer_size` is the capacity source. *Producer (write).* If the bundle *publishes* a layout-bound topic, it is the ring's single producer (SPMC): the prelude CREATES the segment via `lotus_bus_register_shm_ring_layout(subject, name, desc, capacity)` — sizing it `data_at + capacity`, writing the header (magic, `version`, `buffer_size = capacity`) and zeroing the cursor — and each `Topic <- value` routes through `lotus_bus_publish_shm_ring_layout`, the exact inverse of the reader: reserve `align_up(len_prefix + payload, align)`, write a `pad_sentinel` and wrap if the record would straddle the end, write the length prefix + a `memcpy` of the (flat) payload, then release-store the cursor. Capacity comes from the binding's `buffer_size:` kwarg (bytes; a per-transport default applies when omitted). A layout binding that is only *subscribed* in this bundle creates nothing — it attaches the foreign producer's ring. *Limitations (v1).* A subscriber reads records published *after* it attaches (no historical replay): it starts at the producer's cursor as it stands when the subscription is registered, so every record published after that is delivered, however late the reader thread first runs; a record published before it is not. Lap handling is lossy + safe: if the producer runs more than `capacity` bytes ahead, the missed bytes are gone, so the reader resyncs to the producer's cursor (a commit boundary) and resumes rather than reading a torn record. Handlers run on the reader thread (same constraint as the native subscriber). The `slots` (fixed-stride) framing kind ships for *consumers*, and the zero-copy writable producer view ships as `Topic.write(max) { w => … }` (A1 — fields written directly into the reserved slot, no intermediate copy). What remains post-v1: a `slots` *producer* with parameterized slot geometry, and multi-cursor back-pressure. **In-memory delivery is absence-of-entry.** A topic with no binding entry is delivered same-process via the cooperative queue. There is no `in_memory` variant — the runtime default covers the case and explicit syntax would be ceremony. **Operational constraints (Form K).** A binding entry may carry an optional `where` clause listing constraint keywords the dev team asserts the route must satisfy: ```hale bindings { L2Updates: unix("/sock") where intra_machine, zero_copy; } ``` Constraints split into two orthogonal axes: - **Scope** — where the bus may reach. `intra_process` (same OS process), `intra_machine` (cross-process, same machine; SHM-capable), `cross_machine` (network in scope). Hierarchy: `intra_process ⊂ intra_machine ⊂ cross_machine`. - **Behavior** — `zero_copy` (no memcpy at locus boundary; requires the payload type to satisfy `is_flat_shapeable`). The typechecker validates three classes of constraint issue (Form K4a): 1. **Intra-constraint consistency.** At most one scope keyword per binding (`intra_machine` + `intra_process` is rejected as ambiguous). `zero_copy` + `cross_machine` is rejected as a contradiction — network transports require serialization. 2. **Transport-constraint compatibility.** Each declared constraint is checked against the binding's transport variant: - `unix(...)` satisfies `intra_machine`. Rejects `intra_process` (sockets cross processes), `cross_machine` (AF_UNIX is host-local), and `zero_copy` (kernel memcpy at the socket boundary). - Adapter loci: trusted for scope constraints (the adapter body knows where its protocol routes). Rejected for `zero_copy` — the Adapter contract (`fn send(subject, bytes)`) requires serialization. 3. **Payload-shape compatibility.** `zero_copy` requires the topic's payload to satisfy `is_flat_shapeable`: its bytes are the whole value, so a raw memcpy of it is the value on the other side. Flat are the fixed-layout primitives (`Int`, `Float`, `Decimal`, `Bool`, `Time`, `Duration`), a struct whose fields are all flat, an identity or a range (its `Int`), and a **fixed-size array of `Int`, `Float`, `Bool`, `Decimal` or `Duration`** (and a `bounded[T; N]` of those): codegen lays a scalar-element `[T; N]` field out inline in its struct (`[N x T]`, since 2026-07-01), so its elements travel in the slot. Not flat: `String`, `Bytes`, `BytesView` and `StringView` (heap-shaped or fat pointers); a fixed-size array of anything else (`[P; 2]` of a struct, `[Time; 2]`), which keeps the out-of-line layout, a pointer that would dangle across the zero-copy / shm boundary; an unbounded array `[T]`; and an enum. The binding is refused at typecheck, at its `zero_copy`, rather than compiled into a cross-process use-after-free: "binding for topic `TA` requires `zero_copy` but payload type `PayA` is not flat-shapeable — it contains a String, Bytes, or fixed-size array field, …". Send variable data as `Bytes` on a binding without `zero_copy`, or as a `layout:`-bound `BytesView` raw frame. The `shm_ring(...)` transport variant above satisfies `zero_copy` (and `intra_machine`) intrinsically; its slot size is derived from the payload's flat layout. Existing bindings without a `where` clause continue to work unchanged. **The entry locus.** A program's entry is its seed's own top-level `main locus`; the checker reads it from one row (`hale_types::entry`, the registry's `entrypoint` family) for rule 1's count and rule 9's closed world, and `--env` and `--matrix` read the same row for what an entrypoint is. Two shapes are not the entry (F.40 phase 3, E0): - **An imported `main locus`** — a library's, renamed with its seed — is a declaration the importing seed does not run. A seed whose only `main locus` is imported has no entry and checks as a seed with no `main` does: the library's placement seeds nothing, and the world is not closed. - **A `main locus` inside a `module { }`** is not the entry either, as a `fn main` inside one is not the entry point (§ "Declarations inside `module { }`"). A seed with both keeps the top-level one, and rule 1 still counts the module-nested one (GH #825). A seed whose only `main locus` is module-nested has no entry, and is **refused**, by `hale check` as by the build, with one located error at that locus's name: ```text main.hl:2:16: type error: the entry must be top-level: `main locus App` inside `module inner` is not the program's entry, and nothing else in the seed is — move it out of the module ``` With more than one candidate (rule 1's error) the checker reads the last. The build deploys the entry and nothing else (F.40 phase 3, L4): its placement, its bindings, its pinned threads. The refusal above is one more diagnostic, never a replacement: the placement-safety rules (the pool map behind the cross-pool method call error, `spec/types.md` § "Single-threaded-method invariant (F.31)", the pinned-in-a-loop rule, placement rule 17, the instance-aliasing warning, one locus shared by two of the root's fields placed apart, #334, which reads where each field runs from the placement table, and the async_io advisory, `spec/verification.md` § "Placement-implied contracts") and every other rule still judge a refused module-nested `main locus` and its members as they judge the same declarations at the top level, so a program refused for another reason is refused for both. The api binding is generated into the same root, the entry or a refused module-nested `main`, and the editor's `hale/placement` view and the model's `entrypoint` name it (`main` when there is none, as for a seed whose only `main locus` is imported). Until L4 the build deployed the first `main locus` of the seed's own, a module-nested one included; the refusal is what changed for the one seed that ran differently, whose only `main locus` is module-nested. Whether a thread runs beside the bus's main drain, so that the bus queue takes its lock, is decided from what the build deploys (F.40 phase 3, E2): a domain of the placement table that is not main (a `pinned` field, a non-main cooperative pool, an adapter binding, the `api` binding's pool), or an entry of the entry's `bindings { }`. An imported `main locus`'s `placement { }` block and `bindings { }` start nothing, so a program that imports one keeps the single-threaded queue. Bundle-wide rules: 1. At most one `main` locus per bundle, counting the entry program's own. A `main locus` that arrives through `import` belongs to the imported seed, not to the entry: it does not count (rule 3), so a program may import a seed that has one and declare its own (GH #1059). Zero is fine — the classic bare `fn main()` shape is still legal. 2. Each `bindings` entry's topic must name a declared `topic`. 3. A topic may appear at most once across the entrypoint's bindings. An imported seed's `main locus` is renamed with its seed and its bindings are inert: they bind nothing, count toward nothing here, and do not make a second `main` (GH #1104 piece 5). 4. Bindings only legal in a `main`-modified locus. The parser rejects them in any other locus position. 5. Every binding's role must be either explicit (`role:` kwarg) or unambiguously inferable from the bus block. Codegen emits one runtime registration call per binding entry into `fn main`'s prelude, right after the bus queue is published: - Unix bindings call `lotus_bus_register_remote(subject, url, role)`. - Adapter bindings first instantiate the adapter locus with program-lifetime allocation (same m90 routing the `-> LocusRef(L)` return path uses), resolve the locus's `send` method's function pointer, then call `lotus_bus_register_remote_adapter(subject, self, send_fn)`. Subjects use the desugared wire form (so a binding for hierarchical `Login` registers as `"events.login"`). Topics with no binding entry get no register call and stay same-process via the cooperative queue. **3. Closed-world topology optimization.** When a topic has no binding and the publisher / subscriber relationship is statically unambiguous, the desugar pass rewrites the publisher's `Stmt::Send` into a direct method call. Two shapes qualify: - **Intra-locus (same-type):** publisher locus type == subscriber locus type. Every Send happens inside an instance of the same locus that hosts the handler. Rewrite: `Foo <- v` → `self.handler(v)`. Delivery to an instance becomes eligible once its birth has completed (`runtime.md` § "Lifecycle obligations", line 6). Lowering guards the receiver's readiness, including a send in a helper reached from `birth()`: a held send is queued to that exact receiver, with its payload retained until delivery. - **Intra-tower (parent → child):** publisher locus type P has exactly one direct singleton field (declared in `params { }`) whose type names the subscriber locus type S. Every Send in P's body statically routes to that one child. Rewrite: `Foo <- v` → `self..handler(v)`. Common preconditions for both shapes: - No `bindings { Topic: ... }` entry exists for this topic. - Exactly one locus type publishes the topic. - Exactly one locus type subscribes the topic. When eligible, the publish→queue→drain→dispatch path collapses to a synchronous method call. The `subscribe` / `publish` entries stay declared (still type-check) but the bus runtime never sees traffic on the optimized subject. This is a pure-perf rewrite — observable behavior is identical modulo timing (synchronous instead of cooperative-deferred) — *provided publisher and subscriber share an execution context* (see the placement carve-out below; that condition is what keeps the rewrite observably transparent). Out of scope for v1 (fall through to bus dispatch unchanged): - Multi-hop towers (`Outer → Middle → Leaf`). - Plural / vec / capacity-slot children — broadcast semantics don't match the singleton-rewrite shape. - Child-publishes-parent-subscribes — needs a parent-reference mechanism that doesn't exist in v1. - A parent with multiple direct fields of the subscriber type (ambiguous receiver). - **Bus adapters (GH #1032).** A locus named as an adapter in a `bindings { }` entry is never the publisher of a rewrite: the runtime calls its `send` on the publisher's thread, so a publish inside it may execute off the adapter's own thread, and a direct call would run the handler there. Its publishes take the bus, which posts to the adapter's mailbox. - **Off-thread subscribers (F.31 placement).** When the subscriber is a main-locus field placed on a cooperative pool other than `main`, or on a pinned thread, the direct call would run the handler on the *publisher's* thread instead of the subscriber's pool worker — breaking the single-threaded-pool invariant and dropping the pool context that any locus the handler instantiates must inherit (an accept'd child's `run()` would otherwise go synchronous, and its `subscribe`s would register on the global queue rather than the pool). Such publishes stay on the bus dispatch path, which posts to the subscriber's pool via `lotus_coop_pool_post`. `cooperative` with no pool (or `pool = main`) keeps the subscriber on the publisher's thread, so it remains eligible. A bound topic is never optimized: the binding may publish to remote subscribers that aren't visible at compile time. ### Phase 3: routing keys (v0.1 proposal) Phase 3 extends topic declarations with a per-message **routing key** so the bus can shard dispatch by key value at the `(subject, key)` granularity, rather than fanning every published message to every subscriber on the subject. Motivated by a downstream market-data workload: one reader thread publishes book frames for N symbols; N per-symbol loci each want only their own symbol's frames. Without routing keys, every BookSignal would receive every L2Data frame and have to filter in user code — O(N × messages) dispatches; per-symbol state corruption pressure if filtering is forgotten. Routing keys are also reusable for any "many similar loci sharing one publisher" pattern (per-tenant request streams, per-account ledger updates, etc.). **Surface — three pieces.** ```hale type L2Data { sym_id: Int; // i64 routing key field bids: [BookLevel; 100]; asks: [BookLevel; 100]; } topic MarketL2 { // (1) topic-decl additions payload: L2Data; subject: "market.l2"; keyed_by sym_id; // ← new on_unmatched: swallow; // ← new (default if absent) } locus BookSignal { params { sym_id: Int = 0; ... } bus { // (2) subscribe-clause filter subscribe MarketL2 as on_l2 where key == self.sym_id; // ← new } fn on_l2(d: L2Data) { /* d.sym_id == self.sym_id, statically */ } } main locus Ingest { // (3) per-instance bindings params { btc: BookSignal = BookSignal { sym_id: 1 }; eth: BookSignal = BookSignal { sym_id: 2 }; sol: BookSignal = BookSignal { sym_id: 3 }; } } ``` **Width is inherited from the `keyed_by` field's type.** No separate width annotation. Acceptable field types and their bus storage at v0.1: | Field type | Bus storage | Compare cost | |---|---|---| | `Bool` | u64 (zero-extended) | one i64 cmp | | `Int` | u64 | one i64 cmp | | `Time`, `Duration` | u64 (ns since epoch) | one i64 cmp | | no-payload `enum` | u64 (i32 tag zero-extended) | one i64 cmp | | `Decimal` | u128 (i64 pair) | two i64 cmps | | `String` | u64 hash + owned copy | one i64 cmp; full compare on hash match | The bus runtime stores both halves of a u128 uniformly (`key_lo: u64, key_hi: u64`) — narrower types zero-extend. Apps that need compound keys (`(sym_id, venue, side)`) pack them into a `Decimal` field themselves; the language does not bake compound- key derivation at v0.1. `String` keys (2026-07-17) hash-gate the per-entry compare: the registry stores the subscriber key's 64-bit hash plus its own copy of the string (capture-by-value — see "Key stability" below), the publish site hashes the payload's `keyed_by` field, and only a hash match pays the full string compare, so a mismatched key still costs one i64 compare per entry. `StringView` and `Bytes` are not key-eligible. Remote fanout is unkeyed for String keys just as for scalars — no key material crosses a process boundary. The RECEIVE side of a binding derives the key instead (2026-09-09, DNA F.12): codegen synthesizes one extractor per keyed wire subject, the publish site's exact computation over the deserialized payload, and every inbound path (unix serve loop, boot-window flush, UDP reader, adapter inbound) dispatches keyed. A `where key == …` subscription therefore means the same thing on both sides of a socket; before this it received nothing over a binding. An adapter's inbound delivery (`std::bus::__local_dispatch(subject, bytes)`) carries wire bytes and no key, so for a keyed subject the runtime decodes the bytes once — through the binding's `codec(...)` when it has one — into an arena of the delivery's own, derives the key, and delivers keyed: matching `where key ==` subscribers and unfiltered ones hear it, non-matching ones do not, and each subscriber still gets its own decoded copy (GH #1041). That arena is freed when the delivery returns and draws its memory from the thread's chunk pool, so deriving the key costs no heap allocation in steady state. An unkeyed subject takes the unkeyed wire path unchanged. **`where key == EXPR` — what EXPR can be.** The RHS is evaluated at the subscribing locus's instantiation (the point where its `params` defaults are resolved), and the resulting key value is captured into the bus registry alongside the handler's self pointer. v0.1 restricts EXPR to: 1. An integer / decimal / string literal: `where key == 42`, `where key == "lobby"` 2. A const identifier resolving to a scalar of the topic's key type 3. A `self.` path read, where `` is a `params`-block field of the subscribing locus (for a String-keyed topic, a `String` field — e.g. `where key == self.name`) 4. The bare word `replica` (2026-08-12, contextual — only this exact RHS position): the subscribing INSTANCE's 0-based replica index. Each instance of a `pinned(..., replicas = K)` fan-out registers its own index, so K replicas shard an Int-keyed topic with one subscribe line and K spelled once, in the placement entry; a non-replicated instance is replica 0. Requires an Int-family key (String keys rejected — the index is a number). This is the bridge between the placement scale axis and the delivery axis, built so that placement itself stays semantics-free: the filter is still written on the subscription; the placement only decides how many indices exist. A `where key == …` filter of any shape requires a KEYED topic (2026-08-12): an unkeyed publish never runs the key match, so a filtered subscriber would silently receive nothing — previously accepted without a word, now a type error. Higher-shape expressions (`self.a + self.b`, method calls in the filter, cross-locus reads) are reserved for later. The restriction keeps the static check simple ("EXPR is a let- bindable expression with no side effects, types to the topic's key type") and avoids surprising semantics at registration time. **Key stability — captured by value at register.** A routing-key subscription captures its key value at the locus's instantiation (or restart). The key expression is evaluated once, when the subscription is registered: at the instance's construction, before `birth()` runs (GH #737). A key a locus computes in `birth()` is therefore not the registered one — the field's default is — so a key comes in as a param at the literal (`Feed { symbol_id: 7 }`); registering the default on purpose stays valid. The same holds for a subscription a locus imported from another module declares. Subsequent mutations to fields the filter expression references do **not** change which messages the handler receives. If dynamic re-keying is needed, dissolve and re-instantiate the locus. The alternative — re-evaluating the filter on every dispatch — would break the bus's "register once, dispatch many" cost model and introduce ordering complexity against concurrent `self` mutation; the capture-by-value rule is the right default, and a `re-subscribe` API is a follow-up if a workload demands it. **`on_unmatched: V` — policy when no subscriber's key matches.** A keyed publish may find zero subscribers whose `where key == X` filter matches the message's key. Topic-level config picks the behavior; default is `swallow` (matches today's no-subscriber semantics on unkeyed topics). | `on_unmatched:` | Behavior | |---|---| | `swallow` *(default)* | Drop the message silently. Diag visible only with `LOTUS_BUS_LOG_UNMATCHED=1` env var (per-publish stderr line citing subject + key + subscriber counts). | | `fail` | Publish becomes a fallible expression. Caller must attach an `or` disposition: `K <- value or raise` panics via `lotus_root_panic` with a `BusUnmatchedKey` marker; `K <- value or discard` silently swallows on no-match. The err-payload-carrying dispositions (`or handler(err)` / `or fail

`) are reserved for v0.2 — they require synthesizing `BusUnmatchedKey { subject: String, key_lo: Int, key_hi: Int }` as a stdlib type, which is a small follow-up. `or ` is permanently rejected: Send produces no value, nothing to substitute. | | `fallback` | A catch-unmatched subscriber on the subject — `subscribe T as h where key == _` — receives the message. At least one such subscriber is required; cross-module resolve-time check rejects the topic otherwise. The `_` sentinel is legal only on `fallback` topics. | Static checks at typecheck: 1. `keyed_by FIELD` — FIELD must be a declared field of the topic's payload type; FIELD's type must be one of the table above; the topic must not also declare a `keyed_by` via a parent topic with a different field. 2. `where key == EXPR` — EXPR's type must match the topic's keyed-by field type after width inference. 3. `where key == EXPR` is forbidden on topics without `keyed_by`; rejecting prevents silent-no-match bugs from typo'd filters. 4. `where key == _` is forbidden except on topics with `on_unmatched: fallback`. 5. `fail` topics: every `Topic <- value` send site must carry an `or` disposition clause (`or raise` / `or discard` at v0.1 of the impl; `or handler(err)` / `or fail

` reserved for v0.2). The `or` clause attaches to the Send statement, not the value expression — the parser strips it off the value's `Expr::Or` wrapping into `Stmt::Send.or_disposition`. Conversely, an `or` clause on a Send to a non-`fail` topic is rejected. 6. `fallback` topics: at least one program-wide `where key == _` subscriber must exist; checked at resolve after import merging. Routing keys are orthogonal to topic hierarchy (Phase 2): a parent's `keyed_by` and `on_unmatched` are inherited by children that don't override; children may override either independently. A child topic re-declaring `keyed_by` must agree with the parent's key type (subjects derived from a parent's wire prefix share the parent's key shape — anything else makes dispatch ambiguous on the wire). **Runtime — `lotus_bus_entry_t` extension.** The bus router's subscriber-entry struct (`crates/hale-codegen/runtime/lotus_arena.c`, around line 4034) gains a tri-state filter and a u128 key value: ```c typedef struct { /* ...existing fields: subject, self_ptr, handler, etc... */ uint8_t key_filter_kind; /* 0 = no filter (receive-all) * 1 = specific key * 2 = catch-unmatched (`_`) */ uint64_t key_lo; /* i64 key, or low half of i128 */ uint64_t key_hi; /* 0 for i64 / narrower types */ } lotus_bus_entry_t; ``` Dispatch (`lotus_bus_local_dispatch_keyed`): ```c int matched_specific = 0; for (entry in the registration table with matching subject): if (entry.key_filter_kind == 1 && entry.key_lo == msg.key_lo && entry.key_hi == msg.key_hi) { fire(entry); matched_specific = 1; } else if (entry.key_filter_kind == 0) { fire(entry); /* unkeyed receive-all */ } if (!matched_specific) { for (entry in the registration table with matching subject): if (entry.key_filter_kind == 2) fire(entry); } ``` Walk cost is O(N_subscribers_on_subject) for the specific pass, with a second pass only when there's no specific match (fallback case). For workloads with thousands of keyed subscribers per subject, a per-`(subject, key_lo, key_hi)` open-addressing index can be added later — YAGNI until a workload demands. A registration may come from any thread (the instantiating thread, a pinned anchor's thread initializing its subtree, a pool's worker initializing a pool root's), while any thread dispatches. The table is append-only: a registration appends under a lock, and a dispatch walks the entries published when the walk began, without one; an entry registered meanwhile is not in that walk (`spec/runtime.md` § "Bus message router" and `lotus_arena.c`, inventory row R50). Two new runtime symbols: ```c void lotus_bus_register_keyed( const char *subject, void *self, lotus_handler_fn handler, /* ...existing... */, uint8_t key_filter_kind, uint64_t key_lo, uint64_t key_hi); int lotus_bus_dispatch_keyed( /* returns match count; * `fail` topics check this */ lotus_bus_queue_t *queue, const char *subject, const void *payload, size_t payload_size, uint64_t key_lo, uint64_t key_hi, lotus_serialize_fn serialize_fn); ``` Existing `lotus_bus_register` / `lotus_bus_dispatch` stay as the unkeyed entry points (compat for unkeyed topics). **Backward compatibility.** Topics without `keyed_by` and subscribers without `where key ==` behave exactly as today — the new fields default to `key_filter_kind = 0` (receive-all) and the dispatch dispatches uniformly. Existing programs need no source change to keep working; new programs opt in per-topic. **v0.2 — err-payload Send dispositions.** On `on_unmatched: fail` topics, all four `or` disposition shapes are now supported: - `or raise` — no-match panics via `lotus_root_panic` (v0.1 shape; unchanged). - `or discard` — no-match silently swallowed (v0.1 shape; unchanged). - `or ` — evaluates `` for side effects on no-match, with `err: BusUnmatchedKey` in scope. The expression's value is discarded (Send is statement-level). Canonical use: `or log_unmatched(err)` — a free fn that takes the err payload and logs / metrics / etc. - `or fail ` — only legal inside an enclosing `fallible(E)` fn. On no-match, evaluates `` (with `err: BusUnmatchedKey` in scope), stores it into the enclosing fn's err slot, and diverts to the fn's err-exit path. Symmetric to `or fail` on fallible-method calls. `BusUnmatchedKey` is a synthesized stdlib type, injected into scope when any topic declares `on_unmatched: fail`. Layout: ```hale type BusUnmatchedKey { subject: String; // wire subject of the failing publish key_lo: Int; // low 64 bits of the unmatched key key_hi: Int; // high 64 bits (0 for i64 keys) } ``` Codegen allocates a fresh `BusUnmatchedKey` in the current arena on the no-match branch and binds it as `err` for the disposition expression's lowering. Mirror of the existing `KeyError` / `IndexError` / `IoError` synthesis pattern. **Out of scope at v0.1 (explicit non-goals):** - Multi-field / tuple `keyed_by` (`keyed_by (sym_id, side)`). Apps that need compound keys pack into a `Decimal` field in user code. Eligible for a v0.2 sugar once a workload's ergonomics surface the friction. - Wildcard key sets (`where key in [1, 2, 3]`). - Range predicates (`where key > 100`). Equality match is the workload-driven sweet spot; broader predicates would defeat the O(1)-dispatch cost model. - String routing keys. String-equality match defeats the perf goal; only int-shaped scalars at v0.1. - Method blocks on enums to make enum-typed routing keys ergonomic. Treated as a separable language feature; users at v0.1 use bare Int / Decimal or existing no-payload enums. - Per-publish key override (publishing with a key value that isn't derived from the payload field). Tied to the payload's identity so wire-format consumers see a consistent key. - Cross-process keyed dispatch (over the remote-fanout path). v0.1 ships keyed dispatch for the intra-process bus only; remote subscribers still fanout per-subject and filter in their own bus router after deserialize. - `or ` on Send for SUCCESS-value substitution. Send produces no value to substitute. v0.2's `or ` disposition on fail topics is a side-effect handler call (evaluates `` with `err` in scope, discards the value), not a value substitution. ## Placement block (F.31) The `placement { }` block on `main locus` controls per-locus thread placement, parallel to `bindings { }` for bus topology. Placement is a deployment seam — same library, different placement entries, different binary behavior. See `spec/decisions.md` § F.31 for the intrinsic-vs- deployment axis the block sits on, and `spec/runtime.md` § "Placement classes" for the runtime semantics. ### Syntax ```hale main locus App { params { gateway_a: Gateway = Gateway { venue: "venue-a" }; gateway_b: Gateway = Gateway { venue: "venue-b" }; metrics: MetricsServer = MetricsServer { port: 9100 }; ui: Renderer = Renderer { }; } placement { gateway_a: pinned(core = 1); gateway_b: pinned(core = 2); metrics: cooperative(pool = io); ui: cooperative(pool = render); // unspecified main-locus params → cooperative(pool = main) } } ``` ### Type-check rules > These placement/bus rules are part of the broader compile-time > verification surface; `spec/verification.md` is the canonical > catalog of every static check (with severities and enforcing passes). 1. **`placement { }` is `main locus` only.** Any other locus declaring `placement { }` is a parse error (same shape as `bindings { }`). 2. **Keys reference main-locus `params` field names.** The key on the left of each `placement_entry` must match a declared `params` field on the enclosing `main locus`. Unknown field name → typecheck error pointing at the params block. 3. **Field values are locus types.** A placement entry on a non-locus field (`port: Int`, `host: String`) is a typecheck error — placement applies only to locus instances. 4. **At most one placement entry per field.** Duplicate keys are a parse error. 5. **Pool names use snake_case Idents.** The set of pool names is inferred from `cooperative(pool = X)` references across all placement entries in the bundle. Pool `main` is always available; it refers to the program's main OS thread. 6. **Locus-pinning compatibility.** A locus placed `pinned` is subject to the pinned-class restrictions: no `accept(c: Child)` accept-method, and no closure whose epoch is `birth` or `dissolve` (dissolve is the default when no `epoch` clause is written). Those two epochs fire inside the lifecycle cascade, which the owner's thread runs and cannot route across the pinned thread; a tick, duration, explicit or inline closure fires on the pinned thread itself and is permitted (a pinned heartbeat with `epoch duration`, a pinned connection with an inline closure). These restrictions belong to the placement site, not the declaration: the typechecker applies them to every instance that runs pinned — a `pinned` entry's field (each of its replicas) and an adapter inline in `bindings { }`, which has a thread of its own with no entry — and judges the locus the instance realizes (a construction site's override literal, a `std::` locus), at the entry's span. The refusal carries its witness as related locations, in order: the entry that runs the instance pinned (the placement entry, or the binding entry), the declaration the instance realizes, and the member that conflicts (the `accept`, or the closure's assertion). A locus that uses neither feature can be placed either cooperative or pinned at the deployment's discretion. (F.40 phase 0: until then the rule was stated as "no closure declarations" while lowering refused only birth and dissolve closures; the rule now says what ships, and the typechecker judges it. Phase 3, C7: it is judged over the placement table's rows, so the adapter, an `accept()` with no parameter and a field whose written type the entry walk could not resolve are judged too, and lowering keeps no refusal of its own. Phase 4, W2: the refusal's message is unchanged, and the witness is new.) 7. **Dead bus receiver (error).** A locus that declares `bus { subscribe ... }`, is placed `cooperative(pool = X)` with `X != main` (and not `where async_io`), **and** whose `run()` makes a known-blocking stdlib call is rejected. A cooperative locus receives cells only while its pool thread is free to run the dispatch (the cross-process transport reader dispatches into the handler set; the in-process cooperative queue is drained at yield points). A blocking call monopolizes the pool thread, so the dispatch never runs and the subscriber's handlers never fire. **Placement alone is not the condition** — an event-driven subscriber that yields (handlers plus a `time::sleep` loop, or `where async_io`, which parks) receives fine and is *not* flagged; only the blocking-and-subscribing combination is rejected. A subscription to a topic the locus also *publishes* is also spared (an intra-locus self-publish→subscribe is devirtualized to a direct call). Fix: `pinned` (own thread + a mailbox drained at sleep/yield) or keep `run()` non-blocking. (Corrected 2026-06-03: an earlier form rejected on placement alone and over-fired on event-driven non-main cooperative subscribers, which receive reliably.) The rule is judged over the bus graph (rule 9): a topic the locus publishes by its declared name and subscribes by its literal subject is one subject under the graph's canonical key, so that subscription is spared as a self-publish, and the error lists the handlers of the subjects the locus subscribes and does not publish. A subscription whose subject the graph cannot resolve is compared as written. (F.40 phase 3, C4.) 8. **Blocking syscall on a cooperative pool (warning).** A locus placed `cooperative(pool = X)` *without* `where async_io` whose `run()` calls a known-blocking stdlib op (tcp/tls recv, accept, `process::run`/`wait`) but is *not* a dead receiver (rule 7) gets a **warning** (not an error — hale's only non-fatal diagnostic). A blocking call holds the pool's OS thread for its whole duration, stalling every other locus scheduled on that pool and the pool's bus drain. The fix is `pinned` (its own thread — the prescribed shape for blocking I/O) or `cooperative(pool = X) where async_io` (parks on I/O readiness). It is a warning rather than an error because a single-purpose blocking server with nothing co-scheduled is legitimate. Detection is **interprocedural**: it follows the call graph, so a `run()` that blocks indirectly — through a helper fn or a `self.method` it calls (transitively) — is flagged too, with the diagnostic naming the offending call. It remains best-effort at the edges (blocking via a *handle* method like `stream.recv`, or across a cross-locus `self.field.method()` hop, isn't traced). Note rule 7 (the dead-receiver *error*) stays **direct-call-only** — it is not widened onto indirect paths, so the higher-stakes diagnostic keeps its precision. Rules 7 and 8, the pool-starvation warning and the birth-order trap read where each field runs from the placement table: they judge the `main locus` lowering deploys, the entry, or the refused module-nested one of a seed with none (one lowering does not deploy, imported or beside the entry, spawns nothing and is not judged), and each field by the locus it realizes, an alias or an imported seed's qualified path included (a stdlib locus's `run()` is judged by the known-long-running list, as before). 9. **Orphan bus topic (warning).** In a closed-world program (one with an entry: the seed's own top-level `main locus`; an imported or a module-nested one is not the entry: "The entry locus", § "Phase 2: hierarchy, subjects, bindings, closed-world optimization"), a bus subject — a declared `topic` or a literal string — wired to only one end is flagged: *published with no subscriber* (the cells go nowhere) or *subscribed with no publisher* (the handler can't fire), and a declared topic touched by neither is *dead wiring*. Suppressed when the other end is plausibly external: a **transport binding** (`bindings { T: ... }` implies a cross-process peer), a **wildcard** subscriber/publisher covering the subject (`log.**` covers `log.app`), a **cross-seed** reference (`alias::Foo` — the other seed owns the other half), or the same locus being both publisher and subscriber. (A topic is no longer reachable by a caller because a locus subscribes it: the retired api binding's lift of this rule went with it, and a served surface reaches handlers, not topics.) The closed- world gate is why this is skipped for library seeds (no `main`): their consumers are downstream, out of the bundle. (GH #18 #4.) Rules 9 and 10 and the dead-receiver rule (7) are judged over the bus graph. The graph carries as columns what the walk computed: whether a topic is bound to a transport, whether a subject crosses a seed, whether a subscription is a wildcard; rule 9's closed world is the entry row's. A subject is compared under the graph's canonical key, its wire subject (spec/model.md rule 8): a topic published by its declared name and subscribed by its literal subject is one subject, wired at both ends. Where the graph cannot resolve a subject (a topic name no declaration answers, a qualified path through an import the bundle never resolved, an open world), the rule does not fire: an unresolved subject is not a proven orphan, and the row records the hole. The silence is about the orphan judgment, not about admitting the reference: in a whole program a qualified subject that names no declaration is a located "unknown topic" error at the check (spec/projects.md, "An unresolvable qualified name is a located error"), as a local one is. (F.40 phase 3, C4.) 10. **Bus cycles.** An edge `S →(L) D` exists when locus `L` subscribes subject `S` with a handler that sends to subject `D`. A cycle in this graph is a publish→subscribe→publish loop, and the dispatch model splits it two ways: - A **cross-locus** cycle (edges from ≥2 loci) hops between loci through the cooperative *queue* (drained at yield) — it spins the queue / livelocks → **warning**. - An **intra-locus** cycle (every edge in one locus) whose every send the intra-locus rewrite turns into a direct synchronous call (rule 7) recurses on one thread without bound → stack overflow → **error**. To keep the error precise, only **unconditional** sends form intra-locus edges: a self-republish guarded by an `if`/`match`/loop is a terminating state machine, not unbounded recursion, and is not flagged. (GH #18 #4.) - An intra-locus cycle with a send the rewrite leaves on the bus is carried by the queue like a cross-locus one: each cell is handled at a later drain, so the stack does not grow, and it is the queue's **warning**. The rewrite makes a direct call only where its preconditions hold (§ "Phase 2: hierarchy, subjects, bindings, closed-world optimization", *Closed-world topology optimization*), and it counts only a subscription by the topic's declared name, so a topic sent by name and subscribed by its literal subject cycles through the queue. The check reads which sends lowering rewrites from the rewrite's own relation, keyed by each send's identity, never from the subjects' spelling. (F.40 phase 3, C4.) The graph's edges are keyed by declaration identity: a handler body belongs to the declaration that wrote it, so two loci of one name have their own edges, and the cycles follow the edges, not the name — a cycle whose edges two declarations of one name wrote is cross-locus, and neither declaration's intra-locus check sees the other's edges. An edge joins canonical subjects (rule 9): a send by a topic's declared name meets a subscription by its literal subject, and a send whose subject the graph cannot resolve (an unresolved path, a computed subject) forms no edge. (F.40 phase 3, C4.) 11. **Bus backpressure (warning).** A locus that publishes to the bus inside an **unbounded** `while true` loop carrying no flow-control or exit point — no cooperative `yield`, no `time::sleep`/`tick` throttle, no input-pacing blocking `recv`, no `break`/`return` — has no backpressure: it posts cells faster than any subscriber can drain, so the queue and the payload arena grow without bound. A full producer-vs-consumer rate analysis is undecidable, so this is a deliberately narrow structural heuristic (warning): only literal `while true` loops are considered (bounded `for`/`while cond` loops never are), and any flow-control point anywhere in the loop body clears it. (GH #18 #4.) 12. **Bus subject type-mismatch (error).** Every publish/subscribe site addressing the same **literal** subject string must declare the same `of type` payload — otherwise a subscriber decodes the publisher's bytes as the wrong type at runtime. A declared `topic` is already unified by its declaration (and `of type` is forbidden on topic refs), so this closes the literal-subject gap. Grouping is by *exact* subject string, which excludes wildcards (`log.**` and `log.app` are different strings, never cross-compared). The fix is to declare a `topic` (one payload type, fixed in one place) or align the `of type` annotations. (GH #18 #4.) 13. **Empty / degenerate `pinned(cores = …)` (error).** A cpuset affinity spec that selects no cores is rejected statically: an exclusive range whose upper bound is `≤` its lower (`4..4`, `8..4`), an inclusive range that runs backwards (`8..=4`), or a set with a duplicated element (`{2, 4, 2}`). Bounds/elements are integer literals — placement is closed-world — so the selected core list is known at compile time and a spec that reduces to "no cores" (or a redundant one) is an authoring error, not a deploy-box question. Whether the selected cores *exist* on the target stays best-effort at runtime (out-of-range indices are skipped, exactly as `pinned(core = N)` degrades on a smaller machine). Linux-only affinity; a no-op on other hosts. (Topology Phase 1a, 2026-07-04.) 14. **`topology { }` consistency + `pinned(node/l3)` resolution (error).** The declare-only `topology { }` block is validated statically: NUMA `node` ids must be unique; L3-domain names must be globally unique (they're referenced by `pinned(l3 = name)` without node qualification); each `cores` spec must be well-formed (rule 13); a core may belong to at most one L3 domain (overlap is ambiguous affinity); and a domain core may not overlap a `reserve`d range (reserved cores are held back for the OS / main). A `pinned(node = N)` / `pinned(l3 = name)` placement entry must reference a domain the block declares — using either with no `topology { }` block, or naming an undeclared node/domain, is an error. Resolution is closed-world (ids and domain cores are literals), so the selected core set is known at compile time. Whether those cores exist on the deploy box stays best-effort at runtime. A `pinned(node/l3)` locus gets **thread + memory co-location**: its thread is affinity-masked to the domain's cores (Phase 1a cpuset path) *and* its arena — including method-scratch sub-regions — is `mbind`-bound to the node, so its working set lives on the node its thread runs on. Node binding is a raw `mbind` syscall (no libnuma dependency), Linux-only and best-effort (falls back to first-touch when the node can't be honored); non-node arenas are unchanged. (Topology Phase 1b, 2026-07-05.) 15. **`replicas = K` (error on `K < 1`; pinned-only).** A `pinned(..., replicas = K)` entry fans the field into K single-threaded instances — replica `i` pinned to one core of the affinity set (round-robin), each on its own OS thread. `K` must be `>= 1` (`0` / negative is rejected). `replicas` is valid only on `pinned`: K cooperative loci on one pool would share a single thread (not parallel), so `cooperative(..., replicas = K)` is rejected at parse with guidance toward the pinned form. The point is that parallelism comes from *more single-threaded units*, never a multi-worker pool — each replica is its own single consumer, so the lock-free rings, bus devirtualization, and single-threaded-method guarantee all hold. Replicas compose with `node`/`l3` (each replica's arena binds to the target node) and are non-addressable (no `field[i]` surface; they are bus-subscribing or run-loop workers). All K are joined and dissolved at parent teardown. (Topology Phase 1c, 2026-07-05.) Replicas compose with KEYED DELIVERY via `where key == replica` (2026-08-12) — see the routing-key filter rules. 16. **Pool affinity (2026-08-12).** `cooperative(pool = X, core/cores/node/l3 = …)` binds pool X's worker THREAD to the core set — the same affinity forms `pinned` takes, resolved against `topology { }`, same best-effort contract. The pool has one worker, so entries naming one pool must agree: a second entry may name the pool bare (it inherits) but a different affinity is a type error citing both entries. Affinity without a named pool (or on pool `main`) is rejected — the main pool is the program's main thread, whose affinity belongs to the operator. Thread affinity only; pool workers own no arena to node-bind (handler scratch lives in each locus's own arena). 17. **A `pinned` placement forbids a loop (error).** A locus whose `placement { }` block pins any field may not be instantiated inside a loop body — a `while` / `for` at any nesting depth, in a free fn, a locus method or a lifecycle hook. `pinned` gives its field its own OS thread, spawned during the placing locus's params-init and joined at the instantiating scope's exit, and the join record (the deferred-dissolve slot plus the thread handle beside it) is one slot per instantiation *site*: a second pass over the site overwrites the record of the first, so only the LAST instance is joined and arena-destroyed and every earlier pinned thread is orphaned with its arena still live. Placement names static resources — a core, a NUMA node, `replicas = K` — one thread per entry for the program's life, so a per-iteration thread is a category error rather than a reclaim policy to pick. `placement { }` is main-only (rule 1), so the shape this rejects is the deployment root booted once per iteration, and the placement it reads is the deployment root's (an imported `main locus` is not deployed and pins nothing; a module-nested one that is the seed's only `main locus` is refused as not the entry, and judged here too, as a top-level one would be: "The entry locus", § "Phase 2: hierarchy, subjects, bindings, closed-world optimization"). The fix is to instantiate it once outside the loop; a loop that *calls a fn* holding the literal is unaffected and correct (each call joins its own thread at that fn's exit), and the rule is positional on that literal, so it is not a rule about the whole call graph. The typechecker judges it over the placement table's constructions of the root (every literal of the root declaration, as resolved, in a fn or locus body), and over the root literals written in another locus's `params` default: such a literal is built wherever a literal of that locus takes the default, so it inherits that literal's loop, through any depth of defaults, and is rejected at the outermost literal written in a loop (`for i in 0..3 { Holder { }; }`, where `Holder`'s default is `App { }`, at `Holder { }`). A root literal written in a position lowering emits again at every use — a `const`'s value (at each read of the const), a type's field default (at each literal of the type that takes it), a closure's assertion (at each evaluation of the closure) — or reached through `params` defaults from a literal written in one, has no construction the table records, so the position does not let the compiler show the root is built once: when the root pins a field it is rejected outright at that position's literal, loop or no loop, and the fix is to build it in a locus's `params` or a fn body. A root literal in an adapter's inits is built once, by the bindings prelude, and one in a perspective's members is never lowered (a contract fn's body is not the `serves`-ing locus's method); neither is judged. A `const` or `type` written in a locus body is refused on its own rule. The test harness's build, which skips the rest of the check, judges it too, so lowering keeps no refusal of its own. (GH #826, 2026-09-20; F.40 phase 3, C7.) 18. **Every entry is consumed by exactly one instantiation (error).** A placement entry is carried by the locus LITERAL lowered for its field, and by nothing else: the thread class, the cooperative pool and the NUMA node all ride an override that the next `T { }` takes. So a placed field whose value arrives any other way — a factory call, a fallible call, a conditional, a reference to an instance somebody else built — leaves the entry untaken, and the next field's turn through the params-init loop resets it. Nothing is placed and nothing is said. That shape is rejected at the initialiser, with the literal form spelled out. The entry's value is the init written at the instantiation site when the literal supplies one, and the `params` default otherwise, so both spellings are checked — and a default every site overrides is dead text, not a dropped placement. The entry is not applied after the fact because there is nothing left to place: the pinned path does not mark an instance, it spawns a thread that runs the locus's whole lifecycle — birth, `run()`, the mailbox loop, drain, dissolve — and a factory's literal has already run birth and `run()` (and registered its subscriptions against the global queue) before the value returns. Scope matches rule 17's: an imported seed's main locus is renamed `__lib_*`, is not the deployment root, and its entries never reach the plan; the instantiation sites judged are every literal of the root declaration as resolved, wherever it is written: the placement table's constructions of the root (a literal in a fn or locus body), and the literals the table records where no body reaches — another locus's `params` default, at any depth of defaults, a perspective's `params`, a const, a type's field default — which lowering expands wherever their holder is built (`locus Holder { params { app: App = App { w: make_worker() }; } }` is rejected at `make_worker()`). The root's own default is live when one of those literals leaves the field to it, or when no literal lowering emits builds the root (no construction, and no such literal whose holder something builds): lowering then builds no root at all, but a library seed checked on its own takes the default, and so does the rule. So `App { w: Worker { } }` in `Holder`'s default, with `Holder { }` built, leaves a `make_worker()` default dead. The test harness's build, which skips the rest of the check, judges it too, so lowering keeps no refusal of its own. (GH #890, 2026-09-20; F.40 phase 3, C7.) 19. **Uncarriable bus payload (error).** An `of type T` clause on a `publish` / `subscribe` must name a type the bus can carry — a user `type`, an enum with a payload variant, or `BytesView` (§ *Bus subscription dispatch* → *Payload type*, below, has the full statement of what the wire carries). A primitive, a tuple, an array, `bounded[T; N]` or a no-payload enum is rejected at the clause's own span. Before this rule `of type Int` checked clean and could not be lowered: codegen refused the publish (`bus send payload must be a user-type or has-payload enum value`) and the subscribe (`m60 requires a TypeRef, has-payload Enum, or BytesView`), unlocated and from another layer. A payload type the bundle cannot *resolve* — a qualified path into a seed it does not hold, a generic instantiation, a name nothing declares — is left alone: the rule is about what the bus carries, not about which names are in scope. A `topic`'s `payload:` is under the same contract, but the checker does not desugar topics (lowering does), so that half is still diagnosed during lowering. (GH #876, 2026-09-20.) 20. **Unowned subscriber (error).** A locus with bus handlers that is born in another locus's bus handler — a literal in the body of a `fn` that one of its `bus { subscribe … as h }` entries names — must be owned: a handler returns after each message, so a local it binds dissolves at its return, and the subscription could never fire for a later message. A locus with bus handlers is owned: it is the entry's root, a replica or field of an owned locus, a binding's instance, or accepted by an ancestor through an `accept` edge of the ownership graph. The judgment reads the graph's edges, which are keyed by declaration identity: an `accept` that names the subscriber's declaration through an alias, a module path or a generic specialization owns it; a name that merely matches the declaration's last segment, or its template, does not. When two declarations share a name, the graph orders them by declaration order and the diagnostic names the declaration it judged. Where the graph cannot decide which locus is born, or whether the tower is complete (an unresolved type, an open world), the rule does not fire: unknown ownership is not proven absence, and the row records the hole. An unknown construction path cuts the other way: it proves no owner. A literal in a handler's body is not the entry's root, a replica or a binding's instance, so the rule reads its `accept` edges: the nearest accepting ancestor owns it as it owns any other birth, and a literal assigned to a field of `self` is judged by those edges too, since the graph records no assignment target. An ancestor owns it only if one accepts it on every construction path of the handler's locus: the paths the placement table records (each instance row under its owner's declaration; at a template's top, a literal directly in `fn main`, the root's construction, the entry's implicit one or a `bindings { }` adapter, none of which has an ancestor; each dynamic site in a locus's bodies, or in a free fn, whose callers are not followed), together with the graph's own edges (a literal in a locus's bodies or params defaults). A path that reaches no acceptor, a free fn or a hole of the table (a held instance it does not link, a field whose initializer is no literal, a dynamic site of unknown domain) refuses the birth, and the diagnostic names that path: an accepting parent somewhere is no proof that every instance has one. The graph cannot decide in an open world (no entry, so a consumer may complete the tower), for a generic template no declared type specializes, or for a qualified path that names no declaration. A subscriber born in `run()`, `birth()` or a plain method is not judged: it lives for that scope and can receive what is published during it. `--allow-unowned-subscriber` allows the shape for a program that manages the subscriber's lifetime another way. (2026-05-29; judged over the ownership graph since F.40 phase 3, C4.) ### Single-threaded-method invariant A locus's methods may be invoked only on the OS thread that owns the locus's placement's pool. This is enforced at typecheck via a static call-graph walk starting from each top-level placement entry: 1. Seed each placement entry with its pool: `gateway_a` → pinned (own thread), `metrics` → cooperative pool `io`, etc. 2. For each method call expression `recv.foo(args)`, determine the receiver's pool from the receiver's static type and the surrounding pool context. 3. Cross-pool direct method calls are rejected with a focused diagnostic naming both pools and pointing at the `placement { }` entries that picked them. 4. Bus publishes (`Topic <- payload;` / `"subj" <- payload;`) are unrestricted — they route through the substrate's cross-thread dispatch machinery (the existing m28b condvar+memcpy mailbox path generalized to cooperative pools). This invariant is the substrate enforcement that makes M:N safe. Without it, multi-pool deployments would silently race on locus arenas (which are unsynchronized bump allocators by design). ### A form's sync discipline Step 3 has one exemption, and it belongs to the receiver's form. Every `@form` declaration has one resolved row (F.40 phase 3, C1) that keeps two facts apart: - **Its configuration**, what the author wrote: no `sync =` argument; `sync = none`, `serialized`, `striped` or `lockfree`; or a `sync =` argument that names no discipline (`sync = fast`), which the form check refuses. - **Its effective discipline**: the written one; for a `@form(hashmap)` declared at the top level with no `sync =` argument, the one sync inference picks from the pools its `set` / `bump` / `remove` (writers) and `get` / `has` / `len` / `key_at` / `entry_at` (readers) are called from — none when one pool calls them, `serialized` for at most one writer pool, `striped` for several writer pools when a mutate is hot (inside a loop or an `on_` handler), else `serialized`; none for any other form. The rule is applied per **instance**: a `self.field.set(..)` call reaches the caller's own `field`, from the caller instance's pool, so two maps each touched by its own owner on its own pool need nothing, and the declaration gets the most synchronized discipline any of its instances needs. One instance two holders share (handed to both by `fn main`) collects both holders' pools. An instance the placement table cannot identify (one held through a parameter or `self.field`, or a literal in a method body whose pools are unknown) is called from a pool apart from every other, never main. Two questions are asked of the row: 1. **Is it explicitly configured?** Any `sync =` argument, `none` included. Inference runs only over a form that is not, so an author who writes `sync = none` keeps the map unsynchronized. 2. **Is it safe for cross-domain access?** Its effective discipline synchronizes: `serialized`, `striped` or `lockfree`, written or inferred. A cross-pool method call into a receiver whose form is safe is accepted; any other is rejected by step 3. `sync = none` is configured and not safe, so a cross-pool call into a `sync = none` map is rejected, as one into a map inference left unsynchronized is. The build lays each map out by the same row: the discipline a program runs with is its form's effective one, and no `sync =` argument is ever written into the program on the author's behalf. Three readers ask whether a form *synchronizes* as one question — the `depends` law (a held form is an input channel outside the bus graph), the effects contracts (a call into the form or a locus holding it may take its lock, which `@no_block` and `@deterministic` refuse) and the instance-aliasing rule (a field behind a discipline is not unsynchronized state) — and they ask the second question alone. An explicit `sync = none` is configured, unsynchronized, and holds no lock: a `@no_block` method may read the map, a `depends:` locus may hold it, and one instance holding it, reached from two pools, gets the unsynchronized-state warning. ### Nested instantiation Loci instantiated nested in another locus's body (`birth` / `run` / lifecycle methods, let-bound children, or `params` fields of non-`main` loci) inherit their containing tower's pool by construction: ```hale main locus App { params { gw: Gateway = Gateway { }; } placement { gw: pinned(core = 1); } } locus Gateway { params { // Cache instantiated nested in Gateway's params — // inherits Gateway's pool (the pinned thread). No // placement entry is permitted on `cache` at any // main locus. cache: Cache = Cache { }; } } ``` Placement entries on nested fields are a typecheck error — placement is a top-level main-locus surface only. To run a nested locus on a different pool, hoist it to a main-locus sibling. ### Default placement Main-locus `params` fields with no explicit `placement { }` entry default to `cooperative(pool = main)`. The pre-F.31 shape (`: schedule cooperative` on every cooperative locus declaration, with a single shared main thread) is exactly the behavior a program without any `placement { }` block receives. Existing programs that don't declare placement see no observable change. ### Pool inference The set of cooperative pools is the union of `X` values appearing in `cooperative(pool = X)` references across all placement entries, plus the implicit `main` pool. The runtime spawns one OS worker thread per inferred pool name beyond `main`. No `threads { }` declaration block at v1 — pools are named purely by use site, and the runtime materializes them on demand at startup. ## Element chains `xs.filter().count()` and `xs.filter().into(target)` are recognized **forms**, not values. A chain is rewritten to a single loop by a post-parse pass, so typecheck and codegen both see an ordinary `while` and neither knows chains exist. ```hale self.users.filter(it.active).into(self.actives) ``` `it` is bound to the current element by the rewrite. Stages fuse: two `filter`s are one pass, because nothing is produced between them. **Why a form and not a value.** A chain that produced something at each step would need either a lazy object or an intermediate collection. Both are sequence values, which need an owner, which reopens arenas and placement — the knot that made composition look like a language-sized change. A recognized form produces nothing at any step, so the question does not arise. Three properties follow, and they are the contract: 1. **A chain allocates nothing.** It is therefore legal inside `@budget(alloc_per_call = 0)` and `@hot`. A design that returned a new collection would have been illegal in exactly the code Hale exists for. 2. **A chain is eager.** A predicate's effects are attributed to the predicate's own source position. A lazy chain would execute it at the terminal and the witness path would name the wrong line. 3. **A chain needs no lambdas.** The predicate is an argument position the compiler knows about, not a value, so there is no closure to represent — no capture modes, no escape analysis, no cross-thread question. **Boundary.** Elementwise operations fuse. Whole-set operations (`sort_into`, `reverse_into`, `group_count_into`) cannot — they need every element before producing any — so they are terminals that materialize into caller storage: the loop fills (or bumps) a target the caller owns, and any reordering runs on that target after the loop. The chain itself still allocates nothing. Over a stream source (a bus subscription, where the driver is delivery rather than a loop) they are rejected outright: "needs an end" is a property the compiler can decide. A bare method call with no stage (`v.count()`) is an ordinary form method, not a chain, and is left alone. A chain over a non-form receiver fails with the ordinary "no method `get`" diagnostic. ### Vocabulary Elementwise **stages** (fuse into the one loop): | stage | meaning | |---|---| | `filter(pred)` | drop elements where the `it`-predicate is false | | `map(expr)` | rebind the element to the `it`-expression's value | | `take(n)` | stop the whole chain once n elements have passed this point (2026-08-11) | | `skip(n)` | drop the first n elements that reach this point (2026-08-11) | | `enumerate()` | bind `idx` — the 0-based count of elements reaching this stage — in every later stage and the terminal (2026-08-11) | `take` and `skip` count elements **arriving at their own position**, so `filter(p).skip(2)` skips the first two matches, not the first two source elements; their limits are evaluated once, before the loop. `enumerate` is an explicit opt-in stage rather than an always-bound name so a chain never captures a user's own `idx` local; written after a `filter`, `idx` counts the filtered stream. A `min`/`max` key mentioning `idx` is left unrecognized (the best element's key is re-derived from the element at compare time, and its enumerate count is not recoverable from it — rejecting beats a silently wrong comparison). **Terminals**: | terminal | result | |---|---| | `count()` | Int | | `sum()` / `sum(seed)` | `sum()` is Int; `sum(seed)` seeds the accumulator and the seed **is** its typed zero — `sum(0.0)` sums Float elements, `sum(100)` starts an Int sum at 100 (2026-08-11) | | `into(target)` | pushes each surviving (mapped) element | | `any(pred?)` | Bool; **empty selection ⇒ `false`** (vacuous) | | `all(pred)` | Bool; **empty selection ⇒ `true`** (vacuous) | | `first()` | the first surviving element — **fallible** on empty | | `find(pred?)` | `find(p)` ≡ `filter(p).first()` | | `min(key?)` / `max(key?)` | the **element** with the least/greatest key (`min_by_key` shape; bare form compares elements) — fallible on empty | | `each { … }` | run the block per surviving element | | `sort_into(target, cmp?)` | push survivors into the caller's vec, then reorder it in place — `sort()` (primitive ordering) or `sort_by(cmp)` with a named comparator (2026-08-11) | | `reverse_into(target)` | push survivors, then swap ends inward on the caller's vec (2026-08-11) | | `group_count_into(target, key?)` | one hashmap `bump(key)` per survivor — increment-or-init tallying into the caller's `@form(hashmap)`; the bare form keys on the element itself (2026-08-11) | **Chain sources.** A chain rewrites to a loop that fetches each element through the source's `get(Int) -> T fallible(IndexError)`, so answering that name is exactly what makes a form chainable: | source | accessor | |---|---| | `@form(vec)` | its synthesized `get` | | `@form(hashmap)` via `.entries` | `entry_at`, same shape | | `[T; N]` | `get`, over all `N` slots | | `bounded[T; N]` | `get`, over the **live** slots (not capacity) | The two type-level collections are a deliberate exception to the types-have-no-methods axiom, and the only one: their operations are otherwise grammar intrinsics (`at(f, i)`, `count(f)`), and `at` remains the idiomatic spelling for a direct index. `get` exists so the chain source protocol is uniform, and is identical to `at` in signature and semantics — on `bounded` it *is* `at`. The rewrite happens post-parse, before typecheck, so it cannot dispatch on the source's type; a form that does not answer `get` fails with an ordinary no-method diagnostic at the chain's site. **Fallible terminals ride the source's own `get`.** `first` / `find` / `min` / `max` lower to an index search whose value is `src.get(idx)` — an empty result is the ordinary `IndexError`, so `or raise` / `or fallback` / `or handler(err)` all apply with no new error machinery: ```hale,fragment let bob = users.find(it.id == want) or User { id: 0, age: 0, name: "?" }; let oldest = users.max(it.age) or raise; ``` For the same reason they do **not** compose with `map` (the `or` fallback would need the mapped type while `get` yields the source element): project *after* the find, on the returned element. **`each` takes a block, not a lambda.** The block is spliced as the fused loop's body with `it` bound — it executes in the enclosing scope, so there are no capture semantics to define, and `break` / `continue` act on the fused loop (`continue` advances to the next element): ```hale,fragment users.filter(it.age >= 18).each { total = total + it.age; } ``` **Recognition is conservative** so user facade methods sharing these names never get hijacked: with at least one stage, every terminal is recognized (no ordinary method chain looks like that); stage-less, a terminal is recognized only when its argument mentions `it` — which is unbound outside a chain, so no valid ordinary call can look like that — or when it is `each` with a block. Bare `xs.sum()` / `xs.first()` therefore stay ordinary method calls (stage-less first is just `xs.get(0) or …`; a stage-less sum can be written `xs.map(it).sum()`). The whole-set terminals (`sort_into` / `reverse_into` / `group_count_into`) are recognized stage-less too: `xs.sort_into(sorted)` is their natural spelling, and the compound `*_into` names belong to this vocabulary — unlike bare `into`, no plausible user facade carries them (2026-08-11). Every stage and terminal above is recognized only **after a `.`**, so a free `fn map(...)` / `fn first(...)` / `fn count(...)` is admissible and is called as written. The exceptions are the names the compiler claims at a BARE call site — `sum`, `prod`, `min`, `max` among them — which a free `fn` may not take; see [`tokens.md` § Built-in identifiers](tokens.md) for the full set, the rule and the diagnostic. A locus method may still carry any of them (2026-09-20, GH #863 / GH #880). ## Bus subscription dispatch A `bus { subscribe SUBJECT as HANDLER of type T; }` declaration wires: 1. The runtime registers HANDLER as the receiver for SUBJECT on the bound transport. 2. Inbound messages on SUBJECT are decoded as `T`, then HANDLER(payload) is invoked. 3. HANDLER runs in the locus's scheduler context. It may call `publish(SUBJECT, msg)` to emit responses (subject to `bus { publish ... ; }` declarations). 4. HANDLER yields naturally on completion; scheduler returns to other loci. If HANDLER panics: - The current message is dropped. - `on_failure(self, BusHandlerFailure { subject, payload, err })` invoked on the parent if any. - The subscription itself is *not* removed; future messages continue to dispatch. ### Payload type — what a subject may carry A subject's payload type — `T` in `of type T`, or a `topic`'s `payload:` — must be one of exactly three things: - a user `type` (`type T { ... }`); - an `enum` with at least one variant that carries a payload, which travels as that enum's storage struct; - `BytesView` (`std::bytes::BytesView`), the raw-frame path: the payload is not typed at all, and the handler receives a bounded view over each record. This is how a foreign writer's ring is consumed. A delivery is a *serialized struct*, so the payload needs a field layout: `of type Int` has none, and neither does a tuple, an array, or a no-payload enum. An `of type` clause naming one is rejected at typecheck, at the clause (rule 18 above); a `topic` whose `payload:` names one is refused during lowering. **Within** a payload, the wire format supports primitives (`Int`, `Float`, `Bool`, `Decimal`, `Duration`, `Time`, `String`), `Bytes`, and **nested user struct types** recursively composed. A bus payload may carry a struct whose fields are primitives, Strings, Bytes, or other nested structs, at any depth. Serialize walks the field tree in declaration order; deserialize allocates each nested struct in the lazy global payload arena and recurses. Arrays, tuples, and enums as bus payload *fields* are post-v1 polish. **Size.** A delivery is as large as its fields make it, and local delivery is bounded only by the payload arena (`spec/memory.md`): the synthesized serializer honours the cap it is handed — it writes nothing past it and answers the size the payload needs — and the runtime serializes a payload past its 64 KiB wire buffer again into a buffer of that size (GH #1155). A remote transport's frame is 64 KiB (`LOTUS_PAYLOAD_MAX`): a payload past it reaches every local subscriber and **is dropped, uncorrected, for every remote one** — there is no fallible-publish path or counter a program can observe this through yet; the runtime says so on stderr once per subject, for an operator reading logs, not for the program. A publisher that must reach a remote peer keeps every payload on that subject under 64 KiB on the wire until this gets a program-visible signal. Before this fix the serializer overran the buffer, and a reply carrying a `String` past 64 KiB died in it. ## Closure-test evaluation For each `closure NAME { LEFT ~~ RIGHT within TOL; epoch ... }`: 1. At each declared epoch boundary, runtime evaluates LEFT and RIGHT in the locus's scope. 2. Computes `|LEFT - RIGHT|`. 3. If `<= TOL`: closure passes silently. 4. If `> TOL`: flips the locus's "exploded" flag; emits a typed `ClosureViolation` event. Epoch boundaries: - `epoch dissolve` (default): fires once, as part of dissolve sequence. - `epoch tick`: fires on each runtime tick (configurable cadence). - `epoch duration(d)`: fires every `d` of monotonic time. - `epoch birth`: fires once, after birth completes. - `epoch explicit`: fires only when the locus's own code calls `check_closures();` — a statement taking no argument, which evaluates every `epoch explicit` closure of `self` at once and routes a violation to the parent's `on_failure` as the other epochs do (a no-op on a locus with none). There is no per-closure form: `epoch_advance(NAME)`, which this section once named, was never shipped. - `epoch inline` (F.27, v1.x-VIOLATE): never fires automatically; fires only when user code executes `violate NAME;`. The closure body has no assertion (no LEFT / RIGHT / TOL to evaluate). See "Inline closure violation" below. ### Per-epoch field reset (F.34, v1.x-WINDOWED) A closure paired with `epoch duration(N)` may declare `resets_per_epoch(field1, field2, ...);`. The named locus fields are zeroed by the runtime **after** the assertion fires at each duration boundary. Ordering matters: the assertion sees the window's accumulated value; the reset prepares the next window. ```hale closure low_corrupt_rate { self.corrupt_per_min ~~ 0 within 10; epoch duration(1min); resets_per_epoch(corrupt_per_min); } ``` Restrictions enforced at typecheck: - The closure MUST declare `epoch duration(N)`. The clause is rejected on `tick` / `birth` / `dissolve` / `inline` / `explicit` — other epochs either don't recur or recur too fast for a rate-budget framing. - Each named field MUST be declared on the enclosing locus and MUST have numeric type (`Int`, `Uint`, `Float`, `Decimal`). Booleans, strings, and structs are rejected — zero is not a meaningful reset value for them. User code increments / decrements the field as the window accumulates. The closure assertion is the structural contract (rate bounded by a per-window budget); `resets_per_epoch` keeps the substrate honest about which window the counter belongs to without forcing the user to maintain a `last_reset_at` field or a parallel pre-fire hook. ### Recovery events (`persists_through`, `resets_on`) A closure's accumulators (`sum`, `count`, `mean`) are zeroed when its locus goes through a recovery event, unless the closure persists through that event: ```hale,fragment closure within_band { sum(self.delta) ~~ 0 within 100; epoch tick; persists_through(quarantine); } ``` The recovery events are a closed alphabet, the recovery statements a parent applies to a failed child: `restart`, `restart_in_place` and `quarantine` (a spent `restart(c) for N` bound quarantines, and is the `quarantine` event). The check holds each name a clause writes to it: - A name outside the alphabet is an error at the name, which names the alphabet; a misspelling one edit away from an event suggests it (`verification.md` § Structural & design rules, *Recovery event alphabet*). - `dissolve` in `persists_through(...)` is an error: an accumulator does not outlive its locus's dissolve, so the clause can mean nothing (*Persisting through dissolve*). `resets_on(...)` states the default. An accumulator resets on every recovery event its closure does not persist through, whether or not `resets_on` names it, so `resets_on(E)` adds nothing at run time; it is a statement the check holds to the alphabet like any other, and an event a closure names in both clauses is an error at the `resets_on` name, with the `persists_through` name as its witness (*Contradicting recovery clauses*). Two warnings say when a clause cannot take effect: - In a closed world (the program has an entry), an event a closure of the program's own seed names that no recovery applies to its locus: no parent's `on_failure` for the locus's type performs it, and no recovery statement outside a handler performs it on a child of that type. A statement's child is the type the checker gives its receiver, whatever the receiver is (a param, a local, a field of another value, a call's result); in a generic body, the type each specialization gives it. The witness lists each handler and statement that names the locus, with the events it applies. A library checked alone has no parents, so it is not judged; nor is an imported locus, nor an event some recovery applies to a child the check cannot name (a generic supervisor's handler's type parameter, a receiver of a type parameter in a generic body no specialization binds) (*Unreached recovery event*). - `persists_through(...)` on a closure whose assertion accumulates nothing keeps nothing (*Persistence with no accumulator*). ## Inline closure violation (F.27, v1.x-VIOLATE.) Inline closures provide a pull-only structural-failure channel for locus method bodies that catch a value error and want to escalate it. The declaration carries no assertion; the optional `captures:` clause names locus fields whose values are snapshotted into the ClosureViolation payload at fire time. ```hale closure fatal_io { captures: last_error; epoch inline; } ``` `violate NAME;` (optionally `violate NAME with EXPR;`) fires the closure synchronously at the call site: 1. Runtime synthesizes a `ClosureViolation` value carrying: - `locus`, `closure` — string names of the failing locus and the inline closure (always present). - The captured fields named in the closure's `captures:` clause are NOT materialized on the `ClosureViolation` struct, which has a fixed shape. The access pattern for captured state is to read the frozen child through the child handle in `on_failure(c, err)` — see "Reading the audit state" below. - If `with EXPR` was given, EXPR is evaluated for side effects (and to detect typecheck errors on the payload type) but no `payload` field is materialized on the `ClosureViolation`. - The assertion-shape fields (`left`, `right`, `tolerance`) are not fields of the struct, and `diff` is 0 for an inline violation. 2. The locus's exploded flag is set (same as the auto-epoch path; downstream observers can't tell from the flag whether the fire was auto-epoch or inline). 3. The synthetic `__drain_requested` field on the locus is set. Readable from user code as `self.draining`. 4. The parent's `on_failure(child, ClosureViolation { ... })` handler runs — same routing as for auto-epoch closure violations. 5. The method exits. In a `fallible(ClosureViolation)` fn the violation is the call's failure: the caller's `or` disposition runs, with `err` bound to the `ClosureViolation`, and no value is produced. In a fn returning nothing the method exits as a `return` does and the caller continues. The owner's `on_failure` (step 4) runs first, posted to the owner's domain and awaited when the pools differ (decision L0-1); then the caller's `or` disposition runs. Either way, loci the method `let`-bound dissolve and its per-call scratch is freed (GH #1036; `violate` used to skip both). ### A value-returning method that may violate is fallible (F.42.) A value-returning fn or locus method whose body may violate is declared `fallible(ClosureViolation)`, so that its callers say what happens when the locus fails instead of reading a value nothing computed: ```hale,fragment locus Picker { params { picked: Int = 0; } closure exhausted { epoch inline; } fn take() -> Int fallible(ClosureViolation) { if self.picked >= 3 { violate exhausted; } self.picked = self.picked + 1; return self.picked; } } // in the owner, which handles Picker's failures: let n = self.p.take() or 7; // 7 once `take` has violated ``` A fn **may violate** when its own body holds a `violate` statement, or when it calls a value-returning fn that may violate and is not `fallible` (a `fallible` callee is answered at its call by the bare-call law, below, and once this law holds no other kind is left). A violation in a method that returns nothing is not the caller's failure: calling one does not make the caller one that may violate. Lifecycle bodies, bus handlers (the fns a `subscribe … as` names) and `fn main` are exempt: the runtime is their caller. - A value-returning fn that may violate and is not `fallible` is a type error: "\`Picker.take\` returns a value and may violate (\`violate exhausted\`): declare it \`fallible(ClosureViolation)\`; its callers then say what happens when the locus fails". The parenthesis names the path: the closure a `violate` in the body names, or "through \`helper\`" for a call; the diagnostic's notes give each location. - A fn that may violate and is declared `fallible(E)` with any other `E` is a type error: a fn has one error type, and the violation record is the one a violator carries. - A fn returning nothing that may violate and is not `fallible` is a warning with the law's wording and "This becomes a law in a later release". Nothing else changes for it. **A stated gap: a method serving a perspective.** A locus method that implements a perspective fn (`serves`, matched by name as conformance matches it) is exempt from the law and the lint, and a call to it does not make its caller one that may violate. A perspective call cannot carry the failure yet: conformance holds the method to the perspective fn's fallibility (§ "Perspectives"), and the build refuses a `fallible` perspective call, so no spelling of such a method both checks and runs. Until that call lowers, the method keeps today's behaviour, and a warning names the shape: "\`RouterV1.route\` serves \`Router.route\` and may violate (\`violate c\`); a perspective call cannot carry the failure yet, so the method keeps today's behaviour (the caller of a violated perspective call must not read the value)". The value a violated call returns when the owner's `on_failure` absorbs the violation is undefined, as it was before F.42. The call to a `fallible(ClosureViolation)` method follows the bare-call law unchanged: an `or` disposition is required, and the `err` that `or handler(err)`, `or { … }` and `or fail` see is the `ClosureViolation` the owner's `on_failure` received. `or raise` in a fn that is not `fallible` ends the process (§ "Process exit"), which is what a caller says when the activation should not continue past the locus's failure. The stdlib's two such methods are `BytesBuilder.snapshot` and `finish` (a failed allocation violates `alloc_failed`, F.27). ### Reading the audit state The portable access pattern in `on_failure(c, err)` is to read the child's frozen locus state through the child handle: ```hale on_failure(c: Child, err: ClosureViolation) { log::error(err.closure, " ", c.last_error, " fd=", c.conn_fd); } ``` `violate` is divergent — the method body's remaining statements do not execute, so the child's locus state is frozen at the violate moment. `c.last_error` reads exactly the value the violate site observed. The `ClosureViolation` value carries `err.locus` and `err.closure` (Strings) and `err.diff` (an Int: `left - right` for an Int or Duration assertion, 0 otherwise); it does not materialize the captured fields. Source that reads `err.last_error` is a type error at the read ("no field `last_error` on `ClosureViolation`"): read captured state through the child handle (`c.last_error`) instead. (Until F.40 phase 4, S8 the checker left `ClosureViolation` unresolved, so such a read passed `hale check` and failed at build without a location.) The `violate` statement is divergent: the typechecker treats it as `Never`, the same as `fail` in fallible fn bodies and `bubble` in `on_failure`. No statement after `violate` in the same block is reachable; the typechecker does not require a trailing `return` on a `violate` branch. ### `birth_check` synthesis hook (F.27 v2, 2026-05-20.) A declarative form for construction-time invariants: ```hale locus L { params { x: Int = 0; } closure invariant_broken { captures: x; epoch inline; } birth() { /* set up state */ } birth_check { self.x < 0 } -> violate invariant_broken; } ``` After `birth()` body completes and birth-epoch closures have fired — at the first birth and at every restart's re-birth (§ "`restart(child)`") — each declared `birth_check` clause's `cond` expression is evaluated. A `true` result fires the named closure with the locus's fully-constructed state — every field reads its declared post-birth value, so the on_failure handler's capture-snapshot sees coherent state. Multiple clauses evaluate in declaration order; the first to fire short-circuits the rest. Why a separate clause vs. calling `violate NAME;` inside the birth body: a violate mid-birth leaves the locus partially constructed (some fields set, others at defaults) when the on_failure handler reads captures. `birth_check` runs the body to completion before the check fires, so the post-birth invariant of "every field has its declared value" holds at violation time. The runtime-routing semantics are otherwise identical to a regular `violate` (drain_requested set, parent on_failure absorbs or process exits non-zero with diagnostic). The codegen emits the check + violate routing INLINE at the instantiation site, branching to a continuation block on absorbed violations rather than returning from the caller's fn — the absorbed-then- continue contract matches what users expect when wrapping the instantiation in a parent that handles the failure. The check expression is read-only against `self.X` fields; the closure name must resolve to a declared epoch-inline closure on the same locus, same constraint as a regular violate. ### `self.draining` While the locus is draining — after `violate` on it, or once the process drains on SIGINT / SIGTERM (§ "Drain cascade (whole-process)") — the synthetic `self.draining` field reads `true` from any locus method body. The canonical uses are a `run()` loop that ends on it (`while !self.draining { … }`) and suppressing downstream sends once the locus has escalated: ```hale,fragment fn on_tick(t: Tick) { if self.draining { return; } // this locus violated earlier Sampled <- self.count; } ``` An error-check method that substitutes a value or escalates is `fallible(ClosureViolation)` (§ "A value-returning method that may violate is fallible"), and its caller says what an escalation means at the call: `let r = expr or (self.handle_io(err) or { return; });`. `self.draining` is one of the three synthetic members exposed by name to user code (with `self.children` and `self.k_max`); its backing field `__drain_requested` is internal-only. All three names are reserved — a locus may not declare a member that shadows one; see `types.md` § Reserved member names. ### Rejection contexts `violate` is rejected at typecheck in: - **Free fn bodies.** No `self` to resolve the closure name against. A free fn helper called from a locus method body cannot violate transitively: `violate` is lexically scoped to the locus method body it appears in. - **`on_failure` body.** Use `bubble(err)` — `on_failure` is the parent-side handler for child failures; re-firing a self- closure from there mixes the two channels. Allowed everywhere else that has `self`: named locus method bodies, bus-handler methods (`subscribe X as foo` → `fn foo`), `run()`, lifecycle methods (`birth()`, `dissolve()`, `drain()`), mode-method bodies. The same body shape gets the same primitive. ## Perspectives: contract, `serves`, and the slot (Phase 2a) A `perspective P { ... }` is a **contract** — a set of bodyless `fn` signatures that form a stable ABI boundary — plus a program-global, live-rebindable **slot** that holders dispatch through. Phase 2a ships the contract, conformance, the slot type, and dispatch; the live swap (`reperspective`) followed in Phase 2b + 3 (see "The live swap" below). ```hale perspective Router { fn route(code: Int) -> Int; // bodyless contract signature fn health() -> Int; } locus RouterV1 : serves Router { // declares conformance fn route(code: Int) -> Int { return code + 100; } fn health() -> Int { return 1; } } locus Gateway { params { router: perspective(Router) = RouterV1 { }; } // holds the slot fn handle(c: Int) -> Int { return self.router.route(c); } // calls through it } ``` **`serves` conformance (error).** A `locus L : serves P` must provide every contract method P declares — matching arity, param types, return type and fallibility — **and** (Phase 2c) every bus edge P's contract declares: a `bus { subscribe/publish ... }` block in the perspective is part of the ABI, so a serving impl must subscribe / publish each named subject. A missing or mismatched method, a missing bus edge, or `serves` naming an unknown / non-perspective symbol, is a typecheck error. Live-swapping a bus-backed perspective (re-pointing its subscriptions) is a follow-up; until then `reperspective` on such a perspective is rejected. This is the perspective analog of interface structural satisfaction (and reuses its shape). The synthesized `is_stable` (from `stable_when`) is not a contract method the impl must provide. Fallibility matches both ways, unlike an interface's (an infallible method may satisfy a `fallible` interface method): a perspective call lowers the perspective fn's signature against the impl's body, so a `fallible` impl behind an infallible perspective fn would hand the caller a failure path the call never reads, and the reverse a failure the impl never sets. Either is a typecheck error naming both: "\`RouterV1.route\` is \`fallible(ClosureViolation)\` but \`Router.route\` is not: a method serving a perspective matches its fallibility". A `fallible` perspective fn is refused at the call by the build ("fallible method call on non-locus value of type Perspective(\"Router\")"): a perspective call cannot carry a failure yet (F.42). **Qualified contract names (2026-09-19, GH #724).** `P` may be an **imported** perspective, named through its import alias: `locus X : serves lib::Routing`, `perspective(lib::Routing)` and `reperspective self.r as lib::Double` all take a qualified path. The path resolves through the same cross-seed rename table as a qualified type or an `alias::fn()` call, so conformance is checked against the imported contract and every seed's holders reach the one program-global slot. A path that resolves to nothing — a nested alias, a typo — is a located typecheck error at the path, reported with the alias as written. The one exception is a tool that holds a seed *without* its imports (a partial program a harness assembles; `hale lsp` is not one, it loads the seed and every import as `hale check

` does): there a path behind an unresolved alias is opaque, exactly as `lib::Grid` or `lib::f()` already is, and the contract is checked when the whole seed is. **The slot type `perspective(P)`.** A holder programs against `perspective(P)`, never a concrete impl. It is a handle: at the LLVM level a single pointer stored in the holder's field, but dispatch does **not** read that field. **One global slot (1-1, not 1-N).** Each perspective P has exactly one program-global slot — a `{ data, vtable }` cell (the interface fat-pointer layout) named `__persp.

`. Every holder of `perspective(P)` funnels through it: `self.router.route(x)` loads the global slot, indexes the vtable at the contract-method position, and indirect-calls with `data` (the current impl's self) as the implicit receiver. Because the interop is closed- world and 1-1, the compiler sees every call site and there is exactly one target — which is what makes the Phase-2b swap a single atomic store that redirects the whole program. (Contrast `interface`, which is a *per-value* fat pointer, many impls, no global slot.) **Designation.** A `perspective(P) = Impl { }` field default *designates* the slot: it instantiates `Impl` (an owned child of the holder, torn down normally) and stores `{ impl_self, vtable(Impl, P) }` into the global slot. The field itself stores the impl's self-pointer for ownership; the slot holds the same pointer plus the vtable for dispatch. A containing constructor may designate instead (2026-09-04, GH #525): `App { gw: Gateway { router: RouterV2 { } } }` overrides the holder's default with `RouterV2`, which is instantiated as the holder's owned child and stored into the slot exactly as the default would have been. The override must name a locus that `serves P`; one that does not is a typecheck error naming the missing `serves` clause. Because the slot is program-global and 1-1, an override designates the *program's* impl, not a per-instance one — two holders constructed with different impls race on one slot, last designation wins, the same as two holders with different defaults. **Cost.** Steady state is one load + one predicted indirect call per call into a perspective — near-direct. The mechanism is Linux/native-agnostic (no new runtime dependency); a program that declares no perspectives pays nothing. ### The live swap: `reperspective` (Phase 2b + 3) `reperspective self. as ;` is the live redeploy. It re-points the perspective's global slot — identified by the `self`-field's `perspective(P)` type — at `Impl` (which must `serve P`). Because every holder funnels through the one slot, the swap redirects **every** call site at once: the same `self.router.route(...)` resolves to the new impl immediately after. - **State-preserving (Phase 3).** The slot is `{ data, vtable }`: `data` is the running state (an arena-backed struct), `vtable` is the code. They are already separate, so the swap is a single store of the new impl's vtable into the slot — `data` is untouched and the new impl's methods continue on the **same live state** (the note's layout-identity "zero migration"). No re-instantiation, no birth defaults, nothing to tear down. - **Footprint identity (soundness).** The vtable swap is layout-safe only if the new impl's field offsets match the retained state, so the typechecker requires **every** impl of a perspective to share one footprint (same params, by name and type, in order). A footprint *change* is the `migrate` case — rejected for now with an actionable diagnostic rather than silently reinterpreting bytes. - **Rebind authority.** The statement runs on the locus that owns the slot (`self.`), never a mere caller — the ownership tree is the redeploy authority. The typechecker requires the field to be a `perspective(P)` param of the current locus and the new impl to `serve P`. - **Bus edges swap too (Phase 2c-runtime).** When the perspective declares a bus surface, the swap also re-points its subscriptions: it tombstones the current impl's registrations on the shared slot `data` (`lotus_bus_quarantine_self`) and re-registers the new impl's handlers on that same `data`. A message published after the swap dispatches to the new handler, operating on the carried state. Cooperative dispatch is deferred (a publish captures the handler current at that moment; handlers run at drain), so the swap boundary is respected per message. Perspective impls are designated (never `placement`-pinned), so they are cooperative — the re-registration routes through the global queue, no mailbox hand-off. (Cost: tombstoned entries are skipped, not compacted — a bounded per-swap cost.) ## Perspective hot-load > **Status:** Phase 2b + 3 + 2c ship the `reperspective` swap > (above): the atomic slot re-point, state-preserving across impls > of one footprint, re-pointing sync dispatch AND bus subscriptions. > A footprint-changing `migrate`, and the bus-arrival / decode / > `stable_when` / drain flow below (transport-driven redeploy from > the wire), remain the aspirational path. For each `perspective P { ... }` instance currently active: 1. New perspective arrives via bus (or explicit `load_perspective(P, bytes)` call). 2. Runtime decodes against P's compiled-in schema. Type- mismatch → reject; emit `PerspectiveDecodeError`. 3. Validates `stable_when` predicate. If false → reject; emit `PerspectiveNotStable`. 4. Atomically swaps the active perspective: - Pause all readers (readers within the locus see the pre-swap perspective). - Replace. - Resume. 5. Emit `PerspectiveLoaded` event. Old perspective is freed only after the swap completes; no torn read possible. ## Recovery primitives ### `restart(child)` / `restart(child) for N` Restarts the child on the same instance — its arena, its subscriptions and its param values as they stand (a restart never re-evaluates a default): 1. The failure's drain request is lowered: the child is live again. 2. `birth()` runs again, then the birth-epoch closures, then the `birth_check` clauses — every birth is checked, the first and each restart's alike. 3. `run()` runs again. When it takes effect depends on where the failure came from. A birth-epoch closure's failure re-runs birth right after the handler returns, before `run()` has started. So does any other failure of the birth — a `violate` in `birth()`, a failed `birth_check` — whether its handler ran in place or was held: the decision is read against the restart count as the birth began, so a handler that had already returned by the time `birth()` did is not missed, and a restart whose birth fails again — a `violate` in its `birth()`, a birth-epoch closure, a `birth_check` it no longer passes — is decided the same way before `run()` starts: restarted again within the bound, quarantined at it. (A restart used to skip the `birth_check` clauses, so a re-birth that broke its invariant ran on.) The restart runs on the child's own placement — its pinned thread, its pool's worker — as its first `birth()` and `run()` did. A failure raised while `run()` executes — a `violate` in it, or for a pinned child the tick and duration closures its thread checks after it — restarts once `run()` has returned, on the thread that ran it (a pinned child's own thread). A failure held while the parent was still setting params takes effect when its handler returns (§ "on_failure(c, err)"). A restart is not taken while the process is draining: the drain ends the child anyway. **The retry bound.** `restart(c) for N` gives this child at most `N` restarts. The count is per child instance and cumulative over its lifetime, and it is compared *before* the restart, so `for N` admits exactly `N` restarts and the failure that follows the last one **quarantines** the child instead — the supervisor tried `N` times and is done with it. `for 0` is meaningful and quarantines on the first failure without restarting. Exhaustion quarantines rather than falling through: a bounded supervisor has stated when to stop, and stopping means the child does not run — on every placement (a pinned child's thread used to start its `run()` once anyway). This is the difference from an unbounded `restart(c)`, which stops re-running at the default cap of 2 but leaves the child live. `N` is an expression, evaluated at the recovery site. Without the modifier the bound is the default 2, so an unbounded `restart(c)` is unchanged. The bound is recorded in the topology artifact as `retry_bound` on the supervision row (schema 1.10), so the declared policy and the observed restarts are comparable. ### `restart_in_place(child)` The same as `restart(child)`, except that before `birth()` runs again every param is put back to the value **this instance was built with**: the value its literal gave it, or its default as that default evaluated when the instance was built. Params are settled once, from the literal; a restart never re-evaluates a default. So `Worker { tag: "a" }` restarts with `tag` `"a"`, whatever its declared default says, and a default that builds a locus or reads the clock is not run again. A param that holds a locus (or an interface or perspective handle) keeps its child: a restart re-runs this instance, not its children's construction. The retry bound and the default cap are shared with `restart`. Useful for transient failures that don't invalidate the locus's structural commitments (e.g., the locus's k_max is fine; just had a bad message). ### `quarantine(child) [for d]` 1. Pause child (no new messages dispatched, no new accepts permitted, no run scheduling). 2. Preserve arena and state. 3. If `for d` clause given, automatically restart after `d`. 4. Otherwise wait until parent explicitly resolves. **Not lowered:** the `for d` duration clause. `quarantine(c)` itself ships; `quarantine(c) for d` is refused by codegen. Note that this `for` is a *duration* before an automatic restart — a different modifier from `restart(c) for N`, which is a retry count. Also not lowered: the `until` modifier on any recovery op. ### `reorganize(child, ...)` Reserved syntax. Semantics TBD; expected: relocate child's sub-children to a sibling. Not in v0. ### `bubble(err)` Re-raise the error to self's parent. Equivalent to: ``` on_failure(c, err) { bubble(err); } // for self's failures ``` Fully traverses the lotus tower upward until a handler absorbs. ### `dissolve(child)` Force-dissolve child immediately. Skips drain; closure tests at non-dissolve epochs do not fire on this path. Used for forced-shutdown scenarios. ## Drain cascade (whole-process) SIGINT or SIGTERM: 1. The runtime raises the process drain. From then on every locus's `self.draining` reads `true`, on every thread. 2. Timed waits **already in progress** return early so a loop reaches its check: such a `std::time::sleep` returns at its next 100 ms slice (at once, on an `async_io` pool), and a deadline-bounded park on an `async_io` pool — a `recv` with a timeout — reports its timeout. A wait begun after the drain — a `drain()` or `dissolve()` body pacing a flush — runs its full length. A receive blocked in the kernel on a pinned or classic thread returns at its own timeout, not earlier; an untimed wait (a plain `accept`) is not interrupted. 3. Each `run()` that watches `self.draining` returns; one that does not keeps running. 4. Leaves dissolve first, each parent after its children, the root last — the ordinary end-of-program teardown, which also runs the runtime's own (schedulers, bus router, allocator, the atexit hooks). 5. Process exits 0. The drain has a **grace period**: if the process has not exited 5 s after the signal (`LOTUS_DRAIN_GRACE_MS` sets it), the runtime prints one line naming the signal — and what the drain was still waiting on: each cooperative pool with its mode, whether a worker is mid-iteration and in which locus, and how many cells queue behind it, then the live loci that read `draining`, by name — restores the signal's default action and re-raises it — the process dies BY the signal, as it would with no runtime at all (a waiting parent sees it killed by that signal; a shell reports `128 + signal`) — so a `run()` that never reads `self.draining` cannot keep a stopped program alive. A **second** SIGINT / SIGTERM ends the process the same way at once. The handling is installed only in a program that reads `draining` somewhere. A program that cannot observe a drain keeps the default action and ends at the signal with `128 + signal`, as if the runtime were not there. **What can answer a drain is a live instance** of a locus whose own code reads `draining` (GH #1077): the runtime counts each such instance from its instantiation to its teardown. A signal that finds none live — the read sits in an imported package the program never builds, or every instance that could have answered has already ended — takes the signal's default action at once, exactly as a program without the read does, instead of waiting out the grace with nothing to drain. A `draining` read outside any locus body (a free function handed a locus) cannot be counted, so a program with one drains, and waits its grace, on every signal. The signal is caught, never blocked, so a subprocess the program spawns inherits the default disposition. (GH #1039: before 2026-09-24 the runtime caught neither signal, so every program ended at the signal and a `while !self.draining` loop never saw a drain.) **When `main`'s `run()` ends first.** A `main` locus whose `run()` returns while a child's `run()` still loops keeps the process alive until that `run()` returns — when the child is pinned or on a classic cooperative pool (`main`'s own included): a pinned receive loop, a server whose `main` has no `run()` at all. Ending such a program is what SIGINT / SIGTERM are for; the drain reaches the child the same way whether `main`'s `run()` is still running or has returned. An **`async_io`** child is the exception: its `run()` parked on a timer or an fd when `main`'s `run()` ends does not keep the process alive — the pool shuts down, the parked coroutine is abandoned (`runtime.md` § the wakeable park) without seeing a drain, and the child dissolves with the rest of the tree. **Under `hale run`.** `hale run` stands aside for the program's drain: it ignores SIGINT (a terminal's Ctrl-C reaches the program through the process group) and forwards a SIGTERM sent to its own pid, then reports how the program ended (`projects.md` § "What `hale run` starts, `hale run` ends"). ## Closure-failure cascade A closure violation at any epoch: 1. Runtime emits `ClosureViolation` event. 2. Locus's exploded flag is set. 3. Subsequent epochs may also fail; flag persists. 4. At the failing epoch, parent's `on_failure(self, ClosureViolation { ... })` invoked; held while the parent's params are open (`runtime.md` § "Lifecycle obligations", line 9). 5. Parent's policy decides: absorb, recover, bubble. 6. If bubbled, propagates to grandparent; recursively until absorbed or reaching root (process exit). `epoch inline` closures (F.27) take the same cascade path with one addition: at step 2 they also set `__drain_requested`, so the locus enters drain at the next cooperative yield rather than continuing on its current epoch. The drain initiation is the only divergence from the auto-epoch cascade; routing to parent's `on_failure` at step 4 is identical. ## Scheduler dispatch Per `runtime.md`: multi-scheduler cooperative. 1. N schedulers, one per CPU core (configurable). 2. Each scheduler holds a queue of runnable loci. 3. Scheduler picks a locus, runs it until cooperative yield. 4. At yield, scheduler picks next. 5. Cross-scheduler communication via bus (typed messages). 6. Loci may be migrated between schedulers transparently for load balancing. ## Failure-traversal flow Failures flow upward: 1. Child failure → child's parent's `on_failure`. 2. Parent's handler decides absorb / restart / bubble / quarantine / dissolve. 3. If bubble, → grandparent's `on_failure`. 4. If reaches runtime root, process exits with structured error report. Failures never flow laterally (sibling-to-sibling) — the framework's vertical-only-flow expressed at the runtime layer. ## Fallible call semantics (v1.x-FORM-1; PR6 reframe) Hale carries two **orthogonal** failure channels: - **Closure-violation channel** — structural failure of a locus's closure (its assertion / invariant) fires `Signal::Bubble(ClosureViolation)` and routes through the existing `bubble` / `on_failure` machinery. See **F.9**. This is the *substrate-facing* channel: it expresses "a locus's promised invariant broke" and propagates vertically through the locus tower per the failure- propagation-upward mechanic. - **Value-error channel** — value-level `fallible(T)` returns are an *addressing protocol* between immediate caller and fallible callee. They don't constitute a separate runtime mechanism at intermediate frames; they propagate by sret + path-indicator through the static call stack, addressed at each level by a required `or` clause. This is the *application-facing* channel: it expresses "this call-by-call computation might fail; address it inline." The two channels meet at exactly one place: the implicit main locus's root boundary (see "Process exit" below). Everywhere else, the channels are independent. See `spec/design-rationale.md`. ### Where each channel lives (declaration sites) The two channels are realized through different declaration sites. The mapping is canonical, not advisory: - **`fallible(E)` may be declared on:** - **Free fns** — pure application-layer computations whose failure shape matters call-by-call. - **Stdlib-synthesized methods on `@form(...)` containers** (`@form(vec).get` / `.pop`, `@form(hashmap).get` / `.remove` / `.key_at` / `.entry_at`, `@form(ring_buffer).pop`). Application-layer storage substrate: the container's role is application-layer data, not locus-structural participation in the substrate's lifecycle. - **User-declared `fn` member fns on a locus** (open-question #24, shipped 2026-05-25 in two phases: MVP at `d565d6f` with value-only payloads, v0.2 at `98910b9` extending to heap-bearing payloads via the TLS caller-arena snapshot non-fallible heap-returning methods already use). The narrowed rule recognises that a `fn` member fn is *not* substrate-orchestrated — its callers hold a frame, can address the error channel inline, and the value-error path doesn't conflict with the closure-violation channel. - **Interface method signatures** (GH #732). A call through the interface carries the channel exactly as a direct call on a fallible method does: every `or` form applies, and a call that does not address the error is rejected as any unaddressed fallible call is. See `spec/types.md` § "Interface types (F.20)" for which methods satisfy one. - **`fallible(E)` may NOT be declared on:** - **Lifecycle methods** (`birth` / `run` / `accept` / `drain` / `dissolve` / `on_failure`). The substrate orchestrates these — bus dispatch invokes the handler, parent invokes `accept`, runtime invokes `run` — and there's no caller frame in user code to address a value error. Physically rejected at the AST level (`LifecycleDecl` carries no `fallible` field). - **Mode methods** (`bulk` / `harmonic` / `resolution`). Same shape: AST doesn't carry the field. - **Closure assertions.** Substrate evaluates the assertion at the epoch boundary; there's no caller in the expression's frame to address a value error. Closures route failure via their own structural channel (assertion firing → `on_failure`), not a value channel. - **Bus-subscribed handlers.** Verified at the `subscribe ... as handler` site rather than at the fn decl: a fn that's `fallible(E)` by declaration may not also be referenced by a `subscribe` entry. Bus dispatch has no caller frame; subscribing a fallible fn would have nowhere to send `out_err`. The typecheck diagnostic fires at the subscribe site, naming the handler fn. The rule is **two-channel separation at substrate-facing surfaces**, not "no fallible on any locus method." The load-bearing constraint is *who's the caller* — when the substrate orchestrates a method (lifecycle, mode, closure assertion, bus handler) there's no caller frame to address the error channel, so `fallible(E)` would describe a contract that cannot be satisfied. User-declared `fn` members called from inside a method body or from another locus's method body have an addressable caller; they carry `fallible(E)` like free fns do, with the same `or` disposition surface. Example shape post-narrowing: ```hale type ParseError { msg: String; } locus Reader { // Allowed: user-declared `fn` member with fallible(E). // The body can `fail ParseError { msg: ... }` or call // other fallible functions and propagate via `or raise`. fn parse_message(b: Bytes) -> Message fallible(ParseError) { // ... if bad { fail ParseError { msg: "bad header" }; } return Message { ... }; } // Allowed: lifecycle method calling a fallible member fn // and addressing the error inline. run() { let m = self.parse_message(b) or default_message(); // ... } } ``` The earlier v0 rule (blanket "no fallible on locus methods") was narrowed because the friction signal across multiple apps and libraries showed devs extracting free fns just to get a value-error channel back — losing `self` ergonomics and splitting closely-related code across two top-level decls. See `notes/open-questions.md` § #24 for the narrowing's full reasoning and the rejected alternatives. ### `fail` statement `fail ;` inside a fallible fn body: 1. Evaluates `` to a value `v` (typed as the fn's declared payload type E). 2. Exits the enclosing fallible fn body via the error path. 3. The caller's `Expr::Call` sees the result as `FallibleErr(v)` — a tagged value the immediate caller's `or` clause is required to address. `fail` outside a fallible body is a typecheck error; statement-position recognition is also parser-gated to a fallible-body scope (so `let fail = 0;` outside such a body stays admissible). ### A bare fallible call is an error (GH #738) One rule holds for every fallible call, whether the callee is a fn or method the program declares, an interface method, a container's or an array's `get`, or a stdlib entry point: **only an `or` handles it.** The call must be the operand of an `or` disposition (`or raise`, `or `, `or handler(err)`, `or fail `, `or discard`). In any other position the call is **bare**: an argument (`g(f())`), an operand (`f() > 1`), a `match` scrutinee (`match f() { .. }`), a `let` initializer (`let v = f();`), a statement (`f();`), a returned value (`return f();`, in a fallible fn too). A `match` does not handle a fallible call. A bare call is a **type error** naming the callee, the payload it can fail with, and the shapes that address it (`or raise`, `or `, `or discard`, `or handler(err)`). That includes the legacy Int-status form of the stdlib write fns, `let r: Int = std::io::fs::write_file(..)`. It is reported by `hale check`, `hale verify`, `hale build`, `run`, `test` and the language server alike, one error per call. There is no flag or mode that accepts it. The rule for stdlib calls was staged (a warning, then `--strict-fallible`, then this) and the flag is removed. For any other callee the bare call was always refused, by the build if not by the check. A narrowing into a range (`Session(n)`, spec/units.md § Conversions) is fallible the same way, with a `RangeError`, and is bare in the same positions; its error lists the narrowing's own discharges (`or `, `or clamp`, `or wrap`, `or handler(err)`, `or raise`). A handled call and a deliberately discarded one (`or discard`, which needs a `()` success type) are not reported. The stdlib entry points concerned are the signature table's rows with a payload (`stdlib_surface.rs`): 94 at the time of the ruling, across `std::io::fs`, `std::process`, `std::http::client`, `std::io::tcp`, `std::compress`, `std::tar`, `std::bytes`, `std::str` and `std::time`. Lowering carries no bare form of any of them: it reads the same rows, and the last bare forms (`std::bytes::at`, which answered -1, and `std::io::fs`'s `read_file`, `read_bytes`, `write_file`, `write_file_append`, `mkdir`, `file_size`, `list_dir_count` and `list_dir_at`, which answered a direct value or an Int status) are gone (F.40 phase 4, S5). A bare call that reaches lowering, in a build that skipped the check, is an internal error naming the row. **Limitations to lift.** These are where lowering's support stops today, not part of the rule: - **A fallible handler.** An `or`'s handler that can fail itself (`g() or f(err)`) takes an implicit `or raise` (`spec/types.md` § "Disposition operators (`or`)") only when it is a fn the program declares (not a generic one), an imported or bundled stdlib fn written as Hale, or a locus's member fn called on `self`, a local or a field of `self`. Any other fallible handler is refused with the nested spelling that works, `or (f(err) or raise)`. That covers a stdlib entry point, a generic fn, an interface's or a perspective's method, and a container's, an array's or a stdlib handle's method. - **A call through an interface-typed value.** The checker types a local or parameter whose declared type is an interface as unknown (an interface slot accepts any locus that satisfies it), so it does not see a call through one as fallible: a bare `s.put(k)` with `s: Store` passes `hale check`, and `hale build` refuses it (``error not addressed: `Store.put` is fallible``). A call through a locus-typed value is checked. ### `or` disposition ` or ` evaluates ``. If the result is a non-error value, that value is the expression's value (disposition is a no-op). If the result is `FallibleErr(p)`: - **`or raise`** — propagate the error one frame up the static call stack. Inside a fallible(E) fn, this writes `p` into the enclosing fn's error sret slot and exits via the enclosing fn's error path; the enclosing caller's `or` clause then addresses the error in turn. The closure- violation channel is **not** entered. (An application may later promote a value error to a closure violation explicitly, but no such syntax exists in v1.) - **`or `** — binds `err` to `p` in scope and evaluates ``. Its result is the expression's value. Type must match the success type. `` may itself be a call (`or handler(err)`); the identifier `err` in the fallback expression resolves to the typed payload. A **diverging** fallback is exempt from the type match (2026-08-03, #353): a block whose last statement is unconditionally `break` / `continue` / `return` / `fail` / `terminate` never yields, so there is no value whose type could match, and requiring one forced callers to invent a substitute that is provably never used. Deliberately conservative — only an UNCONDITIONAL divergence qualifies, because a block that can fall through genuinely does need a value, which is what the rule protects. `v.get(i) or { break; }` is the shape a generic desugar needs, since it cannot invent a typed default for an arbitrary element type. - **`or discard`** (added 2026-05-16) — swallows the error and produces Unit. The underlying call's success type MUST be Unit; the typechecker rejects `or discard` on value-bearing calls with a message pointing at `or ` or `or raise`. Sugar for the previously- idiomatic `or noop(err)` pattern with a no-op handler fn. - **`or fail `** (added 2026-05-17, B3 / G6) — symmetric to `or raise`, but the caller picks a fresh payload of the enclosing fallible fn's declared error type instead of forwarding the inner call's payload verbatim. Lets a caller translate one error shape into another inline (`std::str::parse_int(s) or fail AppErr { msg: "bad number" }`) rather than bouncing through a helper fn. The payload expression sees `err` bound to the inner call's error value, exactly as a substitute RHS does (2026-08-11), so a field-carrying translation is written inline: `src() or fail DstError { kind: err.kind }`. Same divergence rule: chain value type collapses to the inner success type. Typechecker rejects outside a fallible fn body with a hint to use `or raise` or `or `. Chains are right-associative: `a() or b() or raise` reduces the value to the success type level by level. ### Process exit The runtime ends a program against its will via one of two boundary events at the implicit main locus's root: 1. **Closure-violation escape (F.9).** If a closure violation bubbles past every `on_failure` handler back to the runtime root, the process exits with the violation's payload as the structured error report. 2. **Value-error escape (PR6).** If an `or raise` reaches the implicit main locus's body with no enclosing `fallible(E)` frame to absorb it, the value error escapes the locus's body. The runtime panics via `lotus_root_panic(payload, size, typename)` — today dprintf to stderr (`"Hale panic: unhandled escaping main locus"`) + `exit(1)`. Architecturally this is the seat for a future routing-through-main-locus- `on_failure` extension: when (if) the main locus declares `on_failure`, the runtime will route the synthesized ClosureViolation through that handler before falling out to the dprintf+exit fallback. Until then the boundary collapses both channels to the same exit shape. Both paths preserve the framework's vertical-only-flow: every failure exits through the top of the recursion, never laterally. ## Declarations inside `module { }` `module NAME { * }` (grammar `module_decl`) groups declarations for the reader. It introduces **no namespace of its own**: the resolver registers a module's declarations in the bundle's one flat top-level scope, under their **bare** names, at any nesting depth. `module geo { type Point { x: Int; } }` makes `Point` — not `geo::Point` — the name every use site spells, two modules declaring the same name are the ordinary duplicate-name error, and the module's name is not itself a value, a type, or a path head. The bundled stdlib's declarations share that one scope, under their internal names (`std::http::Server` is the locus `__StdHttpServer`). A program declaration of any kind that reuses one of those names is the same duplicate-name error, in the same one namespace: `hale check` refuses it where it stands and names the stdlib's declaration. The stdlib's own source checked as a program declares the same thing again, not a second thing, and is not refused; and a library's names reach an importer mangled (`__lib_…`), so only a library checked on its own can meet one. The stdlib itself declares each name once, which its own test holds it to. The consequence is a rule, not an implementation detail: > A declaration inside a `module { }` is **first class**. It is > resolved, typed, mangled, lowered and linked exactly as the same > declaration written at the top level. This holds for each kind: a `type` (struct, enum or alias), a `locus`, a `perspective`, an `interface`, a `topic`, a `const`, a generic template, and a free `fn`. It holds through an `import` in both directions: a library's module-nested declaration gets a mangled name and a rename-table row like any other, so the importer reaches it as `alias::Name`, and a qualified path the importer writes *inside* a module — a type in a signature, a call, a struct literal, an enum variant — resolves as it would at the top level. Two things a module still does not change, because neither is a name lookup: - **The entry point.** A seed's entry point is its **top-level** `fn main` (or the `main locus` that one instantiates). A `fn main` written inside a module does not start the program — and is **refused**, by `hale check` as well as by the build, with a located error at the declaration: ```text main.hl:2:8: type error: the entry point must be top-level: a `fn main` inside `module inner` does not start the program — move it out of the module, or rename it if it is an ordinary function ``` Reading it as an ordinary free fn named `main` was the alternative, and it made a seed whose only `fn main` was one brace deeper check clean and then fail to build (codegen's spanless `program has no fn main()`). The two layers say the same thing now (2026-09-20, GH #911). A `main locus` written inside a module is not the entry either (F.40 phase 3, E0): rule 1 counts it, but the entry is the seed's top-level `main locus`, so a module-nested one does not close the world, is not a deployment target for `--env`, and is not what the build deploys ("The entry locus", § "Phase 2: hierarchy, subjects, bindings, closed-world optimization"). Beside a top-level one it is rule 1's error. Where it is the seed's only `main locus` nothing in the seed is the entry, and the seed is **refused** at its name, as a nested `fn main` is (F.40 phase 3, L4): ```text main.hl:2:16: type error: the entry must be top-level: `main locus App` inside `module inner` is not the program's entry, and nothing else in the seed is — move it out of the module ``` The refusal is one more diagnostic: every other rule still judges that `main locus` and its members as it would at the top level. - **Scoping of locals.** Ordinary lexical scope is unchanged; a module is not a scope. One declaration is not admissible inside a module at all: - **`target NAME { }`** is a program-level build directive, not a declaration a namespace can hold, and the **parser** refuses it at depth: ```text main.hl:2:5: parse error: `target` is a program-level declaration; move it to the top level ``` Every consumer of a target reads `program.items` — the checker's capability gate, the wasm-entry detection in `desugar`, the stdlib gating — so one declared inside a module used to be silently INERT: the same program that was gated at the top level reported `ok` one brace deeper. Honouring it at any depth instead would have changed what the build does rather than what a diagnostic says, which is why the refusal was taken (2026-09-20, GH #901). The grammar's `module_decl = "module" , IDENTIFIER , "{" , { top_decl } , "}"` is therefore read with `target_decl` excluded. **Analysis is on the same line, one check at a time.** The rule above is about resolution and lowering, and it holds without exception. The bundle-level *checks* are being brought to it individually: the hot-path allocation lint and the decorator-stack check reach inside a module (GH #764, `verification.md`), while several siblings in `check.rs` still stop at the top level and are tracked by GH #825 — there, a declaration one brace deeper can escape a check the identical top-level declaration would fail. The behavior summary that effect inference, the allocation analyses and the certificate engines read collects module-nested bodies like top-level ones (F.40 phase 3): a module-nested fn has a row, a call into it resolves, and what it does is in its callers' effect sets. (GH #764 for the lint; GH #884 and GH #854 for codegen's declaration collection, the cross-seed mangler and the pre-typecheck rename pass. Before those, the same program was accepted at the top level and either silently unanalyzed or unbuildable one brace deeper.) ## Cross-seed namespace resolution (v1.x-IMPORT) A file may declare `import "" as ;` at the top. References to library decls go through the alias as `alias::Name`. Resolution is two-step: 1. **Parse / merge.** The CLI resolves each import's path (per `spec/projects.md` "Resolution order"), parses every `.hl` file in the resolved target, applies the auto-mangler with a stable path-derived `` + each file's stem, and merges the mangled items into the importing program's item list. A per-build path-rename table is built mapping `["", ""]` to `__lib_____` — the `` is the importer's local namespace choice; the `` is the lib's canonical path identity, so two consumers importing the same lib under different aliases see identical mangled symbols. 2. **Codegen lookup.** Codegen's qualified-name resolution consults three tables in order — static `STDLIB_PATH_RENAMES`, static `MOA_PATH_RENAMES`, and the per-build import table — when lowering any path-qualified type expression, struct literal, or method receiver. The first matching table wins. **A qualified literal checks like a local one.** `alias::Type { ... }` resolves to the merged declaration before its initializers are validated, so field names, field types, interface and perspective coercions, and missing required fields are all checked exactly as they are for a literal on a locally declared type — an unknown field is the same `type T has no field f` error, at the offending initializer's span. A literal whose path resolves to no visible declaration keeps the permissive `Unknown` typing. Diagnostics name the spelling the author wrote (`alias::Type`), not the mangled symbol. (GH #707, 2026-09-19 — downstream handoff. Until then only the RESULT type was resolved: a misspelled field in an imported literal was dropped in silence and the field's default constructed instead, so `check` reported `ok` on a program the same literal on a local type would have rejected.) Cross-seed references in user code (`foo::Bar`) and intra-seed references inside the imported library (bare `Bar` from a file that uses a type declared in a sibling file) BOTH resolve to the same mangled symbol. The mangler builds a unified rename map across every file in the imported library before rewriting, so `greet.hl`'s reference to a `Formatted` type declared in `format.hl` rewrites to the same `__lib___format__Formatted` symbol that `format.hl`'s decl ends up at. Local bindings (`let`, `let mut`, fn params, lifecycle params, for-loop vars, pattern bindings, generic params) shadow top-level names per ordinary lexical scope; the mangler's scope-aware walker leaves shadowed references unrewritten. **Per-importer scoped imports (A4).** Imports declared inside imported library files **are** followed transitively by the resolver, but each library's imports land under that library's own alias namespace — they do not become visible to the top-level program. So library A importing library B exposes A's surface to the importer; B is reachable only through A's API surface (or by the importer re-declaring its own `import "lib/B" as ...;`). This replaces the prior strict barrier (which rejected transitive imports outright) and unblocks composition without leaking dependency identity. See `spec/projects.md` for the rationale and per-alias scoping rules. **`hale run` interaction.** `hale run` compiles through the same codegen path as `hale build`, and — as of WS3.3 — the same *import* path: both the single-file and directory forms (`hale run ./dir`) resolve cross-seed imports, build the per-build path-rename table, and rewrite qualified `alias::Name` references identically. A directory `run` produces the same resolved program as the corresponding `build` and execs it. (Previously the directory `run` form bundled files without the rename table, so cross-seed imports only worked under `build`; that gap is closed.) ## Region lifetime guarantees Per `memory.md`: - A locus's region is freed atomically on dissolve. - Sub-regions are freed before the parent's region (drain cascade ensures this). - No pointer-into-a-freed-region is reachable after region release (compile-time-checked + region-lifetime-checked). > Forward-looking / deferred items for this area now live in the > decision log — see [`decisions.md` § Deferred & future > work](./decisions.md#deferred--future-work). ================================================================================ ## Type system ## source: spec/types.md ================================================================================ # Type system This document specifies Hale's type system: what types exist, how they relate, what the compiler verifies. Where the grammar (`grammar.ebnf`) tells you what's syntactically valid, this document tells you what's *meaningfully* valid. ## Primitive types | Type | Repr (v0 codegen) | Notes | |---|---|---| | `Int` | i64 | Signed; default for integer literals | | `Uint` | i64 | Unsigned at the type level; codegen lowers as i64. Parser-recognized; full lowering pending a workload that exercises the unsigned-arithmetic distinction. | | `Float` | f64 | IEEE 754 double; default for float literals | | `Decimal` | i128 | Mantissa with implicit scale 9 (`mantissa × 10^-9`). Distinct from `Float` at the type level; same-shape arithmetic with scale-adjusted mul/div. Suffix `d` on literals (`1.50d`). Real arbitrary-precision deferred. | | `String` | ptr (NUL-terminated) | UTF-8 bytes, C-style NUL-terminated. Single-pointer ABI to fit return-by-value through the m49 calling convention. Embedded NUL truncates — use `Bytes` for binary content. Allocated in the caller's arena (or the lazy global payload arena for stdlib returns whose lifetime needs to outlive the call). | | `Bool` | i1 | `true` / `false` | | `Time` | i64 | Nanoseconds since the Unix epoch, UTC (GH #607, 2026-09-14). A literal is ISO-8601 between backticks — `` `2026-05-08T12:00:00Z` ``, an optional fraction of one to nine digits, an optional `Z`; an offset such as `+01:00` is a compile error, never read as UTC — and is parsed at check time. Declared by the stdlib (`type Time = point Duration;`, GH #1076 U4, spec/units.md § The stdlib's time catalogue): a point over `Duration` with no `origin:`, so the unit dialect's algebra of points is its arithmetic: `Time ± Duration → Time`, `Duration + Time → Time`, `Time − Time → Duration`; anything else with a `Time` operand is refused, naming both. Orders and compares as an instant. `to_string` / `println` render ISO-8601, the fraction only when it is not zero. Before the epoch is negative, not an error. On the wire and in a struct it is the same 8 bytes as `Int`; over FFI it is `i64`. `std::time::current()` reads the wall clock as one; `iso8601`, `parse_time`, `unix`, `nanos`, `from_nanos`, `time_from_unix` convert. Was a string-shaped placeholder until v0.20.0. | | `Duration` | i64 | Nanoseconds. Declared by the stdlib (`type Duration = quantity Int in ns;`, GH #1076 U4, spec/units.md § The stdlib's time catalogue): a quantity of the time catalogue (`ns us ms s min h day`), whose literals are its units' (`5s`, `100ms`, `2min`, `1day`), each its count of nanoseconds. Arithmetic is the unit dialect's algebra of quantities: `Duration ± Duration → Duration`; scalar scale `Int * Duration` (either order) produces `Duration` (the runtime-computed-delay shape, `ms * 1ms`); `Duration / Duration → Int`, the quotient of the two counts (`1h / 1min` is 60); `Duration / Int → Duration`, the integer division for a runtime divisor, and for an integer literal other than one a narrowing whose `or` says what becomes of the remainder (`timeout / 2 or floor`, `or ceil`, `or `; a bare `timeout / 2` is refused, U4's second correction); `Duration * Duration` and `%` are refused, naming both. Keeps its representation: prints its nanoseconds (`1500000000ns`), tags a topic field `u`, is its own FFI class. `d.in(u)`, `d.split(u)` and `Duration(x)` are the dialect's conversions (spec/units.md § Conversions). | | `Bytes` (m89) | ptr → `[i64 len][u8 data[len]]` | Binary-safe. Single-pointer ABI like String, but the underlying blob carries an explicit length prefix so embedded NUL bytes don't truncate. `len(b)` reads the prefix. Distinct from `String` at the type level; the typechecker keeps them apart. Operations: `std::io::fs::read_bytes` (m89), `Stream.send_bytes` (m89), `Stream.recv_bytes` (Phase 2g; both `fallible(IoError)` since #209), `std::bytes::at` / `std::bytes::slice` / `std::bytes::from_string` (Phase 2g), `std::str::from_bytes` for the inverse direction (Phase 2g). | | `BytesView` (F.30) | `lotus_view_t { src: ptr, epoch: i64 }` (16 bytes, by-value; SysV AMD64 returns in `rax`/`rdx`) | Non-owning view over a `BytesBuilder`'s buffer. Returned by `BytesBuilder.view()`. `src` is the builder pointer; `epoch` snapshots the builder's `mutation_epoch`. The underlying Bytes-shaped data pointer (`buf - 8`) is *recomputed* at read time by `lotus_bytes_view_data`, so the view itself doesn't allocate or carry the data pointer. Coerces implicitly to `Bytes` at function-argument READ positions (e.g. `std::bytes::at(view, i)`, `len(view)`, user-defined fallible-fn args, self/external/interface method args, monomorphized-generic args); codegen emits a call to `lotus_bytes_view_data` which checks the stamped epoch against the builder's live epoch, panics on mismatch (F.30b mutation-while-view-live guard), and returns the recomputed data ptr on the OK path. Rejected at `Bytes`-typed storage sites — callers wanting owned storage must `std::bytes::clone(view)` for a deep-copy into the caller's arena. Storage typed `BytesView` is allowed; a `String` / `Bytes` literal at a `BytesView`/`StringView` storage default is wrapped via `lotus_view_from_static_data` with `epoch == LOTUS_VIEW_EPOCH_STATIC = -1` — the unpack helper sees the static sentinel and returns `src` directly without an epoch check (the literal lives in the global string table at program-lifetime, so there's no source builder to check against). 2026-05-22 PM: ABI compacted from a 24-byte heap-allocated struct to this 16-byte by-value shape; no arena allocation per `view()` call. | | `StringView` (F.30) | `lotus_view_t { src: ptr, epoch: i64 }` (16 bytes, by-value) | Non-owning view over a `BytesBuilder`'s NUL-terminated buffer. Returned by `BytesBuilder.text_view()`. Symmetric companion to `BytesView`: same layout, same epoch guard, same static sentinel. Coerces to `String` at read sites via `lotus_str_view_data` (which recomputes the C-string pointer as `b->buf` — the `buf[len] == '\0'` invariant maintained by every mutating op makes this well-formed); rejected at `String`-typed storage; `std::str::clone(view)` upgrades to owned. | | `BytesMut` (#3) | raw `{ptr, len}` window (by-value; no `[i64 len]` prefix) | A non-owning **raw writable/readable window** — distinct from `Bytes` (which carries a length prefix the handle points *into*) because `BytesMut` is a bare `{ptr, len}` pair over memory owned elsewhere. Handed out by the zero-copy ring producer (`Topic.write(max) { w => … }` binds `w: BytesMut` over the reserved slot) and by `std::io::MirrorRing` (`readable()` / `writable()` return a `BytesMut` over the live / free region). Read it zero-copy with the `_raw` siblings of the binary-pack family (`std::bytes::read_*` / `at` / `find_byte` accept a `BytesMut` directly — length is the window length, not a prefix); write into it with the binary-pack writers (`std::bytes::write_*`). The window is valid only until the next ring commit / mirror advance — no epoch guard, so the lifetime discipline is the caller's. | **FnPtr (m80):** First-class function values, type-spelled `fn(T1, T2) -> R` (or `fn(T1, T2)` for void-returning). LLVM lowering is `ptr` (raw fn pointer); calls go through `build_indirect_call` with an LLVM `FunctionType` synthesized from the FnPtr's args/ret at the call site. The implicit `__caller_arena: ptr` first param of every user fn (m49 calling convention) is also expected on the FnPtr's call ABI — indirect calls prepend it before user-visible args. See `stdlib/io_tcp.hl` for the canonical use: `Listener.on_connection: fn(std::io::tcp::Stream)`. **FFI-portable subset (Stage-1 FFI):** the primitive type set above carries an additional axis of distinction at the `@ffi("c")` boundary: which types have a stable C-ABI mapping. `Int` / `Float` / `Bool` / `Duration` / `Time` / `String` / `Bytes` / `BytesView` / `StringView` may appear in `@ffi` parameter and return positions; `Decimal` / `Uint` are typecheck-rejected (platform-variable ABI or Hale-internal). See [`spec/ffi.md`](./ffi.md) for the full marshalling table and the lifetime contract. ## Scalar types (GH #1076) Beside the primitives, a program declares the unit dialect's scalar types. spec/units.md is their whole contract (the declarations, the laws, the algebra, the conversions and their policies, printing and the wire); what the type system holds of them is this. Each is a nominal named type, not a new primitive: the name survives resolution, two of them never unify, and each is represented as an `Int`. | Kind | Declared | As a type | |---|---|---| | Identity | `type OrderId = distinct Int;`, optionally `{ range: a..b; }` | no arithmetic; compares within one; widens to nothing (`Int(id)`, `OrderId(n)` convert) | | Range | `type Byte = Int { range: 0..256; }`, or over an identity or a range | widens to its parent for free; narrowing into it is `Byte(n) or …` | | Quantity | `type Money = quantity Int in cent;`, or a boundary denomination of one, `type Ledger = quantity Int in cent { round: half_even; }` | carries its denomination; converts by an exact factor | | Point | `type Price = point Tick;`, optionally `{ origin: N UNIT; }` | a position over its quantity | An expression's quantity or point type carries its denomination, and one no declaration names is a synthesized type, keyed by its quantity and its denomination as a monomorph is by its arguments, and displayed `Money in 1/10000 cent` (spec/units.md § Synthesized denominations). `Duration` and `Time` are a quantity and a point declared by the stdlib, which keep their own representation class (the table above). ## Compound types | Construct | Form | Notes | |---|---|---| | Slice / array | `[T]` or `[T; N]` | Dynamic or fixed-size | | Bounded collection | `bounded[T; N]` | Fixed-capacity counted list, INLINE in its containing type/params (`{ i64 len, [N x T] }`). See § "bounded[T; N]" below. | | Tuple | `(A, B, C)` | Fixed-size heterogeneous | | Struct | `type Foo { x: Int; y: Int = 0; }` | Named record, a **value**: `let b = a;` copies (see § "A struct binding is a copy"). Each field can declare a default value (`= expr`); literals omitting a defaulted field fill it from the default at instantiation time. | | Enum | `type Foo = enum { A, B(int) };` | Tagged union (sum type) | | Alias | `type Thing = Int;` | A second SPELLING of a type, not a new type. See § "Type aliases". | | Function | `fn(A, B) -> C` | First-class function values | | Generic | `Foo` | Parametric over type T | ### One name, one field (GH #1141) A struct declares each field once, and an enum declares each variant once. A second declaration of a name already declared in the same `type` is a type error at the second, naming the first: ```text main.hl:4:5: type error: field `a` is already declared in type `T`; a name is one slot, so declare it once main.hl:2:5: the first declaration ``` The same rule holds for a locus's or a perspective's `params`, for the named members of a `contract`, and for an `interface`'s methods. Before it, the second declaration passed `hale check` and one of the two won the slot, with nothing in the source to say which — while the json codecs and a surface's description assume one slot per name. ### A struct binding is a copy (GH #713) `let b = ;` where the place holds a struct — a field of `self` or of a child, a local, an element — binds a **copy**: a fresh struct in the frame's own region (a method's scratch; the caller's arena for a binding the fn hands back), its `String` and `Bytes` fields cloned, nested structs copied the same way, locus handles left as handles. The binding then follows nothing: replacing the source (`self.row = Row { }`) leaves `b` as it was read, and writing through the binding (`let mut c = self.row; c.n = 9;`) leaves the source untouched. A literal or a call result is fresh already and is bound as it is. Assignment reads the same way (GH #992): `b = ` on a struct local, and `arr[i] = ` or `b.inner = ` under one, store a copy. And a write through a local never reaches another (GH #993): a copy may share an unchanged `String` with its source, so a `String` or `Bytes` field under a local root is replaced, never overwritten in place — only a locus's own storage takes the in-place path. This finishes for structs the single-owner rule `spec/memory.md` states for `String` and `Bytes` stores (2026-09-22; before it the binding was a view of the storage, and an acknowledgement body read after the proposal field was cleared came back empty). A deliberate view has no spelling yet; read the field again when the current value is wanted. ## Type aliases `type Name = Type;` declares an **alias**: a second spelling of an existing type. ```hale type Thing = Int; type Row2 = Row; type Names = Vec; type Span = (Int, Int); ``` An alias is **transparent**, not nominal. It is replaced by its target wherever a type is written, so the alias and its target are **the same type** — they unify in both directions, share every field, and dispatch to the same methods: ```hale type Thing = Int; fn bump(t: Thing) -> Thing { return t + 1; } fn main() { let t: Thing = 3; // an Int let u: Int = bump(t); // and back, with no conversion } ``` The consequences are all one rule: * **Structural, never nominal.** An alias declares no type of its own, so nothing attaches to the alias name. `type Held = Holder;` names the locus `Holder`; `held.method()` is `Holder`'s method. There is no way to give an alias behaviour its target does not have — for a distinct type with its own methods, declare a locus or a struct. * **Transparent in every type position**: `let` ascriptions, struct fields, `params`, function parameters and return types, `capacity` cell types (the form's `indexed_by` resolves to the target's field), generic arguments, and nested shapes (`[Row2; 2]`, `(Thing, Thing)`). * **An alias chains.** `type A = B; type B = Int;` makes `A` an `Int`. A chain that returns to a name it already visited names nothing and is a type error: ```text main.hl:1:1: type error: type alias `A` is cyclic — an alias chain must end at a declared type ``` A chain that ends at a bare name nothing declares is refused the same way, at the target rather than at every use of the alias (see "Bare type names" below). * **Transparent in construction too.** A struct, locus or perspective literal, and an enum-variant path, may be spelled with the alias name: with `type Row2 = Row;`, `Row2 { id: 1 }` builds a `Row`, and with `type C2 = Color;`, `C2::Red` is `Color::Red` — both where a variant is constructed and where it is matched. Construction resolves the name through the alias chain to the declaration it ends at, so the value's type, its fields and its methods are the target's; the alias adds nothing and forgives nothing (a field the target does not declare is still an error). A literal whose alias target is not a declaration is still refused — nothing is constructible from `type Thing = Int;` or `type TwoRows = [Row; 2];`: ```text main.hl:5:13: type error: `Thing` is not a struct type ``` * **The alias form takes no generic parameters.** `type Twin = Pair;` is not supported — the alias target must be a concrete type expression (which may itself be a generic *instantiation*, as `type Names = Vec;` is). The parser refuses the parameter list, at the `<`: ```text main.hl:1:10: parse error: generic type aliases are not supported; write the concrete alias `type Name = Pair;` ``` ## Projection-class types `Rich`, `Chunked`, `Recognition` are **language-native generic constructors**. The compiler recognizes them and selects allocator + implementation strategy based on which projection- class wrapper a value carries (per `memory.md`). The constraint `` (per F.2) is a built-in "any-of-three" constraint: T must instantiate to `Rich`, `Chunked`, or `Recognition` for some U. No trait system required. ``` fn process(input: P) -> P { ... } ``` The compiler monomorphizes per concrete `P` instantiation. ## Locus types A `locus L { ... }` declaration introduces a *locus type* L. Locus types have: - A set of **params** (name, type, default value or `: inferred`); these are also the locus's mutable state (per F.3 / §3 in decisions). - Optional **contract** (expose / consume entries). - Optional **capacity slots** (F.22 — `pool X of T;` / `heap Y of T;` declarations naming slots 1..N beyond the implicit slot 0 / Arena). - Optional **lifecycle methods** (`birth`, `accept`, `run`, `drain`, `dissolve`, `on_failure`). - Optional **mode declarations** (`bulk`, `harmonic`, `resolution`). - Optional **bus interface** (subscribe, publish). - Optional **closure tests**. - Optional **member fns**. Instantiating a locus type produces a **locus handle** of that type, allocated as a region within the enclosing scope (per `memory.md`). ### `type` is a top-level declaration A locus body may not declare a `type`. `type X { ... }` is a top-level declaration only (see `grammar.ebnf`: `type_decl` is a `top_decl`, not a `locus_member`), and a `type` inside a locus body is a type error **at the `type` keyword**: ```text main.hl:2:5: type error: `type Pair` is declared inside locus `Holder`: `type` is a top-level declaration, not a locus member. Move it above the locus — a top-level `type` is in scope everywhere in the seed, including inside every locus. ``` A top-level `type` is the supported spelling and loses nothing: it is in scope inside every locus of the seed, so a locus that wants a record of its own declares it above itself and uses it unqualified. A locus-level `type` had no reading at all before this rule — the resolver registers no member type, so the name it introduced was invisible everywhere, including inside the locus that declared it. Before this rule, the parser accepted the member and the checker ignored it, so such a program passed `hale check` and then failed in the backend with `locus L member kind not yet lowered to codegen` — a program the gate accepted that could not be built (GH #756, the sibling of the member `const`). ### `const` is a top-level declaration A locus body may not declare a `const`. `const NAME: T = value;` is a top-level declaration only (see `grammar.ebnf`: `const_decl` is a `top_decl`, not a `locus_member`), and a `const` inside a locus body is a type error **at the `const` keyword**: ```text main.hl:2:5: type error: `const limit` is declared inside locus `Holder`: `const` is a top-level declaration, not a locus member. Move it above the locus — a top-level `const` is in scope inside every locus of the seed — or, if each instance should carry its own, make it a params field with a default (`params { limit: ... = ...; }`). ``` The two spellings the diagnostic names are the supported ones: a top-level `const` is in scope inside every locus of the seed (one value, shared, compile-time), and a `params` field with a default gives each instance its own copy (`self.name`). A locus-level `const` is neither — it is not per-instance state, so it is not reachable as `self.name`, and it had no settled spelling for the other two readings (`L::name` from outside, bare `name` inside). Before this rule, the parser accepted the member and the checker typechecked its value, so such a program passed `hale check` and then failed in the backend with `locus L member kind not yet lowered to codegen` — a program the gate accepted that could not be built (GH #747). ### Reserved member names Every locus type also carries three **synthetic members** the compiler injects, readable by name from any method body: | Name | Type | Meaning | |---|---|---| | `children` | `[ChildType]` | the accept'd-child collection (F.11); `self.children.count` / `.is_empty` summarize it | | `k_max` | `Float` | the F.1 displacement bound computed from `B` / `c` / `sigma` / `phi` | | `draining` | `Bool` | the F.27 drain flag, true once the locus is winding down | `self.` resolves the synthetic member **before** any member the locus declares, so these three names are **reserved**: a params field, member fn, or capacity slot spelled `children`, `k_max`, or `draining` is a type error *at its declaration*, naming the synthetic member it collides with and suggesting a rename. The rule is unconditional — it holds for a locus that accepts no child type as much as for one that does, because the synthetic member is resolved in both. The reservation is per-locus-namespace, not per-spelling: a `type`'s struct field may be named `children` (types carry no synthetic members), and so may a local binding or a fn parameter. ## Capacity-slot cell handles (F.22) `Cell` is the value type returned by `acquire()` (Pool slots) and `alloc()` (Heap slots), and accepted by `release(c)` / `free(c)`. It is **not** user-spellable in source — there is no `let x: Cell = ...;` syntax. The type appears only at typecheck and codegen. | Aspect | v1 behavior | |---|---| | LLVM repr | `ptr` — a typed pointer to T's struct layout | | Element type | The boxed inner type carries T from the slot's `of T` declaration; `Cell` and `Cell` typecheck distinctly. | | Validity surface | Round-trip only: a value can flow through `let`-bindings, get re-supplied to `release` / `free`, and live inside the locus body. | | Forbidden ops | println, arithmetic, comparison, fn-return-boundary crossing. Each rejects with a focused build-time diagnostic. | | Field access (v1.x-2) | Struct cells support `cell.field` reads and `cell.field = v` writes; lowers to struct GEP + load/store. Primitive cells (`Cell` etc.) reject field access with a focused diagnostic. | | Slot-of-origin tracking (v1.x-5) | `Cell` carries both T AND the originating `(locus, slot)` pair. Releasing a cell into a different slot than it came from is a hard error at codegen, with a diagnostic naming the originating slot. | ## Perspective types A `perspective P { ... }` declaration introduces a *contract type* P — a set of bodyless `fn` signatures (optionally a `bus { subscribe/publish ... }` surface) that form a stable ABI boundary. Holders program against the **slot type** `perspective(P)` — never a concrete impl — and dispatch through a single program-global, live-rebindable slot. A locus `L : serves P` provides the contract and can be swapped in behind the slot at pointer-flip cost via `reperspective`. An optional `stable_when { ... }` predicate feeds the (aspirational) transport-driven hot-load path. See [`semantics.md` § Perspectives](./semantics.md) for the full model (contract / `serves` / slot / live swap). ## Interface types (F.20) An `interface I { fn ...; ... }` declaration introduces a **structural interface type** I — a named set of method signatures. A locus L satisfies I iff for every method in I, L declares a method with the same name, the same arity, compatible param types, and a compatible return type. Satisfaction is **implicit**: there is no `impl I for L` declaration. Interface types appear in fn parameter positions: ``` fn render(sink: Sink) { sink.line("hello"); } ``` The structural-impl check fires at every call site where a fn declares an interface-typed param: missing-method, arity- mismatch, param-type, or return-type mismatches all produce typed diagnostics at typecheck time. **Fallible methods (GH #732).** An interface method may declare `fallible(E)` after its return type, as a fn does: ``` interface Store { fn put(k: String) -> Int fallible(StoreError); } ``` A call through the interface is a fallible call: `or `, `or handler(err)`, `or raise` and `or discard` apply as on a direct call, and a call that does not address the error is rejected. Which methods satisfy one: - An **infallible** method satisfies a fallible interface method; a call through the interface then always takes the success path. - A **fallible** method does **not** satisfy an infallible interface method. - The error types must be the **same type**; there is no subtyping of error payloads. A mismatch is a check-time error naming both signatures. An imported interface keeps its methods' error types and enforces them the same way. The vtable slot of a fallible interface method holds a callee with the fallible locus-method ABI (`i1` return, `out_val` / `out_err` slots); for an infallible method, the compiler puts a small adapter with that ABI in front of it. **v0.1 scope (Phase A + Phase B).** Interface declarations parse, register, and the typechecker enforces the structural rule (Phase A, shipped 2026-05-10). **Codegen vtable dispatch (Phase B) is shipped 2026-05-11.** Interface values are fat pointers `{data, vtable}` allocated in the current arena; the data slot holds the underlying locus pointer (same single-ptr ABI as `LocusRef`) and the vtable slot holds a per-(locus, interface) static global of fn pointers indexed by interface- method declaration order. A locus flowing into an interface slot coerces at the call site; method calls on an interface value lower as indirect calls through `vtable[i]` with the data pointer passed as the implicit self arg. Interface values are usable as fn parameters, fn returns, locus param / field values, and `@form(vec)` cell elements. Method-call receivers, polymorphic return through control flow, and pass-through aliasing (the original instantiator's binding and the returned binding refer to the same underlying locus) all work end-to-end. The `std::text::Sink` stdlib migration (split `Sink` into `StdoutSink` / `StringSink` / `FileSink` loci behind one `Sink` interface) shipped 2026-05-11; see `spec/stdlib.md` and `crates/hale-codegen/tests/sink_polymorphism.rs`. The implicit LocusRef → Interface coercion fires at the following positions: - **Free-fn arguments.** `fn render(s: Sink)` accepts any LocusRef of a locus satisfying `Sink`. - **Locus-method arguments.** `fn add(t: Tower)` on a `Registry` locus accepts the same coerce (added 2026-05-18). - **Returns.** `return r;` from a fn declared `-> Sink` builds the fat pointer at the return site. - **`type` field initializers.** `TowerEntry { t: r }` where field `t` is interface-typed coerces the LocusRef `r` (added 2026-05-18). - **Locus `params` / field initializers.** Same shape as above for locus param defaults and `locus L { params { t: Tower; } }` slots. - **An interface VALUE into a field of the same interface (GH #730, 2026-09-22).** `Attempt { performer: self.performer }` where both are `Performer`: identity, no structural check, no fat-pointer rebuild from a locus. The holder stores its own `{data, vtable}` pair and owns nothing — the impl is a **borrow**, exactly as a name in a field initialiser is (F.39) and as the assignment form `self.f = handle` has been since GH #967. The impl must outlive the holder; that is the same contract every borrowed handle carries, and the checker's lifetime pass for it is #730's remaining work. A value of a *different* interface is still refused. - **`@form(vec)` cell `push`.** A `Registry @form(vec) of Tower` accepts pushes of any satisfying LocusRef. - **`or ` fallback expressions.** When a fallible has success type `Interface(I)`, an `or fallback` expression of LocusRef type satisfying `I` coerces (added 2026-05-18). E.g. `lookup(...) or Hello { }` where `lookup` returns `Greeter fallible(...)`. - **let-binding ascription with composite type (G20).** `let arr: [Greeter; 2] = [Hi {}, Hey {}];` coerces each element through the ascription's element type; same shape for `let pair: (Greeter, Greeter) = (Hi {}, Hey {});` and `let arr: [Greeter; 3] = [Hi {}; 3];`. The codegen routes the RHS through `lower_expr_into(expr, hint)` so the composite-element coerce fires per position. The single- element let-ascription case is still permissive-via-Unknown (interface names resolve to `Ty::Unknown` at typecheck per `collect_known_names`); composite-element coercion picks up where the single case's implicit handling leaves off. The return path uses two cooperating mechanisms: at the return site, an implicit locus → interface coercion builds the fat pointer, and the locus-instantiation routing extension (the same m90 shape that handles `-> LocusRef(L)` returns) routes any instantiation of a satisfying locus inside an `-> Interface(I)` fn body to the program-lifetime payload arena. The fat-pointer struct itself is then deep-copied into the caller's arena by `emit_return_value_deep_copy`. Single-element coverage is in `crates/hale-codegen/tests/interface_return.rs`. **Composite-construction coercion (G20).** Interface elements inside fixed-size arrays, array-repeat literals, and tuples are now coerced at the construction site when the destination type is known. The let-RHS with a composite ascription is the wired entry point: ```hale let arr: [Greeter; 2] = [Hi { }, Hey { }]; let arr3: [Greeter; 3] = [Hi { }; 3]; let pair: (Greeter, Greeter) = (Hi { }, Hey { }); ``` The codegen propagates the ascription's element type through `lower_expr_into(expr, hint)` so per-position `coerce_to_interface` fires before the array's "mixes element types" check would otherwise reject heterogeneous LocusRefs. Tests live in `crates/hale-codegen/tests/interface_in_composites.rs`. Once the let-RHS coerces, the array's static type is already `Array` (or the tuple's positional types are `Interface`), so flowing it to fn-args, struct fields, or return positions of the same type goes through plain type matching — no further coercion is needed at the consumer side. **Still deferred:** locus-routing across nested return positions — a fn declared `-> [Greeter; N]` instantiating loci inline in its return expression still aliases the fn's stack frame. Closing that needs the m90 routing extension to fire on nested locus instantiations inside composite returns, the same gap that governs tuple-of-`LocusRef` escape today. F.11 child acceptance (`accept(c: ConcreteLocus) { ... }`) is intentionally NOT in the coerce list above. The child-accept mechanism keys dispatch by exact concrete locus name across substrate sites — accept fn signature, the `self.children` storage layout, and the parent's accept-dispatch table — so `accept(c: Iface)` is a multi-system change, not a coercion wire-up. Single-accept-type per parent is the v1 design. Interfaces have no default methods at v0; the body is signature- only. No interface inheritance, no multi-interface bounds on generics, no interface equality. F.21 sketches a paired substrate-aware (cascading-dimension) interface form for the n-dim growth case; not implemented at v0. ## Type compatibility ### Subtyping Lotus is **invariant** at the type level — no implicit subtyping. A `Rich` is not assignable to a `Chunked` even though both wrap `int`; explicit conversion required. Exception: contract-graded subtyping (next section). ### Numeric coercion: Int → Float (Phase 2c) A single documented one-way widening fires at the following surfaces: - **let-binding type ascription:** `let nf: Float = self.n;` where `self.n: Int` widens `n` to a Float at the binding site (codegen `sitofp`). - **fn-arg coercion:** when a parameter is typed `Float` and the call-site argument is typed `Int`, the argument widens at the call site. Same rule applies to user-declared fns and to stdlib path-calls (`std::math::sqrt(n)` with `n: Int` works without `2.0` literals). - **binary-op promotion** (B13 / G30): when exactly one side of a numeric binop is `Int` and the other `Float`, the `Int` side widens to `Float` and the op produces `Float`. Same rule covers comparison ops (`<`, `>=`, `==`) so `i < 0.5` typechecks. Symmetric — either side can be the one that widens. - **user-type field init:** assigning an `Int` value into a `Float`-typed struct field at literal-init time widens at the store, mirroring fn-arg coercion. Lets a config bundle declare `timeout: Float` and accept an `Int` from the caller without sprinkling `Float(n)` casts. - **return:** a fn or method declared `-> Float` that returns an `Int` — `return n;` or a block-tail `n` — widens it at the return, mirroring fn-arg coercion. A `fallible` fn's success return does not widen: `return n;` in a `-> Float fallible(E)` fn is refused. The widening is **strictly one-way**. `Float → Int` narrowing remains explicit (round + cast). `Decimal` never participates in implicit cross-type conversion. Only an `Int` widens: a quantity (`5ms` is a `Duration`, `spec/units.md`) or a range type is not one, so `let f: Float = 5ms;` is refused. The five surfaces are the whole list: a locus param's default and a locus literal's param override (`Worker { rate: 3 }`) take a `Float`. The rule was added 2026-05-11 as part of the float-surface-gaps friction-log resolution; see F.23 in `spec/decisions.md` and the Phase 2c entry in `spec/stdlib.md`. #### Explicit numeric conversions Where the implicit widening above does not apply — most often a `Float → Int` narrowing, or an `Int → Float` conversion needed in the middle of an expression rather than at one of the coercion surfaces — there are two explicit forms, both round-toward-zero for the narrowing direction (LLVM `fptosi` / `sitofp`): - **The `Int(x)` / `Float(x)` casts** — the idiomatic in-language form. `Int(f)` narrows a `Float` to an `Int` (truncates toward zero); the cast is opt-in, so there is no silent `Float → Int`. `Float(i)` widens an `Int` to a `Float` (`sitofp`). Each is the identity on its own type, and rejects any other argument type. They are the only two numeric casts: there is no `String(x)`, `Bool(x)`, `Bytes(x)` or `Decimal(x)` — those names are types. `Duration(x)` and `Time(x)` are the unit dialect's conversions of a value of the time component (spec/units.md § Conversions), never of an `Int` (`n * 1ns` makes a count a `Duration`). Rendering is `to_string(x)`, and reading a value back out of text is `std::str::parse_*`. - **`std::math::int_to_float(i: Int) -> Float` and `std::math::float_to_int(f: Float) -> Int`** (WS3.1) — the named-function spelling, callable in any expression position. Semantically identical to the casts (`sitofp` / `fptosi`, round-toward-zero); provided so numeric code does not have to round-trip through ASCII (`to_string` + `parse_*`) and so the conversions sit alongside the other `std::math` numeric primitives. An already-correct-typed argument passes through unchanged. - **`std::math::round(f: Float) -> Int` and `std::math::trunc(f: Float) -> Int`** — the Float→Int conversions that round at a chosen mode. `trunc` is round- toward-zero (an alias of `float_to_int`); `round` is round- half-away-from-zero (`3.7 → 4`, `2.5 → 3`, `-2.5 → -3`), computed as `fptosi(f + copysign(0.5, f))` via a compare/select half-shift (no `llvm.round` intrinsic, so the path needs no libm libcall — it lowers host-free on `wasm32`). `round` is the spelling numeric code wants when building an integer field from a Float quantity; `Int(f)` / `float_to_int` / `trunc` all truncate. ### Contract compatibility When parent declares `consume X: T` and child declares `expose X: T`, the compiler requires: - `X` is the same name in both. - The child's type for `X` is a *subtype* of (or equal to) the parent's expected type. For v0, "subtype" is just type equality. Future versions may admit covariant / contravariant relationships; v0 is invariant. This is the F.8 commitment expressed as a typing rule. ### Three-way interface (F.14) Per F.14: any function injected by L into its arena that satisfies a contract entry must return the contract's typed surface. The compiler verifies, for each contract `expose X: T` declaration: - L has either (a) a param named `X` of type `T`, OR (b) a fn returning `T` named `X` (or an annotated impl), OR both. - Multiple impls (the projection-class-specific case) are permitted; all must return T. Default-implementation rule: if no fn is annotated, the param named X is the default implementation (read field directly). ## Mutability Per F.E (design-rationale): bindings are **immutable by default**. `let x = 0;` produces an immutable binding. `let mut x = 0;` produces a mutable binding; reassignment permitted. Mutability is a per-binding property, not a per-type property. There's no `Mut`; the binding either is or isn't `mut`. For `params` / locus state, the implicit rule is that fields are mutable through `self.x = ...` (per F.3). The locus's state is the locus's mutable bundle. **Reassigning a locus-typed field is a lifecycle transition.** A field that holds a child locus (`params { conn: WsClient = WsClient { … }; }`) can be whole-value reassigned from a member fn (`self.conn = WsClient { … }`). Because a locus is not a plain value — it owns a region and possibly `@ffi`-acquired resources — this is lowered as **dissolve-the-old + construct-the-new**, not a pointer store: the previous instance is reclaimed (its `drain` / `dissolve` run, releasing its resources) and the new instance is constructed into the owning locus's arena, owned by the field (so the parent's dissolve cascade reclaims it). The field always points at a fully-live instance. (Before this rule the new instance was a scope-bound temporary dissolved at the method's exit — a use-after-free; see WS1#4.) For "same instance, reconfigure," prefer **in-place mutation** (`self.conn.url = …`), which keeps the locus's identity and resources and is cheaper. v1 limitation: the reassigned instance inherits the owner's pool; reassigning a field with an explicit non-default `placement` does not re-apply that placement. ## k_max as a typing rule Per F.1 / F.3: the compiler computes `k_max = B / [(1 - phi) * c + phi * sigma]` from the locus's declared params. This determines the maximum coordinatees an `accept()` can attach. If params are constants (compile-time-known), k_max is a compile-time integer. The compiler may reject `accept` call sites that statically exceed k_max. (For dynamic params, the runtime checks at each accept; exceeding k_max raises a typed `KMaxExceeded` failure handled by the parent's `on_failure`.) ## bounded[T; N] — fixed-capacity collections in types Types are pure data, so they cannot hold a `@form(vec)` (a locus). `bounded[T; N]` is the type-level collection: a fixed-capacity counted buffer laid out inline as `{ i64 len, [N x T] }` — the capacity is part of the type (K made value-level, the F.22 philosophy). Works in `type` fields and locus `params`. Operations are GRAMMAR INTRINSICS (like `len(s)`), not methods, so the types-have-no-methods axiom holds: ```hale push(f, x) -> () fallible(CapacityError) // full = error; // displacement policy // lives in the or-arm at(f, i) -> T fallible(IndexError) set(f, i, x) -> () fallible(IndexError) // overwrite live slot count(f) -> Int clear(f) // len = 0 truncate(f, n) -> Int // len = clamp; returns it for x in f { } // iterate live slots f.get(i) -> T fallible(IndexError) // = at(f, i); see below ``` `get` is the one operation in method position, and the same is true of `[T; N]`. Element chains rewrite to a loop that fetches through the source's `get`, so answering that name is what makes a form chainable at all — without it, `f.filter(…).count()` failed with "no field `get`" and the type-level collections could not anchor a chain. It is identical to `at` in signature and semantics (on `bounded` it *is* `at`, bounded by the live count rather than the capacity); `at` remains the idiomatic spelling for a direct index. The six free-fn-shaped names are NOT reserved. They dispatch on the first argument's type, so a program may declare a free `fn` of any of them, and a declaration whose first parameter is the receiver's own `bounded[T; N]` **shadows** the intrinsic for that receiver type — checker and codegen alike. See [`tokens.md` § Built-in identifiers](tokens.md) for the exact rule. `f.get(i)` is unaffected: method position is not the free-fn namespace. Semantics: - Fields auto-initialize EMPTY. Literal init and whole-field assignment are rejected — the intrinsics are the only mutation surface. Whole-STRUCT copies carry elements + count by construction (the storage is inline). - Scalar elements (Int/Float/Bool/Decimal/Duration) are flat under `zero_copy` and travel the bus as raw bytes. Pointer-shaped elements (String/Bytes/struct) work in-process — push/set arena-anchor the element into the receiver's owning arena — but are rejected in cross-process bus payloads (post-v1 polish). - Drop-front/FIFO is the shift-left idiom: `set` live slots down, then `truncate`. - `CapacityError { cap: Int; count: Int }` and the shared `IndexError` are the injected error shapes. - The unbounded-alloc analysis treats bounded fields as bounded by construction. `@form(vec)` remains the unbounded, locus-owned collection: unbounded data lives on a locus; bounded data can live in a type. ## Generics **Generic type-expr ↔ monomorph unification:** a generic instantiation type-expr (`Box`) resolves at typecheck to its mangled monomorph name (`Box_Int`) — the same name codegen synthesizes and that `Box_Int { ... }` literals produce — so declarations and literals unify, and a `Box_String` literal in a `Box` slot is a caught mismatch. Monomorph literal fields validate against the template with the type args substituted, and field reads on monomorph values type as the substituted field. A declared type parameter shadows a same-named top-level declaration or alias throughout its template's signatures, fields and body annotations. Before specialization it remains unbound; a global name cannot supply its type or create an apparent concrete monomorph. **Generic loci monomorphize the same way.** `locus Cache` is a template; `Cache` names the monomorph `Cache_Int_String`, whose `params` are the template's with the arguments substituted. A field read on a monomorph locus value (`c.cap`) types as the substituted param, exactly as a monomorph struct's field read does. Generic function calls inside a generic locus are typed for each concrete locus monomorph. A call on `self.value`, or on a local bound to that field, uses the substituted field type. The checked call rows retain their source body and call identities; lowering selects the row for that locus's arguments. Params defaults use their declaring locus's specialization even when construction happens inside another generic function or locus. Specializations discovered in these bodies are checked in turn. Omitted function and method defaults are typed at each invocation in the caller's scope. Their generic call rows retain the default's source site and distinguish the invocation path and the caller's specialization. The same default can therefore use `Int` in one caller and `String` in another. Lowering reads the corresponding checked row; supplied arguments do not evaluate the default. **Where the arguments come from.** A struct / locus literal spelled with the template name (`Box { value: 1 }`, `Cache { cap: 2 }`) carries no type arguments of its own; it takes them from the declared type at the site. The sites that declare one are: - a `let` ascription — `let c: Cache = Cache { cap: 2 };` - a declared return type — `fn make() -> Box { return Box { value: 4 }; }` - a declared field or param, at its DEFAULT — `params { b: Box = Box { value: 0 }; }` - a declared field of a data type, at a literal's init — `Outer { inner: Box { value: 9 } }` At a `let` ascription, a transparent alias of the whole instantiation supplies the same arguments: `type IntBox = Box;` permits `let b: IntBox = Box { value: 1 };`. Imported template paths resolve through their import aliases before specialization. Different aliases for one imported template retain the same declaration identity; each construction carries its own concrete arguments and child layouts. Anywhere else — an un-annotated `let`, a literal in statement position — the arguments cannot be recovered, and the literal is a type error naming the template and the annotation to write. There is no inference from a literal's field values back to the parameters. A generic instantiation's argument COUNT must equal the template's parameter count: `Box` on `type Box` is a type error at the annotation. The mangled monomorph name is spellable for a generic TYPE (`Box_Int { value: 1 }`, and through an alias — `type IntBox = Box; IntBox { ... }`). It is **not** spellable for a generic LOCUS: `Cache_Int_String { cap: 2 }` is a type error pointing at the template spelling. Generic params are declared with angle brackets: ``` fn map(xs: [T], f: fn(T) -> U) -> [U] { ... } type Stack { items: [T]; } locus Cache { params { cap: Int = 1; } } ``` The constraint syntax `` admits: - `ProjectionClass` (built-in any-of-three; F.2) - A specific projection class: `Rich`, `Chunked`, `Recognition` - Concrete types: `` is illegal (use the type directly); `` is also illegal (no trait system in v0) V0 supports only projection-class constraints. Future versions may add traits. Monomorphization: the compiler emits one machine-code instance per concrete generic instantiation (per F.1 commitment to runtime perf over compile-time perf). Compile times grow with generic surface; runtime is full-speed. ### What may be a generic argument A monomorph has to be NAMED before it can be declared — `Box` is lowered as `Box_Int` — so each argument must contribute a token to that name. The primitive vocabulary is: ``` Int Float Bool String Duration Decimal Time Bytes BytesView BytesMut StringView ``` `Uint` is the one primitive that may NOT be a generic argument: it is parser-recognized with no codegen representation in any storage position (see the table at the top of this document), so there is no monomorph to name. `hale check` refuses the instantiation at the argument's span and names the supported set: ``` main.hl:6:12: type error: `Uint` cannot be a generic argument: no monomorph name exists for it (supported: Int / Float / Bool / String / Duration / Decimal / Time / Bytes / BytesView / BytesMut / StringView) ``` A non-primitive argument is a single-segment named type (a user `type`, or another generic instantiation, which mangles recursively). An array, tuple, `bounded[T; N]` or function type as an argument is refused at lowering; a qualified path is rewritten to a single segment by the import renames before the mangler sees it, which is why the checker's rule above is confined to primitives — the only argument form whose answer is the same in both layers. ## Type inference ### `let` bindings The type of `let x = expr;` is inferred from `expr`. Explicit annotation `let x: T = expr;` overrides inference; if `expr`'s type is incompatible with `T`, compile error. ### Function return types If a fn omits `-> T`, the return type defaults to `()` (unit). Explicit `-> T` is required for any non-unit return. ### Locus params Params must declare types explicitly. `params { x: Int = 0; }` is the full form. (Inference of param types from defaults is not supported in v0; explicit is preferred for the `inferred`-vs- `= default` distinction.) Three init shapes: - `name: T = expr;` — default. Used when the caller omits the field; evaluated in the caller's scope at instantiation time. - `name: T;` — **required**. The caller MUST supply the field at the locus literal site; instantiation without it is a compile error. Use for fields where no sensible default exists (e.g. `Server { handler: ... }` where the handler is the whole reason the locus exists). **Exception (B7 / G19):** if `T` is a user-defined `type` (struct) and every field of `T` has a declared default, the compiler synthesizes `T { }` as the param's default. So `params { cfg: Cfg; }` against `type Cfg { host: String = "localhost"; port: Int = 8080; }` works without `= Cfg { }` spelled out. Required-shape is preserved when any field on `T` lacks a default. - `name: T : inferred;` — F.3 inference path; compiler / runtime determines the value. **T may be another locus (B10 / G24).** A param typed as a locus name (`params { db: DB; }`) stores a `LocusRef` — a single-pointer borrow. The param-holding locus does **not** own the referenced locus; the caller keeps it alive. Cross-decl declaration order doesn't matter: a forward reference (`User { db: DB; }` declared above `locus DB { ... }`) resolves via the codegen-side `pending_locus_names` pre-pass. Reading through the borrow (`self.db.name`, `self.db.draining`) goes through the same field-access lowering as any other LocusRef receiver. Synthetic fields (`self.db.k_max`, `self.db.draining`) work on non-self receivers too (B14 / G31). **A locus may not contain itself by value (GH #813).** A param default that *constructs* the locus it belongs to — directly, or around a cycle through other loci — is an error at the param ("param `next` of `Node` defaults to a `Node`; a locus cannot contain itself by value"). Every instance the default builds needs another, and no call site can end the chain: `Node { next: ... }` needs a `Node` to hand over, and building one asks the same question again. The rule is over locus LITERALS in a default, anywhere in it — inside an `if` or `match` arm or a block's statements too, since every branch is lowered — and a literal's own supplied fields count: a default that spells out every param of the locus it builds expands no default of its own and is not a cycle. **A call that builds one counts (GH #870).** `next: Node = make()` with `fn make() -> Node { return Node { }; }` is the same ring spelled through a function, and the same error at the param ("param `next` of `Node` defaults to `make()`, which builds a fresh `Node`; a locus cannot contain itself by value"). Lowering a call emits a call rather than inlining the callee, so that program used to compile and then overflow its own stack at run time instead — every `Node` `make` builds leaves ITS `next` to the same default, which calls `make` again. A fn counts when every value it hands back is freshly built: a literal of its declared locus, a call to another such fn, or a local binding of either — the binding the returned name resolves to, so an inner `let` that reuses the name does not hide it (GH #1140) — including through an `if`, a `match` or a block whose every arm is one of those (`return if c { Node { } } else { make() };`), the set lowering reads too. A call the compiler cannot see as fresh is **not** an edge and stays accepted — an accessor handing back a locus somebody else owns (`next: Node = registry.head()`), a method, a `std::` or cross-seed path, a carrier with an arm that is none of those. That program may still recurse at run time; `@no_recursion` is the contract for unbounded recursion, and this rule only reports the rings it can prove. A cycle whose loci live in different files of one seed is reported when the seed is checked together, since a single file holds no declaration for its sibling's types. The test harness's build, which skips the rest of the check, judges this rule too, so lowering keeps no guard of its own (F.40 phase 3, C7). ## `inferred` params Per F.3: a param declared `: inferred` (instead of `= value`) indicates the compiler / runtime determines the value, not the author. The compiler treats: - Hand-declared `= value` → prior, fixed at compile time. - `: inferred` → unknown; resolved at compile time if possible (constant propagation), at runtime otherwise (via the perspective-stability machinery). Typing-wise, inferred params have the declared type; they're just not bound to a value at declaration time. ## Function types Functions are first-class values. `fn(A, B) -> C` is a type; function literals can be assigned, passed, returned. A locus's `fn` member can be a method (takes `self`) or a free function within the locus's scope. Lifecycle methods (`birth`, `accept`, etc.) are not regular `fn`s — they have their own syntax and don't take `self` (it's implicit). ### Calls to bare names A call whose callee is a bare identifier must name something: a local binding (a fn pointer), a free `fn`, a generic `fn`, or one of the builtins the compiler answers itself. That set is exactly: | group | names | | --- | --- | | length and rendering | `len`, `to_string` | | the numeric casts | `Int`, `Float` | | the printers | `print`, `println`, `eprint`, `eprintln` | | the numeric trio | `abs`, `min`, `max` | | the string predicates | `starts_with`, `contains` | | the `bounded` intrinsics | `push`, `at`, `set`, `count`, `clear`, `truncate` | | accumulators, inside a closure assertion | `count`, `mean` | | the explicit-epoch closure surface | `check_closures` | (`sum(x)` and `prod(x)` are accumulator vocabulary too, but the parser gives them their own syntax rather than a call, so they are never a bare callee. `__fmt`, the desugaring of `f"{x:spec}"`, is the compiler's own and is not written by hand.) In a **whole program** — every import resolved, which is every command's: `hale check` on a seed (``, what the organization's gate runs) or on one file, every command that compiles (`hale build` / `hale run` / `hale test` / `hale replay` compile exactly what they bundle) and `hale lsp` — any other bare callee is a type error — `call to X: no free fn, generic fn or fn-pointer binding with that name is in scope`, with a did-you-mean over the program's fns. The rule holds wherever the call stands: a statement, an operand, a condition, a fn or method body, a lifecycle hook, a bus handler, a `params` default, a `const` initializer. Only a partial program a library caller assembles (a styleguide snippet, a test harness's fragment) keeps the permissive reading (dna/FRICTION.md F.18). `hale check ` loads that file and what its own imports reach, never the other files of its directory — exactly what `hale build ` loads — so a fn only a sibling file defines is unbound in the program both commands see, and both refuse the call. Until 2026-10-11 the file target kept a permissive reading for a sibling's fn, so `hale check f.hl` accepted a call to a fn nobody wrote that `hale build f.hl` refused. A multi-file seed is checked as the seed: `hale check `. When a file target is refused for a name nothing binds and other `.hl` files sit beside it, the check says so after its diagnostics (not under `--json`, whose stream carries the records alone): ```text main.hl:1:21: type error: call to `sib_helper`: no free fn, generic fn or fn-pointer binding with that name is in scope note: `main.hl` was checked alone, as `hale build` builds it; the 1 other .hl file(s) beside it are not loaded, so a name only they declare is unbound here — `hale check .` checks the whole seed ``` The rule was on for the seed check alone until GH #911 B1, so the build path let the call through to codegen, which refused it as `unsupported in codegen v0: call to X: …` — from a layer below the one that had just approved the program, with no file, line or caret, and a did-you-mean drawn from compiler-internal symbols. The two layers gave two answers to one question; they now give the located one. The list is a contract in **both** directions, and neither is optional. **A name the compiler answers must be exempt from the rule.** A builtin the rule does not know about turns the admission gate into a refusal of correct code — `hale check` red, `hale run` fine — which is strictly worse than the late diagnostic the rule exists to replace (GH #779). **A name the rule exempts must be one the compiler answers.** An exemption for a name nothing lowers is the divergence the rule was written to remove, moved inside the rule: `hale check` accepts the call, and `hale build` refuses it at lowering, without a source location. The list is therefore exactly the set above and carries no aspirational entries — a name is on it when codegen dispatches it, not when it looks like it should (GH #800). The compiler tests both directions over its whole program corpus rather than trusting the list. The value builtins have types, and the checker and the compiler read them from one signature table: | call | operands | type | | --- | --- | --- | | `len(x)` | a `String` or `Bytes` (or a view of either), a fixed-size array | `Int` | | `to_string(x)` | a printable value | `String` | | `Int(x)` / `Float(x)` | an `Int` or a `Float` | `Int` / `Float` | | `abs(x)`, `min(a, b)`, `max(a, b)` | `Int`, `Float`, `Duration` or `Decimal`, one type throughout | the operands' type | | `starts_with(s, p)`, `contains(s, p)` | two `String`s | `Bool` | A call over operands outside its row is typed as unknown: the checker names no type for it, and the compiler refuses it where it lowers it. So `first(len(s))` instantiates `first`, and `let n: String = len(s)` is a type error at the `let`. ### Bare identifiers The same rule holds in value position. A bare identifier must name something in scope: a local binding, a fn param, a match-arm binder, the implicit `err` of an `or` handler, a chain's `it` / `idx`, a top-level `const`, or a top-level declaration (`fn`, `locus`, `type`, `perspective`, `interface`; a `topic` resolves and is then refused by its own rule — it addresses a channel, it is not a value). In a **whole program** — every import resolved, which is `hale check` on a seed or a file, every build (`hale build` / `hale run` / `hale test` compile exactly what they bundle) and `hale lsp`, which loads the whole seed as `hale check

` does, imports followed, and typechecks it only once every member has read and parsed — any other bare identifier is a type error at its own span: `unknown identifier X: no binding, param, const or declaration with that name is in scope`, with a did-you-mean over the locals in scope and then the program's top-level names. Before this, an identifier that bound nothing typed as `Unknown`, which is permissive everywhere, so a one-character typo passed `check` and `verify` and failed in the backend as `unknown identifier` with no source location. One file of a multi-file seed, checked alone (`hale check `), is held to the rule as its build is: a `const` only a sibling file declares is unbound in the program the file target loads, and the check refuses the read with the note above. (It kept a permissive reading until 2026-10-11, which `hale build ` never had.) A name a block of the same body bound and released — `if take { let x = 1; } return x;` — is the block's (§ "Dissolve timing rules" in spec/semantics.md), so the refusal says so: `unknown identifier `x`: … it was bound in a block that has ended, and a block's binding is the block's` (GH #1139). A bare unknown CALLEE reports the call diagnostic above and not this one — one mistake, one message (GH #721). ### Bare type names And the same rule holds in TYPE position. A bare name written where a type is expected must name a declaration: a primitive, a `type` (struct, enum or alias), a `locus`, an `interface`, a `perspective`, a generic parameter of the declaration being checked, or one of the types the compiler synthesizes for its own channels (`IoError`, `ParseError`, `CryptoError`, `IndexError`, `KeyError`, `EmptyError`, `CapacityError`, `BusUnmatchedKey`, `ClosureViolation`). In a whole program — the same set of callers as above — any other bare name is a type error at its own span: ```text main.hl:1:16: type error: unknown type `int`: no type, enum, locus, interface or alias with that name is declared — did you mean `Int`? ``` The rule holds in every annotation position: a `fn` parameter and return, a `fallible(E)` payload, a struct field, an enum variant's payload, an alias target, a `const` ascription, a locus `params` field, a `capacity` cell type, a lifecycle / mode / `on_failure` parameter, and a `let` ascription — including inside the compound forms, so the name in `[Row; 4]`, `(Int, Row)`, `bounded[Row; 8]`, `fn(Row) -> Row` and `Rich` is checked as well. Before this, an unresolvable bare type name resolved to `Unknown`, which is permissive everywhere, so `fn helper() -> int` typechecked and then failed in the backend as `unknown type name 'int' in signature` — no location, and the annotation had silenced every check that would have used it (GH #877). A QUALIFIED name (`lib::Thing`, `std::text::Sink`) is NOT subject to this rule. It resolves against the bundle's import-rename table, which only a caller that merged the imported seeds has, so a tool holding one seed without them keeps the permissive reading for paths (GH #803 / #833). One file of a multi-file seed, checked alone, does not: it loads what its build loads, so a type only a sibling declares is refused by both. ## Contract subsumption For two contracts `C1` and `C2`, `C1 ⊆ C2` iff every entry in `C1` has a matching (compatible) entry in `C2`. This is used for: - Parent-child compatibility: parent's `consume` ⊆ child's `expose`. - Locus-type substitutability: when a child locus type is expected, any locus type with a compatible expose-surface may substitute. ## Vertical-only flow as a typing rule Per F.6 / F.11 / `memory.md`: cross-locus references at the type level are limited to the contract's typed surface. Specifically: - A reference to a coordinatee accessible only via `self.children[i].x` where `x` is in the contract. - Sibling references: not typeable. No syntax exists for reaching from one sibling to another; if attempted via manual pointer construction, the compiler rejects on region-lifetime grounds. - Grandparent references from a child: not typeable. Failure flows through `bubble`; intent flows through the parent. This makes the framework's vertical-only commitment a type-system invariant, not just a convention. ## Single-threaded-method invariant (F.31) A locus's methods may be invoked only on the OS thread that owns the locus's placement's pool. Cross-pool direct calls are typecheck errors; cross-pool coordination goes through the bus. The invariant is enforced via a static call-graph walk over the placement table (the `placement` family: one row per static instance, seeded from the `main locus`'s `placement { }` entries): 1. Each main-locus `params` field has a pool — explicit (`placement { field: cooperative(pool = X); }` or `placement { field: pinned; }`) or default (`cooperative(pool = main)`). 2. Each nested locus inherits its containing tower's pool (see `spec/semantics.md` § "Nested instantiation"). Methods on a nested locus run on the parent's pool's thread. 3. For a method-call expression `self.field.foo(args)`, the caller is each instance of the enclosing locus, on its own pool, and the receiver's pool is the pool of that instance's *field instance*: the enclosing locus's own `placement { }` entry for `field` if it names one (e.g. a `db: pinned` field on the main locus), otherwise the field co-locates with its owner (the caller's pool). A field that holds an instance built elsewhere (`App { w: held }`) runs in its holder's pool whatever an entry names for it; such an entry is refused (GH #890). The pool is a property of the **instance**, not the field's type — the same locus type used as a field in two loci on two pools is two independent instances, one per owner, each single-pool. A field is a receiver however its type is written: a single name, a qualified path (`std::log::Logger`), an alias, or a contract the field's literal implements, named in the diagnostic by the locus it realizes. An enclosing locus the table holds no static instance of (one built only inside a method body, or one held through a parameter) is not judged: its pool is unknown, never assumed to be main. 4. If the receiver instance's pool differs from the caller's pool, the call is rejected with a diagnostic naming both pools and pointing at the `placement { }` entries that picked them. 5. Bus sends (`Topic <- v;` / `"subj" <- v;`) are unrestricted — the runtime's cross-thread dispatch (m28b condvar+memcpy) handles the boundary safely. The invariant is the substrate enforcement that makes M:N cooperative pools safe. Without it, multi-pool deployments would silently race on locus arenas (unsynchronized bump allocators by design). The typecheck happens once per main locus (the placement-bearing locus is unique per binary), so the rule applies at binary compile time rather than at every library typecheck. **Interaction with `LocusRef` borrows.** A locus param of type `LocusRef(L)` carries a borrow of an `L` — but the borrow's pool is the locus's own placement, not the borrower's placement. So `self.db.query(...)` where `self.db: DB` and the `DB` instance is on pool `db_pool` is a cross-pool call from any non-`db_pool` thread, and routed through the bus. This is the "vertical-only flow" rule generalized to cooperative pools: cross-pool access is the same shape as sibling access — bus only. **Interaction with builtins / stdlib.** Free functions and stdlib path-calls (`std::io::fs::read_file`, `std::str::*`, etc.) are pool-neutral — they run on whichever thread calls them. Their arena routing through `lotus_current_caller_arena` TLS handles the per-thread isolation. The single-threaded- method invariant applies only to locus member functions, which are what carry per-locus arena state. **Interaction with `@form(...)` loci.** A locus declared with a `@form(...)` annotation (`@form(hashmap)`, `@form(vec)`, `@form(ring_buffer)`) is **single-pool by default** — its methods participate in the same single- threaded-method invariant as any other locus. Plain `@form(...)` cells have no runtime synchronization; concurrent writers from different pools corrupt the underlying structure. Cross-pool access is opt-in via the `sync = ` kwarg on the form annotation: | Annotation | Discipline | Trade-off | |---|---|---| | `@form(hashmap)` | single-pool only | densest layout, no sync overhead, cross-pool calls rejected | | `@form(hashmap, sync = serialized)` | per-map mutex (F.32-1α) | correct cross-pool; throughput bounded by lock contention | | `@form(hashmap, sync = striped)` | cell-level CAS + per-map rwlock for grow + cache-padded cells (F.32-1β2-v2) | parallel writers; grow path serializes; rwlock overhead can outweigh parallelism on cheap-payload workloads | | `@form(hashmap, sync = lockfree)` (optional `cap = N` hint) | cell-level CAS, no rwlock or mutex on the steady-state path; `remove` via tombstones + lazy grow with a brief migration stall (F.32-1γ-v2) | highest measured throughput on the false-sharing bench | When a locus carries a recognized sync discipline, cross- pool method calls into it are accepted without diagnostic — the substrate's chosen discipline carries the safety contract. Plain `@form(...)` (no sync kwarg) gets the same cross-pool diagnostic as any other locus, extended with an upgrade-path hint naming the sync kwargs. Because a form field's pool is its instance's (rule 3 above), two loci that each hold their own `@form` field of the same type on different pools are two **separate**, single-threaded structures — each accessed only by its owner's pool. Neither is a cross-pool access, so neither is flagged, and neither needs a `sync` discipline (there is no sharing to synchronize). The diagnostic fires only on a genuine cross-pool access — reaching one instance from a pool other than the one it lives on, e.g. a form field explicitly placed off its owner. (This is why two same-type form instances on two pools do **not** require byte-identical twin types.) Concrete shape: a `Registry @form(hashmap, sync = striped) of Counter indexed_by name` shared across producer pools (gateway loci incrementing counters) and a consumer pool (`MetricsEndpoint` rendering Prometheus text) typechecks clean. Without the `sync = striped`, every cross-pool `self.registry.counter(...)` would be rejected. Non-form receiver loci have no sync discipline available; cross-pool coordination must go through the bus. **Inference.** F.32-1∞ adds a closed-world inference pass that picks a `sync` default per form-bearing locus type from the pool-propagation graph. The explicit annotation always overrides; the inferred pick is shown via a compile-time diagnostic at the decl site. See `notes/f32-cache-aware-delivery-plan.md` § F.32-1∞. **History.** Commit `3ec6391` (2026-05-24, first cut) admitted any `@form(...)` locus into the cross-pool-safe set unconditionally, on the strength of a not-yet-shipped "form ABI serializes" claim. Bench-prep for F.32-1 surfaced that the runtime had no synchronization on the form paths; F.32-0 (this section's current state) scopes the exemption to explicit opt-in via `sync = X`. See `notes/f32-cache-aware-delivery-plan.md` § F.32-0. ## Closure-test typing A `closure name { left ~~ right within tolerance; ... }` declaration types as: - `left` and `right` must be expressions of compatible numeric types (integer or numeric). - `tolerance` must be a non-negative numeric expression. - The compiler verifies left/right resolve in the closure's scope (which is the locus's scope; `self.x` is permitted, member fns may be called). - For `epoch tick` / `epoch duration(d)` / `epoch dissolve` / `epoch birth` / `epoch explicit`, the runtime evaluates the closure at the appropriate event boundary. A closure failure at evaluation produces a typed `ClosureViolation` event (per F.9), not a generic error. ## Fallible typing (v1.x-FORM-1) A function declared `-> T fallible(E)` produces a value of type `T fallible(E)` at every call site. This type cannot be used where a plain `T` is expected — the caller MUST address the error before the value is consumable. See `spec/design-rationale.md` for the design rationale. **Declaration sites are restricted by the two-channel rule (see `spec/semantics.md` § "Fallible call semantics" § "Where each channel lives").** `fallible(E)` may be declared on: - Free fns. - Stdlib-synthesized methods over `@form(...)` containers (`@form(vec).get` / `.pop`, `@form(hashmap).get` / `.remove` / `.key_at` / `.entry_at`, `@form(ring_buffer).pop`). - **User-declared `fn` member fns on a locus** (open-question #24, shipped 2026-05-25). Heap-bearing success and err payload types are supported via the same TLS caller-arena snapshot non-fallible heap-returning locus methods use. - **Interface method signatures** (GH #732) — see § "Interface types (F.20)" for which methods satisfy one. `fallible(E)` is **rejected** on substrate-facing surfaces that have no caller frame to address the error channel: - **Lifecycle methods** (`birth` / `run` / `accept` / `drain` / `dissolve` / `on_failure`). Physically rejected at the AST level — `LifecycleDecl` doesn't carry a `fallible` field. - **Mode methods** (`bulk` / `harmonic` / `resolution`). Same shape as lifecycle: AST doesn't carry a `fallible` field. - **Closure assertions.** Substrate evaluates the assertion at the epoch boundary; no caller frame exists in the expression. - **Bus-subscribed handlers.** A fn that's declared `fallible(E)` may not also be referenced by a `bus subscribe ... as ` declaration. Bus dispatch has no return path. Rejected at the subscribe site, not the fn decl (one fn may be referenced by zero subscriptions; the subscription is what fails to typecheck). A subscribed handler MAY declare a return type (GH #1106): bus dispatch ignores the value. A reply to a caller is a served `rpc` row's response (`spec/api.md` § Surfaces and their rows), not a bus handler's return. The narrowing from "no fallible on locus methods" to "substrate-facing surfaces only" preserves the two-channel separation (structural failures still flow vertically via closure violations + `on_failure`) while removing the friction that made devs extract free fns just to get a value- error channel back. The typechecker emits the diagnostic at the offending site (locus member decl for lifecycle / mode; subscribe site for bus-handler-fallible conflict). ### `Ty::Fallible { success, payload }` The checker represents fallible returns as a wrapper around the underlying success type: | Source | Inferred type | |------------------------------|-------------------------------------| | `fn f() -> T fallible(E)` | `f()` has type `Ty::Fallible { success: T, payload: E }` | | `or` on fallible | unwraps to `T` | Only an `or` addresses a `Ty::Fallible`: it must be unwrapped at the immediate call site, as the operand of an `or` (or an `or`'s handler, below). Anywhere else the call is bare, and the `bare_fallible` law reports it with one error naming the callee and its payload (`spec/semantics.md` § "A bare fallible call is an error"), at: - `let v = f();` with `f` fallible - `let v: T = f();` with `f` fallible (typed binding) - `f();` as an expression statement - `g(f())` — fallible passed as an argument - `match f() { .. }` — a `match` does not handle it - operand, assignment, return, condition positions The checker types a bare fallible value as its success type `T`, so the position reports no type mismatch beside the law's error. ### Disposition operators (`or`) ` or raise` : Propagate the error one frame up the static call stack. Evaluates the inner; on `FallibleErr` payload, re-enters the fallible-return shape of the enclosing `fallible(E)` fn (the error climbs the call stack until a frame addresses it). The value-error channel is value-level and **orthogonal** to the closure-violation channel; the `bubble` / `on_failure` machinery is not entered by default. (An application may later promote a value error to a closure violation explicitly; no such syntax exists in v1.) On success, passes the inner value through. The resulting expression's type is the success type T. Past every enclosing `fallible(E)` frame — at the implicit main locus's root boundary — the runtime panics via `lotus_root_panic`. See `spec/semantics.md` § "Process exit" for the boundary semantics. ` or ` : Substitute a fallback value of type T. On failure, evaluates the fallback expression with `err` implicitly bound to the payload (typed as E). On success, passes through. Fallback type must be assignable to T. The fallback may itself be a call (`or handler(err)`), making `err` a regular expression-position binding inside the fallback. **Fallible handlers:** the handler may itself be `fallible(E2)`. Its success value substitutes; its FAILURE propagates through the ENCLOSING fn's error path — implicit `or raise`, sugar for the already-legal nested spelling `call() or (handler(err) or raise)`. E2 must be assignable to the enclosing fn's declared payload ("handler's failure has nowhere to go" / "propagated payload must match" otherwise). The implicit `or raise` applies where lowering supports it, a limitation to lift rather than a rule: a fn the program declares (not a generic one), an imported or bundled stdlib fn written as Hale, and a locus member fn called on `self`, a local or a field of `self`. Any other fallible handler (a stdlib path-call, a generic fn, an interface's or a perspective's method, an `@form`-synthesized or an array's method) is refused with the explicit nested spelling to write instead. In statement position the substituted value is discarded, so the handler's success type needn't match the call's. The fallback may be a **`{ block }`** — `or { … }`, with `err` in scope — for multi-statement recovery. Two cases: - A block that **always diverges** (`return` / `fail` on every path) produces no substitute value, so it imposes no constraint on T and is accepted for **any** success type — `let s = read_file(p) or { return "fallback"; };` where the fallible's success type is `String`, `Bytes`, a struct, etc. (It disposes like `or raise`: the err branch is closed and only the success value reaches the continuation.) - A non-diverging block substitutes its **tail expression** as the fallback value, whose type must be assignable to T: `let s = read_file(p) or { log(err); "default" };`. On a **Unit-success** fallible (`() fallible(E)`, e.g. `std::io::fs::write_file`), `or { block }` runs the block for effect — including in **statement position**: `write_file(p, s) or { println("failed"); };` — the same as `or raise` / `or discard` there. The `or` operator is right-associative: `a() or b() or raise` parses as `a() or (b() or raise)`, so each level disposes one fallible in turn until a non-fallible value remains. ### `ClosureViolation`, the error of a violating fn (F.42) A value-returning fn or locus method whose body may violate (`spec/semantics.md` § "A value-returning method that may violate is fallible": a `violate` statement in its own body, or a call to a value-returning fn that may violate and is not `fallible`) is declared `-> T fallible(ClosureViolation)`. The checker refuses it otherwise, at the fn's name: "\`P.take\` returns a value and may violate (\`violate exhausted\`): declare it \`fallible(ClosureViolation)\`; its callers then say what happens when the locus fails". A fn has one error type, so one that may violate declared `fallible(E)` with any other `E` is refused too. Lifecycle bodies, bus handlers and `fn main` are exempt; a fn returning nothing that may violate is warned about rather than refused. The violation is the call's failure. The caller's `or` runs, and the `err` an `or handler(err)`, an `or { … }` or an `or fail` sees is typed `ClosureViolation` (`err.locus`, `err.closure`, `err.diff`), the record the owner's `on_failure` received; at a stdlib method's call (`BytesBuilder.snapshot` and `finish`, the stdlib's two) as at a program's own. ### `fail` statement `fail ;` is only valid inside a fallible fn body. It evaluates ``, requires the result type to match the fn's declared payload type E, and exits via the error path (the caller sees a `FallibleErr` value). ### Custom payload types The payload E is an ordinary type expression — usually a small user-defined record (`type ParseError { ... }`) or a stdlib- synthesized type. The runtime / typechecker does NOT impose a common base — there is no `Error` trait, no `impl Error for ParseError`. Failure is a single anonymous fact; the payload is just a value tagged onto the failure for diagnostic purposes. Across an API boundary the payload is a contract: a `fallible(E)` handler that a surface row names (`spec/api.md` § Surfaces and their rows) has `E` as the row's error type, hashed into the surface's digest, described as the member's error schema and carried to the caller as the handler error outcome (422 over HTTP, `{"ok": false, "error": E}` over the Unix socket; `spec/api.md` § Outcomes), encoded by the exposure's codec like the response. ### Synthesized stdlib payload types The resolver injects four fallible-payload types into the top scope so user code can name them in `fallible(...)` markers and `or` substitute clauses. All are idempotent — a user-declared type with the same name wins. | Trigger | Type | Fields | |---|---|---| | `@form(vec)` | `IndexError` | `kind: String`, `index: Int`, `len: Int` | | `@form(hashmap)` | `KeyError` | `kind: String` (also surfaces `IndexError` on `key_at` / `entry_at` — those are index-based, added 2026-05-16) | | `@form(ring_buffer)` | `EmptyError` | `kind: String` | | `std::io::fs::*` / `std::io::tcp::*` | `IoError` | `kind: String`, `errno: Int`, `path: String` | The `IoError` payload is the unified shape for the fallible I/O surface — see `spec/stdlib.md` § "IoError" for the errno → kind tag taxonomy. ## Roles (GH #1109, #1417) `role NAME;` at top level declares a **role**: authorization vocabulary, the way `group` declares claim vocabulary and `effect` declares an effect class. A role is a name the deployment maps to principals; the program never says who holds it. `role NAME includes A, B;` is the hierarchy: whoever holds `NAME` holds `A` and `B`, and whatever they include, transitively. Composition is **grant-only and union-only** (the constitutions rule): `includes` can only widen what a holder may do, so a cycle says nothing and is an error, and a name declared twice is an error rather than a merge. A role an `includes` names must be declared; no role is implicit (`owner` is a role only a program that declares it has). Role names are deployment vocabulary (the keys of `[environments..roles]`, the word a refusal names) and are never mangled: an imported seed's `role x;` and the entrypoint's are one `x`, and declaring it in both is the duplicate error. A role is named by a requirement: a surface row's `requires: [R, …]` (`rpc Orders::cancel requires: [operator];`, or `@rpc(requires: [R])` on the handler) or a hub binding's `requires:` (`spec/api.md` § Surfaces and their rows, § Streams). `R` must be declared (surface law 4). The requirement is a property of the surface row, never of the handler, so one handler shared by two surfaces meets each surface's; it is a boundary check, named so, and says nothing about the program's own call paths; who holds the role is the serve site's role source (`spec/api.md` § Identity sources). `@gated(role: R)`, which gated a subscribed handler, an `expose` member or a `publish` through the retired api binding, is refused by the parser with the replacement (`requires` on an `rpc` row, `spec/api.md`). ## Recovery-primitive typing Recovery primitives (`restart`, `restart_in_place`, `quarantine`, `reorganize`, `bubble`, `dissolve`, `drain`) are statement-level keywords (per `precedence.md`); they don't have types in the value sense. They take a locus handle or error value as argument: ``` restart(child); restart(child) for 3; quarantine(child); bubble(err); ``` The compiler verifies the argument is a valid handle / error in the current scope. ## Working-set estimator (F.32-2) F.32-2 ships a compile-time working-set estimator that projects each user-declared locus's approximate byte cost and compares against a cache-tier budget. The estimator runs post-typecheck, pre-codegen; it doesn't change codegen output and emits diagnostics rather than changing program behavior. **Estimator formula** (per-locus, in bytes): ``` working_set(L) = sizeof(L's struct) [arena + user fields with alignment padding] + sum(slot in L's capacity slots) cap × cell_stride + sum(child in L's params if locus-typed) working_set(child) ``` Cache-tier budgets are read from `/sys/devices/system/cpu/cpu0/cache/index{0,2,3}/size` on Linux at first probe (cached for the build's lifetime); static fallbacks 32 KB / 512 KB / 8 MB apply on non-Linux or when sysfs is unavailable. See `hale_types::working_set` for the engine. **Per-locus annotation** (F.32-2 v0.2): `@locality(L1)` / `@locality(L2)` / `@locality(L3)` declare a per-locus cache-tier expectation. `@locality(any)` explicitly opts the locus out of any global gate. The annotation stacks with `@form(...)` in either order: ```hale @form(hashmap, sync = lockfree, cap = 64) @locality(L2) locus Registry { capacity { pool entries of Entry indexed_by k; } } ``` The grammar surface is in `grammar.ebnf` § `locality_annotation`. **Build-flag surface** (CLI, on `hale build`): | Flag | Effect | |---|---| | `--locality-report` | Emit a per-locus stderr report listing each locus's estimated bytes, smallest-fitting tier, and a struct / capacity / children byte decomposition. Build proceeds. | | `--target-cache l1\|l2\|l3` | Evaluate each locus against the named tier's budget. Over-budget loci surface as a stderr warning by default. | | `--strict` | Convert the warning into a build error (exit 1 before codegen). Only meaningful in combination with `--target-cache` or a program that carries `@locality(...)` annotations. | **Effective budget precedence** (per locus): 1. `@locality(L1|L2|L3)` annotation → that tier (hard contract; evaluated regardless of CLI flag). 2. `@locality(any)` annotation → no budget (opts out even under `--target-cache`). 3. No annotation → falls through to `--target-cache` global tier (or no budget when the flag isn't set). `--strict` controls warnings vs errors uniformly across both sources. The diagnostic names which source applied (per the `BudgetSource::label()` "@locality" / "--target-cache" attribution). **Estimator approximations** (deliberately imprecise; see the working_set module doc for the full list): - `params { }` field layout uses alignment-correct accumulation (each field rounded up to its natural alignment; struct rounded up to max field alignment). Was packed-layout in v0.1; v0.2 adds the padding. - Arena overhead modeled as a flat 64-byte budget for synthetic headers. - Heap-managed primitives (`String` / `Bytes` / `*View`) count as 16 bytes (ptr + len); the heap buffer's contents are not counted (lives in the locus's arena). - Per-method scratch high-water mark — not modeled. The scratch arena is destroyed at method exit, so contents are transient, not resident. - Unbounded-cap capacity slots (no `cap = N` on the form) surface in `WorkingSetEstimate::unbounded_slots` rather than contributing zero silently. > Forward-looking / deferred items for this area now live in the > decision log — see [`decisions.md` § Deferred & future > work](./decisions.md#deferred--future-work). ## Verification responsibilities Where each typing rule lives in the compiler pipeline: - **Parse + AST construction**: grammar.ebnf rules. - **Name resolution**: identifier scopes, qualified-name lookup. - **Type inference**: let-binding types, fn return types when explicit `-> T` omitted. - **Type checking**: assignment compatibility, function call signature, generic instantiation, projection-class constraints. - **Locus-shape checking**: contract compatibility (F.8 / F.14), k_max bounds (F.1 / runtime-checked when dynamic), mode signature consistency, lifecycle method signatures. - **Region-lifetime checking** (compile-time): no escape from shorter-lived to longer-lived scope; no sibling references. - **Closure-cycle existence check**: closure assertions reference defined values in the closure's scope. The Phase 1 compiler in Rust implements these checks; the Phase 6 self-hosted compiler ports them. ================================================================================ ## Memory model ## source: spec/memory.md ================================================================================ # Memory model This document specifies Hale's memory model: how regions are allocated, organized, and freed; how locus structure constrains memory layout; how access between loci is mediated. > **Naming note:** The language is **Hale**; the runtime > substrate is called **lotus** ("lotus structure provides the > memory hierarchy" below refers to the substrate concept, not > the language). C-runtime symbols stay `lotus_*`. ## Foundational rule **A locus owns a region. The region's lifetime is the locus's lifetime. When the locus dissolves, the region is freed wholesale.** There is no garbage collector. There is no borrow checker. The lotus structure provides the memory hierarchy for free; the allocator just respects it. ## Hierarchy Regions form a tree. The runtime root locus has a top-level region. Each child locus's region is a *sub-region* of its parent's region. ``` root region ├── main's implicit-locus region │ ├── coordinator region │ │ ├── leaf-1 region │ │ ├── leaf-2 region │ │ └── leaf-3 region │ └── attribution-service region │ └── ... └── runtime services regions ``` A region's allocations are bounded above by its own size; sub- regions allocated within count against the parent's budget. Regions cannot escape their hierarchy: a value allocated in leaf-1's region cannot be referenced by leaf-2 (lateral access is blocked at the type system level; physical layout makes it moot anyway since wholesale dissolution would invalidate cross- region references). ## Per-projection-class allocation A locus's projection class (rich / chunked / recognition) is a **perspective-resolution commitment** — a declaration of what observation granularity the locus serves to perspectives one tower up. Rich serves named-child observation (fine resolution); chunked serves chunk-level observation (mid resolution); recognition serves population-level observation (aggregate resolution — "represent as a curve," "as a histogram," "as a count"). The allocator strategy below is a *consequence* of the resolution commitment, not the commitment itself — same syntactic source, three generated implementations chosen to make observation at the declared resolution cheap: | Projection class | Allocator | Coordinatee model | |---|---|---| | **Rich** (proj_rich, N≈4–10) | Per-locus bump arena. | Coordinatees attached as small array; full state per coordinatee; freed wholesale on locus dissolution. Low churn expected. | | **Chunked** (proj_chunked, N≈10–30) | Per-locus arena with **per-coordinatee sub-regions**. Each accept allocates a sub-region; each dissolution frees one wholesale. Bookkeeping slots reclaimed via free-list. | Typed-message-header per coordinatee; moderate churn supported. | | **Recognition** (proj_recognition, N≈100–500) | Sub-mode-typed recpool selected at the locus declaration site (`fixed_cell`, `shared_slab`, `spillover`, or `summary_only`). v1 ships `fixed_cell` (bitmap-tracked fixed cells; child's arena lives inline in the cell) and `shared_slab` (one bump arena shared across all children; per-child release is a no-op). See **Recognition sub-modes** below. | Summary-only per coordinatee; many supported; minimal per-coordinatee state. | The `N≈…` figures are the *expected* coordinatee counts each class is tuned for, not hard caps. The `for child in self.children` iteration handle list — the codegen-level array of accepted child `self_ptr`s, distinct from where coordinatee state lives above — is a heap buffer that grows geometrically (`lotus_children_push`), so a parent may accept arbitrarily many children regardless of class. (Before 2026-05-29 this was a fixed 16-slot inline array with no bounds check; accepting a 17th child while iterating `self.children` corrupted adjacent struct memory.) Recognition's `fixed_cell` cap is the one real hard limit, and it is an explicit, declared budget enforced as a runtime error — not an implementation ceiling. The compiler picks based on the locus's declared projection class. A locus with no explicit `projection` annotation defaults to `chunked` if it accepts coordinatees, `rich` if it doesn't. Recognition is **explicit-only** — there is no implicit recognition fallback, and v1.x-3 made the sub-mode commitment required at the declaration site (bare `: projection recognition` is a parse error). Same forcing-function discipline as the 2026-05-12 two-channel rule: the substrate doesn't pick a default for you. ### Recognition sub-modes (v1.x-3) A locus annotated `: projection recognition(cap=N, )` commits to a storage discipline for its accepted children at the declaration site. The cell stride (`K` in the table below) is *not* a user knob — it is derived at codegen time from the union of accept-method param types on the parent locus, taken as the max. The contract the author writes is "how many children, what discipline"; the layout is the compiler's job. v1 ships two sub-modes; the other two parse + typecheck but reject at the resolver with a "v1.x pending" diagnostic. | Sub-mode | Commitment | Backing | Per-child release | |---|---|---|---| | `fixed_cell` | "Each child fits in a cell sized for the accept-type union. Cap of N children. Overflow is a hard runtime error." | `lotus_recpool_fixed_*`. One contiguous block of `N × stride` bytes; each cell holds an inline `lotus_arena_t` + chunk header + payload. Bitmap-tracked acquire/release. The cell IS the child's arena. | Clears the bitmap bit. Slot is reusable. | | `shared_slab` | "All children share a single bump arena sized for the accept-type union × cap. The whole slab frees at parent dissolve." | `lotus_recpool_slab_*`. One `lotus_arena_t` with a single fixed-size chunk; `fixed_size=1` so it never grows. Every acquire returns the SAME arena pointer — sibling allocations interleave. | No-op. Slab freed wholesale at parent dissolve. | | `spillover` *(v1.x pending)* | "Each child fits in a cell; overflow malloc-fallback with one-time warning. Graceful degradation under load." | Future: `lotus_recpool_spillover_*`. Per-cell `fixed_cell` plus a heap-allocated fallback. | TBD. | | `summary_only` *(v1.x pending)* | "Children carry zero per-instance state; all allocations live in the parent's arena." | Future: type-system rule prohibiting child arena allocation; parent's `__arena` is the only storage. | No-op. | The arena handle returned by `lotus_recpool_fixed_acquire` / `lotus_recpool_slab_acquire` is a `lotus_arena_t*` so child body code stays projection-class-agnostic per the F.22 architectural invariant. Overflow on the child's `arena_alloc` returns NULL (the arena's `fixed_size=1` flag is honored); v1.x-3 wires that to a hard NULL return — routing through `lotus_root_panic` for value-error escalation is a future polish. The codegen dispatch at child dissolve picks the matching `release` fn via a synthetic `__recpool_release_kind: i64` discriminator on every locus struct (0 = regular `lotus_arena_destroy`, 1 = `lotus_recpool_fixed_release`, 2 = `lotus_recpool_slab_release`). Set at the parent's accept step; consumed at child dissolve. Uniform layout so the dissolve path doesn't branch on whether the locus opted into the recognition surface. ## Capacity slots (F.22) A locus's storage discipline is an **N-tuple of capacity slots**. Slot 0 is the locus's own Arena (everything above this section). Slots 1..N are user-declared in a `capacity { ... }` block: ``` locus Foo { capacity { pool entries of Int; // slot 1: cell-recycling of Int-sized heap registry of Command; // slot 2: growable, individual free } } ``` Each non-Arena slot kind is a commitment the locus makes about its own state, not a hidden implementation detail: | Slot kind | Commitment | Backing | Lifetime | |---|---|---|---| | **Arena** (slot 0, implicit) | "I'm scratch — everything I touch dies with me." | Single bump arena per locus. | Wholesale-free at locus dissolve. | | **Pool of T** (slots 1..N) | "I hold a bounded shape of recyclable state." | Chunked free-list (`lotus_pool_*`). Cells acquired / released; chunks grow geometrically (16 → 32 → 64, capped at 4096) when the free-list empties. | Wholesale-free at locus dissolve. | | **Heap of T** (slots 1..N) | "I hold growable state bounded by my own lifetime." | Doubly-linked live list with intrusive header (`lotus_heap_*`). Cells alloc / free individually. | Wholesale-free at locus dissolve (live list walked, every still-live cell freed). | Slot init runs in declaration order after slot 0; slot destroy runs in reverse declaration order before slot 0. Cell alignment is 8 bytes (the v0 substrate's universal scalar alignment); cell size comes from T's LLVM struct layout. Restriction: a cell type cannot be a `LocusRef` — locus membership goes through `accept(c: Child)`, not slots. See `spec/semantics.md` "Capacity slot lifecycle and dispatch (F.22)" for the user- facing method-shaped surface and full restriction list. **Slot 0 parent-override** is governed by projection class (table above): Chunked / Recognition parents sub-region-allocate their accepted children's slot 0; Rich parents do not. F.22 names this existing v0 behavior so future **slot 1..N parent- override** (`pool entries of Int as_parent_for Child;`) sits on consistent vocabulary. Slot 1..N parent-override is deferred to v1.x — the first workload that demands a parent-owned Pool shared across accepted children will unlock the syntax. **Naming note.** The Recognition projection class's recpool (see § "Recognition sub-modes (v1.x-3)" above — `lotus_recpool_fixed_*` / `lotus_recpool_slab_*` per the chosen sub-mode) is the *slot 0 storage strategy for recognition- classed loci*. F.22's `pool` slot is a user-declared slot at 1..N with chunked-+-free-list backing and no projection-class entanglement. The two systems may unify in v1.x once F.22 slots 1..N stabilize; until then they are structurally distinct mechanisms that happen to share the word "pool." ## Lifetime rules ### Bound handles ``` let h = Locus { ... }; // h is bound. Locus lives until `h` goes out of scope. // Then: drain() runs (cascades), dissolve() runs, region freed. ``` A locus a free `fn` RETURNED follows the same rule, in either spelling of the call: ``` let h = make_locus(); // factory result let c = std::process::spawn(argv) or raise; // fallible factory // Both are bound, and both live until the binding goes out of // scope. Then: drain() runs (cascades), dissolve() runs, region // freed — exactly as for a literal. ``` What makes that sound is that the binding is the only place such a result can come to rest: storing a locus VALUE into a locus-typed field is refused (see spec/semantics.md § "Reassigning a locus-typed field", GH #383), so no second owner can appear behind the frame's back. Two positions hand the handle on instead of consuming it, and there the frame contributes no teardown: a result written directly as a **field of a locus literal**, which that literal owns (F.17), and a binding the fn **returns**, which the caller owns. "That literal owns it" is a transfer the owner honours: the field's value goes with the owner's teardown cascade, exactly as a nested literal does, at the param's default site as well as the call site, and under a diverging `or` as well as bare (GH #836), and under `or ` on both branches, where the field holds whichever branch ran and owns it (GH #853). A call that hands back a locus it did not build — one of its arguments, a handle it was given — transfers nothing, in that position or on either branch of an `or`, and is left to the value's real owner. The consequence a caller can rely on: a locus that holds an **external** resource (a file descriptor, a child process) releases it in `dissolve`, so a `let`-bound `std::process::spawn` closes its pipes and reaps its child when the binding's scope exits (GH #793). Retaining such a handle *past* that scope still needs an owner that outlives it — a module that publishes a transfer moving the handle's state into an instance the owner reclaims (`std::process::adopt`) — because the resource's teardown follows the instance, and the instance follows its binding. Until GH #793 a factory-returned locus went to a program-lifetime arena and was never reclaimed at all. That leak is retired; a factory result is reclaimed by whatever consumes it. ### Unbound expressions Per design-rationale §A: - **Ephemeral** (only `birth` + `params`, no ongoing-work surface): dissolves at the enclosing statement boundary. - **Long-lived** (has `run`, bus subscriptions, mode declarations callable from outside, or any post-birth work surface): becomes anonymous child of enclosing scope; lives until scope dissolves. ### Lifecycle methods Lifecycle methods (`birth`, `accept`, `run`, `drain`, `dissolve`, `on_failure`) **do not have their own implicit locus** (per F.6). They run *as the locus*. Locals in their bodies are bound to the lifecycle method invocation; child loci instantiated in their bodies attach to the enclosing locus, not to the lifecycle method's scope. ### Free `fn` functions `fn main()`, `fn helper()`, etc. — every free function has its own implicit locus (per §D). Bound handles and anonymous children are children of that implicit locus; the function returns when: - Body's last statement completes, AND - All children of the implicit locus have dissolved. ### Value allocations vs. the free-fn implicit locus The free-fn implicit locus governs the lifetime of **child loci and bound handles** instantiated in the body — those dissolve at the function's return. It does **not** give the function a value-allocation arena of its own. Ordinary **value allocations** (struct / record literals, `String` concatenation, array and `Bytes` literals) made in a free `fn` body bump into the nearest enclosing **locus's** region and are reclaimed only when that locus dissolves — **not** when the function returns. A value allocated inside a helper `fn` called from a hot loop therefore accumulates in the caller's region exactly as if it had been written inline; the function boundary is not a reclamation boundary for values. The practical consequence: a value allocation inside an unbounded loop (a `run()` / bus-handler loop, a `while true`) grows the locus region without bound for the locus's lifetime — the recurring leak class behind several past OOMs. To bound it, either bound the loop, route the value over the bus (the payload arena reclaims per dispatch), or move the allocating work into a child locus that dissolves per iteration. The compile-time analysis that surfaces this is GitHub issue #18 item 1 (memory-bound proofs); see `spec/verification.md`. ### Non-escaping fixed arrays are frame locals One value allocation is exempt: a fixed-size array literal (`[c; N]`) bound to a `let` whose every use inside the function is an element read (`t[i]`) or an element write (`t[i] = v`) is a **stack local** in that function's frame, not an arena allocation. It costs nothing at the arena and is gone when the function returns, so a fixed scratch table inside a helper is no longer per-call churn in the caller's region. The rule is deliberately narrow and one-sided — a literal `[c; N]` only, a `let` only, and any other use of the name (passing it to a function, returning it, storing it in a field or a form, publishing it, aliasing it with a second `let`, iterating it with `for`) puts the array back on the arena path. Two further limits keep the frame bounded: the element must be a scalar (`Int`, `Float`, `Bool`, `Duration`, `Decimal`, an enum), and one function's array literals may take at most **8 KiB** of frame in total. 8 KiB is one eighth of a cooperative-pool coroutine stack, and whether a given function runs on one is not knowable at its definition, so the cap holds everywhere. An array past the cap keeps the arena. This is a placement rule, not a semantic one: the values, the indexing, and the reclamation order a program can observe are unchanged. ## Bookkeeping reclamation (per-arena defrag) Per F.3: within a parent's arena, dissolved-coordinatee bookkeeping slots are reclaimed via a **per-arena free-list** (chunked-class loci) or **periodic defrag** (high-churn). Reclamation is: - **Per-arena** — never crosses arena boundaries. - **Bounded** — reclamation work is O(slots-being-reclaimed), not O(heap). - **Deterministic** — no stop-the-world, no opaque tracing. Coordinatee sub-regions remain pristine arenas freed wholesale on dissolution; only the parent's *bookkeeping* about coordinatees (registry slot, dispatch entry) needs free-list reclamation. ## Drain cascade Per F.4: `drain()` always cascades depth-first. 1. Recursively call `drain()` on each child first. 2. Wait for all children to finish draining. 3. Drain self. There is no separate `drain_cascade()` syntax — `drain()` is *always* cascading. SIGINT triggers `drain()` on the runtime root, cascading through the whole process tree. This implies that during drain: - New child accepts are refused. - In-flight messages on bus subscriptions are delivered; no new messages accepted. - Any in-flight handler invocations complete. - After in-flight work completes, dissolve runs; region freed. ## Mode-projections share the arena Per F.5: a locus's three modes (bulk / harmonic / resolution) operate on the same locus state via the same arena. Generated code reads/writes one region across three implementations. No duplicate allocation, no copy. The compiler verifies that the modes don't write-conflict (resolution-mode mutating state that bulk-mode also writes during overlapping evaluation is a compile-time error if the writes would race). ## Inter-locus access ### Vertical-only at the memory level A locus L can read into a coordinatee C's region via the contract surface (per F.7, F.8, F.11, F.14). C cannot read into L's region except via the contract's `consume` declarations. Siblings cannot read each other (vertical-only flow expressed at the memory layer). Practically: - L → C: typed contract field access (`c.greeting`); routed through C's translation function (per F.14); cost reflects C's projection class. - C → L: only what L declares in `consume`; goes back through the contract; never direct address. - C1 ↔ C2 (siblings): not permitted. Lateral coordination flows through the parent (typed message via bus, or contract-mediated). ### Cross-locus copies, not pointers A typed message crossing a locus boundary is a **copy**, not a pointer. The bus message arrives in the receiver's arena; the sender's state is independent. This is required because: - Sender and receiver may be in different schedulers (per the cooperative scheduler model in `runtime.md`). - Sender may dissolve before receiver finishes processing the message; pointer-based access would dangle. The framework's vertical-only-flow expresses itself at the memory level as: pointers don't cross loci; values do. ## Region escape rules (forbidden patterns) The compiler rejects these at compile time: 1. **Returning a sub-region pointer from a longer-lived scope.** `let r = make_region_in_child(); use(r);` where `r` outlives the child is a region-escape error. 2. **Storing a child reference in a parent or sibling that outlives the child.** Triggers a region-lifetime check. 3. **Sibling-to-sibling reference.** No type rule permits this; the compiler emits a clear "vertical-only flow" error. ## Edge cases ### Failure during birth If a locus's `birth()` panics or otherwise fails, the region is freed wholesale; no `dissolve` runs (since dissolve assumes birth completed). The parent's `on_failure` receives the failure event. ### Failure during accept Per F.7, `accept()` runs before child region allocation. If accept rejects (panics, returns error), the child region is never allocated — no cleanup needed. ### Failure during run Mid-`run()` panic triggers the parent's `on_failure(self, StructuralFailure { ... })`. Parent decides recovery (`restart`, `quarantine`, `bubble`, `dissolve`); region is freed per the recovery primitive's rules. ### Closure violation at dissolve Per F.9, a closure violation at the `dissolve` epoch is an **audit failure** (explosion), not a structural failure. The locus's region is freed regardless; the parent receives `on_failure(self, ClosureViolation { ... })` with typed event data. ### Quarantine A quarantined coordinatee retains its region (the parent has chosen to preserve it for inspection). Region is freed only when explicitly `dissolve(child)`d or `restart`ed. Quarantine is the one case where a "dissolved-from-the-system" coordinatee keeps its region alive. ## Allocator implementation outline (Informative; specifies expected behavior, not the literal implementation.) ### Rich ``` struct RichArena { bump_ptr: *mut u8, end_ptr: *mut u8, coordinatees: [Option; MAX_RICH_N], } ``` Single bump arena. Allocations are pointer-bumps. Dissolution resets bump_ptr to start. ### Chunked ``` struct ChunkedArena { parent_bump: BumpAllocator, coordinatee_subregions: Vec, bookkeeping_freelist: FreeList, } struct SubRegion { bump_ptr: *mut u8, end_ptr: *mut u8, coordinatee_id: u32, } ``` Each accepted coordinatee gets a sub-region (pristine bump arena). Bookkeeping (`coordinatee_id`, dispatch slot) lives in the parent's bump area; reclaimed via free-list when coordinatee dissolves. ### Recognition Sub-mode-typed at the locus declaration site (`recognition(cap=N, )`); v1 ships two sub-modes: ```c /* fixed_cell — bitmap-tracked cells; the cell IS the child's * arena. Per-child release clears the bit. The compiler * computes cell_bytes from the parent's accept-method type * union; user code only spells cap. */ struct lotus_recpool_fixed { size_t cap_count, cell_bytes, cell_stride, bitmap_words; uint64_t *bitmap; char *cells; /* cap_count * cell_stride bytes; * each cell holds an inline arena * header + chunk header + payload */ }; /* shared_slab — one shared arena; every acquire returns the * same pointer; per-child release is no-op. Slab size also * derived from the accept-type union × cap. */ struct lotus_recpool_slab { size_t cap_count, slab_bytes; lotus_arena_t *slab_arena; /* fixed_size=1 */ }; ``` Allocation is a bitmap search (fixed_cell) or a regular arena bump (shared_slab); no dynamic memory in steady state for either. See `crates/hale-codegen/runtime/lotus_arena.c` for the canonical implementation and § "Recognition sub-modes (v1.x-3)" above for the per-sub-mode contract. ## What the compiler emits For each locus, the compiler generates: 1. A region-allocation function (per projection class) called at birth. 2. A drain handler that walks children depth-first. 3. A dissolution handler that releases the region wholesale. 4. Per-mode implementations that read/write the locus's region in-place. 5. Translation function entries (per F.14) accessible through the arena. The runtime provides the underlying bump allocators, free-list machinery, scheduler integration, and lifecycle dispatcher. **Arena alignment contract.** `lotus_arena_alloc(a, size, align)` returns a pointer whose address (not the within- chunk offset) is aligned to `align`. The chunk header (`{next, used, cap}` = 24 bytes on x86_64 LP64) sits before the data region, so a naive "align the offset" approach yields 8-byte-aligned pointers even when callers ask for 16. The correct shape: align the cursor address `(c+1) + c->used` to `align`, then convert back to a within-chunk offset. The codegen side passes `align = 16` from `arena_alloc` to cover the widest scalar type (i128 / Decimal) — earlier the codegen passed 8 and the C side only aligned the offset, leading to `movaps` segfaults on Decimal stores into struct fields (i128-alignment segfault, root cause for two downstream repros). Both layers are necessary: the codegen must ask for the natural alignment of the widest scalar it can emit, and the C arena must honor that alignment at the pointer level, not the offset level. The same discipline governs the **bus payload path**, which uses distinct allocation sites. (a) The mailbox cell `lotus_bus_cell_t.payload_inline` is forced to 16-byte alignment via a struct attribute: a *pinned* subscriber is handed `&cell.payload_inline` directly (unlike a cooperative drain, which copies into a 16-aligned scratch), so an 8-aligned cell — its widest natural member is a pointer — traps a whole-`Decimal`-payload copy (an aligned `vmovaps`) even though at `-O3` LLVM scalarizes individual i128 *field* ops into misalignment-tolerant paired 64-bit moves. (b) The wire *deserialize* allocations for nested `TypeRef` (struct) fields request `align = 16`, matching the fixed-size-array element path, since a nested struct may carry a `Decimal`. A payload/cell allocation must request the natural alignment of the widest scalar the struct can hold — never a hardcoded 8. ## Codegen ABI (v0) The native-codegen path (`hale build`) lowers each locus to an LLVM struct one field per declared param, and each lifecycle method to an LLVM function whose first parameter is a pointer to that struct. Field reads / writes via `self.X` lower to `getelementptr` + `load` / `store` against the `self_ptr`. This is the substrate the region allocator + scheduler will sit on top of when they land — the ABI is the load-bearing contract; the allocator and dispatcher refine *where* the struct is allocated and *how* methods get scheduled, not the struct's shape. ``` locus Greeter { params { greeting: String = "hi"; } contract { expose greeting: String; } } locus Coord { params { factor: Int = 1; } contract { consume greeting: String; } accept(g: Greeter) { ... } run() { ... } } ``` lowers to: ``` %locus.Greeter = type { ptr } ; greeting %locus.Coord = type { i64 } ; factor declare void @Coord.accept(ptr %self, ptr %child) declare void @Coord.run(ptr %self) ``` Statement-level instantiation `T { ... };` lowers to: `alloca` on the caller's stack, store each field (call-site override or declared default), then dispatch lifecycle methods in the F.7 order: 1. **If we're inside a parent locus's lifecycle method AND that parent has an `accept(child: T)` method matching the locus being instantiated** → call `parent.accept(parent_self, child_ptr)`. 2. Call `T.birth(child_ptr)` if declared. 3. Call `T.run(child_ptr)` if declared. 4. Call `T.drain(child_ptr)` if declared. 5. Call `T.dissolve(child_ptr)` if declared. `accept` runs *before* the child's own `birth`, per F.7. This is how `02-parent-child`'s `Coord.accept(g: Greeter)` fires for each `Greeter { ... }` instantiated in the coordinator's `run()` body. Inside `accept`, `self.X` GEPs through the parent's struct and `g.X` GEPs through the child's struct — different `getelementptr` chains, same lowering machinery. `drain` / `dissolve` run last, in that order, before the alloca dies. The F.4 depth-first cascade is implicit in v0's synchronous-instantiation model: any descendants instantiated inside this locus's `run()` body have already gone through their own full birth → run → drain → dissolve sequence (each via this same lowering, recursively) before `run()` returns. So when this locus's `drain()` fires, all descendants are already gone — no explicit cascade walk is needed at the substrate level. When the cooperative scheduler lands and loci can be long-lived, the cascade becomes explicit; the lifecycle-method ABI doesn't change. v0 codegen is **ephemeral-only**: every alloca is on the caller's stack and freed when the enclosing fn returns. Long-lived loci and the parent-child region hierarchy described above (each child's region nested in the parent's) wait on the cooperative scheduler + region allocator work. Constraints v0 codegen enforces (will relax as more lands): - Lifecycle methods supported: `birth`, `accept`, `run`, `drain`, `dissolve`. - `birth`, `run`, `drain`, `dissolve` take no user-declared params (only implicit `self`); `accept` takes exactly one param, the typed child reference. All lifecycle methods return `void`. - Locus param defaults must be literals (Int / Float / Bool / String / Duration). Non-literal defaults are rejected. - Contracts are typecheck-only at this layer — they're accepted in the AST and skipped by codegen. The expose / consume surfaces are still type-checked across coordinator / coordinatee per F.8 by the typechecker pass. - Locus members beyond `params`, `contract`, the five-method lifecycle set, `bus { subscribe / publish }` declarations, and `fn` members (used as bus handlers) are rejected at declare time. Modes, closures, failure handlers, and nested consts/types wait on later milestones. The struct ABI + accept + drain/dissolve dispatch is what makes `01-locus-with-run`, `02-parent-child`, `10-stateful-locus`, and `11-drain-dissolve` compile to native ELF. ### Bus router (m12) The bus router lowers as **one global subscription table per program**, sized at compile time from the total `bus subscribe` declaration count. Layout: ``` %lotus.bus_entry = type { ptr, ptr, ptr } ; subject, self, handler @bus.entries = internal global [N x %lotus.bus_entry] zeroinitializer @bus.count = internal global i64 0 ``` Each `bus subscribe "S" as h ...` declaration on a locus contributes one slot; registration happens when the locus is instantiated, BEFORE its `birth()` runs: ``` @bus.entries[bus.count] = { @.str.S, %self_ptr, @.h } bus.count += 1 ``` `<-` lowers to a call into the generated dispatch fn: ``` define void @lotus.bus_dispatch(ptr %subject, ptr %payload) { ; for i in 0..bus.count: ; if strcmp(bus.entries[i].subject, %subject) == 0: ; bus.entries[i].handler(bus.entries[i].self, %payload) } ``` Subject equality uses libc `strcmp` (subjects are NUL-terminated global strings). Handler functions are called through a type-erased function pointer — every bus handler has the same LLVM signature `void (ptr self, ptr payload)`, with payload typing enforced by the typechecker upstream. #### Long-lived locus deferral A locus with any `bus subscribe` declaration is **long-lived**: its drain/dissolve must NOT fire at the end of its instantiation expression (which would dangle its `self_ptr` in the bus table before later publishes can reach it). Instead, each fn body / lifecycle method body opens a deferred-dissolve frame; long-lived loci instantiated inside push their `(self_ptr, locus_name)` onto the frame; at body exit (just before `ret`) the frame is flushed in reverse instantiation order, calling drain → dissolve on each. Ephemeral loci (no subscribe) at *statement-position* keep the original semantics: drain → dissolve fires at end of `lower_locus_instantiation`, inside the same lifecycle body that instantiated them. m82 changed the *let-bound* case: `let h = LocusName { ... }` now defers dissolve to the enclosing fn's scope-exit flush instead of the struct-literal boundary, so the user-visible binding stays valid for subsequent method calls. GH #710, then GH #711 / #812, extend the same deferral to a literal in any *expression* position — receiver (`LocusName { ... }.method()`), call argument (`serve(LocusName { ... })`), field read (`LocusName { ... }.field`), operand. The expression consuming the literal is its handle, so the eager path destroyed the arena before the method it is the receiver of ran, before the field it is read for was loaded, and before a callee that retained it was done with it. Long-lived loci (with `bus subscribe`) continue to defer regardless of binding shape. See `spec/semantics.md` "Dissolve timing rules" for the full rule. A deferred entry's slot is one entry-block alloca per instantiation *site*, and so is the locus struct it points at, so a site inside a **loop** rewrites both every iteration. The instantiation therefore reclaims the slot's previous occupant before overwriting it — the same per-entry spine the flush emits, guarded by the slot's NULL sentinel so the first pass is a no-op — and the flush still owns the last instance. A loop's residency is one instance, not one per iteration (GH #815). The same applies to a `let` bound to a locus-returning factory, whose binding alloca *is* its dissolve slot, and to the fallible spelling `let h = make(...) or raise;`, whose slot is written on the `or`'s ok branch only (GH #793) — except where the binding is returned or `=`-moved: those keep the leak rather than risk freeing a value another name still holds. The F.4 cascade still falls out structurally — children dissolve before their parent, regardless of which mechanism handles each. ### Region allocator substrate (m19) The codegen path links a small C arena runtime (`crates/hale-codegen/runtime/lotus_arena.c`, bundled into the compiler via `include_str!`) into every emitted binary. Public ABI: ``` ptr lotus_arena_create(void) // new arena ptr lotus_arena_alloc(ptr arena, i64 size, i64 align) // bump void lotus_arena_destroy(ptr arena) // wholesale free ``` An arena is a linked list of bump chunks (default 64 KiB each; oversized requests get a fresh chunk sized to fit). Allocation is a pointer-bump in the head chunk; on overflow, a new chunk is malloc'd and pushed to the front. Destruction walks the list and frees every chunk wholesale — no per-object free, ever. ### Locus-owned arenas + bus copy semantics (m20) Every locus struct carries a synthetic `__arena: ptr` field at **struct slot 0**. Initialized at instantiation time (right after the `alloca`) via `lotus_arena_create()` and torn down via `lotus_arena_destroy(self.__arena)` after the user's `dissolve` method runs (in both the ephemeral path and the deferred long-lived-locus flush at body exit). Per spec: "A locus owns a region. The region's lifetime is the locus's lifetime." The arena field's fixed-offset placement is load-bearing for the bus path: `lotus.bus_dispatch` is type-erased — it sees only `ptr self` from the subscription table — so its only way to find the subscriber's arena at runtime is a fixed-offset load. Slot 0 makes that a constant GEP. Allocation routing inside codegen has three tiers, in order: 1. **`current_arena_override`** — set during locus-instantiation field init so composite-literal defaults / overrides land in the new locus's arena (rather than the parent's arena where the default expression literally executes). 2. **`current_self`'s arena field** — when we're inside a lifecycle method body (or any fn with a `current_self` binding), allocations go to the locus's own arena. 3. **`@lotus.arena.global`** — fallback for `main` and free fns, which have no enclosing locus. Initialized in main's prelude; destroyed at every `ret` from main. The three tiers above route *value* allocations (composite literals, strings, payload copies). The **locus-struct** allocation (where a freshly-instantiated locus's own struct lives) is a separate dispatch in `lower_locus_instantiation`, ordered: returned/payload-routed → lazy global payload arena; `parent_accepts_us` → the accepting parent's arena; **`parent_owns_via_field`** → the owning locus's arena (see below); otherwise → entry-block stack alloca. #### Owned param-field child allocation An F.29 owned param-field child (`parent_owns_via_field` — e.g. `locus PerConn { params { reader: ConnReader = ConnReader { }; } }`) allocates its struct in the **owner locus's arena**, taken from `current_arena_override` (which the owner's instantiation set to its own arena before running params-init, where the field default is constructed). The alignment passed is **16** (the widest scalar — i128 / `Decimal`), per the Arena alignment contract above. Before this, such children fell through to a stack alloca in the instantiating method's frame. That was a latent dangle on any cross-lifecycle read of `self.children[i].` (owner birthed in one method, the field read in another) and became a hard crash once an owner's `run()` is posted to a cooperative pool (`spec/runtime.md` § "Runtime pool inheritance"): the posted `run()` executes after the instantiating frame returns, so the field-child's stack slot is gone — garbage field reads, or a segfault on the first parking `recv` in a field-child method one locus-boundary deep. Allocating in the owner's arena makes the whole owned subtree share the owner's lifetime (wholesale-freed with the owner's arena), fixing the dangle without leaking into a longer-lived parent arena. (Per-child reclamation, 2026-05-30. An `accept`'d child whose type is a **flow** — some parent declares `release(c: Child)` — is reclaimed when its `run()` completes (or it calls `terminate;`): drain → `release(owner, child)` → dissolve → `__arena` reclaim, on the child's own pool worker while the parent lives. So a daemon that accepts one flow child per connection reclaims each as its connection ends — bounded, no per-instance accumulation. A **resident** child — a type no parent `release`s — still lives until the parent dissolves (it is meant to: e.g. a fixed cohort of subscribers). The earlier "a daemon accepting many children leaks until process exit" held only when neither reclamation trigger applied; declaring the flow's `release` closes it. See spec/semantics.md § "release(c) and flow children".) A message already queued for a locus that ends before it is dispatched — a `terminate;` from a handler with a second message for the same locus behind it, a parent that publishes to its accepted child and terminates in the same handler — reaches nobody, exactly as a publish after the dissolve does: the cell is dropped at materialization, on its consumer's thread, before anything is deserialized into the reclaimed arena or the handler runs. (GH #703; the registry quarantine alone only stopped messages published *after* the locus ended.) #### Accept'd-child struct recycling The child's **locus struct** (as opposed to its `__arena` contents) lives in the OWNER's arena — that's what keeps `owner.__children` reads valid cross-lifecycle. Arena allocations are never individually freed, so per-child reclamation alone left ~sizeof(child struct) pinned in the owner's arena per child ever accepted: a churn daemon grew O(total children), violating F.3's O(peak-alive) intent (measured ~110 B/child; 443 MB at 4M children). The substrate closes this with an intrusive per-owner free-list of dead child structs, threaded through the owner's arena struct (`child_struct_free`, guarded by `subregion_lock` under multithreading): - `lotus_child_struct_release(owner_self, child, size)` — called from the teardown chokepoint (`emit_locus_arena_destroy`) after the `__arena` NULL-latch, exactly once per reclaimed child (the latch gates it). Node layout inside the dead struct: offset 0 is never written (it is the child's own NULL'd latch), offset 8 holds `next`, offset 16 the block size. Covers subregion-owning children AND arena-elidable (empty-lifecycle) children, which share the parent's arena pointer and latch on their (otherwise unused) `__arena` field. - `lotus_child_struct_alloc(owner_arena, size, align)` — the instantiation front: pops a size-matched block (bounded first-fit, ≤8 probes) before falling back to the bump allocator. Alignment is **16** per the Arena alignment contract (was 8 — a latent `Decimal`-param `movaps` trap). Steady-state churn (accept → run → reclaim per event) therefore reuses one struct slot per concurrent child, per type. Resident children don't route through mid-life reclaim and are unaffected. Bus dispatch implements the spec's copy-not-pointer semantic: ``` void lotus.bus_dispatch(ptr subject, ptr payload, i64 size): for i in 0..bus.count: if strcmp(bus.entries[i].subject, subject) == 0: sub_self = bus.entries[i].self sub_arena = load (sub_self + 0) copy = lotus_arena_alloc(sub_arena, size, 16) memcpy(copy, payload, size) bus.entries[i].handler(sub_self, copy) ``` Each `<-` call site passes the payload's compile-time-known struct size as a third arg. The subscriber's handler receives a pointer into the subscriber's own arena, valid until that subscriber dissolves — independently of when the publisher's locus dissolves. This unblocks `self.current_kernel = msg` patterns where the subscriber stores a payload reference across multiple bus events (fitter-applier-demo's central pattern). m20 deliberately keeps free fns + main on the program-wide arena (no per-call arena yet) and doesn't yet specialize per projection class — chunked-class per-coordinatee sub-regions land in m22, the recognition-class fixed pool in m23. **Phase-3 hard byte-cap on `g_bus_payload_arena` (2026-05-19; safety net).** The arena now refuses to grow past `LOTUS_BUS_PAYLOAD_ARENA_CAP` (default 64 MiB, env-overridable for capacity-planning experiments). When the cap fires `lotus_arena_alloc` returns NULL; one diagnostic line goes to stderr identifying the cap event and the arena's name; subsequent allocations against the capped arena keep returning NULL. Existing callers — BytesBuilder `snapshot()` / `finish()` via the alloc-fail sentinel + violate routing, recv_bytes returning empty Bytes, `lotus_bytes_create` returning NULL through `empty_global` — already surface NULL as degraded service, so the cap converts a slow OOM into structural failure that surfaces through the F.27 channel. This is the floor for a long-running program leaking into the payload arena, not the fix; the fix is per-subscriber arena routing for m70 + `__caller_arena` threading for the stdlib primitives that land here. **The operating system refusing memory is not a NULL.** The two NULLs above are contracts: a `fixed_size` cell that is full, and an arena at its byte cap, are answers the caller is written to route. `malloc` or `mmap` refusing a chunk (an address-space or cgroup limit, an exhausted machine) is neither, and nothing downstream is written to take NULL from an allocator: it used to surface later, as a SIGSEGV in a `memcpy` or a store that had done nothing wrong. `lotus_arena_alloc` now aborts at the failing call. It writes one line to stderr with `write(2)` (composed on the stack; a process just refused memory cannot count on `malloc`) naming the arena by the name it was created with (the locus, for a locus arena), its address, the size the call asked for, the chunk it needed, the bytes the arena already holds, and the address the call came from — on glibc followed by that address resolved to `binary(symbol+offset)`. An arena that cannot be created is reported the same way. The process ends with SIGABRT. **Phase-3 Task 11 intra-process bus per-subscriber routing (2026-05-20).** Extends Task 9's per-sub arena pattern to the intra-process `<-` path. Previously `lotus_bus_dispatch` enqueued the publisher's struct bytes verbatim into each subscriber's queue cell — payload String / Bytes pointers stayed aliased to the publisher's locus arena. For long-running publishers (a high-rate normalizer class) that meant an unbounded leak in the publisher's locus arena (the per-arena cap from Task 10 doesn't apply to locus-owned arenas), bounded only by the publisher's eventual dissolve — which for a daemon's root locus never happens. The fix: when the codegen has synthesized a wire codec for the payload type (the common case — every `<-` payload type gets one), the dispatcher serializes the publisher's struct to wire bytes once, then routes through `lotus_bus_dispatch_wire`. The wire path's per-subscriber TLS routing rebuilds the struct in each subscriber's own `__arena`; payload pointers end up bounded by the subscriber's lifecycle. Cost: one serialize + N deserializes per publish (N = matching subscribers). For cooperative-only programs with no remote subs, the previously-skipped serialize work is now paid on every publish. For programs with both local and remote subs, the serialize cost is amortized (same wire_buf feeds both). A payload-typeless subject (no codegen-synthesized wire codec — the `serialize_fn` arg is NULL) falls back to the legacy verbatim enqueue, preserving the pre-Task-11 v1 behavior. This escape hatch is intentional: it lets a hot-path subject opt out of the round-trip cost when the publisher controls all subscribers and can guarantee the payload's pointer-aliasing discipline. **Phase-3 Task 9 m70 per-subscriber arena routing.** `lotus_bus_dispatch_wire` no longer parks deserialized String / Bytes pointers in the program-lifetime g_bus_payload_arena. Instead it iterates the matching subscribers, sets the TLS caller-arena (Task 8 indirection) to each subscriber's own `__arena` (via the m20 fixed-offset slot-0 GEP), and deserializes the wire bytes per-subscriber into that arena. The payload pointers in the enqueued struct_buf now alias the subscriber's own arena, bounded by the subscriber's lifecycle — no program-lifetime deposit, no eventual OOM. **Adapter `send` bytes are transient (GH #1038).** The outbound half had the deposit this paragraph removed from the inbound one: `lotus_bus_remote_fanout` built each adapter `send`'s `bytes` in `g_bus_payload_arena`, one copy of the wire payload per publish, kept until the arena's cap made the copy fail — after which `send` was silently no longer called. The copy now lives in a **per-call** arena (`lotus_bus_call_arena_open` / `_close`), freed when `send` returns. Uses nest (a `send` that publishes, a loopback relay) and cooperative coroutines interleave on one thread; each use owning its own arena makes both safe with no bookkeeping. (A first cut shared one per-thread scratch behind a depth count, freed only when the outermost use ended: a `send` that parks on an `async_io` pool while other publishes overlap it never let the count reach zero, and the scratch grew at the full per-message rate.) Nothing is malloc'd per call: the arena struct is carved from the head of a pooled default-size chunk, which is also its first bump space, the inline layout recpool's `fixed_cell` uses, and every chunk goes back to the thread's chunk pool at close — so memory tracks the sends in flight, one chunk each, not the messages. The call arena is not a residency target; `LOTUS_BUS_CALL_ARENA_STATS=1` reports what call arenas held (opened / live / peak bytes) at exit. Adapter inbound's key derivation (GH #1041) decodes into a call arena the same way. Cost: deserialize is invoked once per matching subscriber rather than once total. Acceptable for typical fan-out (1–3 subs per subject); high-fan-out subjects pay a real bill that could be optimized via deserialize-once-then-clone-per-sub if a workload demands it. Closes the original Phase-2 (4) investigation's finding ("not reclaimable under current semantics"): the answer was never to reclaim the global arena but to skip it entirely — the m20 spec ("each subscriber's arena outlives the payload pointer") now holds by construction because the deserialize-time allocator IS the subscriber's arena. **Cross-thread wire cell per-delivery reclaim (2026-07-15; refines Task 9 for the owner-routed path).** Task 9 deserializes into the subscriber's arena on the *publisher's* thread. When the target subscriber lives on a *different* thread (a `pinned` publisher fanning out to a `where async_io` pool subscriber), that write races the subscriber's own unlocked arena mutations — so the owner-routed dispatch instead posts a *wire cell* carrying the payload's wire bytes plus its deserializer, and the subscriber's own thread deserializes it just before invoking the handler (`lotus_bus_cell_materialize`). The first cut of that path deserialized directly into the subscriber's locus arena — correct for lifetime, but a per-delivery **leak**: on a long-lived subscriber whose `run()` is parked (the canonical server loop — an accept / recv that never returns), the locus arena never dissolves, so every delivery's payload fields (String / Bytes) piled up unboundedly (~one payload's heap per message; measured ~320 MiB over 20k 16-KiB deliveries). The fix deserializes each wire cell into a fresh **per-delivery region**, destroyed the instant the handler returns (alongside the existing per-cell payload free): alive for the handler, reclaimed after. That is safe for the `self.current_kernel = msg` retention pattern because a locus-field store deep-copies nested String / Bytes fields into the *locus* arena (the only way user code can retain payload data: address-taking is disallowed, so the payload pointer itself never escapes the handler). The region is its own arena (on the subscriber's NUMA node), not a subregion of the subscriber's: a handler may end its own locus (`terminate;`), freeing that arena before the drain destroys the region. **Every wire delivery takes the wire cell (2026-10-11).** A subscriber on its publisher's thread kept the deserialize-at- dispatch path, and that path deserialized straight into the subscriber's locus arena — the same per-delivery leak, on the thread it was thought absent from. A served exposure, whose transport publishes each request's ingress to it on the same thread, kept every request's raw bytes for good (~240 B a call). Now every subscriber of a wire-format publish (`dispatch_wire`, its keyed variant, the static devirt path) gets a wire cell and a per-delivery region, on any thread. A payload with no owned fields (the flat paths) is unaffected (no wire cell, no region). **"The instant the handler returns" includes parks (GH #781, 2026-09-19).** On a `where async_io` pool the handler runs on a coroutine, so its return may come after any number of parks — and until this fix the delivery's payload was only sound up to the point the coro *first yielded*, not to the point the handler returned: the drain's stack cell (which the handler's pointer aimed into) was overwritten by the next dequeue, while the spilled heap payload and the subregion were never reclaimed at all on that path. All of a delivery's payload storage — the cell's inline bytes, a spilled heap payload, and the per-delivery region above — therefore belongs to the coro, is released exactly once when the handler returns, and is never shared with another delivery. The single-owner rule (§ cell stores) is what makes that sufficient: a handler that keeps payload data past its own return has deep-copied it into the locus arena, so reclaiming the delivery's storage at return can never orphan retained state. **Phase-2 (4) `g_bus_payload_arena` reclaim investigation (2026-05-19; superseded by Phase-3 Task 9).** The handoff posed: "should `lotus_bus_dispatch_wire`'s `g_bus_payload_arena` deposit reclaim per dispatch since m20 memcpy's into subscriber arena anyway?" The answer is no, and the reason exposes a load-bearing constraint. m20's `memcpy(copy, payload, size)` is a flat struct copy: the publisher's payload bytes (size = compile-time-known struct size) land in the subscriber's arena. The struct's String / Bytes / TypeRef fields are POINTERS inside that struct; the memcpy copies the pointers, not the pointed-to bytes. For cross-process wire dispatch (`lotus_bus_dispatch_wire` → deserialize → struct_buf → `lotus_bus_local_dispatch`), the deserialized String / Bytes data lives in `g_bus_payload_arena`. After m20's struct memcpy, the subscriber's copy still aliases that arena. Handler-side assignment (`self.foo = payload.string_field`) is a pointer store — `lotus_str_clone` is invoked only at *free-fn return* boundaries, not at struct-field assignment. So if the handler retains payload fields on its own struct, the retention extends the `g_bus_payload_arena` deposit's lifetime to the subscriber's entire lifetime. Reclaiming per dispatch would dangle the subscriber's retained pointers. Enabling per-dispatch reclaim requires changing handler-side String / Bytes assignment to clone-on-store from payload, OR introducing a per-dispatch arena that's reset only after every subscriber's handler has run — neither is a small change. For v1 the arena grows unbounded for high-message-rate cross-process subscribers; bounding it is forward work, not a follow-up to F.28 / F.29 / F.27. Documented here so the next surface that asks "can we reclaim per dispatch?" finds the previously-investigated answer. **Phase-4 per-method scratch reclaim.** Locus method bodies (lifecycle `birth` / `run` / `accept` / `drain` / `dissolve`, user-fn members, mode bodies) open a per-call scratch subregion of `self.__arena` on entry, route transient allocations through it via `current_arena_ptr()`, and destroy the subregion at every return point — except a body proven to allocate nothing that returns a by-value scalar (or Unit), where the scratch is elided entirely. Eliding is sound because there is nothing to reclaim and no return value to deep-copy: it removes a `malloc`/`free` per call with no observable change to lifetimes or bounds. Before this, every allocation made by a long-running `run()` loop (JSON parse strings, format-string concats, metric-label entries, every stdlib primitive that lands on `lotus_caller_arena_or_global`) landed in `self.__arena` directly — bounded only by the locus's lifetime, which for a daemon's root locus or any event-loop service is the entire process. A real workload measured multi-MB/sec growth on a hot message-dispatch path before the fix, OOM-killed at a typical container cap within minutes. Post-fix the scratch resets each method call so transient allocations have a bounded lifetime matching the call's frame. Two correctness invariants make this safe: 1. Heap-typed `self.X = expr` stores deep-copy `expr` into `self.__arena` BEFORE the store, so the persisted pointer outlives the scratch destroy. `String`, `Bytes`, `TypeRef`, `Tuple`, `Array`, `Interface`, and payload-bearing `Enum` are heap-typed; `BytesView` / `StringView` / `LocusRef` / scalars / cells pass through. The copier reuses `emit_return_value_deep_copy`. Bytes now uses `lotus_bytes_clone` (was a pass-through under the previous program-lifetime assumption — broken once payloads can live in scratch). 2. Heap-typed return values from a method are deep-copied into the caller's arena before the scratch destroy. The caller publishes its `current_arena_ptr()` via `lotus_set_caller_arena` immediately before each method call (mirroring the stdlib primitive contract). The callee snapshots `lotus_caller_arena_or_global()` at the method's entry block into a fn-local alloca and uses THAT snapshot — NOT a fresh TLS read at exit — as the deep-copy destination. The snapshot dance avoids a subtle bug where any nested method call inside the body would clobber TLS, leaving the epilogue to deep-copy into the wrong arena (whichever nested callee was called last). Cost: two mallocs (subregion arena struct + initial 64 KiB chunk) and two frees per method call. For typical short methods this is roughly 100–400 ns of overhead per call; at a typical hot-path rate of dozens of methods per dispatched frame at ~70 frames/sec, that's ~7 µs per second of aggregate overhead — invisible next to the JSON parse / decimal arithmetic the methods do. An `lotus_arena_reset` primitive (subregion reuse: keep chunks, set `used=0`) could amortize this to ~5–10 ns per call but is deferred — the leak's the load-bearing bug; the fast-path optimization can follow. Locus instantiation routing is unchanged. Child locus structs allocate via their own routes (parent arena if parent accepts them, lazy-global if the fn returns the child type, otherwise stack alloca) — `lower_locus_instantiation` doesn't read `current_arena_ptr()`. So a method body that does `let _w = ChildLocus { };` still gets the child instantiation routed to the parent's arena, not scratch, and the deferred-dissolve mechanism continues to govern child teardown. The free-fn path (`lotus_arena_create_subregion(__caller_arena)` at entry, destroyed at exit, with allocations routed to `__caller_arena` directly post-cross-seed-segv-fix) is unchanged. Free fns called from inside a method body get `__caller_arena = method's scratch` — anything they alloc lives in the same scratch and gets reclaimed at the outer method's exit. The cross-seed-segv test pattern (foreign vec push from inside a free fn) continues to work because those tests call from `main`, not from a method body. A method body that pushes a heap value into a foreign locus's vec without the wrapping locus owning it would still dangle — same boundary the cross-seed-segv fix originally documented; the fix here doesn't widen or narrow it. **Free-fn prologue publish (GH #375, gated per GH #522).** The caller-arena TLS is a set-and-forget channel, so a callee chain that exited down an error edge could leave it pointing at a destroyed method scratch; the next TLS reader without its own preceding publish then allocated out of freed memory. Free-fn prologues therefore publish their `__caller_arena` param to the TLS, re-healing it on entry and giving TLS readers the lifetime an inlined body would have used. The publish is emitted only when the body could actually observe the TLS. Two conditions must BOTH hold: 1. the body syntactically contains a call or a struct/locus instantiation — every direct TLS-reading lowering lives under one of those, and a scalar-only leaf fn skips the publish (emitting it measured +86% on the `fn_call` microbench); 2. the fn is not classified `non_allocating`. The TLS is read by allocation sites, so a body that provably never allocates has no reader to heal. Condition 2 exists because condition 1 alone is a syntactic test that cannot see through a function pointer: a two-deep helper chain calling through an opaque `fn` value armed the publish inside the caller's loop and cost 29% on the `fn_modular` microbench from v0.14.0 until it was gated. Neither condition weakens the #375 guarantee, which concerns a use-after-free reachable only FROM an allocation. The other two layers of that fix — `lotus_arena_destroy` clearing the TLS when it points at the dying arena, and method epilogues restoring their entry-time snapshot — are unconditional. **Phase-4 perf follow-ons.** Three substrate tunings that fell out of profiling the per-method scratch reclaim on a real-world long-running workload: 1. **Lazy initial chunk.** `lotus_arena_alloc_struct` leaves `head = NULL`; `lotus_arena_alloc` falls through to its existing fresh-chunk path on the first call. The dominant scratch shape on a hot dispatch path is "open scratch → do non-allocating work → close scratch" (e.g. a method body that only reads / does arithmetic). Eagerly mallocing a 64 KiB chunk for those scratches was pure waste — the lazy variant pays zero mallocs end-to-end for non-allocating bodies. 2. **Thread-local chunk pool, 256 slots.** Bumped from 16 after observing ~99.6% miss rate at hot-path scratch- churn rates. Each scheduler thread holds up to 256 × 64 KiB = 16 MiB of recycled chunks ready for the next scratch open. Pool predicate: only default-sized chunks (`c->cap == LOTUS_ARENA_CHUNK_BYTES`) recycle; larger one-off chunks bypass the pool and free via libc directly. `LOTUS_CHUNK_POOL_STATS=1` dumps the per-thread counters at exit. **Recycling hides use-after-free, so instrumented builds turn it off.** A recycled chunk hands the SAME bytes back out, so a load from an arena that was destroyed reads memory the process still owns: AddressSanitizer never sees a `free` and reports nothing. `LOTUS_NO_CHUNK_POOL=1` makes `lotus_arena_destroy` really `free()` each chunk and every chunk request really `malloc()`, so an arena use-after-free surfaces as a `heap-use-after-free` with the free stack and the read stack. It covers the coroutine free-list on the same grounds — a released coro keeps its 64 KiB stack for the next handler — and is the state the ASan corpus oracle runs in. A sanitizer build (`LOTUS_ASAN=1`, `LOTUS_UBSAN=1`) defaults it ON via `-DLOTUS_NO_CHUNK_POOL_DEFAULT=1` in the runtime cflags, so no instrumented run has to ask; the env var overrides that default in both directions (`=1` on an ordinary build, `=0` under a sanitizer). Ordinary builds are unaffected: recycling is the shipped allocator behavior, and turning it off costs a `malloc`/`free` pair per arena chunk. 3. **`lotus_str_clone` / `lotus_bytes_clone` skip optimizations.** Two cases pass through without allocating: * Static-literal skip — `src` is in the binary's `[__executable_start, _edata)` range (text + rodata + initialized data). String literals (`"foo"`) lower to globals in .rodata; cloning them into an arena is pointless since the original pointer is already program-lifetime. Glibc-only via `__GLIBC__` ifdef; other platforms pay the clone. * Same-arena skip — `src` is already inside one of the destination arena's chunks. Catches the dominant `Counter.inc` / `Gauge.set` pattern where the caller reads `e = store.get(key)` and writes back `store.set(MetricEntry { key: e.key, ... })` — the fields are already in the store's arena, so re-cloning them is wasted memcpy. O(chunks); typical arenas have 1–10 chunks so the walk is single-digit ns. A sortable chunk index would tighten the bound for very-long-running arenas; deferred until a workload surfaces the cost. 4. **Outer-struct same-arena skip at pointer-storage containers.** @form(vec).push / @form(vec).set / @form(ring_buffer).push slots hold an 8-byte pointer to a struct allocated separately. The deep-copy path runs a runtime `lotus_arena_contains_ptr(dest_arena, src)` check before allocating a fresh outer struct: if `src` is already in dest, the existing pointer is stored and the allocation is skipped. Catches read-modify-write shapes where the value flows through dest's arena. 5. **Anchor-in-place at inline-storage containers.** @form(hashmap) cells live inline in the slots buffer (`value_size = sizeof(Cell)`) — the runtime memcpys the value's bytes directly into the slot, so the deep-copy's outer struct allocation is wasted work. Instead, walk the source struct's fields and rewrite each heap-typed field to a `dest_arena`-anchored version; leave scalars untouched. The runtime's memcpy reads from the now- anchored source struct. Net for the canonical `Counter.inc()` / `Gauge.set()` RMW pattern (same-pointer heap fields carried through from `e = store.get(key)`): zero new allocations per call. The per-field deep-copy routes through the same `lotus_arena_contains_ptr` gate as #4 — implemented by `emit_cross_arena_store_deep_copy_ptr`. For String/Bytes fields the gate degenerates into `lotus_str_clone` / `lotus_bytes_clone`'s own same-arena skip; for pointer- shaped compound fields (`Array`, nested `TypeRef`, `Tuple`, `Interface`, `Enum`) the gate is a runtime arena-membership check on the loaded field pointer — identity-store when the field already lives in `dest_arena`. Until 2026-05-25 the compound-field path went straight through the unconditional deep-copy: cells with a fixed-size array field (e.g. `BookSignalState`'s two `[BookLevel; 100]`) allocated a fresh element buffer on every `set` of the same key. A downstream market-data app's long-burn surfaced this as ~3 KB / set × ~100 sets/sec → OOM at the container cap in ~20 min; the field-level gate folded the RMW back to zero allocations once the previously-stored buffer is already in the hashmap's arena. Cell single-owner (2026-07-18): the String/Bytes leaves of the walk no longer use the plain clones' same-arena SKIP — they route through `lotus_str_clone_cell_owned` / `lotus_bytes_clone_cell_owned`, which force-copy a same-arena input (statics still pass through; cross-arena values clone exactly as before). And the walk operates on a stack *snapshot* of the value struct rather than the source: anchoring in place used to rewrite a self-storage source (`m.set(self.rec)`) so the locus field and the cell ended up pointing at the same blob — an in-place field overwrite then mutated the cell silently, and anchor retirement could free a blob the other owner still held. With snapshot + owned-clone, a cell's top-level String/Bytes fields are always exclusively owned. Nested-block single-owner (2026-10-12): a retiring map (`sync = none` / `serialized`) retires a replaced cell's nested records, tuples and out-of-line arrays too (forms.md § Cell reclamation), so those fields can no longer identity-skip on arena membership — a block another cell held would be freed under it. On such a map the walk copies a nested-block field unless it is the very block the same key's current cell holds at that field (`lotus_hashmap_peek`, `sync = none` only; a serialized map always copies): the get-modify-set of a cell with a large nested field still allocates nothing, and the retire sees the block survive. Interface and payload-enum fields keep the identity-skip; they are not retired. The same holds for a retiring form's OWN fields (a `@form(vec)`, or a `@form(hashmap)` that retires — rule #6's `self.X = ...` in the form locus's methods): they live in the arena the cells' nested blocks are retired from, so a compound store into one copies every nested block (and the String / Bytes inside) instead of skipping a same-arena one, and walks a stack snapshot of the rhs, never the rhs itself — `self.saved = self.get(k)` used to leave the field sharing the cell's nested record, and `for x in self.items { self.saved = x; }` rewrote the element it visited so the two shared one block, which the next replace freed under the field. Scalar-cell fast path (2026-08-03): the snapshot and the owned-clone walk exist solely to keep heap-pointer leaves un-aliased, so a cell with no String / Bytes field and no field pointing at a block of its own (a nested record, a tuple, an out-of-line array — 2026-10-12: a nested record of scalars used to count as scalar, and its pointer into the setting method's scratch was stored as-is) skips both at compile time and takes the pre-single-owner codegen — the aliasing hazard cannot arise where there is no pointer to alias. Unrecognized field types conservatively keep the snapshot. (The snapshot's store-to-load round trip had serialized scalar-cell insert loops: +30% on the Int-keyed 1M-insert bench.) Note the RMW zero-allocation claim above narrows accordingly: same-pointer String/Bytes fields carried through a get-then-set now cost one owned copy per set, served by the retire freelist (the replaced generation recycles into the next), so steady-state memory stays flat while a compound field carried over from the same key's cell keeps the zero-copy skip. 6. **In-place mutation at `self.X = Struct{...}` and `self.X[i] = Struct{...}` assigns.** Locus self-fields (and elements of self-array-fields) are pre-allocated at instantiation time — every field has either a default or an instantiation-supplied value, so by the time any method body runs the slot's pointer is non-null and points to a struct in `self.__arena`. Assignment to such a slot anchors the rhs's heap fields in `self.__arena` (same mechanism as #5), then memcpys the rhs's bytes over the existing struct's bytes via the slot's pointer. The slot's pointer doesn't change. Since 2026-07-17 the store also runs a per-String-field replace fixup (`lotus_str_field_replace_fixup`): each old field pointer the memcpy overwrites is *retired* (pending until the user-method activation-boundary flush, then reusable — the same anchor-retirement machinery as @form(hashmap) set/remove), and a same-arena rhs pointer that the anchor walk passed through unchanged is force- copied so two self-storage slots never share a blob (single-owner; see #7's aliasing note). RMW round-trips (`self.X = self.X`, a scratch copy with untouched fields) keep their pointers and retire nothing. Net: repeated whole-struct replaces with fresh String contents hold `self.__arena` flat — previously each replace orphaned the old clones for the locus lifetime. String and Bytes fields (Bytes since GH #1033, `lotus_bytes_field_replace_fixup`: before it, a replaced Bytes blob — 8 bytes even for an empty payload — was orphaned on every whole-struct write); pointer-shaped compound fields of the replaced struct keep the pre-fix behavior, and fields of @form vec / bounded cells and stores from boundary-less contexts (a `run()` loop) do not retire. 7. **In-place String / Bytes reassignment at `self.X = heap_value`.** `lotus_str_assign_in_place(arena, old, new)` reuses the existing slot's buffer when `strlen(new) == strlen(old)`: memcpy the new bytes into `old`'s buffer and return `old` unchanged. `lotus_bytes_assign_in_place` is the Bytes companion — same shape against the `[int64 len] [payload]` Bytes header, in place when `new_len == old_len`. Static-literal `old` falls through to the clone path (`.rodata` isn't writable). At any OTHER length the abandoned buffer is *retired* (String since 2026-07-17, Bytes since 2026-07-18) — reusable after the activation-boundary flush instead of leaking — and the slot takes a fresh block from the retire freelist, or no block at all when the new value is a literal (stored as is) or empty (a static empty value). The retire freelist mixes align-1 String blocks and align-8 Bytes blocks; pops are alignment-aware (a String request reuses either kind, a Bytes request only 8-aligned blocks) and best-fit within their 8-block probe window (GH #1037): first fit let a String request take a Bytes-sized aligned block and left the Bytes request only a smaller one, stranding a block per cycle when an element freed one of each. Single-owner rule (2026-07-17): on every path that stores a replacement pointer, an incoming pointer that is already inside `a` is another self-storage slot's blob (fresh values live in method scratch; only self-storage reads produce same-arena pointers here) and is force-copied rather than shared. Pre-fix, `self.g = self.f` on the non-fitting path stored `f`'s own pointer into `g`'s slot, and `f`'s next in-place overwrite silently mutated `g` — a value-semantics violation, and unsound to combine with retirement. The codegen emits the right helper at every `self.X = String|Bytes` site inside a method-with-scratch, and at a deeper self-rooted path (`self.a.b = s`). A slot under a LOCAL root (`c.id = s`, `c.inner.id = s`) is never assigned in place: a `let` copy of a struct may share an unchanged String with its source, so the new value is cloned into the frame's arena and stored over the pointer (GH #993). Closes the per-update heap-field-reassignment leak class — measured against a per-frame `self.last_ts = ts` pattern in a long-running daemon: the receiver locus's arena dropped from ~+1-3 chunks per instance per 4 min to flat. A block is never shrunk in place (GH #1033). The length-tracking field — `strlen`, the Bytes prefix — is the block's only size record, so an in-place shrink lost the block's size: the next longer write retired it under the shrunk size, which no request of the real size matches, and allocated afresh. A field alternating between a heap value and an empty one leaked a block per cycle (~32 bytes for a 5-byte String, until the locus dissolved). Keeping the record exact makes every retire reusable. That exact record was still only the VALUE's size: once the general freelist handed a block to a shorter value (best fit within `size + size/4 + 16`), the block's next retire recorded the shorter length, so a block only ever shrank and a field whose length varied kept bump-allocating (~31 B per replace at lengths 6–55). Field blocks (GH #1490): once a locus replaces a String field, the field's values take blocks of size classes of their own (`lotus_slot_alloc`: 16-byte steps to 128 bytes, then four classes per power of two, to 16 MiB), each its class's whole capacity, recorded in the arena's capacity table (block → class, allocated with the arena's first replace and freed with the arena; one entry per field value it keeps). The retire (`lotus_slot_retire`) takes the block's class from the table, so the flush files it whole on that class's list, and the next value of the class takes it from the head. A field's memory is bounded by its largest value, however its length varies; a block the table does not know (a field's first values, a value above 16 MiB) is retired at its strlen+1 through the general freelist, as before. The compound store of #6 clones a struct's top-level String fields into field blocks too (`lotus_str_clone_slot`), and its fixup retires them the same way. Bytes fields keep the general freelist. 8. **m49 sret-pattern return-arena routing.** When a method- with-scratch is about to return a heap-typed value, the codegen sets `current_arena_override = caller_arena` for the duration of the return-expression lowering. Fresh allocations during that lowering (struct literals, nested call results) route through `current_arena_ptr` which honors the override, landing directly in caller storage instead of the method's scratch subregion. The boundary deep-copy in `emit_method_return_deep_copy` then contains- checks the value via `emit_cross_arena_store_deep_copy_ptr` and passes it through unchanged — no second memcpy. `populate_user_type_fields` conditionally deep-copies each heap field when the override is active (the `BookSignalSnapshot { buys: let_bound_buy_res, ... }` alias- field case anchors `buy_res` in caller_arena at field-init); the populate gate flips on `current_arena_override.is_some ()` so ordinary struct construction (`hashmap.set`'s Cell argument, locus param defaults) stays on the pre-rework path and downstream anchor same-arena skips remain intact. Closes the SweepResult / BookSignalSnapshot return-value leak class. **m49 closes the free-fn gap.** Every non-main free fn takes an implicit `__caller_arena: ptr` first param at the LLVM ABI. `main` keeps the program-wide `arena.global` it always had — it's the single fn without a caller. Heap-typed returns of Array, TypeRef-struct, or has-payload-Enum are rejected at v0.1 — none currently appear as free-fn returns; ship as a follow-up when a workload demands. **Allocation routing (post-2026-05-18 cross-seed-segv fix, commit 907837a).** Free-fn-body allocations now route to `__caller_arena` directly. The earlier m49 design routed them through a per-call subregion (`lotus_arena_create_subregion( __caller_arena)`); that proved unsound because the codegen has no escape analysis, so any value alloc'd in the subregion and stored on a longer-lived structure (canonically: pushed onto a `@form(vec)` on a foreign locus arg) dangled at fn-exit. The fix routes allocations directly to `__caller_arena`. The subregion is still created / destroyed at entry / exit so the cleanup hooks for `fail E { ... }` payloads still have a short-lived arena to anchor in, but the per-call performance tier the subregion was meant to provide is deferred — it needs escape analysis to ship safely. The fn-exit deep-copy into `__caller_arena` is now a same-arena memcpy in the common case (correct, marginally wasteful; can be elided in a follow-up). **Subregion elision for non-allocating bodies (FORM-3).** Codegen classifies each user fn at declare time via a conservative syntactic walk (`fn_body_definitely_non_allocating`). A body is non- allocating iff every expression in it lowers to a known-non- arena-touching shape: literals (incl. String — global static), identifier reads, KwSelf, field/index reads (excluding range-index slices), numeric/bool/bitwise Binary (Add excluded since it could be String concat without type info threaded in), Unary, If with non-allocating arms. For fns that pass the classifier, the subregion `create` + `destroy` are skipped entirely and the return-value memcpy epilogue is skipped — the return value is either a primitive or a pointer to a region stable across the fn frame (String-literal global, caller-passed pointer, field read of one of those). Closes the bench's per-call cost for leaf fns (`fn_call` went 188 ms → 37.1 ms = 5×, ratio vs Go 0.04× → 0.21×; `form_vec_push` reached 1.00× = Go parity). The `__caller_arena` LLVM param is still passed even to non-allocating fns (kept uniform per-fn ABI); the optimization is purely on the body side. Fallible fns always pay the full subregion lifecycle because `fail E { ... }` allocates the payload struct into the subregion. Post-907837a the elision benefit is narrower than its m49-era framing: with allocating-body allocations now routed to caller-arena directly (see "Allocation routing" above), the deep-copy epilogue is a same-arena memcpy and the subregion lifecycle is mostly overhead for cleanup hooks — the optimization still skips both, just with a smaller per-call cost being avoided. **Scratch-local free fns (GH #1148).** The escape analysis the routing above waits for exists for one narrow class, and that class allocates into its own subregion again. A free fn is *scratch-local* when nothing it allocates can be reachable after it returns except its return value: - its signature is not fallible, `@ffi` or `@export`, has no generics, and every parameter and the return (if any) is a by-value scalar (`Int`, `Uint`, `Float`, `Bool`, `Duration`) or a `String` — nothing that can carry a pointer to a longer-lived structure in or out; - its body holds no struct or locus literal, no method call, no publish and no `self`; it assigns only bare locals; and it calls only other scratch-local fns (by name, by import path, or by a `std::` path naming a Hale-source stdlib fn, which is held to the same rule), the value builtins (`len`, `to_string`, `min`, `max`, `abs`, `Int`, `Float`, `print`/`println` and their `e`-forms), or runtime primitives under `std::str`, `std::math`, `std::json`, `std::crypto` and `std::env`. The class is a greatest fixpoint over the call graph, computed once over the program lowering walks as a row of the lowering view (`hale_types::alloc_routing`). Its body allocates into the per-call subregion; the epilogue deep-copies the return value into `__caller_arena` and destroys the subregion, so everything else the call made is reclaimed at return. Every other free fn keeps the caller-arena routing. The case it closes: a helper that walks a String by re-slicing a loop-carried local (`rest = rest[(nl + 1)..len(rest)]`) makes one suffix copy per iteration. In the caller's arena those copies lived until the caller's scope ended, so a method asking such a helper for each line of an N-line text in turn held O(N³) bytes of garbage at once; a downstream handoff's API head scanning a ~600-row record exhausted its 512 MiB address-space bound inside one command. In the fn's own subregion the garbage lives for one call. Why a Hale-source stdlib fn is not waved through by its namespace: `std::str::bytes_view` returns a struct carrying its argument, deep-copied into the calling fn's arena. In the caller's arena the copy of a String already living there is skipped (`lotus_str_clone`'s same-arena passthrough); in a fresh subregion it is a real copy of the whole text per call. This delivers the spec's "every free function has its own implicit locus" memory boundary at the codegen substrate. Bound handles in free fn bodies still attach to the enclosing deferred-dissolve frame (lifecycle parity with main and lifecycle methods); the implicit-locus *handle-rooting* semantic — fn return waits for in-fn-bound children to dissolve as if the fn were itself a locus — remains a future-work item, not exercised by any current example. ### Per-projection-class arena strategies (m22 + m23) Each locus's projection class is resolved at codegen-declare time: from an explicit `: projection rich|chunked|recognition` annotation when present, otherwise per the spec/memory.md default rule (chunked if the locus declares accept; rich otherwise — recognition is explicit-only). The class drives the *child arena allocation* strategy when this locus accepts coordinatees: - **Rich** parents: each child gets a fresh top-level arena via `lotus_arena_create()`. Independent allocation lifetime; parent does no bookkeeping. v0 default for non-coordinator loci. - **Chunked** parents: each accepted child gets a sub-region via `lotus_arena_create_subregion(parent_arena)`. The parent tracks a slot index per child; on child dissolve, the slot returns to a per-arena free-list so peak slot space stays O(concurrent children alive), not O(total children ever accepted). Per F.3. The free-list + `next_slot` counter are protected by a per-arena `pthread_mutex_t subregion_lock` — without it, two threads concurrently creating or destroying children of the same parent (common under cross-pool cooperative placement where a worker's handler-scratch sub-region sits under the App arena) would race on the slot tracker: concurrent `realloc` of the freelist buffer, double-pop yielding duplicate slot indices, lost pushes. The lock window is O(1) per create/destroy (a counter increment or a freelist push); steady-state allocations within a sub-region remain lock-free. **Single-thread fast-path:** the lock/unlock and `pthread_mutex_destroy` are skipped — and init is a const `PTHREAD_MUTEX_INITIALIZER` copy rather than a `pthread_mutex_init` call — until the program spawns a second thread. A monotonic `g_runtime_multithreaded` latch (`lotus_mark_multithreaded`, set before every `pthread_create`: coop-pool workers, pinned-locus spawns via `lotus_bus_mark_pinned`, and unix/udp/shm transport reader threads) flips the lock sites on. The first transition happens while only the main thread exists and is inside spawn code (not an arena op), so no in-flight op observes it mid-op; thereafter all threads lock as before. Single-threaded programs pay zero mutex cost on the arena hot path (recovers the bulk of the +91% `locus_instantiation` regression the mutex introduced); the residual is the const-copy init, removable only by a lock-free freelist (deferred). - **Recognition** parents (v1.x-3): the sub-mode commitment spelled at the declaration site picks the allocator family. `fixed_cell` routes children through `lotus_recpool_fixed_acquire` (bitmap-tracked cells, inline arena per cell); `shared_slab` routes children through `lotus_recpool_slab_acquire` (every child shares one bump arena). Cell stride for either sub-mode is derived at codegen time from the parent's accept-method param type union — not a user-supplied byte budget. At parent instantiation the recpool is allocated via the matching `_create` fn and stashed on the synthetic `__recpool: ptr` struct field; at parent dissolve it's torn down via `_destroy`. The child's arena teardown is dispatched at the C ABI level: a discriminator on the child struct picks `lotus_arena_destroy` (kind=0, regular), `lotus_recpool_fixed_release` (kind=1), or `lotus_recpool_slab_release` (kind=2). The surface contract ("parent owns a pool, no dynamic allocation in steady state for `fixed_cell`; one bump for `shared_slab`") is exercised by `examples/14-projection-classes`. C runtime ABI as of v1.x-3: ``` ptr lotus_arena_create(void) ptr lotus_arena_create_subregion(ptr parent) // m22 ptr lotus_arena_alloc(ptr arena, i64 size, i64 align) void lotus_arena_destroy(ptr arena) // auto-detects sub-region // and returns slot to // parent's free-list ptr lotus_recpool_fixed_create(i64 cap, i64 bytes) // v1.x-3 ptr lotus_recpool_fixed_acquire(ptr pool) // v1.x-3 void lotus_recpool_fixed_release(ptr pool, ptr arena) // v1.x-3 void lotus_recpool_fixed_destroy(ptr pool) // v1.x-3 ptr lotus_recpool_slab_create(i64 cap, i64 bytes) // v1.x-3 ptr lotus_recpool_slab_acquire(ptr pool) // v1.x-3 void lotus_recpool_slab_release(ptr pool, ptr arena) // v1.x-3 void lotus_recpool_slab_destroy(ptr pool) // v1.x-3 ``` `lotus_arena_destroy` is unified across the regular + subregion shapes — it inspects the arena's optional `parent` pointer and slot, and returns the slot to the parent's free-list when present. The recpool variants are NOT unified with `lotus_arena_destroy` — their backing storage layout is distinct (inline-in-cell for fixed_cell; shared-bump for shared_slab) and routing through `lotus_arena_destroy` would corrupt the recpool's bookkeeping. The codegen dispatch discriminator (`__recpool_release_kind`) is what keeps the right release function reachable at child dissolve. > Forward-looking / deferred items for this area now live in the > decision log — see [`decisions.md` § Deferred & future > work](./decisions.md#deferred--future-work). ================================================================================ ## Runtime ## source: spec/runtime.md ================================================================================ # Runtime What every compiled Hale binary always ships with. Always- loaded; not optional; no `import` needed; the substrate every Hale program depends on. This document distinguishes the **runtime** (always there) from the **standard library** (`stdlib.md`, importable but bundled). Go's distinction between `runtime` and other stdlib packages is the model: runtime is automatic; stdlib is explicit. > **Naming note:** The language is **Hale**; the runtime/ > substrate concept is called **lotus**, and the C-runtime > symbols stay `lotus_*` (per project memory). When this doc > says "lotus" it means the substrate; "Hale" means the > language proper. ## What's in the runtime ### Memory - **Region allocator.** Per-locus arenas, hierarchical, freed on dissolution. Bump allocation within a region; no per-object metadata; no GC. The framework's lotus structure provides the scope; the allocator just respects it. - **Per-method scratch.** A locus method body (lifecycle / user-fn / mode) opens a per-call subregion of `self.__arena` at entry and destroys it at every return — *unless* the body provably allocates nothing and returns a by-value scalar (or Unit), in which case the scratch is elided (2026-06-28; an optimization with no observable effect — there's nothing to reclaim, so skipping the subregion just removes a `malloc`/`free` per call). Transient allocations made inside the body — `to_string`, `String` concat, `std::str::*` / `std::json::*` / `std::bytes::*` results, format-string composition — route through the scratch via `current_arena_ptr()` and get reclaimed at method exit. Heap-typed `self.X = expr` stores deep-copy into `self.__arena` before the store so persisted state outlives the scratch destroy. Heap return values are deep-copied into the caller's arena via a fn-local snapshot of `lotus_caller_arena_or_global()` taken at the method's entry block. Callers publish their `current_arena_ptr()` via `lotus_set_caller_arena` immediately before each method call (same TLS contract as stdlib primitives). Without this, long-running `run()` loops accumulated every transient allocation into the locus's lifetime arena — a real workload measured multiple MB/sec of growth on a hot message-dispatch path, OOM within minutes under a typical container cap. See `spec/memory.md` "Phase-4 per-method scratch reclaim" for the full design (invariants, cost model, interaction with the cross-seed-segv routing). - **Per-projection-class allocation strategy.** Rich → simple arena; chunked → arena with per-coordinatee sub-regions; recognition → recpool, sub-mode-typed at the declaration site (see "Recognition pool allocators" below). Selected at compile time per locus. - **Free-list within parent for bookkeeping reclamation.** When a coordinatee dissolves, its bookkeeping slot in the parent's arena is reclaimed via a per-arena free-list (chunked-class loci) or periodic defrag (high-churn loci). Reclamation is **per-arena**, **bounded**, **deterministic** — never stop- the-world. Coordinatee sub-regions remain pristine arenas freed wholesale on dissolution. - **F.22 capacity-slot allocators.** Each `pool X of T;` / `heap Y of T;` declaration on a locus adds a per-instance allocator. The C runtime ships two symbol families: | Family | Surface | Backing | |---|---|---| | `lotus_pool_*` | `create(cell_size, cell_align) -> pool*`, `acquire(pool) -> cell*`, `release(pool, cell)`, `destroy(pool)` | Linked list of chunks; each chunk is one malloc holding N contiguous cells. Free-list threads through the cells themselves (each free cell stores the next-free pointer at its base). Chunks grow geometrically (initial sized so one chunk fits in a host page when stride permits, else 16 cells; doubling; capped at 4096). Cell stride = max(cell_size, sizeof(void*)) aligned to cell_align. v1.x-17: initial chunk cell count is `max(16, page_size / cell_stride)` capped at 4096 — sysconf(_SC_PAGESIZE) queried once and cached; falls back to 4 KiB on systems where sysconf returns implausible values. | | `lotus_heap_*` | `create(cell_size, cell_align) -> heap*`, `alloc(heap) -> cell*`, `free(heap, cell)`, `destroy(heap)` | Doubly-linked live list with intrusive header (prev/next pointers) sitting just before each cell. `free()` unlinks in O(1); `destroy()` walks the list and frees every still-live cell wholesale. | Both allocator families are type-erased at the C ABI (sizes + aligns are i64 args). Cell alignment is 8 bytes uniformly in v0; loci with cells requiring >8-byte alignment (e.g. AVX-aligned types) are not supported. Per F.22 §"Slot lifetime", slot init runs after slot 0 / arena and destroy runs in reverse before slot 0 / arena. See `spec/semantics.md` "Capacity slot lifecycle and dispatch (F.22)" for the language-level surface and `spec/memory.md` "Capacity slots (F.22)" for the lotus-substrate framing. - **Recognition pool allocators (v1.x-3).** A locus with `: projection recognition(cap=N, )` allocates one recpool at instantiation; child loci accepted by that parent draw their arena from the pool instead of `lotus_arena_create _subregion`. Two symbol families ship in v1: | Family | Surface | Backing | |---|---|---| | `lotus_recpool_fixed_*` | `create(cap, cell_bytes) -> recpool*`, `acquire(recpool) -> arena*`, `release(recpool, arena)`, `destroy(recpool)` | One contiguous block of `cap × cell_stride` bytes. Each cell carries an INLINE `lotus_arena_t` + `lotus_arena_chunk_t` header at its front followed by `cell_bytes` of payload — the cell IS the child's arena. Bitmap (`uint64_t[ceil(cap/64)]`) tracks occupancy; acquire scans the lowest unset bit via `__builtin_ctzll`. Release clears the bit so the slot is reusable. The returned arena has `fixed_size=1`, so `lotus_arena_alloc` returns NULL on overflow (caller routes to the closure-violation channel). | | `lotus_recpool_slab_*` | `create(cap, slab_bytes) -> recpool*`, `acquire(recpool) -> arena*`, `release(recpool, arena)`, `destroy(recpool)` | One `lotus_arena_t` with an initial chunk of `slab_bytes` and `fixed_size=1` so it never grows. Every `acquire` returns the SAME arena pointer — children share the bump space and per-child release is a no-op. The whole slab frees at parent dissolve via `lotus_arena_destroy(slab_arena)`. `cap` is recorded but not enforced at the C layer (codegen's birth-cap check bounds concurrent children; the slab is a memory budget). | Both families return `lotus_arena_t*` from `acquire` so child body code stays projection-class-agnostic per the F.22 architectural invariant — the same `arena_alloc` path handles fresh, subregion, fixed-cell, and shared-slab children. The codegen dispatch at child dissolve picks the matching `release` fn via the synthetic `__recpool_release_kind` discriminator (0 = regular `arena_destroy`, 1 = fixed_cell release, 2 = shared_slab release). v1 ships `fixed_cell` and `shared_slab`; `spillover` and `summary_only` parse + AST through but reject at typecheck with a `v1.x pending` diagnostic (the spillover malloc-fallback machinery and the `summary_only` "no child arena allocation" type-system rule are separate work). ### Lifecycle - **Lifecycle dispatcher.** Invokes `birth → run → drain → dissolve` per locus; invokes `accept` on coordinatee attachment; invokes `on_failure` on child failure with the parent's policy. - **Interest-based ownership (accept bubbling)**. `accept(c: I)` collects not only a *direct* child but the nearest such acceptor for an `I{}` instantiated anywhere in the subtree: when a locus instantiates `I{}` and its direct enclosing locus does not `accept(I)`, ownership *bubbles* to the nearest enclosing ancestor that does (innermost-wins). Resolution is entirely static — there is no polymorphic locus instantiation, so the closed-world instantiation graph fixes every owner edge at compile time; no runtime ancestor walk. Backward-compatible by construction: innermost-wins selects the direct parent whenever it accepts, so no existing parent↔child edge changes; bubbling only *adds* an owner where a child would otherwise be a transient throwaway. An `I{}` with no accepting ancestor stays transient — ownership is opt-in via `accept`, and the absence of an owner is never an error. Same-tower bubbling costs nothing beyond the direct-parent case (the owner pointer is a constant for a singleton owner, or threaded down the birth chain for multiple owner instances — giving each owner instance its own isolated collection — then the ordinary accept path). A cross-pool owner (e.g. a `main locus` registry collecting entities spawned on a worker pool) is served by an async handoff over the bus queue: the child is born on the owner's thread and reclaimed by the owner's same-thread cascade, so a cross-pool `I{}` is **fire-and-forget** — it may only be a bare statement; using the instance as a value is rejected at compile time. Which of the two an `I{}` is follows the placement table per instance of the enclosing locus (F.40 phase 3, P1): the enclosing instance is paired with the owner instance above it, and a locus nested under a field placed off main runs on that field's thread. When an enclosing locus has instances on both sides of its owner's thread and the owner is a `main locus`, a bare `I{};` tests `lotus_on_main_thread()` at the literal and takes the same-tower birth on main, the handoff off it; a value use there, or such an edge to an owner with several instances, is refused at the literal. - **Order by construction, with latches.** A locus can't run before its birth completed, can't be torn down twice, etc. There is no runtime state machine: the order is the order the compiler emits, and the latches (`__arena` NULL, `__drain_requested`, `__quarantined`, the held-failure node's state) keep a step from running twice (§ "Lifecycle obligations", line 14). - **`drain()` cascades depth-first.** Calling `drain()` on a locus first recursively drains all its children (depth-first), waits for them, then drains itself. SIGINT and SIGTERM raise the process's draining flag and call no `drain()`: the `run()`s that read `self.draining` return, and the ordinary teardown follows (§ "Process control", § "Lifecycle obligations", line 15). No separate cascade syntax — `drain()` is always cascading. For locus-typed param fields specifically (F.29), the codegen walks `LocusRef` fields in declaration order at the cascade-teardown sites (ephemeral scope-exit and deferred-flush) and calls each child's drain BEFORE the outer locus's own drain. The subsequent dissolve cascade runs the outer's `closures → dissolve` body next, then per child `closures → dissolve → arena_destroy`, then outer's arena_destroy. **The walk is recursive, to the leaves**: a child's own `LocusRef` fields drain before the child does and are dissolved (and their arenas destroyed) before the child's arena, which holds their structs. Every level's gate is that level's own ownership mask, so a subtree handed in from outside is skipped wherever it appears and is torn down once, by its real owner. A param field typed by a **contract** rather than by the child's locus — an `interface` slot, a `perspective(P)` handle — carries an owned child on the same terms, and the cascade reaches it: the declared type names no impl, so the instantiation records the child's teardown in a synthetic per-field slot, a pair of its drain and the rest of its spine (dissolve → arena reclaim), and the cascade runs each half through it where a `LocusRef` field's runs, under the same ownership-mask gate: the drain with the other fields' drains, before the outer's drain, the rest after the outer's dissolve. The consequence users can check is arena residency: no locus arena, at any depth and behind any field type, survives its owner. A pinned locus's thread drains its own fields before its `drain()`; their dissolve cascade runs after its join. The cascade walks the fields in the order the lifecycle plan places them (§ "Lifecycle obligations", line 12: declaration order). Each field's dissolve completes before the next starts; physical release may wait for an active run. The owner retains those children until its own storage can be released. An `accept`'d child is reclaimed on its OWN run-completion / `terminate` when it is a flow (see "Per-child reclamation" below) rather than waiting for the parent's cascade. - **Per-child reclamation**. An `accept`'d child's `run()` is posted to its pool as a coro, run through a synthesized `__coop_pool_run_` wrapper. When that run() completes, the wrapper reclaims the child — drain → (for a flow) the parent's `release(owner, self)` → dissolve → arena/recpool release — iff the child is a **flow** (some declared locus has `release(c: ThisType)`) OR it set the `__drain_requested` latch via `terminate;`. A non-flow ("resident") child whose run() merely returns is NOT reclaimed (it lives to parent dissolve). The reclaim runs on the child's own pool worker while its arena is valid; `emit_locus_arena_ destroy` is idempotent (NULLs `__arena`), so a later parent-dissolve of the same locus no-ops. At pool shutdown a coro may still be PARKED (a listener in `accept()`); the **wakeable park** handles it: a per-pool wake `eventfd` in the pool's epoll lets `shutdown_all` unblock a worker sitting in `epoll_wait(-1)` (the condvar broadcast can't), so the worker returns from the drain and the join completes instead of hanging. The parked coros are then *abandoned* — their stacks freed without resuming them — because a forever-loop `run()` (`while true { accept }`) cannot be cooperatively unwound without the loop checking `self.draining` (a future refinement), and the process is exiting anyway. Per-child reclamation proper (terminate / flow run-completion) never needs this: there the coro returns from `run()` on its own. - **Classic-pool blocking-accept shutdown.** A *classic* (non-`async_io`) pool worker blocked in a blocking `accept(2)` inside a locus's `run()` (e.g. `std::http::Server` or `std::io::tcp::Listener` placed on a plain `cooperative(pool = X)`) can't be woken by the wake `eventfd` (there is no epoll on a classic pool) — so two rules keep its teardown clean: (a) the classic `accept` polls the listen fd with a short timeout and checks its pool's shutdown flag, so it returns a sentinel `-1` once `shutdown_all` is signalled and the stdlib accept loops (`Server`/`Listener`) break out of their forever loop; and (b) the **main locus joins all pool workers before dissolving its `params` fields** — whether it dissolves eagerly or, being a subscriber, at the exit of the fn that instantiated it, `main` or another (`fn main() { start() }` with the locus built in `start`; GH #1148) — so a worker still executing a pool-placed field's `run()` can never touch that field's arena after it's freed (the alternative — freeing first — is a use-after-free; the alternative join-without-(a) is a hang). Together these let a program whose `main` run() returns while a classic-pool server child is live shut down cleanly rather than hanging or segfaulting. - **Teardown delivery contract (GH #253, 2026-07-27).** A dissolving parent joins its own **pinned** children — mailbox shutdown, `pthread_join`, then a bus drain — BEFORE cascading its field children's drain/dissolve, and the fn-exit flush likewise joins subscription-less pinned entries before any cooperative entry's teardown. Consequence: events a pinned worker publishes in its final moments (up to `run()` return) are **delivered** to sibling subscribers, in any declaration order — a parent whose `run()` returns immediately no longer silently drops its workers' last publishes (the hale-bun install-fanout shape). This is the pinned mirror of the pool-worker join rule above, and it also closes the lifetime hazard of destroying the owner's arena (which holds the pinned children's self structs) while their threads still ran. What still drops, by contract: a publish made **after** its last subscriber has dissolved — e.g. from a pool-placed publisher whose queued work outlives the process teardown, or from a `dissolve()` body publishing to a subscriber that tore down earlier in the same cascade. Those cells are discarded via the deregister-on-dissolve invariant (never dispatched to freed memory); `LOTUS_BUS_LOG_DROP=1` surfaces them. A workload that must not lose such events coordinates completion explicitly ("exit when done" — poll a tally / await a completion event) rather than relying on teardown ordering. **Deferred-path completion (Crumb batch-4, 2026-07-28):** the contract holds regardless of which teardown path the parent takes. Previously a bus subscription on the main locus made it long-lived → deferred, and the deferred flush processed the parent (cascading its subscriber fields) BEFORE joining its own pinned children — the exact inversion the contract forbids, triggered by the one-line addition of a `subscribe` on the root, dropping every in-flight result silently. A deferred parent's own pinned entries are now re-ordered after its own frame entry, so the reverse-order flush joins + drains them while every subscriber field is alive — identical semantics to the eager path. On both paths a main locus's head (the ingress quiesce, the wait-abort, the pool join) comes before the first of those joins (§ "Lifecycle obligations", line 7), so a pinned field parked in an `or wait` is released into the raise path before it is joined, never hanging the join. - **Recovery primitives.** `restart`, `restart_in_place`, `quarantine`, `reorganize`, `bubble`, `dissolve`, `drain` — all language keywords; runtime implements the actual effects. ### Scheduler — multi-scheduler cooperative Lotus uses a **multi-scheduler cooperative** model (closest existing analog: Erlang BEAM, *not* Go's M:N). The reasons are framework-discipline: - **Lateral-access prohibition is physical, not just typed.** Within a single cooperative scheduler, sibling loci cannot run concurrently — only one locus is executing at a time per scheduler. There is no thread of execution that could attempt a lateral memory reference. The compile-time type rule ("vertical-only flow") is reinforced by the substrate. - **Substrate-cell atomicity is naturally aligned.** Cooperative yield points — between message-handler invocations, between lifecycle phases, on bus dispatch — are exactly where the substrate-cell boundary lives. No preemption inside a substrate-cell because the runtime can't preempt at all; it only switches at yield points. - **Per-scheduler region allocators.** Each scheduler is single-threaded, so its allocator state is naturally per-scheduler with no synchronization. Lock-free by construction. - **Failure-traversal is a call-stack walk on one scheduler.** No cross-thread synchronization for parent-catches-child failure when both are on the same scheduler. Concurrency comes from running **multiple cooperative schedulers in parallel** (one per CPU core, by default). Loci belong to a specific scheduler; cross-scheduler communication uses the bus just like cross-process communication. Loci may be migrated between schedulers transparently for load balancing because all their communication is bus-mediated already. Specifically: - **One scheduler per CPU core** at startup, configurable. - **Cooperative yield points**: between handler invocations, between lifecycle transitions, on bus message dispatch, on explicit `yield` (rare, for long-running computations). Plain fn exit is NOT a yield point — and since 2026-07-02 a proven-non-allocating fn's exit provably skips the queue drain (a non-allocating body cannot have published; payload copies allocate). A cooperative compute-only loop that leaned on helper-call returns for delivery never had that guarantee and must use an explicit `yield;`. - **No preemption within a scheduler.** A locus's handler runs to completion or an explicit yield. - **Cross-scheduler is bus.** No shared memory; no locks. - **Failure-traversal**: if parent and child are on the same scheduler, failure-traversal is a stack walk. If different schedulers, the failure is delivered as a typed bus message to the parent's scheduler, which dispatches to `on_failure`. ### Placement classes (per-locus execution strategy) Just as **projection class** governs a locus's memory strategy, **placement class** governs its execution strategy. Placement is a *deployment seam*, not an intrinsic property of the locus (see `spec/decisions.md` § F.31). Placement entries live in a `placement { }` block on `main locus` only, parallel to `bindings { }` for bus topology: ```hale main locus App { params { gateway_a: Gateway = Gateway { venue: "venue-a" }; gateway_b: Gateway = Gateway { venue: "venue-b" }; metrics: MetricsServer = MetricsServer { port: 9100 }; ui: Renderer = Renderer { }; } placement { gateway_a: pinned(core = 1); gateway_b: pinned(core = 2); metrics: cooperative(pool = io); ui: cooperative(pool = render); // unspecified main-locus params → cooperative(pool = main) } } ``` Placement remains honestly **bimodal**: either a locus shares a cooperative pool (an OS thread running a cooperative drain loop) or it owns its own OS thread. There is no third position. | Class | Yield discipline | Resource | |---|---|---| | **`cooperative(pool = X)`** (default for unspecified main-locus params, with `X = main`) | Yields between substrate cells (handler exit, lifecycle transition, bus dispatch, `time::sleep`, explicit `yield`). `time::sleep` slices into ≤100ms intervals and folds in `lotus_bus_queue_drain` for the locus's pool after each slice, so cells posted by other threads deliver mid-loop even during a long keep-alive sleep. Handler bodies are atomic. | Shares pool `X`'s OS thread with other cooperative loci placed on the same pool. | | **`pinned`** / **`pinned(core = N)`** / **`pinned(cores = A..B \| A..=B \| {a, b, c})`** / **`pinned(node = N)`** / **`pinned(l3 = name)`** / **`pinned(..., replicas = K)`** | No yield to siblings; owns its OS thread. Bus events to/from cross-thread boundaries via formal mailbox post. | Dedicated OS thread. `core = N` pins it to one CPU; `cores = ...` (Phase 1a) sets its mask to a core *set*; `node = N` / `l3 = name` (Phase 1b) set the mask to a NUMA node / cache domain from `topology { }` (and bind the arena there); `replicas = K` (Phase 1c) fans into K single-threaded instances, one per core. The OS schedules freely within the mask. Linux-only; best-effort no-op elsewhere. | **Pool inference rule.** The cooperative pool set is inferred from `cooperative(pool = X)` references in the `placement { }` block. The runtime spawns one OS worker thread per inferred pool name beyond `main` (which is always the program's main thread). No separate `threads { }` declaration block at v1 — when per-pool attributes (priority, affinity, realtime hint) become useful, the block lands then as a typed extension. Pool `main` exists in every program regardless of whether `placement { }` references it. **Nested-instantiation inheritance.** Placement entries apply only to top-level `main locus` `params` fields. Loci instantiated nested in another locus's body (in `birth` / `run` / lifecycle methods, or as let-bound children) inherit the parent's pool. There is no way to spell "this nested child runs on a different pool than its parent" — that would require the nested-instantiation expression to carry placement, which would re-mix the deployment and intrinsic layers F.31 separates. **Topology block (Phase 1b).** A `main locus` may also declare a `topology { }` block — a **declare-only** description of the host's core partition, a sibling deployment seam to `placement { }` / `bindings { }`: ```hale topology { reserve cores 0..2; // held back for the OS / main node 0 { l3 fast { cores 4..8; } // a CCD / shared-L3 group l3 slow { cores 8..12; } } node 1 { l3 heavy { cores 12..16; } } } ``` A `placement { }` entry then targets a domain: `pinned(node = 0)` sets the thread's affinity mask to node 0's core set (the union of its L3 domains — here `{4..12}`), and `pinned(l3 = fast)` sets it to the named domain's cores (`{4..8}`). The compiler resolves the domain to a concrete core set at compile time (closed-world: node ids and domain cores are literals), reusing the same cpuset affinity mechanism as `pinned(cores = ...)`. Validation (unique node ids, globally-unique L3 names, non-overlapping domains, no domain/reserved overlap, and every `pinned(node/l3)` referencing a declared domain) is static. L3 domain names go through the identifier rule, so a hard keyword (e.g. `bulk`) can't name a domain. **Replicas (Phase 1c).** `pinned(..., replicas = K)` is the parallelism sugar: it fans the field into **K single-threaded instances**, replica `i` pinned to one core of the affinity set (round-robin — `pinned(cores = 4..12, replicas = 8)` puts replica `i` on core `4 + i`; more replicas than cores wraps; with no affinity the K instances are OS-scheduled). This is deliberately *not* a multi-worker pool — a cooperative pool is one consumer thread, and the lock-free rings, bus devirtualization, and single-threaded-method guarantee all rest on that invariant. Parallelism instead comes from more single-threaded units, each its own single consumer, so every invariant survives. `replicas` is **pinned-only** (K cooperative loci on one pool would share a thread, which isn't parallel) and composes with the topology targets (`pinned(node = 0, replicas = 4)` fans across node 0's cores with each replica's arena bound to node 0). Codegen emits K instantiations at the field's init site; all K register their bus subscriptions (a subscribed topic fans out to every replica) and all K are joined + dissolved at parent teardown via the deferred- dissolve frame. The replicas are non-addressable — there is no `field[i]` surface; they are workers that pull from the bus or run their own loop. **The dispatch plan (GH #476 Change 8).** Which lowering a subject's dispatch receives — `dynamic` (runtime hash lookup), `static_bucket` (compile-time subject id, dispatch still queued), `static_direct` (synchronous direct calls to every subscriber) — is a CONCLUSION, not a fact declared anywhere in the program. A program has one plan, derived once (`DispatchPlan::from_gates`) from one gate set: the bus graph's sites, each keyed by its wire subject, and the bundled stdlib's sites after them (the logger's `log.**` and its sinks, which every program's lowering dispatches). Lowering lowers that plan, the execution identity frames its digest, and the canonical model holds it restricted to the subjects the program's own bus sites name and the loci the program declares. The bus graph's per-subject eligibility gates decide the flavor (a single ladder, `DispatchFlavor::of`, which the backend calls rather than re-deciding; `static_direct` takes three legs: every publisher and subscriber same-thread, every handler quiet, and the gate's `payload_flat` column, a payload struct whose every field is an inline scalar — a direct-eligible subject with a managed payload is `static_bucket`), and the Change-8 arrangement supplies each row's publisher/subscriber **thread domains** and `same_domain` — "every publish site and every subscriber of this subject sit in one domain", the precondition the future placement-driven flavors (GH #464) need, withheld rather than guessed whenever the model cannot place a locus's WHOLE population — a locus with both an arranged instance and a dynamic birth is incomplete, and its arranged instance does not answer for the instances the model admits it cannot see. A locus's domains are found by the gates' own spelling of it, the canonical post-merge name, never the display name, so a locus of an imported seed has its domains like any other (before F.40 phase 3's C5 they were found by the display name, which demangles an imported locus and never met the gates' spelling, and every such row printed no domain). Plan subjects are WIRE subjects; `hale model dump` prints the model's restriction of the plan and its same-domain count. The gates' placement leg reads the placement table (F.40 phase 3, P1): a type is same-thread only when every instance of it runs on main, so `static_direct` needs every publisher and every subscriber type to be. An instance nested under a root field placed off main runs on that field's thread, an adapter in `bindings { }` on its own, and an instance the table cannot place (a literal in a scope whose domain is unknown) is never taken for main; each keeps its subject on a queued flavor. The same answer decides where a subscriber's `bounded(N, …)` is legal (main-queue registrations only, `spec/decisions.md` F.37). **Binding roles and replica indices in the model.** A binding's role is the authored `role:` kwarg when present and otherwise the inferred one — publish-only is `connect`, subscribe-only is `listen`, and an ambiguous or unused binding is a typecheck diagnostic, never a default. One rule serves both the desugar and the canonical model (`hale_syntax::desugar::binding_role_for`); before GH #476 Change 8 the desugar ran its half AFTER topic references were rewritten to literal subjects, so the publish and subscribe ends were always empty and no role was ever inferred — every binding had to spell `role:` or codegen refused it. Loss behavior follows the role, because the runtime does: the connect side is the publish side, where a send failure marks the entry lost and `or wait` parks through the reconnect window; the listen side re-arms on peer EOF and a link it cannot serve is structural. A `pinned(..., replicas = K)` field contributes K instances whose model rows carry their own 0-based INDEX — the same `i` codegen bakes and a keyed subscriber registers under — and the model refuses a replica set that is not contiguous from 0. Replica-ness belongs to the LAST path component: the arrangement walks into each replica, so what a replica owns (`App.workers[0].leaf`) is an ordinary child that inherits its owner's domain and carries no index of its own. A subject named by a `bindings { }` entry is never devirtualized: an external peer is (or may be) the real counterparty, so the dispatch has to go through the transport the binding realizes. That exemption is keyed at BOTH grains — the topic decl name a binding entry writes and the wire subject a desugared program dispatches on — because the backend builds its graph after topic desugaring, and a decl-name-only gate silently let a bound subject be bucketed past its own adapter. The plan is part of what a build **is**: its digest is framed into the executable identity (`exec_digest`) alongside the toolchain hash, the compiler version, the build options, and every source byte. The plan framed there is the one the backend lowers: derived once from the bus graph over the merged, topic-desugared program the backend walks, with the same ladder and the same domains as the model's plan (one domain map, from the arrangement the model's rows are projected from; F.40 phase 3, C5). The plan's digest covers what lowering reads, each subject's flavor and subscribers; the `same_domain` column is consulted by no flavor today, so the byte it held is reserved and always 0, until the same-domain flavors (GH #464) lower by it. Two builds of identical sources that lower dispatch differently — notably the all-dynamic `LOTUS_NO_BUS_DEVIRT=1` control arm — therefore have different identities, and a recording made under one is refused against the other rather than replayed against a bus that behaves differently. **Thread + memory co-location.** A `pinned(node = N)` / `pinned(l3 = name)` locus binds not just its thread but its *memory*: its arena is created via `lotus_arena_create_labeled_on_node`, which flags the arena's NUMA node, and every chunk that arena grows is `mmap`'d (page-aligned) and bound to the node with the `mbind` syscall (`MPOL_BIND`) before first touch — so pages fault in on the node regardless of which thread touches them first (the locus struct is instantiated on `main` but runs on its own pinned thread). Sub-regions inherit the node, so a node-pinned locus's **method scratch** — the dominant per-invocation allocation — lands on its node too. `mbind` is invoked as a raw syscall, so this adds **no libnuma dependency**; the whole feature is zero-cost for programs that don't opt in (an unbound arena, the default, takes the ordinary malloc / chunk-pool path byte-for-byte). Best-effort and Linux-only, exactly like `pinned(core = N)`: an `mbind` the box can't honor (node absent, capability denied) falls back to first-touch, and on non-Linux hosts the arena allocates normally. (Huge-page-backed chunks and node binding don't currently combine — a node-bound arena uses regular pages; a follow-up.) **Single-threaded-method invariant.** A locus's methods may be invoked only on the OS thread that owns its placement's pool. Cross-pool method calls and lateral field accesses go through the bus's existing copy-and-condvar dispatch machinery, which already crosses thread boundaries safely. The typechecker walks the static call graph from each top-level placement entry, propagates pool ownership through method receivers, and rejects calls that cross pools without going through the bus. This is the substrate enforcement that makes M:N safe — without it, multi-pool deployments would silently race on locus arenas (which are unsynchronized bump allocators). The single shared **bus payload arena** is the deliberate exception to "one owning thread per arena": it is reachable concurrently from any pool, because some stdlib primitives allocate their result there directly rather than into a per-locus arena (e.g. `std::io::tls::recv_bytes`, which always targets it regardless of the caller's per-thread scratch). That arena therefore carries an internal lock on its bump — two pinned loci calling such a primitive at once do not corrupt each other's allocations. Per-locus arenas keep the lock-free bump; only this one shared arena pays for the lock. #### Why no "greedy" class A natural temptation is to want a third option: "shares a pool's thread but doesn't yield." That would be a bimodality violation. Cooperative already guarantees handler-level atomicity — no preemption within a substrate cell — so the only thing such a class could add over cooperative is "don't yield *between* cells either." But that means leaving the shared pool entirely. The place you go when you leave is your own thread. That's pinned. Latency-critical work, or anything that genuinely shouldn't share with neighbors on its pool, is signaling that it belongs on its own pool (placed `cooperative(pool = some_quiet_pool)`) or on its own thread (placed `pinned`). The first option is new in F.31: pools partition the cooperative substrate so "shouldn't share with siblings" no longer forces pinned — but the underlying bimodality (cooperative-pool vs pinned-thread) holds. #### `time::sleep` drain semantics The codegen lowering of `std::time::sleep(d)` slices the request into intervals of at most **100ms** and ends each slice's EINTR-retry loop with an inline call to `lotus_bus_queue_drain` against the program-wide cooperative queue (plus a pinned-mailbox drain). The total wall-clock sleep is preserved (the slices sum to `d`); sleeps of 100ms or less take exactly one slice, so the common case is unchanged. A cooperative subscriber looping ```hale run() { while !self.bail { std::time::sleep(100ms); // ... loop body sees handlers fired during the sleep } } ``` receives cells posted by other threads — unix-bound reader threads, pinned publishers via `lotus_bus_local_dispatch`, etc. — right when each sleep returns, without an explicit `yield;`. The drain is idempotent, so existing code with `sleep; yield;` stays correct. **Which path delivers to whom.** The queue drained *here* is the program-wide *in-process cooperative* queue, drained on the `main` thread — so this sleep-loop drain delivers in-process cells to a cooperative subscriber on the `main` pool. It is **not** the only delivery path: cross-process topics (`udp://` / `unix://` bound via `LOTUS_BUS_CONFIG`) are delivered by the transport reader thread, which dispatches directly into the subscribed handler set and reaches a cooperative locus on *any* pool — **provided that pool's thread is free to run the dispatch.** So a non-`main` cooperative subscriber receives reliably as long as it doesn't monopolize its pool thread with a blocking call. (Pinned loci are a third path: a per-locus mailbox drained at each `sleep`/`yield`.) The failure mode is a cooperative subscriber whose `run()` *blocks* — the dispatch can't run and its handlers never fire; that blocking-and-subscribing combination is what the dead-bus-receiver rule rejects (see "Type-check rules" in `spec/semantics.md`), **not** non-`main` placement on its own. **Long sleeps no longer starve main-pool handlers.** Before the slicing, the drain happened only *after the whole sleep returned*, so the natural keep-alive idiom ```hale run() { while true { std::time::sleep(60s); } } // on the main thread ``` starved every `pool = main` bus handler for 60s at a time: a main-pool subscriber registers with `coop_pool == NULL`, so the wire/reader-thread dispatch path lands its cells on the global cooperative queue (`g_bus_queue_for_remote`, the same object only `main` drains). A 60s blocking sleep on `main` left those cells unserviced for the full 60s — indistinguishable from "the handler never fires." Slicing keeps the queue serviced ~10×/s during any sleep, so a main-pool handler that republishes onto a topic an async-pool subscriber listens to (e.g. a udp-reader-fed producer forwarding to per-connection writers) now flows promptly regardless of how long `main` is asleep. The pinned-mailbox path is unchanged: pinned subscribers wake on `lotus_mailbox_post`'s condvar broadcast regardless of what the cooperative scheduler is doing. #### Owner-executed handlers (2026-07-15, downstream handoff) Two runtime rules restore the single-threaded-locus invariant (F.31) *dynamically* — the compiler already enforces it for direct calls, but two bus paths used to violate it under load (reproduced as a SIGSEGV in a 10k msg/s ingest bench): 1. **The global cooperative queue is drained only by its owner thread** (`main`, recorded at queue creation). The scope-exit flush emitted at the end of every fn/method body — and the sleep-slice / `yield` drains — call `lotus_bus_queue_drain` on whatever thread ran the body; on any thread but the owner the call is now a no-op. Previously a pinned publisher's flush would execute a main-pool subscriber's handler on the publisher's thread, concurrently with `main`'s own drains (the locked drain releases the queue mutex before each handler invocation) — two threads inside one locus. 2. **Payload deserialization happens on the subscriber's owner thread.** The non-flat dispatch paths deserialize each published payload into the subscriber's arena (Task-11 arena routing); for a target owned by a different thread this write used to happen on the *publisher's* thread — an unlocked cross-thread write into a foreign arena. Now a cross-thread publish enqueues the *wire bytes* plus the deserialize fn in the cell, and the owner materializes (deserializes into its own arena) at drain, just before invoking the handler. Same-thread targets take the same wire cell (2026-10-11): the deserialize-at-dispatch path they kept wrote the payload's owned fields into the subscriber's locus arena for good (spec/memory.md § Cross-thread wire cell per-delivery reclaim). Consequences: a main-pool subscriber's handlers run **only on `main`** (at sleep-slice / yield / scope-exit drains — worst-case ~100ms after a cross-thread publish, per the slicing above); pool subscribers run only on their pool's worker; pinned subscribers only on their own thread. Flat (pointer-free POD) payloads are exempt from rule 2 — a verbatim byte copy writes no arena. Delivery and FIFO order per subscriber are unchanged. **Item B from the 2026-05-21 friction log** (a cooperative publisher's `<-` to a pinned subscriber) is **resolved as of 2026-06-01** — the earlier "drains only at dissolve in some configurations" was a *sequencing* effect, not a lost wakeup. The mailbox condvar path is correct: a pinned subscriber whose `run()` **returns** proceeds into the blocking `lotus_mailbox_drain_one`, and a cooperative publisher's `<-` wakes it via the `not_empty` broadcast — confirmed by `coop_to_pinned_mid_program::pinned_returning_run_drains_mailbox`. The residual constraint is inherent to a single pinned thread: a pinned subscriber with a **long-running `run()`** that never returns or yields cannot drain its mailbox *during* `run()` — the one thread is busy in the loop. Such a `run()` must reach a cooperative yield point (`time::sleep` / `yield`) for the TLS-cached `lotus_mailbox_drain_pending` to service the mailbox (the `coop_to_pinned_mid_program` sleep-loop test), or let `run()` return so the post-run blocking drain takes over. This is a property of the bimodal model — "pinned owns its thread, no yielding between cells" — not a bug; a pinned subscriber that must receive bus traffic while busy should yield in its loop. Cross-binary flows (unix / shm_ring with their own reader threads) land in `g_bus_queue` and benefit from the sleep-folded drain above. #### Long-running cooperative children: placement closes Item D Pre-F.31, declaring a long-running cooperative child as a `params` field (`metrics_server: std::http::Server = ...`) serialized parent and child onto the main thread: the child's `run()` body never returned, so the parent's `run()` never started. The workaround was the **sibling-in-main pattern** — hoisting the child to a top-level `main locus` param so it ran "alongside" the parent rather than "inside" it. Under F.31 the sibling-in-main pattern IS the canonical shape, and it composes cleanly because `placement { }` lets each sibling pick its own pool: ```hale main locus App { params { gateway: Gateway = Gateway { }; metrics: std::http::Server = std::http::Server { port: 9100 }; } placement { gateway: pinned(core = 1); metrics: cooperative(pool = io); } } ``` Parent's `run()` no longer serializes against `metrics`'s accept loop — they sit on different OS threads. To shut down gracefully when one finishes (e.g. a duration-bounded gateway exits), call `metrics.shutdown()` from the finishing locus's thread; the C-iii interruptible-accept work makes this the supported pattern. The nested-as-child shape (long-running cooperative locus as a `params` field of a non-`main` locus) remains structurally serialized — that's a consequence of the nested-instantiation inheritance rule (children share their parent's pool by construction). Nested long-running children are an antipattern under F.31: hoist to main-locus siblings. **Typecheck enforcement.** The compiler rejects the antipattern at typecheck. A non-main locus with a non-trivial `run()` body holding a `params` field of a locus type whose own `run()` is also non-trivial — including `std::http::Server` and the other entries on the known-long-running stdlib allowlist — gets a hard error pointing at the canonical sibling-in-main + placement fix. The runtime starvation that motivated this rule is silent (the parent's `run()` simply never executes), so the type-side rejection is load-bearing: it converts a class of hard-to-diagnose runtime bugs into a clear compile-time signal. The rule asks whether a `run()` is **long-running**, which is not whether it **never returns**, and the two are two definitions, two columns of each locus's run row (`hale_types::flows::RunRow`, read through the flow rows): - **long-running**: the `run()` body has a statement of its own. A nested child's `run()` runs to completion before its parent's begins, so any body delays the parent whether or not it returns: a child whose `run()` is `std::time::sleep(1min)` is long-running and draws this error. This rule reads it. - **never returns**: the `run()` body's last statement is a `while` with no exit whose condition never flips false (`while true`, `while !self.draining`, or a Bool params flag no member assigns, whose default keeps the loop live). Only such a body starves the cells a cooperative pool runs after it: the pool-starvation warning and the birth-order trap read it. Every body that never returns is long-running; the converse does not hold. A stdlib locus, whose body the checker does not see, is both when it is on the known-long-running allowlist. #### `where async_io` — green-I/O cooperative pools (F.35) The sibling-in-main fix puts each long-running child on its own OS thread, which caps concurrent connections at one-per-pool. To scale beyond that without spawning a thread per connection, a placement entry may declare `where async_io`: ```hale main locus App { params { listener: std::websocket::Server = std::websocket::Server { ... }; worker: WsWorker = WsWorker { ... }; } placement { listener: cooperative(pool = ws_accept) where async_io; worker: cooperative(pool = ws_workers) where async_io; } } ``` `where async_io` opts the pool into green-I/O scheduling: the pool's worker drain loop integrates an epoll instance, and blocking I/O syscalls inside locus methods on this pool park- and-resume instead of blocking the OS thread. The user code inside the locus is unchanged — `recv_bytes(stream)` reads the same line of source whether the pool is `async_io` or not; the substrate picks the right lowering at the syscall boundary. `std::http::Server` is the stdlib's case of it: placed on an `async_io` pool it serves each connection on a coroutine of its own, and anywhere else one connection at a time (`spec/stdlib.md` § "Server concurrency"); the TCP connection substrate it is built on places its listener and its consumers apart (`pinned` accept, `async_io` serving: `spec/stdlib.md` § "The TCP connection substrate"). **One worker thread per named cooperative pool — a promise, not an implementation detail** (Crumb batch-3, 2026-07-28). Every named cooperative pool (`async_io` or classic) has exactly one OS worker thread for the lifetime of the program. Consequences a consumer may depend on: every `run()`, bus handler, and parked- coro resume for loci on the pool executes on that one thread (coroutines interleave on it; they never migrate), so a thread-affine C library — a JS engine, SQLite in serialized mode, a GUI toolkit — placed on a named pool is entered from a single thread by construction, and `@export` re-entry from foreign code called on that thread stays on it. Concurrency within a pool is cooperative only. If a scale-out mechanism ever lands, it will be a new opt-in placement form; `cooperative(pool = name)` keeps the single-worker guarantee. Because parking yields the shared worker, N reader loci that each park on their own fd — the F.35 one-reader-per-signal shape — are serviced concurrently by a single pool. Two invariants make that multiplexing correct: (1) the drain loop starts a queued `run()` the moment the running coro *parks*, not only when it completes, so a long-lived reader never starves the readers queued behind it; and (2) each coro's caller-arena — the thread-local that decides where its stdlib allocations (recv result blobs, string builders) land — is snapshotted across the park and restored on resume, so a coro that resumes after a sibling ran (and perhaps dissolved) never allocates through an arena the sibling has since torn down. Every blocking-recv primitive on the pool honors invariant (1) by parking rather than blocking `recvfrom`/`read` (the `std::io::udp` recv family joined the `tcp`/`tls` siblings here in the 2026-07-15 downstream handoff, and `std::time::sleep` joined them 2026-07-28 (Crumb batch-5) via a timer-only park — a deadline with no fd, serviced by the same expiry sweep — so sleeping coros overlap on one worker and a `sleep` inside a handler never blocks the pool; the timer half of an event loop now costs what the design always claimed). Each bus delivery to a subscriber on an `async_io` pool runs its handler on a coroutine (a struct + a 64 KiB stack). Rather than allocate and free that pair per delivery, the pool keeps a bounded per-worker free-list (cap 64) of completed coro slots and reuses them — a warm fan-out skips the per-dispatch stack allocation entirely (2026-07-16). The free-list is worker-thread-local (no lock) and drained at pool teardown, so a busy async pool retains up to 64 × 64 KiB (~4 MiB) of coro stacks at steady state. Transparent to user code — a pure allocation optimization, no behavior change. **A handler's payload is its own until it returns, across parks** (GH #781, 2026-09-19). The storage a delivery's payload lives in belongs to the coroutine that runs the handler, for the whole invocation: the cell's inline payload bytes are copied into the coro when it starts, a spilled heap payload and the wire path's per-delivery subregion transfer to it, and all three are released once the handler *returns* — however many parks later. So a handler that parks on a `sleep`, a socket read, or a subprocess drain and then reads its payload parameter again reads what it read at entry; concurrent deliveries to the same subscriber never share payload storage. This did not hold before: the drain dequeued each cell into a stack local and handed the handler a pointer into it, so a parked handler returned to a frame the next dequeue had already reused, and every parked delivery read the LAST published value (the same mechanism leaked a >512-byte spilled payload per park). Pinned subscribers were never affected — a mailbox cell outlives the handler it dispatches, which has no coro to park on. Typecheck rules: - All placement entries on the same named cooperative pool must agree on `where async_io`. The pool's drain loop is one-or-the- other. - `where async_io` is rejected on `pinned` entries. Pinned loci own their own OS thread and have no shared drain loop to park on. - `where async_io` is rejected on pool `main`. The main pool runs inline on the binary's primary thread, with no dedicated worker to integrate epoll into. See `spec/decisions.md § F.35` (forthcoming) for the green-I/O substrate design + perf-axis trade-offs. (Compare: rich / chunked / recognition projection classes are genuinely three-way because N≈10, N≈30, and N≈300 are different cost regimes at scale — memory has more genuine intermediate ground than time does. Placement, even with M:N pool partitioning, stays bimodal.) #### Cross-class bus semantics - **Cooperative → cooperative, same pool**: handler enqueues on the pool's queue; runs at the next substrate cell on that pool's drain loop. Sender never blocks. - **Cooperative → cooperative, different pools**: cross-thread post via the destination pool's queue; the destination pool's drain thread wakes on the condvar broadcast (same machinery as the cooperative→pinned path). Sender never blocks. - **Any → pinned**: cross-thread post via the pinned locus's lock-protected mailbox. Sender never blocks. - **Pinned → any**: cross-thread post; pinned publisher doesn't block waiting for delivery acknowledgement. #### Implementation status (m26 + m27 + m28a + m28b + m28c; F.31 pending) m25 wired the annotation through parse / typecheck / codegen. **m26 ships cooperative semantics; m27 ships pinned threads (run-only); m28a lifts pinned to full lifecycle; m28b lights up cross-thread bus mailboxes — pinned loci can subscribe and publish, with cells routed across threads via per-locus mailboxes; m28c adds optional CPU-core affinity via `pthread_setaffinity_np`.** **F.31 (2026-05-23, Phase 1-5 + Phase 4a shipped):** the placement-at-main surface and M:N cooperative pools. The per-locus `: schedule` annotation is removed; the placement choice moves to a `placement { }` block on `main locus`. Cooperative subscribers can be partitioned across N pools (N OS threads, each running its own `lotus_coop_pool_worker` drain loop against its own per-pool ring buffer); cross-pool bus dispatch reuses the m28b condvar+memcpy machinery. A pool's worker thread can take the same affinity forms as `pinned` — `cooperative(pool = X, core/cores/node/l3 = …)`, applied right after the worker spawns (2026-08-12; entries naming one pool must agree, and the main pool takes none). The single-threaded-method invariant — a locus's methods may be invoked only from its pool's thread or via the bus — ships as a typecheck rule (Phase 5). **Phase 4 v1 limit (handler-only cross-pool delivery).** The runtime ships pool-aware **bus dispatch** for now: a subscriber whose enclosing locus is placed on a non-`main` cooperative pool gets its handler invoked on that pool's worker thread. Lifecycle methods (`dissolve` / `accept`) still run on the main thread for cooperative-pool loci — the codegen does NOT yet relocate them to the pool worker (`run` and a pool-placed root's own `birth` are relocated, below). For state mutated only inside bus handlers this is enough to honor the single-threaded-method invariant (the handler is the only writer of locus state on the pool thread). State touched by both lifecycle bodies and handlers on a non-main pool is a Phase 4b concern; the typechecker doesn't flag it today because lifecycle methods are not user-callable in the `recv.method()` shape that Phase 5 checks. Plan: Phase 4b moves lifecycle dispatch onto the pool worker via the same queue mechanism (post "run_init" / "drain_exit" cells at instantiation / scope-exit boundaries). The pool-placed field's `run()` is posted to its worker (below), and its **subtree** initializes there (§ "m27 + m28a", the pool side): everything nested under it is built, registered and born on the worker, and a nested cooperative child's `run()` runs there inline. The placed field's own `birth()`, with its birth-epoch closures and its `birth_check`, runs on the worker too, as one more job the instantiating thread posts after the field's registrations and waits for, before its readiness (§ "Lifecycle obligations", line 3). Its `accept` and `dissolve()` still run on the instantiating and the teardown thread. **Runtime pool inheritance for in-method-body instantiation (2026-05-29).** A locus instantiated *inside a method or bus-handler body that is itself executing on a pool worker* inherits that pool **at runtime**. Codegen has no static placement name for such a locus (placement keys on main-locus `params` fields only), so the run-posting site and the subscription-registration site resolve the pool as: the compile-time-known pool when the locus IS a placed main-locus field, else the pool whose worker is currently on-CPU (`lotus_coop_pool_current()`, which reads the per-thread `g_current_pool_tls`; NULL on the main thread). When the resolved pool is non-NULL the child's `run()` is posted to it (its own cell — and, on an `async_io` pool, its own parkable coro, so a blocking `recv` in the child's `run()` parks that child's coro rather than the spawning handler's) and its bus subscriptions are tagged with that pool so dispatch routes to the right worker. The `run()` post is **gated on the child being owned beyond the spawning scope** — `accept`'d, an owned param field, or returned. A handler-local `let`-bound long-lived locus is *not* owned (its deferred dissolve fires at the handler's scope exit), so posting its `run()` would execute after it's dissolved; its `run()` stays synchronous. Its subscriptions are tagged with the pool all the same (§ "Subscriptions follow the tower": a run-time subscriber takes the constructing thread's route whatever its ownership). They used to go to the global queue, where main ran their handlers beside the worker's own use of the locus. The canonical N-dynamic-children shape (per-connection handlers, per-tenant workers) is therefore an `accept`'d child whose `run()` holds the recv loop — it multiplexes on an `async_io` pool by construction. See `spec/memory.md` § "Owned param-field child allocation" for the companion arena rule that makes the owned child's full subtree outlive the spawning frame. **Adapter loci instantiated inline in `bindings { Topic: AdapterLocus { ... }; }` are NOT main-locus `params` fields** and so receive no `placement { }` entry. Their `run()` recv- loops need a dedicated thread by construction; the substrate places them pinned-equivalent implicitly (same m90 routing + pthread spawn that pre-F.31 fired via the adapter's `: schedule pinned` annotation). The annotation goes away but the behavior is preserved automatically — the bindings-inline shape unambiguously signals "transport adapter with a recv loop." The implementation notes below describe pre-F.31 shapes (m25 through m28c). They remain accurate for the cooperative-only- on-main-pool case, which is the v1-compatible default when no `placement { }` block is declared. **m26 (cooperative):** Each `<-` enqueues `(handler, self, payload_copy)` cells onto a program-wide FIFO queue (`@lotus.bus_queue.global`) instead of running handlers inline. The scheduler drain loop pops cells one at a time and invokes the handler — handler-atomic per substrate cell, with cooperative yields BETWEEN cells rather than nested call frames. Handlers may publish more events; drain continues until empty. Drain runs at the start of every `flush_dissolve_frame` — before any long-lived locus dissolves — so subscribers process pending cells while still alive. Plus an explicit `yield;` statement (m26b) drains at user-placed points inside long internal loops. v0 limitation: cells enqueued DURING a dissolve are leaked. The C runtime gained the queue surface: ``` ptr lotus_bus_queue_create(void) void lotus_bus_queue_enqueue(ptr q, ptr handler, ptr self, ptr payload) void lotus_bus_queue_drain(ptr q) void lotus_bus_queue_destroy(ptr q) ``` m20's "memcpy payload into subscriber's arena" step happens at ENQUEUE time (publisher's frame). **m27 + m28a (pinned threads + full lifecycle):** Pinned-class loci spawn a pthread at instantiation; the locus's params and its full declared lifecycle (birth → run → drain → dissolve, each only if declared) execute on that thread, in order. A pinned locus's subtree initializes on its thread. The instantiating thread creates the locus's mailbox, if it has one, and then its thread. The thread makes the mailbox current and initializes the locus's params: every nested construction, its subscriptions, its `birth()` and a cooperative child's inline `run()`, and the params bracket and its settle. The initialization has a scope of its own: a temporary locus a default builds (`Helper { }.value()`) is dissolved when the initialization ends, on the pinned thread, not when the instantiating function's scope does. So every lifecycle body of a locus nested under a pinned one runs on the pinned thread, the domain it is placed in, and a yield inside one (`std::time::sleep`, an `await`) drains the pinned locus's mailbox, as a yield on main drains main's queue. The instantiating thread waits until the params are initialized (`lotus_pinned_start_await_ready`), so nothing observes the tree before it is built. While it waits it services its own mailbox exactly as a yield on it would: on main it drains main's queue, on a pinned thread that thread's mailbox (a yield on a pool worker drains neither, and neither does this wait). So a nested body that waits during the initialization for a reply from a subscriber on the instantiating thread gets it. Then it finishes the instantiation (the locus's own subscriptions; its synthetic fields and failure route were stored before the thread was created, so the initialization never reads one unset) and releases the thread (`lotus_pinned_start_go`) into `birth()` and the rest of its lifecycle, and continues. An override written at the literal (`Worker { started: pthread_self() }`) is the instantiating code's: it is evaluated on the instantiating thread, before the pinned thread starts. A locus an override builds as the field's value is part of the subtree, and is built on the pinned thread. At scope exit (deferred- dissolve flush), `pthread_join` blocks until the pinned thread has finished its lifecycle and returned; the main thread's only remaining work for a pinned entry is the join plus the locus's arena destroy wholesale (drain / dissolve are SKIPPED on the main side — they ran on the pinned thread). m28a synthesizes a per-locus `__pinned_main_` function whose signature matches pthread's start-routine contract directly (`ptr (ptr)`); pthread_create gets that function pointer with the locus's start block as its argument: the locus, the start gate, and each value of the instantiating function that the params' initialization reads (the instantiating thread runs nothing but its own mailbox's handlers while they are read, and those reach their own subscribers, not its frame). No C-side adapter. The synthesized body makes the mailbox current, runs the params' initialization (`__pinned_init_`), reports ready, waits for its release, calls each declared lifecycle method in sequence, then returns null. **The pool side.** A root field placed on `cooperative(pool = X)` initializes its subtree on X's worker the same way, as the first job of that field. Its params' initialization is lowered into `__pool_init_`, the start block is the pinned one, and the instantiating thread posts the initialization to X as one job (`lotus_pool_start_post`) and waits for it as for a pinned locus (`lotus_pool_start_await_ready`, which services its own mailbox the same way). The worker runs every nested construction, its subscriptions (routed to X), its `birth()` and a cooperative child's inline `run()`, and the params bracket and its settle, so a failure a nested child raises during the initialization is delivered on the worker at settle. The instantiating thread then finishes the instantiation (the field's own subscriptions; its synthetic fields were stored before the post), posts the field's own birth step (its `birth()`, birth-epoch closures and `birth_check`, lowered into `__pool_birth_`) to X as a second job and waits for it the same way (§ "Lifecycle obligations", line 3), and posts the field's `run()` to X behind both, as before. The three are X's jobs in that order: init, birth, run. An override written at the literal is evaluated on the instantiating thread before the post, as for a pinned locus. A delivery to the subtree during the initialization is X's own: it runs on the worker, at a yield of the initialization or after it. A yield inside the initialization (`std::time::sleep`, `yield;`) drains X's queue on the worker, as a yield on a pinned thread drains its mailbox; a yield inside a cell that drain runs drains nothing, and outside an initialization a yield on a pool worker drains nothing, as before. The job never parks: on an `async_io` pool it runs on the worker's own stack, not a coroutine, so a `sleep` or a socket wait inside it blocks the worker, and the initialization is complete before the worker starts another cell. The roots of one pool therefore initialize in post order, each complete before the next is posted. Ordinarily the job waits behind cells already queued on X: a field placed after a sibling on the same classic pool whose `run()` never returns is never initialized, and the instantiation waits for it (the sibling's `run()` already holds the worker against everything else on X). No wait may be on itself (§ "Lifecycle obligations", line 1): when the worker is itself blocked on the instantiating thread, waiting for the decision on a held failure that thread gives only at settle, it runs the posted initialization in place, still on the worker, and waits on. The constructor offers this pending initialization independently of queue capacity and tries to enqueue without blocking. Once the worker claims the pending initialization, the constructor proceeds to the readiness wait even if the queue remains full; it need not enqueue a second copy. The pending slot and any queued copy each hold a reference, and only one path runs the initialization. A target without threads initializes on the instantiating thread. **m28b stage 1 (inline-payload queue):** Bus queue cells now carry an inline `[u8; 512]` payload buffer (with `pthread_mutex_t` guarding the cell array) instead of a pointer to subscriber-arena memory. The publisher memcpy's into the cell at enqueue; the drain (running on the subscriber's thread) memcpy's from the cell into the subscriber's arena before invoking the handler. This makes the queue the single point of cross-thread synchronization: each per-locus arena stays single-threaded territory, the boundary between layers is where the lock lives. Per spec/memory.md, "every locus boundary copies the payload" still holds — just with two memcpy's per cell instead of one. **m28b stage 2 (cross-thread mailboxes):** Each pinned locus that declares `bus subscribe`, or nests one that does, allocates its own `lotus_mailbox_t` at instantiation: a bounded ring buffer with `pthread_mutex_t` + `pthread_cond_t` + a shutdown flag, sharing the same inline-payload cell shape as the global queue. The locus's struct grows a `__mailbox: ptr` field to hold it. The bus entry table grows from `{subject, self, handler}` to `{subject, self, handler, mailbox}`. Cooperative subscribers register with `mailbox = NULL`; pinned subscribers register with their mailbox pointer. At dispatch time, the `bus_dispatch` fn loads `entry.mailbox` and branches: null → enqueue on the global cooperative queue (handler runs on the cooperative thread); non-null → `lotus_mailbox_post` on the pinned subscriber's mailbox (handler runs on the pinned thread). **Subscriptions follow the tower.** A locus nested under a root field placed off main runs on that field's thread (§ Placement classes), and so do its bus handlers: its subscriptions register with its anchor's route, not the global queue. That holds for a locus built at run time too — in a hook or a method, whatever owns it (a field it is stored in, an accepted child, or nothing beyond the constructing scope: a `let` or a statement-position literal the scope dissolves): on a pinned thread it registers with that thread's mailbox, which the thread makes current before its params and its `birth()` run; on a pool's worker, with that pool; on main, with the global queue. (`std::http::Server` builds its consumer in `birth()`; pinned, its deliveries used to reach the global queue and be served by main. A scope-local subscriber built on a pool's worker or a pinned thread did the same until the registration stopped asking about ownership: main ran its handlers while the thread that built it was still using it.) A scope-local subscriber's handlers run where that thread runs cells while the scope is open: on an `async_io` pool, while the constructing body parks; on a pinned thread, at the body's sleeps and yields, which drain its mailbox; on main, at the drains of its statement boundaries and its scope exit. A classic pool's cells are handler-atomic, and the constructing body holds the worker for the subscriber's whole life, so a delivery to a scope-local subscriber there waits and is dropped with it; `hale check` warns at such a literal (spec/verification.md, "Dropped scope-local delivery"). Its dissolve is a reclaim like any other: the subscriber is quarantined before its storage goes, so a cell still queued for it on the pool or the mailbox is dropped at materialization (GH #703), and the release waits for a handler of it that has started (R52). A release on an `async_io` pool parks while it waits, and the reclaim state a thread keeps as stacks — releases entered, owner scopes, records deferred to a handler's end — is the parked coroutine's own: a park saves it and puts back the drain's, so two coroutines dissolving their subscribers on one worker never pop each other's entries. A pinned anchor has a mailbox if and only if something may post to it, whether or not it subscribes itself: a locus nested in its params tree subscribes, or a subscriber may be built at run time on its thread — a literal in one of its hooks or methods, or in its nested loci's, or in any fn they reach through the call graph (free fns included, and the bodies of every locus those bodies build, a locus a params default builds included), of a locus that subscribes or whose construction template builds one (its params defaults, transitively, which are lowered wherever it is built), as the placement table's dynamic sites record it; a literal whose enclosing scope's domains are unknown (a fn passed as a value, which may run anywhere) counts as one that may run on every pinned thread. Any other pinned thread that subscribes to nothing has none, and its lifecycle is as before: its `drain()` and `dissolve()` follow its `run()` on its thread. A thread with a mailbox serves it after its `run()` returns, until the deferred-dissolve flush shuts it down (below): its `drain()` and `dissolve()` then run at main's teardown, not as its `run()` returns. Under a pinned anchor that is the anchor's mailbox; under an anchor on `cooperative(pool = X)` it is pool `X`. The route exists before the subscriptions that use it: the anchor's mailbox is created before its params are initialized, which is where every nested instance registers. A pinned anchor's params initialize on its own thread (m27 + m28a, above), so a nested instance that waits during its initialization for a delivery through the mailbox is served by the thread the mailbox belongs to. An anchor on a pool initializes its params on the pool's worker (the pool side, above), so a nested instance's bodies and its handlers run on the one worker, and a delivery during its initialization runs there at a yield or after it, never beside it. The route outlives the subscriptions: each nested instance deregisters in its own `dissolve()`, on the anchor's thread, before the join below returns, and the join retires any registration still routed to the mailbox before destroying it. The synthesized `__pinned_main_` body grows a mailbox loop between `run()` and `drain()`: it calls `lotus_mailbox_drain_one`, which blocks on the condvar until either a cell arrives (returns 1, after dispatching the handler) or shutdown is signaled with empty queue (returns 0, breaking the loop). Pending cells flush before the loop returns 0 even after shutdown — the order check is "queue empty AND shutdown." Coordinated shutdown: at the deferred-dissolve flush, the main thread calls `lotus_mailbox_shutdown` on the pinned locus's mailbox (sets the flag + broadcasts the condvar), then `pthread_join`. The pinned thread observes the empty+shutdown condition, breaks its loop, runs `drain()` and `dissolve()`, and exits — main joins, then destroys the mailbox and the arena. Per The Design / lotus, this is the canonical "any → pinned" bus path: publisher and subscriber sit in different layers of the lotus, the substrate cost lives at the layer boundary (the mailbox lock + the inline payload's two memcpy's), and each arena stays single-threaded territory. Bimodality holds. Still gated: pinned loci cannot declare `accept()` (children of pinned would need cross-thread cascade-dissolve coordination, which is meaningful new infrastructure beyond m28b's mailbox post-and-continue) or a closure whose epoch is `birth` or `dissolve` — dissolve being the default with no `epoch` clause — (cross-thread routing inside the cascade). Tick, duration, explicit and inline closures fire on the pinned thread and are supported. The typechecker refuses the two gated shapes at the placement entry or adapter binding (adapters also run on their own pinned thread; rule 6); codegen keeps a backstop for builds that skip the checker. **m28c (CPU-core affinity):** When a pinned locus declares `: schedule pinned(core = N)`, codegen emits a call to `lotus_set_core_affinity(tid, N)` immediately after `pthread_create` succeeds. The C-side helper wraps `pthread_setaffinity_np` (with a `cpu_set_t` zeroed and bit N set) so codegen doesn't have to know the cpu_set_t layout (opaque + size-variable across glibc versions). Best-effort semantics: if the requested core is unavailable (e.g., CI box with fewer cores than the source declares) or the syscall is denied, the runtime silently falls back to ordinary OS scheduling rather than refusing to run the binary. The underlying bimodality is unchanged — `pinned(core = N)` is a refinement WITHIN the pinned mode, not a third position. **Topology Phase 1a (cpuset affinity):** `pinned(cores = A..B)` / `pinned(cores = A..=B)` / `pinned(cores = {a, b, c})` generalize the single core to a core **set**: the thread's affinity mask is the whole set and the OS schedules it freely within it — a range carves out an isolation domain rather than picking one CPU. Bounds are integer literals (placement is a closed-world deployment seam), so the compiler expands the spec statically — sorted, deduplicated — into a constant array and emits one call to `lotus_set_core_affinity_set(tid, cores, count)` after `pthread_create`. Range inclusivity follows expression ranges: `..` excludes the upper bound, `..=` includes it. The typechecker rejects a spec that selects no cores (`4..4`, `8..=4`) and a duplicated set element; whether the cores exist on the deploy box stays best-effort at runtime — the C helper skips out-of-range indices and applies the mask only if at least one valid core remains. CPU affinity is Linux-only: on other hosts (macOS) both helpers are compiled as no-ops and the loci run unpinned. `pinned(core = N)` continues to route through the single-core helper unchanged. Linker dependency: clang invocation now passes `-lpthread` unconditionally; small fixed cost in the resulting binary (libpthread is on every modern Linux). ### Bus message router The runtime's bus is **transport-agnostic**. From the framework's perspective, a transport is the bus kernel projected through a parameter regime: NATS and UDP multicast and TCP and Unix sockets are the same primitive (typed pub-sub) at different (B, c, σ, φ) values. The runtime knows about subjects, channels, and modes; specific transports come from stdlib (`std::bus::*`). - **Subject → handler dispatch.** Declared `bus subscribe "..." as fn` declarations are wired by the runtime at startup; inbound messages on declared subjects route to the declared handler. - **Outbound publish.** Declared `bus publish "..."` allows emit from any handler return; the runtime routes to the configured transport. - **Multi-transport dispatch.** A single binary may bind different channels to different transports (a real-time event channel to UDP multicast; a control channel to NATS; a test channel to in-memory). The router maintains per-channel transport bindings established at deployment time from config. - **Transport adaptation interface.** Cross-host transports (NATS, MQTT, TCP-with-framing, custom) plug in via `interface std::bus::Adapter` — a contract for user-supplied loci that ship messages on whatever protocol they choose. The contract definition lives in `runtime/stdlib/bus.hl`; concrete adapter implementations live in user code or downstream packages, NOT in std. The substrate-provided `unix(...)` transport is in the runtime itself (substrate-guaranteed atomic delivery via SOCK_SEQPACKET) and doesn't go through the Adapter interface. **v1.x source surface.** Subjects are now declared as typed top-level `topic Foo { payload: T; subject: "..."; }` decls (with optional `: Parent` for hierarchical wire subjects); deployment-time bindings live in the `main` locus's `bindings { Topic: ; }` block — the program's OWN entry main: a `main locus` that arrives through `import` (a test seed importing the application it tests) keeps its `bindings { }` inert, so a test binary never binds, or unlinks, the sockets of the running application (GH #529 D7). It is also not counted against the one-`main`-locus rule, so a program that imports such a seed may declare a `main locus` of its own (GH #1059). Two transport shapes ship: substrate-provided `unix("/path", role: ...)` and user-supplied adapter loci named directly on the right-hand side (any locus satisfying `__StdBusAdapter` — `fn send(subject: String, bytes: Bytes)` — qualifies). In-memory delivery is absence-of-entry. Adapter bindings let protocol-layer transports (NATS, MQTT, TCP-with-framing, custom JSON-over-WebSocket) live in user code without the language having to enumerate protocol variants. See `spec/semantics.md` "Topic declarations → Phase 2" for the full surface, including the closed-world topology optimization that elides bus dispatch for unambiguous intra-locus and single-hop parent→child tower patterns when no binding is declared. **Binding realization failure (GH #227) + transports-as-loci (GH #233 steps 1–2, 2026-07-22).** Source-level `bindings { T: unix(...) }` entries are sugar: codegen instantiates a stdlib transport locus (`__StdBusUnixListenTransport` / `__StdBusUnixConnectTransport`, `runtime/stdlib/bus.hl`) as a cooperative child at the main prelude — converging with the adapter path. The locus is the control plane; the data plane stays in C: - `birth()` calls `lotus_bus_transport_realize(subject, path, role)` synchronously on the boot path (unix `socket + bind + listen` via `lotus_transport_listener_create` for listen, connect-with-retry for connect). Realization failure routes into `lotus_bus_binding_fail(subject, url)` — the structural-failure shape (stderr diagnostic + `exit(1)`), the same seat as `lotus_root_panic`. The listen locus's birth then spawns the serve thread (`lotus_bus_transport_spawn_server`). The transport loci are deliberately NOT pinned: a pinned locus runs birth on its spawned thread, which would make realization asynchronous; the serve thread belongs to the C data plane. - The serve loop (`lotus_bus_unix_serve`) serves MANY peers (2026-09-09, DNA F.13): it polls the listener beside every accepted connection (up to 64), admits connections as they arrive, keeps a framed seq space per peer, closes only the peer that hangs up, and — once the listener is shut at exit — drains every connected peer to EOF before leaving. Before this it accepted one peer, dispatches its messages, and on peer EOF **re-arms** — closes the dead connection and loops back into `accept()` for the next peer (GH #233 step 2; peer EOF is not connection loss, and a rolling restart of the connect-side binary just works). - `dissolve()` calls `lotus_bus_transport_reclaim`: sets the serve loop's `closing` flag, shuts down both fds to unblock a parked accept/recv, joins the serve thread, destroys the transport. The husk entry stays in the remote table for `lotus_bus_remote_destroy_all` to free uniformly. - **When that dissolve runs (GH #893).** The transport's struct is allocated for the program's lifetime (the payload arena, so it outlives `fn main`'s subregion) but it is OWNED by `fn main`: the prelude registers it on main's deferred-dissolve frame, before any user statement, so it is that frame's first entry and the reverse-order flush tears it down LAST — after every user locus has dissolved (a `dissolve()`-body publish still reaches the wire), after the main-exit ingress quiesce and cooperative-pool join the exit path sequences ahead of the flush, and before the global arena destroy and `lotus_bus_queue_destroy`. `lotus_bus_remote_destroy_all` then finds the entry already reclaimed — transport NULL, serve thread joined — and frees only the husk. Program-lifetime ALLOCATION and no OWNER are separate questions; a transport answers the first without the second. Publish fanout is untouched — realized entries land in the same `g_bus_remote_entries` table the fanout walks. **Connection-loss supervision (GH #233 steps 3–4).** Publish fanout marks a locus-served connect entry `lost` on send failure (skipping it thereafter — down-window publishes drop, never falsely succeed) and pushes it onto a mutex'd pending list (`lotus_bus_transport_mark_lost`). The top of `lotus_bus_queue_drain` — owner thread, the only place failure handlers may run — drains the list (`lotus_bus_drain_lost_transports`): each handle goes to the codegen-registered dispatcher (`lotus_bus_set_loss_handler`, emitted only when main declares the matching `on_failure`) or straight to the structural exit (`lotus_bus_transport_lost_fallback`). The dispatcher invokes `main.on_failure(main_self, transport_self, link_lost_violation)` and, when the handler bumped `__restart_count` via `restart (t)`, calls `lotus_bus_transport_reconnect` (re-runs connect-with-retry against the entry's stored path; success clears `lost`). Codegen support: `lotus.main.self` global (stored at main-locus instantiation), `lotus_bus_transport_bind_self` (entry ↔ locus self, emitted after each connect instantiation). Env-configured routes (`LOTUS_BUS_CONFIG`) have no source-level declaration to hang a locus on and keep the direct C path: `lotus_bus_register_remote(subject, url, role)` returns an i32 status (0 ok / -1 unrealizable — scheme/addr validation, socket/bind/listen, connect-retry timeout, thread spawn), with listener-side work done synchronously at registration (udp: parse + bind + multicast join via `lotus_bus_udp_listener_setup`). On failure the entry is popped (no dead slot for fanout to silently skip) and `lotus_bus_load_config` routes into `lotus_bus_binding_fail`. Config-route unix listeners share `lotus_bus_unix_serve`, so they re-arm identically. Normative contract: spec/semantics.md, "The publish contract". **Adapter dispatch.** At codegen, an adapter binding instantiates the adapter locus into the program-lifetime payload arena (same m90 routing the `-> LocusRef(L)` return path uses), resolves the locus's `send` method's fn pointer, and registers the (self, send_fn) pair with the runtime via `lotus_bus_register_remote_adapter`. The runtime stores both in `lotus_bus_remote_entry_t`'s adapter slot. Outbound fanout packages the wire bytes as an Hale-level `Bytes` value (built via `lotus_bytes_from_buf` against the lazy global payload arena) and indirect-calls `send_fn(self, subject, bytes)`. No vtable lookup is needed at the runtime layer — codegen resolved the method at binding-emit time. **Adapter inbound (m105).** Adapters receiving wire-bytes from their protocol layer call `std::bus::__local_dispatch( subject, bytes)`; the primitive backs onto `lotus_bus_dispatch_wire`, which looks up the subject's registered deserialize fn in the registration table (`g_bus_table`, the same table the publish-side fanout consults), reconstructs the struct-layout bytes, and fans into local subscribers via `lotus_bus_local_dispatch`. Symmetric to the unix reader- thread path; out-of-band recv loops (any code holding wire bytes for a bound subject) can use this too. **SPSC observation ring (GH #244).** A single-producer fixed-slot ring over CALLER-PROVIDED memory, exposed as lotus primitives (`lotus_spsc_init` / `_emit` / `_note_drop` / `_set_tag_b` / `_read`) and the raw all-Int Hale surface `std::ring::__spsc_*`, every address an `Int`: statements `__spsc_init(desc, data_off, tag_a, tag_b)`, `__spsc_emit(seg_base, desc, ring_slots, w0, w1)`, `__spsc_note_drop(desc)` and `__spsc_set_tag_b(desc, v)`, and `__spsc_read(seg_base, desc, ring_slots, cursor_io, overruns_io, out, max) -> Int`, the records copied. Built for observation planes (the iris observer attaches to these rings inside an shm segment, read-only, from a foreign process), so the layout is a STABLE documented contract: - Descriptor, 64 B, 64-aligned, caller-placed: `u64 data_off` (slot array offset from the segment base), `u64 head` (producer cursor — monotonic, never wraps, published with a release store; slot index is `head & (ring_slots-1)`, ring_slots a power of two), `u64 dropped` (producer-side drop accounting; the ring itself never blocks — overwrite-oldest by construction), `u32 tag_a`, `u32 tag_b` (user tags; tag_b has a relaxed-store setter for gauge use), 32 B reserved (zero). - Slots: `ring_slots × 16 B`, two u64 words, written plain after a RELEASE FENCE and before the head release-store. The fence pairs with an acquire fence on the read side (below) — the Boehm seqlock recipe. Without the pair, a relaxed slot load may observe a future record while the h2 re-read returns a stale head, delivering a mixed record past the discard; GenMC exhibits it (the stress soak cannot — TSO masks it). - Read side (any process, no Hale runtime required): snapshot h1 (acquire) → copy `[cursor, min(h1, cursor+max))` → ACQUIRE FENCE → re-read h2 (acquire) → discard records with index `<= h2 - ring_slots` and count them as overruns. The `<=` is load-bearing: the producer's in-flight (unpublished) write for record `h` is already clobbering slot index `h - ring_slots`, so the live window given a published head `h` is `(h - ring_slots, h]`. Verified concurrently in `tests/spsc_driver.c` and modeled in `verification/spsc_ring_model.c` (which also refutes the strict-`<` boundary). Consumer cursors and overrun counters live OUTSIDE the shared segment (caller-owned); external readers never write the ring. This ring is the convergence target for iris's observation protocol (its PROTOCOL.md pre-freeze sketch is this layout with `tag_a`/`tag_b` as `sched_id`/`current_locus`). **SHM ring substrate (Form K5).** POSIX shared- memory ring backing the zero-copy bus route. Six C primitives in `runtime/lotus_shm_ring.c`, linked unconditionally so user programs that bind a topic to a zero_copy route resolve cleanly: - `lotus_shm_ring_open(name, slot_size, slot_count)` — open or attach (creates if it doesn't exist; validates header on attach). Returns a per-process handle. - `lotus_shm_ring_claim(ring)` — publisher gets a pointer to the next slot. v1 is single-producer; never fails. - `lotus_shm_ring_commit(ring)` — release-orders the slot writes before atomic-incrementing the published seqno. - `lotus_shm_ring_published(ring)` — acquire-load the seqno for subscriber-side polling. - `lotus_shm_ring_read_slot(ring, seqno)` — subscriber gets a pointer to the slot for `seqno`. Returns NULL if not yet committed OR wrapped past (slow consumer). - `lotus_shm_ring_close(ring)` — unmap + close fd; unlink the SHM object if this handle created it. Layout in SHM: a 64-byte cache-aligned header (`lotus_shm_ring_header_t` — magic, slot_size, slot_count, atomic seqno) followed by N slots of `slot_size` bytes each. Header magic + sizes are validated on attach to catch ABI mismatches across binaries pinned to the same ring name. **Foreign-layout consumer (Proposal B).** Two more primitives read an *externally*-defined ring described by a `ring_layout` (see semantics.md § "Foreign rings"), rather than the native LRSRNG1 shape: - `lotus_shm_ring_open_layout(name, desc)` — attach an existing foreign segment READ-ONLY (never creates), `fstat` for the map length, validate `magic`/`version`, read `buffer_size` for the data-region capacity. `desc` is a `lotus_shm_layout_t` built from a flat 16-entry uint64 descriptor codegen emits. - `lotus_bus_register_subscriber_shm_ring_layout(subject, name, desc_words, self, handler)` — open via the above and spawn a `byte_records` reader thread that walks `[len_prefix][payload]` records (modular over `capacity`, skipping `pad_sentinel` tail-pads, advancing by `align_up`). Shares the native subscriber registry + `atexit` teardown; a layout subscriber is marked `is_layout` and torn down via `lotus_shm_ring_close_layout`. The producer side (Proposal B M3a) mirrors these: - `lotus_shm_ring_create_layout(name, desc, capacity)` — CREATE + own the segment (size `data_at + capacity`, write the magic/`version`/`buffer_size` header, zero the cursor). Attaches read-write without re-init if it already exists. - `lotus_bus_register_shm_ring_layout(subject, name, desc_words, capacity)` — create via the above + register a producer (one per subject). `lotus_bus_publish_shm_ring_layout(subject, value, size)` frames one `byte_records` record (the inverse of the reader: reserve `align_up`, `pad_sentinel` at the wrap, write the length prefix + payload, release-store the cursor). The producer rings are closed + `shm_unlink`'d at `atexit`. Field reads/writes are host-native endianness (the foreign producer and Hale are both little-endian x86-64). v1 is `byte_records` only; the `slots` framing kind and a zero-copy writable producer view are post-v1. v1 scope: single-producer, multi-consumer; in-memory delivery is in scope (POSIX shm_open works intra-machine cross-process). Multi-producer (CAS-based claim), back-pressure / timeout modes, and named-ring registry are post-v1. The Hale-side `fallible(ClaimError)` signature is reserved for those; v1's `claim()` never actually fails. **Lifecycle / cleanup.** Both `lotus_bus_register_shm_ring` (publisher) and `lotus_bus_register_subscriber_shm_ring` (subscriber) register a single `atexit` hook on first call. The hook: 1. Signals every subscriber reader thread to stop via an atomic `should_stop` flag. 2. `pthread_join`s each reader thread, ensuring no in-flight handler is interrupted. 3. Frees the subscriber state allocated by the registration call. 4. `lotus_shm_ring_close`s every ring opened in this process — which `shm_unlink`s the ones this process created (`owns_unlink=1`), keeping `/dev/shm/` clean across restarts. The atexit hook runs on a clean process exit (return from main, `exit(3)`), which includes a SIGINT / SIGTERM that drains to its end (GH #1039). Termination that does not return from main — SIGKILL, a signal the program does not observe, a drain that outlives its grace, `_exit` — bypasses atexit and leaves the SHM namespace entry behind until reboot or manual `shm_unlink`. **Constraint: subscriber handlers must not call `exit()`.** The handler runs on the reader thread; calling `exit()` from inside the handler invokes atexit on the reader thread, which then attempts to `pthread_join` itself (undefined behavior). Use `_exit()` if a handler needs to terminate the process immediately. Codegen surface for K5 lands in K4 (route-selection + slot-locus synthesis) and K6 (subscriber view + epoch guard). K5 ships the substrate; user code can't reach these symbols directly without going through the slot-locus surface a zero_copy binding produces. ### Closure-test infrastructure - **Default epoch is `dissolve`.** Closures with no `epoch` clause evaluate at the locus's dissolution. Other epochs: `epoch tick`, `epoch duration(...)`, `epoch birth`, `epoch explicit` — runtime-managed per declaration. - **Accumulator engine.** For each `closure name { ... }`, the runtime maintains accumulators for the left and right sides of `~~`, scoped per epoch (when accumulation is needed; not needed for one-shot self-referential closures like `self.x ~~ self.y within 0`). - **Band checking + reporting.** At each epoch boundary, the runtime evaluates left and right expressions, checks the band, and emits a typed `ClosureReport` event the application can subscribe to via bus. - **Collapse vs. explosion.** A closure-pass at any epoch is silent. A closure-fail flips an "exploded" flag on the locus. At the failing epoch (held while the parent's params are open; § "Lifecycle obligations", line 9), the parent's `on_failure(self, ClosureViolation { ... })` is invoked with a typed event carrying closure name, epoch, left/right values, tolerance, diff. Distinct from hard substrate failures (OOM, divide-by-zero, null-deref from miscompilation) — those terminate the process directly without the ClosureViolation routing path. See decisions §F.9. - **Recovery-event interaction.** At each recovery event a parent applies to the locus (`restart`, `restart_in_place`, `quarantine`; a spent `restart(c) for N` bound is `quarantine`), the runtime zeroes the accumulators of every closure whose `persists_through(...)` does not name that event, and keeps those of the closures that do. Nothing reads `resets_on(...)` at run time: it states the default, and the check holds it to the alphabet and refuses an event a closure names in both clauses (`spec/semantics.md` § Recovery events). The exploded flag itself persists across `restart_in_place` and `quarantine` (per default; future `clear_violation_on(...)` clause may override). ### Perspective infrastructure - **The global slot.** Each `perspective P` has one program-global `{ data, vtable }` slot (`__persp.

`). Every holder of `perspective(P)` dispatches through it — a load plus a predicted indirect call, near-direct cost. A program that declares no perspectives pays nothing. - **Live swap (`reperspective`).** Re-points the slot at a new `serves P` impl with a single atomic store, redirecting every call site at once. State-preserving across impls of one footprint: the `{ data, vtable }` split means `data` — the live, arena-backed state — is untouched and only the vtable changes. When the perspective declares a bus surface, the swap also re-points its subscriptions on that same `data`. (See `spec/semantics.md` § Perspectives.) - **Wire hot-load (aspirational).** Transport-driven redeploy — decode a serialized perspective against the compiled-in schema, gate on `stable_when`, atomically install with no torn read — is specified but not yet shipped. See `spec/semantics.md` § "Perspective hot-load". ### Failure handling - **Failure = `ClosureViolation` propagation.** Any `closure` assertion that fails in a locus body produces a `ClosureViolation` record routed to the parent's `on_failure(child, err)` handler per **F.9**. The parent picks one of `restart` / `restart_in_place` / `quarantine` / `reorganize` / `bubble`, or absorbs (returns without calling any). A violation that bubbles past the root exits the process non-zero with the violation report on stderr. - **No source-level panic / exceptions.** Hale has no `panic(msg)`, `assert(cond)`, `throw` / `catch`, or implicitly-propagating exception machinery. Failure is either structural (closure violation; parent-policy recovery, Erlang let-it-crash with the parent locus as the supervisor) or value-level via `fallible(E)` (v1.x-FORM-1 addressing protocol; every fallible call MUST be addressed by an `or` clause at the immediate caller). The two channels are orthogonal at every frame except the implicit main locus's root, where a value error escaping past every enclosing `fallible` frame triggers `lotus_root_panic` — the runtime's only value-error escape valve. See `spec/semantics.md` § "Process exit". - **Decision L0-1 (F.40 phase 3, 2026-10-01): handlers run only on the queue owner's thread.** A child's failure is delivered to its owner's `on_failure` on the owner's execution domain, never on a thread outside the owner's domain. When owner and child share a thread, the child's thread is the owner's domain, and the handler is called inline. The owner's execution domain is the thread that drains the owner's queue: `main` for a locus on the main pool, the pool's worker for a locus placed on a cooperative pool, and the locus's own thread for a pinned one (§ "Owner-executed handlers"). - When the child fails on that same thread, the handler is called in place (§ Scheduler, "Failure-traversal": a stack walk). - When the child fails on another thread, the failure travels to the owner's queue as a typed cell (§ Scheduler: "the failure is delivered as a typed bus message to the parent's scheduler, which dispatches to `on_failure`"). This is the path a cross-thread publish takes, and the cross-pool accept handoff takes it too. - The failing child and its copied violation stay alive until the handler has returned. The child waits for the handler's decision before it restarts, carries on or is reclaimed, exactly as a held failure is waited for today (`lotus_failure_await`). - **Delivery makes progress while the owner joins its children.** The owner must remain able to complete outstanding failure decisions until its dependent children have quiesced, and shutdown must never silently discard a failure cell whose child is awaiting it. A child's failure may come after the owner's last drain: from a pinned child the owner is joining, or from a pool child while the pool workers are joined. The owner then has to be able to run that cell even though it is inside the join. Otherwise the owner waits for the child's thread while the child waits for the owner's decision. This decision adopts the requirement; F.40 phase 3's L1 (the obligation) and L5 (the implementation) supply the mechanism. - **Restart during drain is converted to cancellation.** A restart the handler asks for after the owner has entered teardown, or while the process drains, is not performed. The child ends as if the handler had returned without asking: it reaches its ordinary run end, where it is reclaimed if it is a flow or terminated child and otherwise kept for its owner's cascade. For the process drain this is shipped (`emit_restart_requested`, `crates/hale-codegen/src/locus/restart.rs`, refuses a restart while `lotus_process_draining_flag` is up). For an owner's teardown it is shipped where the owner's reclaim reaches the child while the failure is outstanding (the reclaim wins, below), and not yet where the teardown joins the child's pool first and reaches its reclaim only after the delivery (inventory row C42, `rd_restart_during_teardown.hl`). - A failure held while the owner's params are open is still delivered when they settle (`spec/semantics.md` § "on_failure(c, err)"). One subcase was open: an owner placed on a cooperative pool. This decision names the pool's worker as that owner's domain. `spec/semantics.md` names "the thread settling the parent", which for a pool root is now its worker too (C50), the domain a later failure is posted to (L5's fourth part). A fixture holds the thread both deliveries run on, by its id inside the handler (`fd_pool_owner_worker.hl`), and the subcase is shipped with the construction-time decision (§ "Lifecycle obligations", line 1). - Transport loss already follows this rule. Its dispatcher runs from the top of `lotus_bus_queue_drain`, "owner thread, the only place failure handlers may run" (§ "Bus message router"). **What the runtime does (F.40 phase 3, L5's fourth part).** Every thread that runs locus code is an execution domain: `main`, a cooperative pool's worker, a pinned locus's own thread. An owner's domain is the thread that opens its params, which settles them and delivers what was held there; `lotus_params_open` records it once the program has a second thread, so a single-threaded program records nothing. `emit_on_failure_call` (`crates/hale-codegen/src/channels/mod.rs`) asks `lotus_failure_hold` first, then, in a program with a thread besides `main`, `lotus_failure_post`: - On the owner's domain the handler is called in place. - Off it, the failing thread posts the delivery to the owner's domain, carrying the copied violation and the child, which stays alive until the handler returns; wakes that domain; and waits for the handler's return before it restarts, carries on or is reclaimed. While it waits it services its own queue as a yield on its thread would, so an owner that is itself waiting on the child is served and neither waits on the other. - The domain runs a posted delivery wherever it services its queue: `main`'s bus queue drain, a pinned thread's mailbox drains and yields, a pool worker between cells, every wait that services its queue (a held failure's wait, a reclaim's wait for a started run, a readiness wait), and the two joins below. A handler is never started inside another on the same thread. A failure held while its owner's params were open runs its handler at the settle under the same guard, so a delivery posted to the settling thread waits until that handler returns. - A domain that has ended consumes nothing more: its thread has exited, or its pool's worker has left its loop. A delivery still posted to it, or posted after, runs in place where it was raised, as a failure whose owner no longer holds it does, and shutdown never drops one. The lifecycle matrix's `drain/grandchild/cross_pool` cell is this case: a pool-placed owner's child drains on the teardown thread after the pool join has ended the owner's worker. The lifecycle plan claims the raising thread there, under this rule, and the owner's domain for every other delivery, in place or posted. - The owner's reclaim of a child waits for the child's posted delivery (`lotus_failure_defer_reclaim`), running it if it was posted to the reclaiming thread; a reclaim the handler itself asks for runs after the handler. A reclaim never waits for a delivery only the waiting thread can run: one reached inside a handler, for a child whose delivery is still held for that thread (an owner replacing a failing sibling), is deferred behind that delivery too, so the field holds the new child at once, the replaced child's failure is still delivered after the running handler returns, and the child is reclaimed right after its own handler. **The reclaim wins:** a reclaim that finds a failure of its child outstanding marks the child's reclaim claim owed before the handler decides, whether it defers behind the delivery or waits for it, and the child's restart decision is refused while its claim is owed or taken. A `restart` or `restart_in_place` the handler asks for about that child is not performed: it is reclaimed once, after its handler, and no `birth()` or `run()` of it starts again. At a settle, a reclaim deferred behind a held failure runs instead of the child's resume. A failure posted from a pool worker's cell holds the child until that cell returns, as a started run does (decision line 19), so the reclaim never releases the child under the rest of the cell (what follows the decision, the closures after a handler, the cell's scratch region). - wasm32 has one thread and one domain: the in-place call is its only delivery, and none of the posting is compiled into its runtime. `notes/f40-lifecycle-inventory.md` lists the raising sites as rows C36–C40. One site is misrouted, not misplaced. It is a dissolve-epoch closure that fails under a flow child's run-completion reclaim. The reclaim spine (`synthesize_reclaim_fns`, `crates/hale-codegen/src/codegen.rs`) lowers the closure with the reclaimed child as `current_self`. `resolve_failure_route` therefore asks the child's own type for a handler of its own type and normally finds none. The owner's handler is not selected at all, and the violation takes the bare report-and-exit. The defect is "owner handler not selected", not "owner handler called on a foreign thread". It becomes the second kind only if the route is fixed without this decision: the reclaim then calls the owner's handler in place on the reclaiming thread. Inventory row C25 records the site; inventory Decisions line 4 chooses the route. The joins make progress. A dissolving parent joins a pinned child through `lotus_pinned_join` (`emit_deferred_entry_teardown`, `crates/hale-codegen/src/codegen.rs`), and `lotus_coop_pool_shutdown_all` joins each pool worker. Each waits for the thread's domain to end, running the deliveries posted to the joining thread meanwhile and nothing else, then `pthread_join`s it (inventory rows C18 and R20). **Regressions.** `lifecycle_flow failure_delivery_domain` (`crates/hale-codegen/tests/failure_delivery_domain.rs`) judges the fixtures under `tests/fixtures/failure_delivery/` into one outcome word each, in the trace build (where the delivery's `FailureDelivery` steps must all be on `main`; the sibling cases in both dispatch modes) and, for the first two, the heap-carrying sibling case and the restart cases, under ASan with chunk pooling off, in both dispatch modes: - `fd_pinned_owner_state.hl`: a pinned child fails in a bus handler while its owner, on `main`, reads the state the handler writes in a window with no yield; the handler records its `pthread_self`. Adopted and shipped: `held-in-window on-owner heard-1`. Before, the handler ran on the pinned thread inside the owner's window (`changed-in-window off-owner heard-1`). - `fd_reclaim_under_delivery.hl`: a child on a pool fails in a bus handler, and its owner replaces it while the delivery is in flight; gate files force the order. Adopted and shipped: `on-owner read-before-dissolve dissolved-once-each`. Before, the old child dissolved first and the handler, on the pool's worker, read its freed name (a heap-use-after-free under ASan). - `fd_sibling_replace*.hl`: the owner's handler for one child replaces a sibling whose own failure is posted to the owner and not delivered; each run is under a deadline. The bare case and its control give `handling 0`, `replacing sibling`, `replaced sibling`, `handling 1`, `finished`; before the rule the bare case hung after `replacing sibling`. A child carrying a heap String the late handler reads (`on-owner read-before-dissolve dissolved-once-each`, under ASan too), three siblings with two replaced from one handler (handled in posting order, each old child reclaimed once after its own handler), and the replacement through a method hold the same rule. - `fd_restart*_replaced*.hl`, the reclaim wins: a handler asks for a restart of a child whose reclaim is owed (the child it was handed, after replacing it, with `restart` and with `restart_in_place`; a sibling it replaced while that sibling's failure was posted; a child its owner replaced outside any handler while its failure was posted). Each gives the old child one birth, one run and one dissolve, after its handler read it, and leaves the new child untouched, under ASan too. Before the rule the old child was born and run again beside its dissolve, and `restart_in_place` hung. - `fd_settle_no_nest.hl`: a held failure's handler sleeps at its owner's settle while another failure is posted to the same thread; the posted handler's entry and exit lines follow the held one's. Before the settle's guard they came inside it. - `fd_handler_cell*.hl` (inventory row R52, decision line 19): a pool-placed child replaced while its bus handler runs keeps its storage until that handler returns, under ASan in both dispatch modes (before the handler's hold it was freed under the handler); the owner's failure handler replacing and restarting the child whose bus handler waits for that delivery, and a reclaim waiting for a handler that waits on main, each end under the deadline. A worker that carries the hold from cell to cell ends it for the reclaim: a subscriber flooded so that its worker never finds its queue empty is replaced mid-flood, and one of two subscribers sharing a worker, their cells interleaved, is replaced mid-stream; each reclaim completes with the publisher still running, under ASan in both dispatch modes and the deadline. The lifecycle matrix holds the domain of every cell's `FailureDelivery` to the plan's: the cells whose failure is raised on a pinned thread or a pool worker deliver on the owner's domain (`run/root_child/pinned` and `run/root_child/cross_pool` stay in its ASan sample as regressions), and the join-progress fixtures `jp_late_failure_pinned_join.hl` and `jp_late_failure_pool_join.hl` deliver on `main` inside the joins. A restart asked for during the owner's teardown is `rd_restart_during_teardown.hl` (known open, C42); a flow child's dissolve-epoch failure under its run-completion reclaim has no fixture of its own yet. ### Lifecycle obligations The lifecycle is a table of obligations (F.40 phase 3, L1): each action the compiler emits or the runtime performs is owed by a domain to an instance, is placed in order by the events it waits for, and ends in one of its named terminal outcomes. The rows are `hale_types::lifecycle` (`crates/hale-types/src/lifecycle.rs`). An obligation is keyed by its source site, the declaration built and the construction template that builds it (P1's instance key); the runtime numbers the live instance and its incarnation, one per `birth()`, and the table never does. A restart begins the next incarnation; a restart asked for and not performed begins none. This section holds the decisions the lifecycle inventory (`notes/f40-lifecycle-inventory.md`, Decisions 1–19) asked for, one paragraph per line, and the requirements adopted beside them. It sits apart from § "Failure handling" because most of the lines are about order and teardown, not failure; decision L0-1 stays there. Each paragraph states the rule and whether it is shipped. Where the adopted rule is not yet what the code does, the paragraph names the inventory row where they differ, and the fixture under `crates/hale-codegen/tests/fixtures/lifecycle/` that pins today's outcome; `lifecycle_fixtures.rs` lists it in its `KNOWN_OPEN` table and fails once the outcome changes, so the entry has to go with the fix. Each fixture also runs under the lifecycle trace (§ "The lifecycle trace"), held to the plan the table's producer (`hale_types::lifecycle::derive`) derives for its program, on its line's rules (one of line 8's, whose shape the producer does not derive yet, to a hand-written plan). A failure's delivery is claimed on its owner's domain, in place or posted there (decision L0-1), and on the raising thread where that domain has already ended (the shutdown rule, join progress below). Occurrences of one declaration whose runs end differently in one execution (one completes, another is canceled on main or refused) are counted from the run's path, and a main locus built more than once owes its eager spine at each of its teardowns: line 19's three cross-pool and shutdown fixtures are derived that way. The six started-run retention fixtures use the derived plan, including the edge from each run's end to its reclaim's completion. A subscriber a pool hosts owes a `Handler` row the same way (line 19, inventory row R52): one per delivered cell, as many as the run delivers, its instance retained until each ends, and the reclaim's completion after the end of every one that began before it (`l19_handler_cell_retained.hl`); the worker's hold, which a classic worker may carry to its next cell, ends no earlier, and no later than the carry's bounds (line 19 below). A posted run may overlap drain and dissolve; an inline run ends before drain. The producer also keeps statement-position subscribers alive until frame exit, where their teardown runs, rather than assigning them an eager teardown at the literal. Any other literal a statement of a lifecycle body builds is torn down where its statement ends, so that body's row completes after the literal's reclaim: a run that exits inside the teardown has not reached the body's end and owes nothing that waits for it. An owner's Reclaim entry follows its children's Dissolve completion; its Reclaim completion follows their Reclaim completion. This permits retained storage while preserving physical release from children to owner. A departure the trace shows and the outcome cannot (a missing step, a step on the wrong thread) is in the same file's `TRACE_KNOWN_OPEN` table. A line still waiting on a condition says so and records today's behaviour. The same rules are evidenced across shapes by the lifecycle matrix (`crates/hale-codegen/tests/lifecycle_matrix.rs`): a generated program for each failure phase, tree position and domain, held to its outcome, the producer's plan for it and AddressSanitizer, with the cells that fail today, the inventory row each fails at (two, for a cell that shows two known defects), and the departures each shows, in its `KNOWN_OPEN` table, which is empty today. - **Line 1, construction-time delivery.** Construction, readiness and failure delivery are one protocol, and its settlement is defined by events: the owner's last param is stored, a held failure is admitted to delivery, its handler completes, the child resumes. A failure raised while the owner's params are open is held; at settle the held failures are delivered in the order they arrived, before the owner's `birth()`, and each child waits for its decision. The child and the copied violation are retained until the handler completes. Shipped for an owner whose params settle on its own domain (`l01_held_failure_settle.hl`). Once the params have settled, a failure is delivered on the owner's domain (§ "Failure handling", decision L0-1, shipped by L5's fourth part): the runtime records that domain where the owner's params open, and a failure raised elsewhere is posted there and awaited, so the held delivery at the settle and a later posted one run on the same thread. The trace reports a held delivery where its handler runs, at the settle. No mechanism carrying this protocol may wait on itself: a held failure whose child and owner share a pool's worker (`l01_neg_same_pool_held.hl`), and an instantiating thread that settles while the worker holding the failed child needs its queue for a sibling's run (`l01_neg_it_waits_worker_queue.hl`), both complete today; so does an instantiating thread waiting for a pool-placed field's initialization on a worker that is itself waiting for that thread's decision, since the worker runs the initialization in place (§ "m27 + m28a", the pool side); and so does an owner whose handler replaces a sibling with a failure posted to it, since that sibling's reclaim follows its own delivery, after the replacing handler returns, instead of waiting for it (`l08_sibling_replaced_kept.hl`). Shipped too for an owner placed on a cooperative pool (L5, held by F.40 phase 3's L1 fifth part). Decision L0-1 names the pool's worker as that owner's domain, and `spec/semantics.md` § "on_failure(c, err)" names the thread settling the parent; for a root field placed on a pool both are the worker, where its params open and settle, and a failure posted to that owner after the settle runs there too. `l01_pool_owner_settle.hl` pins the delivery at the settle, its trace held to the worker, and `fd_pool_owner_worker.hl` (`failure_delivery_domain`) the thread inside the handler: a child failing while the owner's params are open on the worker, and one failing later on `main`, both have the owner's handler run on the worker, in both dispatch modes and under AddressSanitizer. - **Line 2, the tick closures after a posted `run()`.** **Pending:** the decisions choose no option. Today the tick and duration closures of a locus whose `run()` is posted to a pool run on the instantiating thread right after the post, while `run()` is still running on the worker (inventory row C12, `l02_tick_after_posted_run.hl`). The options stand: run them in the posted wrapper after `run()` returns, or drop the post-run tick for a posted `run()`. - **Line 3, where lifecycle methods run on a pool.** A placed locus's own `birth()` runs on the domain the placement table gives it: a pinned one's on its thread, and a pool-placed root's on its pool's worker, with its birth-epoch closures and its `birth_check`, after its subtree's initialization and its own registrations and before its readiness. The birth spine's order is unchanged; only the thread moves, and the instantiating thread waits for the birth as it waits for the subtree's initialization, posting it as one more job of the same kind (`__pool_birth_`). A pool with no worker runs the job in place: a main locus built and torn down more than once joins the pools at its first teardown, so a later construction's birth runs on the instantiating thread, and the plan claims that birth no one domain (`l19_full_ring.hl`, line 19). A failure the birth raises while the owner's params are open is held on the worker and decided on the instantiating thread after the job, as before. Shipped (L4's fifth part, `l03_pool_birth_domain.hl`: the birth and a handler that touch one field run on the worker's one thread; before, the birth ran on the instantiating thread beside them). A restart of that birth, and the `run()` after it, run on the worker too: a resume at settle, and a restart the run gate decides, are posted to the pool as the child's run (inventory rows C42 and C43, line 13). **Pending:** the decisions choose no option for the rest: a pool-placed locus's `accept` runs on the instantiating thread, its `run()` on the pool's worker, and its `dissolve()` on the teardown thread. Everything nested under it is built, registered, born and run inline on the worker (§ "m27 + m28a", the pool side). - **Line 4, the failure route bound at birth, in every spine.** Every spine that evaluates a child's closures reads the failure route the child bound at its birth, so one instance has one route, and the owner and the payload are retained until the delivery completes. Not yet shipped in two places. The reclaim spine resolves the route with the reclaimed child as the parent (inventory row C25, `l04_dissolve_route_reclaim.hl`), and a dissolve cascade lowered in `fn main`, where no locus is `self`, resolves none (row C31, `l04_dissolve_route_cascade.hl`); in both, a dissolve-epoch violation takes the report and the structural exit, and the owner's handler is never selected. - **Line 5, `accept`'s position.** `accept(c)` runs once the child's region exists and its params are built, and before its subscriptions and its `birth()`. Its result is not read: it admits, and cannot reject. An admission interface is separate work. Shipped (`l05_accept_position.hl`). Lines 5, 6 and 8 are the birth spine, and the compiler emits it from the lifecycle plan (F.40 phase 3, L4): an instantiation's steps from params settle to the run's start (accept, registration, `birth()` with its birth-epoch closures and `birth_check`, readiness, the run's start) come in the order the plan's rows and edges place them for its declaration, on the instantiating thread or, from the birth on, a pinned locus's own thread. A cross-pool bubble's child (§ "Lifecycle", interest-based ownership) is the same spine on its owner's thread: the create cell's dispatcher stitches it to its owner (`accept`, the children's tracker), then runs its `birth()`. - **Line 6, registration before birth, and readiness.** A new instance's subscriptions are registered before its `birth()`, so `birth()` may publish to them. Shipped. Delivery to the instance becomes eligible once its `birth()` (and its `birth_check`) has completed: what is published to it before then, its own `birth()`'s sends included, waits in the order it was published, is never dropped, and is delivered afterwards. Shipped (`l06_readiness_main.hl`, `l06_readiness_pool.hl`, and the pinned capacity regression in `topic_phase2.rs`). For each subscriber, the runtime opens a window before the first registration (`lotus_bus_hold_delivery`) and parks each cell posted to it there; its readiness step (`lotus_bus_ready`, right after the birth) posts them, ahead of any published after. A publisher on another thread waits once the window holds a queue's worth. The thread running birth does not wait on its own window. Nor does the subscriber's own consumer, the worker of the pool it is placed on or the pinned thread that drains its mailbox: readiness posts the parked cells into that consumer's queue, so it parks past the bound, as a self-publish never blocks on its own full queue (`topic_phase2.rs`, with a cross-domain control that still waits). Nor does a consumer that a producer is blocked on, since that producer may be a readiness step posting parked cells, or wait on one. Either still parks behind every earlier cell, so order holds. A pinned subscriber transfers that exemption to its thread before birth; its mailbox stays current so already-born nested subscribers can continue receiving. The closed-world optimization that turns a send into a direct call (`spec/semantics.md` § "Topic declarations → Phase 2") preserves the intended receiver while deferring delivery: rewritten sends, including those in helpers reached from birth, use that receiver's registered route while its window is open. Baked broadcast publishes use runtime dispatch while any window is open. A helper cannot bypass readiness or broaden a local send into a broadcast. Not yet true in two cases (inventory rows C51 and R51). A subscribing locus that only stdlib or imported code builds, so that the lifecycle plan holds no template of it, opens no window, and what is published to it during its `birth()` is delivered then. And a `birth()` that publishes to its own subscription without bound parks every cell, since its own thread cannot wait for the window to drain: whether such a birth meets a bound or a refusal is undecided. - **Line 7, waits that only teardown ends.** Every teardown spine aborts the `or wait`s it would otherwise wait on before it joins the workers they block, and an aborted publish is not a success: it raises `BusWaitAborted`. Shipped in all five spines: each runs the ingress quiesce, then the wait-abort, then the pool join (the edges between the process rows the lifecycle plan states for every teardown spine, which emission reads), so a pool-placed publisher waiting for space on a queue only `main` drains takes the raise path and the join returns (`l07_pool_or_wait_teardown.hl`, and one fixture per other spine: `l07_or_wait_deferred_main_entry`, `_main_fall_through`, `_main_return`, `_main_test_failure`). Where a spine owes no pool join (no pool, or a target that rejects every pool), `fn main`'s exits keep the wait-abort after their frame's pre-drain, so a handler that drain runs may still wait. The same holds for the pinned threads a main locus's teardown joins: on every spine its head comes before its own pinned fields' joins and drains, with a pool or without, as the plan places it (`LifecyclePlan::entry_order`). The deferred spine used to join them first, so a pinned field parked in an `or wait` hung the join (`l07_or_wait_deferred_pinned_field.hl`; inventory row C14, corrected by L4's fifth part); its head now runs before the first of them, and the entry's own teardown does not run it again. - **Line 8, a birth failure's shape.** A failure in `birth()` (a birth-epoch closure, `birth_check`) or in `run()` (`violate`, a closure) is a `ClosureViolation`, and the failing child is kept for its owner's supervision: its region stays, the handler reads it, and a restart reuses it. There is no `StructuralFailure`. Shipped (`l08_birth_failure_kept.hl`). A failing child its owner replaces before hearing its failure is kept too, until its handler has run (`l08_sibling_replaced_kept.hl`, held to a hand-written plan: the producer has no field replacement, inventory row C29). A pinned locus's `birth_check` runs on its own thread, after `birth()` and before `run()`; a check's failure its owner is still holding is decided before `run()` starts, as for every other locus (inventory row C38, shipped by L4's birth spine; the lifecycle matrix's pinned birth cells, whose delivery is posted to the owner's domain since L5's fourth part). - **Line 9, when a violation reaches the owner.** At the failing epoch, not at dissolve; held while the owner's params are open (line 1). Shipped (`l09_delivery_at_epoch.hl`). - **Line 10, dissolve-epoch closures, then `dissolve()`.** In every spine a locus's dissolve-epoch closures run before its `dissolve()`, so a violation there is delivered while the user's cleanup has not yet run. Shipped (`l10_dissolve_closures_first.hl`; today its violation also shows line 4's cascade route). - **Line 11, a let-bound literal.** `birth()` and `run()` happen at the construction site; `drain()` and `dissolve()` happen together at the enclosing scope's exit. Shipped (`l11_let_bound_drain.hl`). Its reclaim deregisters a subscriber even when nothing is queued, held or deferred for it: a subscription is neither a run ticket nor an owner's domain, so the idle words that let the reclaim skip its other steps cannot say it has none, and a subscriber reclaimed without its deregistration would leave its registry entry to the next instance at its address (`l11_idle_reclaim_deregisters.hl`, clean under ASan). - **Line 12, owned fields drain before their parent.** A locus's owned locus fields drain before it does, each in its own domain: a pinned locus's fields drain on its thread before its own `drain()`, and nothing is called unconditionally on a parent's pinned thread from outside it. A field the locus was handed and does not own acquires no drain obligation. An owner's fields drain in their declaration order. Each dissolve completes before the next starts; physical release can be deferred while a run holds the child. Every child's storage is released before its owner's. Shipped, and emitted from the lifecycle plan (F.40 phase 3, L4's dissolve cascade): a pinned locus's thread drains its fields before its `drain()` (inventory rows C9, C18; `l12_pinned_fields_drain.hl` and the lifecycle matrix's pinned grandchild cells), and a field typed by an interface or a perspective drains with the others, before its owner's drain, through the drain half of the teardown its instantiation records (inventory row C32; the matrix's interface-field and perspective-slot cells). Before, a pinned locus's fields were dissolved after the join without a drain, and a contract-typed field's whole spine ran after its owner's `dissolve()`. An owned field's lifetime is its owner's, so every replica of a pinned field has its thread joined in its owner's teardown, before the owner drains (the plan's edge from each replica's join to the owner's drain). Shipped for a root returned from the fn that built it too (inventory row C52; `l12_returned_root_pinned_anchor.hl`, `l12_returned_root_pinned_replicas.hl` and `l12_returned_roots_pinned_replicas.hl`, the last two also under AddressSanitizer): the root's instance holds a join record for every replica of each of its pinned fields, replica 0 included (the replica's thread and its instance, as many as the placement declares replicas), not the building fn's frame, so two roots of one type each join their own; the fields' joins are part of their drain in the root's cascade, wherever the caller's binding ends it, in the order the building frame's flush would have used (replica 0, then the others from the last down). The building frame's flush owns the joins only when that frame still owns the root at its exit (the ownership table's handed-back column). It used to join the field when the building fn returned, while the caller still held the root, so a publish to the field afterwards was dropped. Lines 12 and 19 are an instance's own teardown, the dissolve cascade and the reclaim, and the compiler emits both from the lifecycle plan (F.40 phase 3, L4). The cascade's steps around an owner's fields (the fields' drains, the owner's drain, its dissolve-epoch closures and `dissolve()`, the fields' dissolves, the reclaim) and the order of the fields come from the plan's rows and edges for its declaration; so do the reclaim's (the owned children's reclaims, guarded logical entry, cancellation of the runs still queued for the instance, waiting for admitted runs and deferred descendants, the arena's release, the struct's). Logical reclaim hands physical release to a callback that preserves the runtime's run and owner holds. The emitter refuses an order it cannot emit rather than reorder it. A step with nothing to do costs no runtime call. Each runtime function of the reclaim (the retirement check, the reclaim scope's enter and leave, the cancellation, the deferral, the release's enter and leave, the wait for runs, the flush of retained descendants) first tests the runtime's "nothing outstanding" words, no run ticket linked anywhere, no retirement record or reclaim scope alive on any thread and, for the cancellation, no owner's domain recorded, and answers at once when they are zero. The compiled reclaim reads the same words, with the same acquire loads, in front of each call, and skips a call whose answer they settle; the retirement check then reads the instance's claim itself, as the function would. A cascade that tears no field down enters no scope: nothing runs between its enter and its leave for the scope to collect. The guarantees are the protocol's: a guard reads exactly its function's first test, so where it skips the call, the call would have returned the same answer having done nothing. The trace build carries the same guards; a skipped call names no step, as the call would have named none. The trace build holds each instance's emitted reclaim, on the spine the plan holds it on, and a queued run's cancellation, on its reclaim's spine, to the plan's order over every fixture, with exact departures listed in `SPINE_KNOWN_OPEN`. C29's field replacement calls the shared reclaim spine; the producer still describes that field's normal cascade and needs an alternate path for replacement. The process-level teardown steps around them (the pool shutdown and join, the wait-abort, the ingress quiesce, the pinned joins) are not this order. - **Line 13, resume.** A child resumed after a held handler goes through the same placement and admission as a first run, so a pool-placed child's `run()` is posted to its pool; under shutdown the resumed run may end in line 19's not-started outcome. Shipped (inventory row C43): the resume itself is posted to the child's pool as its run (`lotus_failure_resume_post`, taking the run ticket a first start's posted run takes), so its restart's `birth()` and its `run()` execute on the worker and the parent's `birth()` does not wait for them (`l13_resume_pool_child.hl`; before, the resume ran both inline on the settling thread). A locus that declares no `run()` owes none on any incarnation, and the trace shows none. A `Run` is owed exactly where the compiler calls `run()`, by one test both read (inventory row C53): a `run()` with a body, a flow's even when empty (its run wrapper reclaims it when it returns), and a pinned locus's on its thread whatever the body; an empty `run() { }`, written or not, is not called and owes none. Shipped, and so is the resume's half (inventory row C48, L4): the resume starts the resumed incarnation's `run()` only where the plan owes it one, so a locus owed none enters no `Run` when resumed, as its first incarnation enters none (`l01_neg_same_pool_held.hl`); a flow's run end, which is its reclaim, is entered as its first incarnation enters it. A child whose held handler replaced it is not resumed: its deferred reclaim runs in the resume's place, and no restart or `run()` of it starts (the reclaim wins, § "Failure handling", decision L0-1). Line 13 and restart during drain (below) are a failure's recovery, and the compiler emits it from the lifecycle plan (F.40 phase 3, L4): a restart's steps come in the order the plan's rows and edges give its declaration. The recovery decision is read once the handler has returned; the restart follows it, putting back the params as built for a `restart_in_place` and lowering the latch the failure raised, and begins the next incarnation, which owes again what is owed once per incarnation: `birth()` with its birth-epoch closures, then `run()` where the locus declares one. Nothing is torn down between them; the instance is the same one. The decision is carried out on the spine that reads it: the run gate of the instantiation, the posted run's loop, a pinned locus's thread, or the resume at settle; each of them, and the birth-closure check, decides a restart only while the child's reclaim claim is clear (the reclaim wins). The trace build holds the resume and the restart to the plan over every fixture, with three departures named: a held `run()` failure's resume, which the plan does not yet state (C43); a held failure's restart performed by the resume at settle where the plan places it on the posted run (C42); and the restart under an owner's teardown (below). - **Line 14, order.** Order follows the steps the compiler emits; latches and pending-release records keep teardown from running twice (§ "Lifecycle", "Order by construction"). Shipped (`l14_reclaim_exactly_once.hl`), and verified: the trace build (§ "The lifecycle trace") checks every fixture's run, and every runnable example's, against laws that hold whatever the plan (an instance is reclaimed once, and only after it was born; no step is taken on an instance nothing built; every step entered ends), and each adopted line's fixture against its plan. - **Line 15, signals.** SIGINT and SIGTERM raise the process's draining flag, from a watcher thread; nothing on the signal path calls a lifecycle method. The `run()`s that read `self.draining` return, and the ordinary teardown follows, `drain()` and `dissolve()` once each. Shipped (`l15_sigint_flag.hl`). - **Line 16, a target without threads.** The capability matrix selects which of the three process-wide obligations a target owes (its `PoolJoin`, `WaitAbort` and `IngressQuiesce` cells), and the plan orders the ones selected, the same in every spine. Shipped: wasm32 owes no pool join (its premise is that wasm32 rejects every pool other than `main`) and no ingress quiesce (it rejects the listen transports), and owes the wait-abort in all five spines, since the local capacity wait is admitted there and no proof yet shows no waiter is live at teardown; the host owes all three (`l16_eager_spine_pool_join.hl` for the native half, `crates/hale-codegen/tests/target_lifecycle_cells.rs` per spine on both targets). The compiler emits them from the plan: each spine's process rows, in the plan's order, its head before the frame and, at `fn main`'s three exits (one emitter for the fall-through, the test failure and `return`), the frame's pre-drain and the rows the plan places after it in the frame's flush (`crates/hale-codegen/tests/frame_flush_ir.rs`). - **Line 17, the pinned join set and order.** **Pending, conditionally:** the deferred spine's rule (subscription-less pinned children first, pinned subscribers in their slots) is the baseline for every spine, with the eager spine's change pinned, provided GH #253's final-publish guarantees (§ "Lifecycle", Teardown delivery contract) hold across the eager, deferred and declaration permutations; a conflict is resolved before the line is settled. The fixtures add one fact the condition has to meet: a main locus's own pinned subscriber field is joined before a cooperative sibling field's `dissolve()` publishes to it under both spines, because a locus's own pinned entries are moved after its frame entry (`l17_pinned_join_eager.hl`, `l17_pinned_join_deferred.hl`). Which pinned threads are joined before which cooperative entry is the compiler's, not yet the plan's. Where the head goes is settled (line 7): a main locus's head precedes its own pinned fields' joins on every spine, the eager and the deferred alike, and the plan states it. - **Line 18, the pre-drain.** Every teardown spine drains the bus before its first step. The pre-drain is a delivery point, not a witness that anything has quiesced. Not yet emitted by the eager spine (inventory row C13). No program shows the difference today: a body that may have published drains at its own exit, and a main locus's ingress quiesce ends in a drain (`l18_eager_pre_drain.hl` guards the outcome). The trace build shows the missing step: the eager spine's run has no `PreDrain` before its first teardown step. - **Line 19, a run's admission and its terminal outcome.** A run posted to a pool is attempted by the caller and then admitted or rejected; "attempted" is what the caller knows, not a third outcome. An admitted run is executed or canceled. Each ends in one named outcome: | path | terminal outcome | |---|---| | rejected before admission | not started, with the shutdown reason | | admitted, canceled before start | not started, with an acknowledgement | | started, returned | completed | | started, abandoned by an asynchronous shutdown | canceled after start; the worker's quiescence is witnessed separately, by the pool join | Admission is decided on the pool's queue: a post that finds room in the ring is admitted, and one that meets a full ring once the pool's shutdown is set is rejected, not started for the pool's shutdown (`PoolShutdown`). A cell admitted after the worker's last empty-and-shutdown check is never dequeued: its child's reclaim cancels it (not started, with an acknowledgement), or, when no reclaim reached the child first, the pools' teardown frees it, not started for that teardown (`PoolTeardown`). Nothing is silent: every run that does not start is named on one of these paths, and a run post that cannot allocate aborts, as the lifecycle runtime's other allocations do. Run admission is separate from the admission of a failure decision, which shutdown never refuses while its child waits (join progress, below). Whatever the outcome, the child is torn down exactly once. A started run abandoned by an async pool's shutdown ends canceled after start, named where the worker frees its parked coroutine, and the pool join's completion is the separate witness of the worker's quiescence; the trace build records both (inventory row R20a, `l19_parked_started_coroutine.hl`). No release build observes a run's terminal, so the name lives there. An admitted run is retained against its child's teardown: from admission until the worker starts it or a teardown cancels it, the run holds its child. The child's Reclaim begins by canceling the runs still queued for it, on whatever pool, each ending not started with an acknowledgement, before its arena (or an elided arena's struct) is released: every reclaim path makes the call, past its latch, so a queued run finds the child whole or finds its run canceled, never a released arena. The ticket's lock linearizes the cancellation against admission: a reclaim that cancels first wins, and a worker that takes the run first converts the ticket into the run's hold on the child. A child torn down by its owner on the worker its `run()` was posted to is reclaimed without that run starting, and so is one whose run waits on another pool's worker; each is torn down once. A run that has started holds its child until it returns: the child's Reclaim, past the cancellation, waits for its started runs before the arena is released, so a placed field reassigned while its run is running on another pool keeps the old child's memory until that run returns. An async pool's parked run that its pool's shutdown abandons releases its hold where its coroutine is freed. The wait is not a join. The reclaim's drain and dissolve still run beside the run, as before, and the hold guards the memory only. A run executing on the reclaiming thread is not waited for. Such a run is the one whose end reclaims its own child, which happens after `run()` returned. Outside a live handler, the wait services the reclaiming thread's queue as a yield does. Inside a queued main-thread handler, drain, dissolve and queued-run cancellation still happen at the reclaim site, but physical release is queued until that handler returns. The queue guard stays set throughout the handler body: a free function's tail drain cannot start the next handler early. At the boundary, a release callback can wait and service replies normally. A coroutine on an async pool parks while it waits, so its worker runs its other cells and coroutines. Main drains its bus queue between short sleeps, and a pinned thread drains its mailbox the same way (`l19_started_run_retained.hl` and `l19_started_run_retained_async.hl`, both also under AddressSanitizer; `l19_started_run_publishes_back.hl` and its `_async` twin). The handler variants (`l19_handler_replaces_started_run.hl` and its `_async` twin) check dissolve-before-replacement, handler completion order, a handler-local temporary and a started run reading a nested form's storage. Both dispatch modes run these and the run-body controls under ASan. Removing the handler boundary restores the original deadlock under the fixture's deadline. Admission to a child's shared reclaim spine is an atomic claim on that instance. It precedes arena reads and logical teardown, and stays claimed while physical release is deferred. A started run ending in `terminate` or automatic flow reclamation on another worker cannot reclaim the retired child again: it returns from the reclaim entry, ends its run hold, and lets the thread that owns retirement complete release. Failure-handler deferral happens before claiming, so its later callback can enter the spine. That deferral also covers a reclaim reached inside a handler for a child whose own failure is still posted to the same thread, whose wait only that thread could end (§ "Failure handling", decision L0-1). Such a reclaim, and one that waits for a delivery of its child, first marks the claim owed, a third state beside clear and claimed: the first reclaim entrant takes an owed claim as it takes a clear one, and a child's restart decision is refused while its claim is owed or claimed (the reclaim wins). A constructor resets the claim for each new instance, including recycled storage. The handler retention regression exercises termination and flow completion under ASan on classic and async pools, in both dispatch modes. Physical release waits before freeing forms, children trackers, recognition pools, arenas or recyclable structs. While an owner's run can still read its descendants, their logical teardown collects physical-release callbacks under that owner. The owner waits first, releases those descendants, then releases its own storage. Retired fields retain an explicit owner link after their field slots change. Nested handler drains during a release cannot start another release callback beneath it; pending requests are coalesced. Unowned handler-local stack instances retain synchronous release. Before the hold, admission freed the ticket and nothing held the child. Such a reclaim released the arena under the running run, a heap-use-after-free under AddressSanitizer in both dispatch modes. A run whose child is never reclaimed until its pool joins is still ordered against the teardown by the join. A bus handler running on a pool's worker holds its subscriber the same way (inventory row R52): the worker takes a held ticket on the subscriber when it starts the handler for a dequeued cell and ends it when the handler returns, or where a shutdown abandons the parked coroutine, and a reclaim waits for it as it waits for a running `run()`, so a pool-placed child replaced while its bus handler runs keeps its storage until that handler returns. Its drain and dissolve still run beside the handler. Every locus a pool hosts is a placed field of the main locus or nested under one, so main can reclaim it while its worker runs a handler. The main queue's handlers and a pinned thread's take no hold: their subscribers are reclaimed on the thread that runs them, between handlers, or after the pinned thread's join. A yield inside a pinned thread's handler departs from this today: it drains the thread's mailbox, so an owner's handler it runs can replace the suspended handler's subscriber, which is reclaimed under it (`l19_pinned_yield_replaces_child.hl`, known open, R52). A cell still queued for a subscriber whose reclaim has deregistered it is dropped unrun when its worker reaches it (GH #703); the worker takes the hold before it looks, so it either drops the cell or the reclaim waits for its handler. A subscriber registered later at the same address (a scope-local one's stack slot, a recognition parent's recycled cell, a recycled arena chunk) never runs it: each subscriber incarnation has a generation, minted at the first registration made at an address with no live incarnation and shared by that incarnation's other registrations; every cell built for a registration carries its generation, and a cell is delivered only when the incarnation now at its address is live and is the cell's (`l19_reused_address_queued_cell.hl`, R33). A stopped failed child keeps its generation, so a restart delivers what was queued before the stop. A cell built for no registration (a run, a posted failure) is checked by its address's state only. Before the hold, replacing the child while its handler ran freed the storage under it, a heap-use-after-free under AddressSanitizer in both dispatch modes (`fd_handler_cell_no_hold.hl`, under `crates/hale-codegen/tests/fixtures/failure_delivery/`; `l19_handler_cell_retained.hl`, whose trace holds the handler's end before the reclaim's completion). Taking and ending the hold costs two acquisitions of the run tickets' lock, so a classic pool's worker carries it from a handler to its next cell when that cell is the same subscriber's, and ends it: before a cell of another subscriber or one that is no bus cell (a run, a root's init, a failure's wake) and before a failure posted to its domain is delivered; before it parks or idles (its queue empty, replay's idle wait, the end of a root's init yield), so never across its pool's shutdown; when the next cell's dead-self check drops it, which runs on every cell; after the handler in progress when a reclaim waits for the hold (the reclaim marks the ticket wanted under the lock as it counts it, and the worker reads that word once per cell before it carries); and after 64 handlers under one hold, whatever else. A reclaim therefore waits for the handler in progress and no further one. An async pool's worker carries nothing: a coroutine's hold spans its parks, and the cells of one subscriber overlap there, so each costs its cell the two acquisitions. A dispatch on the publishing thread takes none. Shipped (F.40 phase 3, L5): the cancellation is named in the trace build on the thread that reclaims, inside the Reclaim's bracket, and the release build runs the same path (`l19_queued_run_canceled.hl`; `l19_cross_pool_queued_run_canceled.hl`, a run queued on pool `side` for a child its owner replaces on main, also run under AddressSanitizer; the lifecycle matrix's pool cells, which assert the named terminal). Before it, the run started on the freed struct (a heap-use-after-free under AddressSanitizer; an accepted child was torn down twice), or, for a subscriber, was freed unrun with no terminal named. A run refused at shutdown and a cell freed unrun when the pools are torn down are named the same way, by the trace build where the runtime ends them, on the release build's path (L5; before it both were silent). The refused run was never admitted, so its Run's terminal stands alone on the posting thread; the freed cell's is bracketed by the teardown's cancellation. The post's ABI stays `void`: the caller does not learn the outcome, the trace names it. The regressions: a full ring (`l19_full_ring.hl`: after the first `App` literal's teardown has joined the pools, each later one's placed field posts its run to a pool with no worker; the first 64 cells are canceled by their children's reclaims, and once they fill the 64-cell ring every later post is refused), an empty ring after the last check (`l19_empty_ring_last_check.hl`; with the reclaim's cancel removed by the trace build's negative control, the pools' teardown names the cell instead), self-post overflow (`l19_self_post_overflow.hl`, every admitted run completes today), a resumed run (`l19_resumed_run_at_shutdown.hl`), and the parked started coroutine. - **Restart during drain.** A restart the handler asks for after its owner has entered teardown, or while the process drains, is not performed. The recovery decision (what the handler asked for) and its execution outcome (performed, or not started for a shutdown reason) are recorded separately. The owner's entry into teardown and the permission to restart are ordered: a restart executed after that entry is refused. The child ends as if the handler had returned without asking: it reaches its ordinary run end, which keeps a failed child its owner holds (§ "Lifecycle", Per-child reclamation; GH #1069), so the cancellation is never an unconditional reclaim. Shipped for the process drain (`emit_restart_requested` refuses a restart while `lotus_process_draining_flag` is up), and for a child whose reclaim is owed or claimed when it reads the decision: its handler replaced it, or its owner's reclaim reached it while the failure was outstanding (the reclaim wins, § "Failure handling", decision L0-1). Not yet shipped for an owner's teardown that reaches the child through a pool join (inventory row C42): a pool-placed child that fails while `fn main`'s exit joins the pools is restarted, because the join runs the delivery before the cascade reaches the child's reclaim, so nothing is owed or claimed when the child reads the decision (`rd_restart_during_teardown.hl`). - **Join progress.** An owner keeps completing the outstanding failure decisions of its children until the children it waits for have quiesced, and a pool worker that supervises children on another pool owes the same. Shutdown never silently discards a failure cell whose child awaits it. The joins do not acquire a general queue drain beside `pthread_join`: whatever the joining thread runs while it waits has its own reentrancy and admission rules. Shipped (F.40 phase 3, L5's fourth part): the pinned join and the pool join wait for the joined thread's domain to end, running the failures posted to the joining thread meanwhile and nothing else (§ "Failure handling", decision L0-1), so a late failure during either join is delivered on `main` and the join then returns (`jp_late_failure_pinned_join.hl`, `jp_late_failure_pool_join.hl`; inventory rows C18, R20, C36). A posted delivery never waits on a full queue: it is a node the owner's domain finds wherever it services its queue, and the wake cell beside it is dropped when the queue is full, since a full queue has a consumer that is not parked. - **Bubble climbs the tree.** `bubble(err)` passes the failure to the grandparent's `on_failure`, and on up until a handler absorbs it; only past the root is it the report and the non-zero exit (`spec/semantics.md` § "bubble(err)"). Not yet shipped (inventory row C47): every `bubble` is lowered as the root's report and exit, wherever it is raised, and no grandparent's handler is called. F.40 phase 3's L4 corrects it with the failure-delivery spine. #### The lifecycle trace A debug aid, not a contract. A build made with `HALE_LIFECYCLE_TRACE=1` (`BuildOptions::lifecycle_trace`; native host targets only) compiles the runtime with `LOTUS_LIFECYCLE_TRACE` and emits each lifecycle step between an entry call and its completion event, so the program writes one line per obligation event on stderr: ```text lc spine= dom= type= inst= inc= ``` `Kind` is an `ObligationKind`, `Point` is `Entered`, `Completed` or `Terminal()`, and `Spine` is the spine that emitted the step (`-` where the site cannot name it: a failure delivered in place, a restart's re-birth). A `Restart` carries the spine of the step that decided it (the posted run's loop, `PoolRun`; a pinned locus's thread, `PinnedMain`; the run gate of an instantiation, `Instantiation`; the resume at settle, `Settle`), and a `Resume` the settle's. The domain is the thread the event ran on: `main`, `pool:` for a cooperative pool's worker, `pinned:` for a pinned locus's thread, `thread:` for any other. `type`, `inst` and `inc` name the subject; a process-level obligation (the pool join, a wait-abort, a pre-drain) prints `-` for all three. The runtime mints the subject, and this is the one place a runtime subject comes from: the first event naming a struct gives it the next instance number and incarnation 0, `Restart Entered` begins its next incarnation, and `Reclaim Completed` retires the number, so a struct recycled at the same address is a new instance. The events: `ParamsSettle` (the bracket's entry, and its settle), `Accept`, `Birth`, `Run` (and `Run Terminal(CanceledAfterStart)` for a started run whose parked coroutine a pool worker abandons at shutdown, with that `Cancellation`; a run that never starts ends `Terminal(NotStarted(...))`: `Acknowledged` when its child's `Reclaim` cancels it, with that `Cancellation`, inside the reclaim; `Shutdown(PoolShutdown)` when the post is refused at shutdown, on the posting thread; `Shutdown(PoolTeardown)`, with a `Cancellation`, when the pools' teardown frees its cell), `Handler` (a bus handler on a pool's worker, once per delivered cell, carried hold or not: entered as the handler starts, holding its subscriber, completed as it returns, the hold still standing, or `Terminal(CanceledAfterStart)` where a shutdown abandons its parked coroutine; the oracle holds every handler of a subscriber that began before its `Reclaim` completed to have ended first; a hold carried past that end only makes the reclaim wait longer, and the trace does not name it), `FailureDelivery` (entered where the failure is raised, completed when the handler returns, in place or at settle), `ConstructionDelivery` (a held failure, from the hold to its handler's return at settle), `Restart`, `Resume` (a held failure's resume at settle: its decision to restart, start `run()` or end, each traced as its own step after it), `Drain`, `Dissolve` (the dissolve-epoch closures and `dissolve()`), `Reclaim` (past the `__arena` latch: the queued runs' cancellation, then the arena's release), `PreDrain`, `WaitAbort`, `PoolJoin` and `PinnedJoin`. Readiness, subscription and the run's admission have no events yet. The subject table publishes each initialized identity with a release/acquire ready flag: a thread finding a claimed slot waits until its instance number and initial metadata are ready. Later metadata updates and the sequence counter use relaxed atomics. On one thread `seq` order is program order, and if event a happens before event b then `seq(a) < seq(b)`, so a `seq` order that contradicts a required edge is a real violation, and one that agrees with it is evidence of an execution, not a proof. The subject publication and the write itself (one `write(2)` per line) perturb the execution; a traced run does not establish the ordering of an untraced run. `hale_types::lifecycle::trace` parses the lines back into `Event`s and checks them against what a run owes. `LOTUS_LIFECYCLE_SKIP`, read by a trace build's runtime at start, is a comma list of steps a negative control removes: a kind's name skips that step and both its events where it is emitted (and, for `ConstructionDelivery`, holds no failure, so the handler runs in place while the params are open; for `Cancellation`, a reclaim cancels no queued run, which only a fixture whose cells no worker will dequeue may use); two name a hold, not a kind: `RunHold`, where a reclaim does not wait for its child's started runs, and `HandlerHold`, where a pool worker links no hold on a handler's subscriber (and so carries none), its `Handler` events still named; `.` drops that one line and nothing else. A build without the knob emits nothing of the trace and its IR is the same. `lifecycle_fixtures.rs`'s `CONTROLS` use it so that, for every obligation kind a fixture's plan holds a run to, a run with that step removed or reordered fails the oracle, and with the violation that says why: a removed pool join lets a worker's teardown begin before its `run()` has ended, a removed hold delivers a failure before its owner's settle, a removed handler hold lets a subscriber's reclaim complete while its handler still runs, an omitted completion leaves a dependent step entered with its prerequisite unreached, and the host's own order (join, then abort the waits) fails line 7's edge. ### Native observation emission (iris P4, 2026-07-27) With `LOTUS_OBS=1` the runtime publishes an iris-protocol observation segment (`/hale-obs-` + registration file per PROTOCOL v0.4 — the contract's executable form is `crates/hale-codegen/runtime/obs_protocol.h` in this repo since 2026-09-09 (GH #527 B1); `lotus_obs.c` is compiled with it prepended and pins its layouts to it, and the Rust test decoder is checked against its text. 0.2, 2026-08-12 downstream handoff: the header gains `model_hash` at `0x80` — the topology artifact's `shape_hash`, stamped by the CLI from the model of the snapshot it typechecks, so a consumer can establish the RUNNING binary was built from the model it joins against; 0 = unstamped harness build. And the per-binding backpressure cells reserved since v0 are now written: `queue_depth` (cell 3, a last-write-wins gauge of the kernel send-queue occupancy sampled at send time), `send_block_ns` (cell 4, accumulated transport-send duration), and `retries` (cell 5, reconnects) — counters-tier, so a consumer falling behind shows as depth climbing and block time accruing BEFORE anything drops) and the **Canonical model entity ids (GH #476 Change 8, proto 0.3).** Manifest rows name entities and number them in registration order, which is a fact about the run, not about the program. Each row's `aux_b` — in the entry layout since v0, written as 0 by every path until now — carries the canonical `ApplicationModel` entity id for that row, stamped by the CLI at build time: `MK_TOPIC` rows carry the `SubjectId` (the manifest fuses publishers by wire subject, so the address, not the topic decl, is the identity), `MK_LOCUS_TYPE` rows the `LocusDeclId`, `MK_BINDING` rows the `BindingId`. Values are `index + 1`; **0 keeps meaning "unstamped"** (a harness build, or an entity the model does not name — a stdlib subject, say). Those ids index tables that `model_hash` does NOT fully cover: `shape_hash` is structural model identity, and the arrangement's binding rows are not in the topology artifact at all, so two builds can share a `model_hash` while numbering entities differently. The header therefore publishes the id table's own identity at `0x88`: `entity_id_digest`, a digest over the exact `(kind, name, id)` rows the build stamped. **A consumer recomputes it from the model it holds and uses the ids only on a match** — a detectable refusal instead of a silent misattribution. 0 = unstamped. A consumer that joined by name still can. The stamp is all-or-nothing: the mapping table grows with the program (no cap), and if it cannot, the process says so on stderr and publishes NO ids and NO digest rather than a partial table — partial canonicalization is indistinguishable from "unstamped" and would put a consumer back on name matching without telling it. **proto 0.4 (2026-09-04, GH #525 / iris handoff-14 P31).** No layout change. Iris's PROTOCOL §4 and its reference emitters had used `aux_b` since v0 as *binding → owning topic id, scheduler → cpu index*, so from 0.3 a nonzero `aux_b` meant two things depending on which emitter wrote the segment. 0.4 retires the v0 meaning: every emitter writes the canonical entity id or 0, the scheduler cpu index moves to `aux_a`, and the binding → topic pairing is dropped (the counter table and the binding name carry it). A consumer still gates on `entity_id_digest != 0`; at minor ≥ 4 it may additionally trust that a nonzero `aux_b` was never anything else. This was the last open item before the protocol freezes at v0. **Identity is stamped before anything can register.** The prelude publishes `model_hash`, `exec_digest`, and the entity ids ahead of the bindings prelude and the config loader, because registering a binding creates the observation segment and segment creation snapshots the identity fields — a program with a `bindings { }` block used to publish `model_hash 0` for its whole life. Eager recording/replay init still runs after binding realization, so a backend with no replay class refuses at its own seam, naming itself, instead of being pre-empted by a generic identity refusal. The runtime's own choke points emit records: `BUS_PUBLISH` / `BUS_DELIVER` at every dispatch flavor (dynamic, static-devirt, cross-thread wire; deliver is enqueue-time at v0), `NET_SEND` / `NET_DELIVER` at the transport fanout/reader with per-binding monotonic seqs (what iris seq-matches into cross-process edges), `LOCUS_BIRTH` / `LOCUS_DISSOLVE` (both cooperative and pinned lifecycle paths; dissolve rides the `emit_locus_arena_destroy` chokepoint) and `RESTART` at reconnect. Cost contract: env unset = one predictable branch per probe (`g_obs_state`); enabled-but-unobserved (`observer_count == 0`) = counters only, no ring writes; ring emission is SPSC via the #247 primitive with one ring per emitting thread (TLS assignment; overflow threads count into `ring_drops_total`). On the observer-count 0→1 rising edge the live-locus table replays as `LOCUS_BIRTH` records so a late-attaching observer reconstructs the tree. Knobs: `LOTUS_OBS_RINGS` (default 8), `LOTUS_OBS_SLOTS` (default 4096), `LOTUS_OBS_WIRE` (default off — see NET seq semantics: `LOTUS_OBS` alone never alters the wire; the cross-process edge header rides the wire only when the operator opts the whole fleet in with `LOTUS_OBS_WIRE=1`). v0 notes: manifest ids are registration-order; publisher locus attribution on BUS_PUBLISH is unattributed (0); pinned births render parent=root. `lotus_obs.c` is its own TU; the arena TU's probes are weak-guarded so helper binaries compiling `lotus_arena.c` alone still link. Field-hardening (iris handoff 2, 2026-07-27, v0.11.13): NET_SEND now fires on the UDP multicast fanout path (it `continue`s before the stream-transport probe, so multicast publishers emitted only deliver-side records — the seq matcher had nothing to pair); LOCUS_BIRTH is emitted BEFORE a locus's field-default init and carries real parentage (the parent registers before its children look it up — post-birth placement rendered every tree flat), and pinned children register on the spawning thread so a park before their first probe doesn't hide them; BUS_PUBLISH stamps the publishing locus from a codegen-set TLS (the C dispatch doesn't know `self`); topic shape_hash is `fnv(subject, canonical payload structure)` — field names + coarse type tags in declared order, never the declaring type's local name — so two binaries sharing a subject fuse into one manifest row; and each ring re-emits EPOCH every 1024 records so a high-rate ring that wraps its anchor never reconstructs timestamps from a stale base (the ~2^64 ns readings). NET seq semantics (iris handoff 3, v0.11.14): `NET_SEND` and `NET_DELIVER` carry `w1 = origin:16 | seq:48` where **origin is the SENDER's per-process identity and seq is the SENDER's per-binding counter** — the receiver echoes both verbatim from the wire, so a send and its delivers pair on `(origin, seq)` across segments (the cross-process edge). Before this, the receiver stamped its LOCAL delivery counter, which sums across senders on a multicast subject — the send seq never equalled the deliver seq and iris rendered zero edges. (Stream transports are unicast — one sender per connection — so origin 0 + the framed wire seq already pairs correctly.) Edge-emission correctness (iris handoff 4, v0.11.15): - **The record id field IS the topic id** for `NET_SEND` / `NET_DELIVER` (as for the bus records) — it is the consumer's join key onto the fused topic row. It was hardcoded 0, so no NET event could be associated with any topic and edges were structurally impossible regardless of `(origin, seq)`. The probe now resolves the id from the subject (in hand at every emit site); the per-binding counter line still keys off the binding id. - **The published counter is never attribution-gated.** Counters are the dormant-mode contract (enabled-but-unobserved = counters only), so a genuine publish counts even when its locus can't be attributed. handoff-3 gated the counter (and record) behind a positive publisher-TLS that a cross-pool or free-fn publish never set, zeroing the fleet's published counter. Inbound wire re-dispatch is now excluded by NEGATIVE marking instead: the reader brackets its re-dispatch (`lotus_obs_begin/end_redispatch`) and the publish probe consumes the mark; genuine publishes are the unmarked default and always count, with best-effort locus attribution (0 when unknown). The keyed dispatch flavors, which had no publish/deliver probe at all, now emit both. - **`LOTUS_OBS` never alters the wire.** The `(origin, seq)` edge identity is a wire change a pre-header receiver cannot parse — an observed sender would silently drop every datagram at a stale peer, partitioning a mixed-version fleet invisibly. So the UDP self-describing header (`[u64 magic][u64 origin|seq]`) and the framed-transport origin word ride the wire ONLY when the operator opts in with `LOTUS_OBS_WIRE=1`; with `LOTUS_OBS` alone the wire is byte-for-byte identical to an unobserved run. Cross-process edges therefore require `LOTUS_OBS_WIRE=1` fleet-wide; counters and local records need only `LOTUS_OBS=1`. The receiver always peels a header it recognizes (self-describing), so old→new and unobserved peers are unaffected either way. Flavor completeness + quiet-process replay (iris handoff 5, v0.11.18): - **The fully-devirtualized direct dispatch now carries probes.** The single-quiet-subscriber same-thread flavor (the `static_direct` bucket walk with the handler baked as a direct call, and its multi-handler C sibling) emitted NO probes at all — a subject on that path never registered its topic, counted nothing, and produced no BUS records. Both direct flavors now publish once + deliver per matched target like every other flavor. Dormant cost is preserved by the `lotus_obs_live` gate: codegen checks the flag ONCE per function entry (the flag is final before any user publish can run — obs resolves at the first probe, always a locus birth), so LLVM hoists the branch and an unobserved publish loop is instruction-identical to the probe-free lowering. The same per-fn check now also gates the publisher-attribution TLS note. **The AST-level rewrite got the same sentence on 2026-08-11 (downstream handoff P23):** the closed-world intra-locus/tower desugar — one rewrite earlier than these codegen flavors, so the earlier fix never reached it — now emits `BUS_PUBLISH` (publisher-attributed) + `BUS_DELIVER` (subscriber-attributed) at the direct handler call, behind the same per-fn gate. Its first probe registers the topic's manifest row, so an intra-tree subject with traffic is indistinguishable from its bus-dispatched sibling, and manifest absence uniformly means "never mentioned at runtime" on every flavor. - **Observer attach no longer needs probe traffic.** The 0→1 birth replay was driven from inside probes, so a probe-quiet process (a main parked in a read loop with pinned raw-fd readers; hot paths on the direct flavor) never noticed an observer and stayed silent — segment registered, zero records. When `LOTUS_OBS=1`, a detached heartbeat thread drives the replay check every 250ms under the obs lock (teardown takes the same lock around the unmap, so the heartbeat never races it); replay latency after attach is bounded at ~250ms even for a process that never probes again. BUS record w1 layout (iris handoff 7, v0.11.21): `BUS_PUBLISH` / `BUS_DELIVER` pack `w1 = locus:20 (bits 44..63) | seq:44 (low)` — PROTOCOL §8, executable reference `iris emitter/protocol.h` (`obs_bus_w1`). The emitter had these fields transposed (locus low, seq shifted) since handoff-3; attribution was computed correctly and packed unreadably, and the contract tests stayed green because they decoded with the emitter's own layout. The tests now vendor protocol.h's decode, so emitter and consumers cannot disagree silently. Adapter ingest completeness (iris handoff 8, v0.11.22): the Hale-owned-wire ingest (`std::bus::__local_dispatch` → `lotus_bus_dispatch_wire_inbound`) now carries the full probe trio the C reader threads have. The wrapper peels the self-describing obs wire header when the producer emitted one (magic-guarded — a headerless or non-Hale producer is byte-for-byte unaffected, and headered bytes reaching a Hale adapter previously failed deserialization outright), emits `NET_DELIVER` echoing the wire `(origin, seq)` (or `(0, local per-subject seq)` when headerless — countable, not pairable), and the plain `dispatch_wire` fanout gains the per-target `BUS_DELIVER` its keyed sibling received in v0.11.18. Adapter bindings register lazily in the manifest (`aux = 2`). Dynamically-ingested planes now form producer→consumer edges and attribute subscriber loci exactly like statically configured listens. Attribution ordering + the adapter inbound path (iris handoff 6, v0.11.20): - **`lotus_obs_live` is resolved in a constructor**, before `main`. The fn-entry gate hoist's soundness argument requires the flag to be final before ANY function entry — previously it was set at the first probe (a locus birth inside main's body), so a publish lowered into `fn main` itself would snapshot a stale dormant flag forever. Constructor resolution makes the flag genuinely process-constant. The field-shaped ordering case (publishers deep in steady-state loops, observer attaching much later) is pinned in `obs_fleet_contract.rs`. - **`std::bus::__local_dispatch` (the adapter-inbound ingest) is redispatch-MARKED.** An adapter locus dispatching received wire bytes is a delivery, exactly like the reader-thread path — the unmarked entry stamped a spurious `locus=0` BUS_PUBLISH per inbound message and inflated the published counter, so a fleet ingesting via the std adapter surface read as "everything publishes unattributed." Lowers to `lotus_bus_dispatch_wire_inbound`, which brackets the dispatch with the P15 negative marking. Per-subscriber BUS_DELIVER and the topic registration are unchanged. ### Lossless recording mode (GH #296 Phase 1) `LOTUS_OBS_RECORD=` opts a run into **recording**: the observation plane stops being a sampler and becomes a flight recorder. Implies `LOTUS_OBS=1`; the recording counts as an attached observer, so ring emission is on from the first probe with nothing mmapping the segment. Opt-in on the same terms as everything else here — unset, the lowering and the wire are byte-for-byte the unobserved build (the flag resolves in the same constructor as `lotus_obs_live` and gates behind it everywhere). Three deltas from observer mode, all in the service of one rule — **a recording never drops** (a dropped record is a replay that diverges silently): - **Overwrite-oldest becomes block-the-producer.** An in-process drain thread appends every ring record to `` and publishes per-ring cursors; a producer whose ring is full against the cursor waits (50µs naps) instead of clobbering the oldest slot. Recording-on hot cost while the ring has room is one relaxed load + compare per record. A live observer prefers losing records to stalling the program; a recorder makes the opposite trade, and the stall is the contract. - **A thread that cannot get a ring fails the run** (exit 70, diagnostic names `LOTUS_OBS_RINGS`) instead of going silently dark forever (the `-2` disposition observer mode keeps). Recording defaults `LOTUS_OBS_RINGS` to the 64 maximum, so this means more than 64 emitting threads. Block or fail — never drop. - **A drain-side write failure fails the run** (exit 74) rather than truncating silently. The file ends with a trailer written at teardown after a final full sweep; a reader that does not find the trailer holds a truncated recording and must say so. **Durable recording / crash-prefix recovery (Phase 5).** Named precisely: this is a durable flight recorder, NOT a write-ahead log — the application is never gated on the recording reaching stable storage, so a crash can lose records the application already executed past. (A load-bearing WAL grade — persist, fence, then permit delivery — is a possible later mode; nothing here claims it.) What IS guaranteed: the drain appends whole frames in stream order and flushes every sweep, so the on-disk file is an exact prefix of the stream at all times, and the header identity (`model_hash`, `exec_digest`, policy flags) is stamped **eagerly** by the drain as soon as the ctor-sequenced setters have run, not only at finalize. A run that dies without teardown therefore leaves an artifact that is attributable and exact up to one torn frame at the tail. Default recording rides the page cache (survives a process crash, not power loss). `LOTUS_OBS_RECORD_DURABLE=1` is the power-loss grade: every flushed sweep synchronizes the file (`fdatasync` on Linux, `F_FULLFSYNC` on macOS), the parent directory is synced at file creation (a fresh NAME is not durable until its directory entry is), the finalize trailer itself is synchronized before close (a clean exit followed by power loss must not demote a finalized recording to a truncated one), and the grade is recorded in the header's policy flags (bit 1). **Consume records (private recorder namespace).** `BUS_DELIVER` is enqueue-time by design and lands on the *publisher's* ring — correct for fanout accounting, structurally unable to say in what order a consumer actually ran its handlers. Under recording, every dequeue-driven handler invoke (main-queue drain, coop-pool drain, pinned mailbox drain, async coro start) stamps a consume record on the *consuming* thread right before the handler runs, carrying the delivery identity: the target locus in the record's id field and the full 64-bit `msg_id` in `w1`. Its ring position is the per-consumer delivery order — the thing a replay serves back. Pairing rule: per queue, the k-th consume is the k-th queued delivery. The synchronous direct-dispatch flavors deliberately emit no consume record: their handler runs at the `BUS_DELIVER` position, which *is* the consumption point. Recorder events (`CONSUMER`/`CONSUME`/`ENQ`/`JOURNAL`) live on **process-private per-thread rings** in their own event namespace, never in the public observation segment: iris protocol ekinds 8/9/11/12 mean SUPERV_TRANS/PLACEMENT/BINDING_UP/BINDING_DOWN, and an observer attaching to a recording process must never decode recorder bookkeeping as lifecycle transitions. In the recording file, private-ring entries carry the high bit in their ring field, so the two namespaces can never be confused. **Delivery identity (Phase 2).** Every queued delivery carries a deterministic `pub_id = consumer_id:16 | per-publisher-thread seq:48` (full width — the review-round guards fail loudly rather than wrap), re-derivable by a re-executed run without global coordination. Consumer ids are stable across runs, unlike pthread ids: main = 1, cooperative pool workers = 16 + registration index, pinned locus threads = 64 + obs instance id; threads with no stable identity (ingress readers) get run-unique anonymous ids, never a shared fallback. Payload bytes are captured once per publish — **every dispatch flavor, queued or synchronous direct** — under a **stable subject hash** (manifest topic ids are registration-order and racing publishers register in either order). Flags: bit 0 = external wire ingress; bit 1 = raw in-process struct bytes — an ABI snapshot (String/Bytes fields are pointers, padding is uninitialized) that consumers must compare by size only; canonical per-topic recording codecs are the staged fix. Wire captures are canonical bytes, unflagged. The direct-dispatch flavors — the devirtualized same-thread call that replaces the enqueue, in both its baked-inline and helper forms — record through the same writer, at the same publish site, in the same framing. They used to capture nothing, on the reasoning that a closed-world same-thread call carries no external input and re-execution re-derives its payloads: it does re-derive them, which is precisely why `--diff` can compare them, and a fully direct-dispatched workload recorded none to compare. The capture sits behind the same recording gate as the publish probe, so an unrecorded run writes nothing and pays nothing. **Input journal (Phase 3).** Under recording, every user-facing nondeterministic read is journaled per consumer as ONE unified stream carrying the read's kind and its **exact encoded arguments** (length-framed; replay memcmps them — hashes proved collision-prone), so a changed env name, arg index, rand bound, or read length is the first *named* divergence, never a silently substituted value. **Env values are withheld by default**: names, existence, and lengths are recorded; the value itself requires `LOTUS_OBS_RECORD_ENV=full`, the artifact header says which policy applied, and a withheld read replays as a named withheld divergence. The interposed set: `std::time::now`, `std::time::monotonic[_ns]` (now a named primitive, `lotus_time_monotonic_ns` — it used to be inline `clock_gettime` IR that nothing could interpose), `std::rand::next_int` (per call — the global RNG's mutex makes seed-replay unsound across threads), `std::os::getrandom`, and the `std::env` surface. Internal runtime clocks and config `getenv`s are deliberately not journaled. **File format v0.3 (PRE-STABLE).** 96-byte header (magic `HALEREC0`, version, pid, ring geometry, epoch anchors, `model_hash`, a 32-byte framed-SHA-256 `exec_digest`, and policy flags), tagged entries — tag 0: 24-byte ring record; tags 1/2/3: payload / journal / meta blobs (32-byte header + bytes padded to 8; meta carries the run-stable topic and public-ring identity maps) — and a 16-byte trailer (`HALEEND0` + entry count). A recording is *clean* only when the ENTIRE artifact validates: exact parse to the trailer position and a matching entry count, enforced by the CLI reader and independently by the runtime loader (one open + fstat + private mmap, checked arithmetic, per-kind value shapes) — trailer magic at EOF alone proves nothing. Identity fields are stamped eagerly by the drain and re-stamped at finalize (a prelude binding registration can probe — creating the header — before the stamps run; the two stamps agree). A trailer-less artifact is a **crash-truncated recording**: refused by default, but admissible as a PREFIX with `hale replay --allow-truncated` (`LOTUS_REPLAY_ALLOW_TRUNCATED=1`) — the loaders stop at the first incomplete frame, report the parsed extent and dropped tail bytes, and replay that prefix; execution past the tape's end falls back live and is counted, per the degrade-never-refuse rule. Under `--diff` a truncated baseline compares as a prefix — and the runtime verdict agrees with the comparator: recorded-history-exhausted events (journal reads past the tape, deliveries past the consume stream, consumers the recording never saw) count into a separate `post_prefix_live_fallback` status key, never into the divergence totals; mismatches BEFORE exhaustion stay divergences either way. A trailer-FINALIZED artifact keeps the exact full-parse + count checks: with a finalize present, any truncation is corruption. **Replay (`hale replay `).** Admission, strongest check first: the recording's `exec_digest` — a framed SHA-256 over the toolchain source hash (compiler + runtime + stdlib implementation, via the stale-CLI build hash), the CLI version, build options, and every source file's path, length, and contents — must match the recompiled program exactly. A file's path is its path in the program's source map, in the map's order (F.40 phase 4, I3; [verification.md § Source maps and model semantics](verification.md)): relative to one root, the same whichever target loaded the program, wherever the tree is checked out and however the target is typed, and distinct for two files of one name; the build options are the ones the compiling command was GIVEN (`hale run` and `hale replay` take `hale build`'s option flags — 2026-09-20, GH #904; `run` compiled with the defaults and fingerprinted the defaults), so a recording made under `hale run --dev` is admitted by `hale replay --dev` and by no default replay. `hale build`, `hale run` and `hale replay` compute the options one way (F.40 phase 4, I2): the command's flags, `--env`'s role table (`replay --env` resolves the environment as `run --env` does, so a recording made under `--env prod` is admitted by `hale replay --env prod` and refused under another environment's roles or none), and the `[ffi]` link libraries and C sources each imported package's `hale.toml` declares, which every one of the three folds in and builds with, so a program that needs a package's `[ffi] csrc` runs and replays without `--csrc` / `--link` flags, and a flag naming what a package's `[ffi]` names is a no-op that leaves the identity the flagless invocation's. Debug information is not part of it: the DWARF line tables `hale build` adds by default change no behaviour, so a recording made by a binary from `hale build` of a file is admitted by `hale replay` of that file, and a built binary and `hale run` of one program carry one identity. A recording of a DIRECTORY build is admitted by the replay of its entry file: the two name each file by its source-map path, so they frame one program alike. The build options include the environment's build knobs that change the binary (see *Build-time and toolchain environment*: `LOTUS_ASAN`, `LOTUS_TSAN`, `LOTUS_UBSAN`, `LOTUS_LTO`, `LOTUS_DISABLE_PREFETCH`, `LOTUS_NO_BUS_DEVIRT`, `LOTUS_NO_OWNERSHIP_BUBBLE`, `HALE_NO_TS_SHIM`, `HALE_TS_SHIM_A`, `HALE_ZIG`, `HALE_TARGET_GLIBC`, `HALE_TARGET_SYSROOT`, `LOTUS_OPENSSL_PREFIX`), so a recording made under one is admitted by no replay built without it; what only narrates or times a build, the warnings, the linker and the cache directory are not part of it; `shape_hash` is structural compatibility only, the secondary check. An unstamped recording is refused without `--allow-unverified-model`. Stated residue: the digest cannot see the LLVM/libc toolchain outside the hale binary; a post-link binary digest is the staged stronger form. **Safe by default:** replay re-executes real side effects, so the typed effect rows gate admission and fail CLOSED on `syscall`, `ffi`, `unclassified` ("may do anything") — refused without an explicit `--allow-live-effects` (coarse by class granularity — over-refusal is the safe direction; per-primitive replay classes are the staged refinement). `bindings` blocks are **no longer** in that list: under replay the wire is hermetic (below), so a bound topic cannot reach the live world. `LOTUS_REPLAY=` then serves journaled reads back per consumer in recorded order; a read past (or mismatching) the recorded history falls back live and is counted — **replay degrades, never refuses** (RFC Q6) — with a divergence summary at exit and a machine-readable verdict (`LOTUS_REPLAY_STATUS`) the CLI consumes: journal misses, order holds, unconsumed journal entries, and unconsumed deliveries all count, and any of them fails `--diff`. Each consumer's queued deliveries are re-consumed in the RECORDED order (Phase 4): dequeued cells that arrive ahead of their recorded turn are held per-consumer and released in order, with a bounded hold (1s) after which the oldest held cell is released and the miss counted, so a genuinely divergent replay reports rather than deadlocks. A job posted to a pool is a queued cell of its own and so a recorded start: a consume of delivery identity 0 whose target is the locus it starts, matched by the gate against a recorded 0 slot. A pool-placed root posts three, each with its own slot in posting order — its initialization, its own birth step (decision line 3) and its `run()` — and a `run()` posted by any other child is one more; a replay consumes them in that order. A run its child's reclaim canceled in the queue (decision line 19) is dropped before the gate compares it, as the recording dropped it, with no consume; a live run the gate holds keeps its retention on the child and is admitted only when it is dispatched. The hold belongs to its consumer thread for the thread's life: a pool worker or a pinned thread frees it when it exits at the pools' or the owner's join, and a cell found still held then ends as one the pools' teardown frees undequeued, never dropped with the hold. **Async pools replay (Phase 6).** The nondeterminism of a `where async_io` pool is its drain's SCHEDULING: which cell starts when, which parked coro resumes when (epoll readiness order), and when a timed park expires relative to both. Under recording, every such decision is stamped on the pool worker's private ring as a step (`ASYNC_START`/`ASYNC_RESUME`/`ASYNC_EXPIRE`; coros are named by birth ordinal — the index of the START that created them, stable across runs because start order is enforced). Under replay the drain is DRIVEN by that stream instead of the clock: START steps reuse the phase-4 cell gate (start order is consume order); RESUME steps wait for the named coro's readiness, parking early-ready coros aside until their turn; EXPIRE steps resume the named coro with the timed-out sentinel immediately — the recording already proves the deadline fired at this point in the sequence, so replayed sleeps fast-forward rather than re-waiting. A step unsatisfiable within the hold bound counts an `async-schedule` divergence and is skipped (degrade, never deadlock) — and a skipped START **retires** both its birth ordinal and its consume slot (review round 2: ordinals belong to the recorded START slots, not to whichever cell happened to start next — without retirement one missing delivery shifts every later coroutine into an earlier recorded identity, and the pinned consume expectation makes every later START mismatch; with it, later steps that name a retired slot skip immediately and the divergence stays local). **Coverage, named (review round).** A dry tape hands the pool back to the live drain — never silently. The artifact carries a capability bit (header flag 4: this runtime records async schedules); the states are distinct and reviewable: a pre-phase-6 artifact gets a one-shot coverage note ("recording predates async-schedule support"); a truncated tape's remaining schedule is the stated coverage boundary (excluded from the divergence totals); a FINALIZED capable artifact whose tape runs dry while the re-execution keeps doing async work counts each such action as `async_post_tape`, and schedule steps left unconsumed at exit count as `unconsumed_async_steps` — both machine-readable status keys and divergence rows. `--diff` compares the per-consumer async step streams (kind, value) bidirectionally, exactly like the consume and journal streams — "byte-identical output" alone is weaker than "the schedule matched". Boundary, stated: the SCHEDULE replays; the DATA of unjournaled I/O does not — a coro resumed at its recorded turn re-executes its recv against the live world (syscall-class, gated), and bindings ingress arrives via the Phase-5 injector. **Hermetic wire + ingress injection (Phase 5).** Hermeticity is a **binding-kind capability, not a blanket assumption**: the native `unix://` and `udp://` transports (and the transport-locus form) are suppressed and injectable; a backend with **no replay class fails closed** — `shm_ring` in particular is refused by the CLI (backend named) and independently at the runtime's shm-open seam, so a replayed or fed process never creates, opens, or mutates live shared memory. Adapter loci are user logic and re-execute as such (governed by their inferred effects). For the covered backends, under replay a binding is **suppressed at realization**: the entry exists (a husk, `transport` NULL — the same shape the reclaim path already leaves), but no socket is created, nothing binds, nothing connects, and outbound fanout to bound subjects sends nothing (the publish is still recorded and compared under `--diff`). In the listeners' stead, injection runs as an explicit **boot phase**: codegen emits one `lotus_replay_start_ingress()` call at the main locus's boot/run boundary — params children born, bindings realized or suppressed, every boot subscription registered, `run()` not yet entered — where the runtime snapshots the subscription registry ON the main thread (injector workers never read the live registry) and spawns **one worker per recorded ingress source** (the `pub_id`'s consumer bits), each carrying its source's recorded consumer identity so a `--diff` verify recording aligns per-consumer streams with the original instead of collapsing every listener onto one injector identity. Fresh anonymous claims are floored above the recorded range. The snapshot IS the coverage boundary: a subscriber the program only creates during `run()` (spawned or accepted) is not visible to injection — such tape entries classify as `late_subscription_uncovered` (the injector join rescans the live registry at teardown to distinguish them from genuinely absent subscribers), a stated boundary rather than a silent `unmatched`. Injection is keyed by **full identity**: each tape record carries its complete subject string and the subject's canonical payload shape (FNV-32 alone is collision-prone and is kept only for reporting); a record whose shape does not match the live program's shape for that subject is refused as *incompatible*, never fed as a plausible wrong value. Only messages the original application **accepted** enter the tape — the wire capture runs after the deserializer says yes, so identity allocation agrees between record and replay and rejected traffic is never re-fed. **Pacing.** Strict replay injects one message, then waits (bounded by the phase-4 hold) until that message's recorded consumes have all been re-consumed — unpaced injection can shed at bounded queues (`bounded(N, drop_old/drop_new)`, `on_full` policies) before the dequeue-side order gate ever sees the cell, which no amount of holding can undo. Feed mode is deliberately unpaced. **Accounting.** Every tape entry ends in exactly one class: injected, rejected-by-deserializer, unmatched-subject, incompatible-shape, or unprocessed-at-shutdown (the injector is joined at teardown and its remainder classified); injector start failure is its own class. Under strict replay every non-injected class is a divergence, in the machine-readable status (`ingress_*`, `injector_start_failure` keys) and the exit summary. **Feed mode (`hale replay --feed`, `LOTUS_REPLAY_FEED=`) — same recorded ingress, changed code, live nondeterministic environment.** Stated that precisely because "same inputs" would overclaim: feed reproduces recorded **binding ingress** only — time, randomness, env, and argv stay live. The recording is consumed as an input tape: recorded ingress injected, wire hermetic, and nothing else of replay applies — no journal serving, no order enforcement, no model admission (a model-hash mismatch is reported informationally; feeding a tape to changed code is the point), no `--diff`/`--at` (there is no recorded schedule to compare against). The **effects gate still applies**: `--feed` bypasses identity admission, never effect safety — a program whose frontier reaches `syscall`/`ffi`/`unclassified` still requires `--allow-live-effects` alongside `--feed`. Mutually exclusive with `LOTUS_REPLAY`. Injected deliveries carry the new run's own identity. The exit report classifies every tape entry, and an unfed remainder (unmatched, incompatible, unprocessed, start failure) **fails the run by default** — `--allow-unmatched-feed` is the explicit acceptance of a partial feed. **`--diff` (strict replay only — feed rejects it, above).** `--diff` records the replay (under the original's env policy) and compares bidirectionally: per-consumer queued consume streams (target locus + msg_id), per-consumer PUBLIC bus streams aligned by subject and stable consumer id via the meta maps — which is what makes synchronous direct dispatch visible — payloads both ways (topic, flags, bytes; raw metadata by declared size), and per-consumer journal streams (kind, exact args, withheld state, value; grouped per consumer because the drain interleaves concurrent threads' entries in incidental order). Any runtime divergence fails `--diff` through the verdict. `--at ` stops (SIGSTOP) at the nth consume process-wide (meaningful for one consumer); `--at :` is the stable multi-consumer form. Replay implies observation (identity rides the obs machinery). **What a match reports — coverage, per category (GH #728).** A match is only as strong as the categories the recording carries observations for, so the success report names all five and states, for each, either the count it compared or that the category was **not exercised** and why: public bus events (with their consumer count), payloads, queued consumes, async schedule steps, journal reads. A category with no recorded observations is never printed as a compared zero — "0 consumes across 0 consumers" read as a verified queued delivery schedule when the workload direct-dispatched every delivery and no queued schedule existed to verify (and the same template asserted payload identity for a recording with no payload blobs). Direct dispatch stays valid and is named as such: its deliveries are compared, as public bus events, and its payloads are compared with them — a direct-dispatched publish records its payload blob like any other (above), so `payloads` is a compared category for such a workload, not an unexercised one. The payload line says *what* was compared, per the blobs the recording carries (GH #842): an in-process flat payload's blob is metadata only (flag bit 1, above), so for those the comparator checks declared size and publish identity and never the contents, and the report reads `payloads: N (sizes and identities; contents not canonicalised)`; wire captures are canonical bytes and read `canonical bytes identical`; a recording carrying both counts each. A flipped field inside an in-process payload is therefore NOT a divergence `--diff` can see today — the per-topic canonical codec that would make it one is a separate feature item (GH #947), not part of this report. Coverage is derived from the recording the comparator walks and gated on the same async-capability bit, so the report cannot claim a category `diff` skipped. `--json` (strict replay with `--diff`) prints the same verdict machine-readably — `result`, `ring_records`, `recorded_prefix_only`, and per category `compared`, `count`, `consumers`, `not_exercised_because`, and for `payloads` the same split as `contents_canonicalised` and `metadata_only`. Success semantics are unchanged: a divergence in any compared category still fails, and a diverged verdict carries the reason with no per-category counts (the comparison stopped at the first difference). Two honest limits, stated rather than implied: the recorded interleaving is reproduced per consumer, not globally — cross- consumer wall-clock alignment is not a replay property; and the injected tape covers **bindings** ingress — raw user-level socket reads are syscall-class live effects (gated), and adapter loci re-execute their own protocol logic. Fleet-scale replay (multiple binaries against one composed tape, with #262's plan admission for replay-under-a-different-plan) is the next milestone. ### Time - **Monotonic + wall-clock.** `time::now()` and `time::monotonic()` are runtime-provided. `time::monotonic()` returns a `Duration` (i64 nanoseconds since an unspecified reference); only meaningful for elapsed-time differences. Backed by `clock_gettime(CLOCK_MONOTONIC)`. `time::now()` (C7, pond follow-up) returns wall-clock seconds since the Unix epoch as `Int` via `clock_gettime(CLOCK_REALTIME)`; observation only — NTP slewing and leap seconds can warp the value, so `time::monotonic` stays the basis for scheduling. Richer `Time`-typed wall-clock (with calendar arithmetic) is deferred until a consumer surfaces a concrete date-shape need. Mocking is available for tests via `time::mock_clock(...)` (stdlib). - **Monotonic-only scheduling.** Every scheduling primitive in Hale — `time::sleep`, `time::tick`, the cooperative scheduler's deadline queue — is grounded on the monotonic clock. NTP slewing, leap seconds, and wall-clock jumps cannot warp scheduling decisions. `time::sleep` retries on EINTR using the kernel's reported remaining time, so a delivered signal does not shorten the total sleep. `CLOCK_REALTIME` is used by `time::now()` for wall-clock observation only and has no scheduling role. - **Implementation invariant.** `time::sleep(d)` lowers to `clock_nanosleep(CLOCK_MONOTONIC, 0, &req, &rem)` with EINTR retry — important for a system targeting high-precision clock semantics. ### I/O — minimal - **stdout / stderr** for `print` / `println`. That's it for runtime-level I/O. Files, networking, etc. live in stdlib. - **Errno surface helpers** (2026-05-16, used by the fallible `std::io::fs::*` / `std::io::tcp::*` wrappers): - `lotus_get_errno() -> i32` — surfaces the current platform `errno` to LLVM. Each fallible wrapper calls this immediately after the failing primitive (POSIX errno is sticky until the next syscall sets it). - `lotus_io_error_kind(errno_val: i32) -> *const char` — maps errno to a stable kind-tag string (`"not_found"`, `"permission_denied"`, `"is_dir"`, `"already_exists"`, `"would_block"`, `"connection_refused"`, `"timeout"`, `"host_unreachable"`, `"broken_pipe"`, `"interrupted"`, ..., catch-all `"io"`). Returns a static-table pointer; caller must not free. ### Text + string primitives (v1.x adds) - `lotus_str_parse_float(s) -> double` / `lotus_str_can_parse_float(s) -> int` — v1.x-16. Strict trailing-NUL parse; 0.0 on failure paired with a bool predicate. Mirrors the parse_int contract. - `lotus_text_base64_decode(s) -> Bytes*` — v1.x-16. Standard alphabet, whitespace tolerated, non-alphabet / wrong padding returns empty Bytes. Inverse of `lotus_text_base64_encode`. - `lotus_str_builder_new()` / `_append(b, s)` / `_len(b) -> i64` / `_finish(b) -> char*` — v1.x-15. Doubling-realloc malloc buffer. N appends are amortized O(N). `finish()` copies into the bus payload arena (program-lifetime) and frees the builder. - `lotus_bytes_builder_new(i64 initial_cap) -> ptr` / `_append(ptr handle, ptr chunk) -> i64 status` / `_len(ptr handle) -> i64` / `_finish(ptr handle) -> Bytes*` / `_shift_front(ptr handle, i64 n)` / `_clear(ptr handle)` / `_snapshot(ptr handle) -> Bytes*` / `_view(ptr handle) -> Bytes*` / `_free(ptr handle)` — C10 / Phase 0 / Phase-2 (1) (2026-05-19, pond/websocket follow-up). Binary-safe sibling of the str-builder family. Append reads the chunk's `[i64 len]` prefix instead of `strlen`; finish emits a length-prefixed Bytes blob with no trailing NUL. In-place ops: `shift_front` memmoves the tail to the head and drops n bytes (capacity preserved). `clear` sets len=0 (capacity preserved). `snapshot` copies the current `[0..len)` into a fresh Bytes blob in the bus payload arena, builder unchanged. `view` returns a non-owning Bytes pointer aliasing the builder's inline `[i64 len][u8 data]` region — zero allocation, zero copy; lifetime valid until the next mutation on the source builder. `free` disposes the malloc-backed buffer. **F.30 type promotion.** The Hale-visible method surface returns `BytesView` / `StringView` (typecheck-distinct from `Bytes` / `String`). The view-to- owned upgrade paths (`std::bytes::clone`, `std::str::clone`) are backed by `lotus_bytes_clone(arena, src)` (new) and `lotus_str_clone(arena, src)` (existing m49). **F.30b view layout + epoch guard (2026-05-22 PM compaction).** The `_view` / `_text_view` C primitives return a 16-byte by-value struct — no arena allocation in the hot path. Pre- compaction was a 24-byte struct heap-allocated per call, and that allocation was the dominant residual chunk- allocation trigger in long-running recv loops: ```c #define LOTUS_VIEW_EPOCH_STATIC ((int64_t)-1) typedef struct lotus_view { void *src; // builder ptr (epoch >= 0, real view) // OR static data ptr (epoch == -1, // static-lifetime view from // lotus_view_from_static_data or // the null-handle path of // builder_view / builder_text_view). int64_t epoch; // stamped mutation_epoch, or the // static sentinel. } lotus_view_t; ``` The `{void*, int64_t}` layout fits SysV AMD64's "two INTEGER eightbytes ≤ 16 bytes" return-by-value rule — both registers (`rax`, `rdx`) carry the view; arg-by-value passes in two integer arg registers. The underlying data pointer is *recomputed* at unpack time from `((lotus_bytes_builder_t*)v.src)->buf` (Bytes-shape: `buf - 8`; C-string shape: `buf`), so the view itself doesn't need to store it. `lotus_bytes_builder_t` gains an `int64_t mutation_epoch` field bumped by every mutating op (`append`, `append_slice`, `shift_front`, `clear`, `advance`). Codegen at view-coerce sites emits a call to `lotus_bytes_view_data` / `lotus_str_view_data`, which compares the stamped epoch against the live epoch and calls `lotus_view_stale_panic` (noreturn — stderr + `_exit(1)`) on mismatch. The 5b literal-default coercion calls `lotus_view_from_static_data` to construct the view in-register with the static sentinel; the helpers skip the epoch check on that branch and return `v.src` directly as the underlying data pointer. **Memory layout (Phase-2 (1)).** The builder header is `{cap, buf, mutation_epoch}`; the data area is preceded inline by an 8-byte length prefix matching the Bytes ABI: ``` malloc'd region: [int64_t len][u8 data[cap]][NUL] ^ buf ``` `view(b)` returns a 16-byte `lotus_view_t` whose `src` field is the builder pointer; the read-site helper recomputes the data pointer as `b->buf - 8` (Bytes-shape, suitable for `lotus_bytes_len` / `lotus_bytes_at` / `lotus_bytes_data`). Append / append_slice / shift_front / clear / advance all update the inline prefix in sync with the data mutation AND bump `mutation_epoch`. Cost: one extra pointer dereference per len access vs the prior `{cap, len, buf*}` shape, plus a one-load epoch check at every view-coerce site. Zero arena allocation per view() call (the dominant residual chunk-alloc trigger pre- compaction). `lotus_str_builder_t` (for `std::str::*`) keeps the prior layout — no view surface there yet. These primitives are no longer the user-facing surface; they're the C externs called by the `std::bytes::BytesBuilder` stdlib locus (`crates/hale-codegen/runtime/stdlib/bytes_builder.hl`). See `spec/decisions.md` § F.28 for the rationale and the locus's method shape. The locus-side calls reach these via internal `std::bytes::builder::__*` path-call dispatch. **ABI notes.** `_new` takes `int64_t initial_cap` (previously zero-arg, hardcoded 64) — values `<= 0` are treated as the legacy default. `_append` returns `int64_t status` (1=ok, 0=fail on realloc-NULL or null-handle) — previously void; the status return is what the locus's `append` method checks before routing through `violate alloc_failed` per F.27. **Builder handles are NOT layout-compatible with regular Bytes blobs.** The struct shape is `{ size_t cap; size_t len; char *buf; }` (24 bytes); Bytes blobs are `[int64_t len][u8 data[]]`. So `lotus_bytes_at(builder, i)` / `lotus_bytes_len(builder)` read the wrong slots if a builder handle is passed as a Bytes value. The Hale-level enforcement (`BytesBuilder` as its own locus type) makes that mistake impossible to express; this note is the C-side mirror — anyone calling these primitives directly from C / Rust must keep the distinction. - `lotus_tcp_recv_into(fd, builder, max_bytes) -> i64` / `lotus_tls_recv_into(handle, builder, max_bytes) -> i64` / `lotus_udp_recv_into(fd, builder, max_bytes) -> i64` — 2026-05-19 (Phase 1, pond/websocket follow-up). Caller-provided destination at the syscall layer. Reads directly into the builder's tail; grows on insufficient headroom; bumps the builder's len by the count read. Return semantics mirror POSIX read(2): `> 0` bytes appended, `= 0` peer closed cleanly (TCP) / zero-length datagram (UDP), `< 0` error. EINTR retried internally. **A `SO_RCVTIMEO` timeout is distinguished from a fatal error: `-2` = "would-block / timed out, retryable"; `-1` = fatal** (TCP: `EAGAIN`/`EWOULDBLOCK`; TLS: `SSL_read` → `SSL_ERROR_WANT_READ`/`WANT_WRITE`). The `-2` only arises when the caller has set a recv timeout (opt-in via `set_recv_timeout`), so it's backward-compatible — a caller that treats all `< 0` as error keeps working; a liveness loop checks for `-2` to run its ping/pong instead of tearing the connection down. No allocation in `g_bus_payload_arena`. No allocation in `g_bus_payload_arena` — closes the residual ~80% of the pond/websocket recv-loop leak that Phase 0's in-place builder ops surfaced (the syscall layer's own `[i64 len][body]` blob per call). Helpers `lotus_bytes_builder_reserve(handle, n)` + `lotus_bytes_builder_advance(handle, n)` factor the grow + offset-bump so `lotus_tls.c` (separate translation unit) can implement its recv_into without seeing the builder struct layout. - `lotus_str_lower(s) -> char*` / `lotus_str_upper(s) -> char*` — ASCII case folding. One-pass byte-level fold; non-ASCII bytes pass through unchanged. Allocates in the bus payload arena. Used by `http_request_header` for RFC 7230 case-insensitive lookup. - `lotus_str_trim(s) -> char*` — strip ASCII whitespace (space / tab / CR / LF) from both ends. Arena-anchored. - `lotus_str_replace(s, needle, rep) -> char*` — greedy non-overlapping substring replace. Two-pass (count, then fill) so the output is right-sized in one arena alloc. Empty needle is a no-op. - `lotus_str_repeat(s, n) -> char*` — n copies of s concatenated; n <= 0 returns empty. Single arena alloc. - `lotus_str_pad_left(s, width, pad) -> char*` / `lotus_str_pad_right(s, width, pad) -> char*` — width-aligned output using the first byte of `pad` (default space) as the fill char. No truncation: `len(s) >= width` returns s unchanged. ### Process control - **Exit codes.** `main()` returning `()` exits 0; returning `int` exits with that code. Panics exit non-zero. - **Signal handling.** SIGINT / SIGTERM begin the whole-process drain (`semantics.md` § "Drain cascade (whole-process)", GH #1039): the handler writes one byte to a pipe, a watcher thread raises the exported `lotus_process_draining_flag` (which every `self.draining` read loads, beside the locus's own `__drain_requested`), wakes every `async_io` pool so timed parks that began before the drain expire, and then waits out the grace (`LOTUS_DRAIN_GRACE_MS`, default 5000); past it the watcher prints one line — the signal, and what the drain was still waiting on (each started cooperative pool: its mode, whether its worker is mid-iteration and in which locus, its queued cells; the live instances that read `draining`, by locus name) — restores the signal's default action and re-raises the signal, so the process dies by it. The pool's `running_label` and the instance names are kept for that line alone; nothing else reads them. A second signal does the same at once. Installed by the main prelude only in a program that reads `draining`; caught with `SA_RESTART`, never blocked, so spawned subprocesses keep the default disposition. - **SIGPIPE globally ignored** (added 2026-05-17, C2). The prelude installs `signal(SIGPIPE, SIG_IGN)` once at `lotus_io_init` so writes to a closed pipe (subprocess stdin, closed TCP socket, etc.) surface as `EPIPE` through the IoError channel instead of synchronously killing the parent. Applies process-wide — no opt-out. - **Subprocess lifecycle** (added 2026-05-17, C2 — see [`spec/stdlib.md` § std::process](stdlib.md) for the API surface). Every spawned child gets its own process group via `setpgid(0, 0)` in the post-fork prelude. Chosen over `prctl(PR_SET_PDEATHSIG, SIGKILL)` for POSIX portability (macOS / BSD parity); a controlled `Child.dissolve()` covers the orderly-shutdown path. `Child.dissolve()` closes the three pipe fds and kill-escalates idempotently (SIGTERM → 100 ms grace → SIGKILL → waitpid; `ESRCH` / `ECHILD` count as success) so an unwaited child doesn't leak zombies on scope exit. The `std::process::run` synchronous form drains stdout + stderr via interleaved `poll()` so the child can write to either stream without deadlocking; 16 MiB cap per stream. ### stdout buffering stdout is **line-buffered** for the lifetime of the program, regardless of whether it's attached to a TTY or a pipe. The main prelude calls `setvbuf(stdout, NULL, _IOLBF, 0)` once before any user code runs. The default libc behavior (fully-buffered when stdout isn't a TTY) silently dropped output for any program that printed then blocked on a syscall — `println("READY"); accept_loop();` made "READY\n" invisible to a piped consumer until the buffer filled or the program exited. Test oracles, supervisors waiting for a READY handshake, and log tailers all hung. Line-buffering matches Python `python -u` discipline and Go's default; `\n`- terminated `println` calls flush immediately under any stdout target. stderr is line-buffered by POSIX already; the runtime doesn't touch it. ## What's NOT in the runtime (lives in stdlib instead) - Specific bus transports (NATS, UDP, etc.) - File I/O - Networking (sockets, HTTP) - JSON / protobuf / msgpack encoding - Most collections beyond what the language has built-in - Math beyond `sum` / `prod` (which are language-native) - Statistics - Linear algebra - String manipulation beyond literal handling - Time arithmetic beyond comparison and arithmetic - Logging / metrics / tracing These are bundled with the toolchain (no separate install) but require explicit `import std::...`. ## Form-vec runtime (v1.x-FORM-1) The `@form(vec)` form lowers to a contiguous growable buffer implemented in C. See `spec/forms.md` for the form contract and synthesized method set; this section documents the runtime shape. ### C struct layout Each `@form(vec)` locus's heap slot lowers to an inline struct: ```c typedef struct { size_t cap; // allocated capacity (elements) size_t len; // number of valid elements char *buf; // contiguous element array } lotus_vec__t; ``` The `` suffix is conceptual — codegen monomorphizes per cell type T, but the runtime primitives operate on the common prefix layout via `void *` casts. All `lotus_vec_*_t` typedefs share the `{cap, len, buf}` prefix. ### Primitive functions Defined in `crates/hale-codegen/runtime/lotus_arena.c` (v1.x-FORM-1 PR4): | Function | Behavior | |-------------------------------------------------------|----------| | `void lotus_vec_init(void *v)` | Zero-init: cap=0, len=0, buf=NULL | | `void lotus_vec_push(void *v, size_t es, const void *x)` | Append; doubles cap on overflow | | `int lotus_vec_get(void *v, size_t es, int64_t i, void *out)` | Bounds-checked read; returns 1=OK, 0=out-of-bounds | | `int lotus_vec_set(void *v, size_t es, int64_t i, const void *x)` | Bounds-checked in-place write; returns 1=OK, 0=out-of-bounds (does not extend the vec) | | `int lotus_vec_pop(void *v, size_t es, void *out)` | Returns 1=OK, 0=empty | | `int64_t lotus_vec_len(void *v)` | Element count | | `int lotus_vec_is_empty(void *v)` | 1=empty, 0=non-empty | | `void lotus_vec_destroy(void *v)` | `free(buf)`; called at locus dissolve | | `void lotus_vec_sort_int(void *v)` | In-place ascending sort of an `int64_t`-cell vec via `qsort` | | `void lotus_vec_sort_float(void *v)` | In-place ascending sort of a `double`-cell vec; NaN treated as equal-to-anything | | `void lotus_vec_sort_string(void *v)` | In-place ascending sort of a `char *`-cell vec under `strcmp` ordering | | `void lotus_vec_sort_by(void *v, size_t es, int (*cmp)(const void *, const void *, void *), void *cookie)` | `qsort_r` wrapper; cmp is a codegen-synthesized per-(cell_type, direction) trampoline | `es` (elem_size) is the cell type's size in bytes — codegen passes `sizeof(T)` at each call site. ### Growth policy - Initial: cap=0, no allocation at locus birth. - First push: allocates a 4-element buffer. - Each overflow: doubles cap; `realloc`s. Old contents copied by realloc; previous buf freed. - Shrink: not implemented in v1. Buf released at dissolve. ### Failure shapes - `lotus_vec_get` / `lotus_vec_set` / `lotus_vec_pop` return 0 on contract break (out-of-bounds / empty). Codegen wraps this into the `Ty::Fallible { success: T, payload: IndexError }` surface (Unit-success for `set`) via a small adapter that synthesizes the `IndexError` struct from the bool + the call args (shipped v1.x-FORM-2 PR5/6; `set` added 2026-05-16). - `lotus_vec_push` OOM routes through the substrate-trap → closure-violation channel per the two-channel rule (shipped v1.x-FORM-2 PR6). - Sort family (`sort`, `sort_by`, `sort_desc_by`, added 2026-05-16) is infallible from the language surface; `sort_*` C wrappers do not return a status code. If the user-supplied comparator in `sort_by` faults (a fallible call inside the comparator body raised through `or raise`), the fault propagates and `qsort_r` stops mid-sort — the vec is left with every element still present, ordering partially applied. ## Form-hashmap runtime (v1.x-FORM-4) The `@form(hashmap)` form lowers to an intrusive open-addressing hash table implemented in C. See `spec/forms.md` for the form contract and synthesized method set; this section documents the runtime shape. ### C struct layout Each `@form(hashmap)` locus's pool slot lowers to an inline struct: ```c typedef struct { size_t cap; // power-of-two slot count size_t len; // live entry count size_t key_size; // sizeof(K), set at init size_t value_size; // sizeof(S), set at init int key_type_tag; // 0 = Int, 1 = String char *slots; // cap * (1 + key_size + value_size) bytes } lotus_hashmap_t; ``` Each slot is `1 + key_size + value_size` bytes: ``` [occupied: u8] [key: key_size bytes] [value: value_size bytes] ``` `occupied = 0` means empty. Backward-shift deletion (no tombstones) — probes terminate at the first empty slot. The C ABI is type-erased: codegen passes `key_size` / `value_size` at init time, and per-call sites pass raw `void *` key/value pointers. Codegen GEPs the indexed-by field on the caller's side to derive the key pointer before each `set`. ### Primitive functions Defined in `crates/hale-codegen/runtime/lotus_arena.c` (v1.x-FORM-4 PR4): | Function | Behavior | |----------|----------| | `void lotus_hashmap_init(void *m, size_t key_size, size_t value_size, int key_type_tag)` | Allocate `cap=8` slots, zero them; freeze key/value sizes and key-type tag | | `void lotus_hashmap_set(void *m, const void *key, const void *value)` | Insert or replace; grow at load factor 0.7 | | `int lotus_hashmap_get(void *m, const void *key, void *out_value)` | Bounds-checked read; returns 1=OK, 0=missing_key | | `int lotus_hashmap_has(void *m, const void *key)` | 1=present, 0=missing | | `int lotus_hashmap_remove(void *m, const void *key)` | 1=removed, 0=missing | | `int64_t lotus_hashmap_len(void *m)` | Live entry count | | `int lotus_hashmap_is_empty(void *m)` | 1=empty, 0=non-empty | | `void lotus_hashmap_destroy(void *m)` | `free(slots)`; called at locus dissolve | ### Key types and hashing | `key_type_tag` | Type | Hash function | Equality | |---|---|---|---| | `0` (LOTUS_HASHMAP_KEY_INT) | `int64_t` | Knuth multiplicative (`k * 0x9E3779B97F4A7C15`) | `==` on i64 | | `1` (LOTUS_HASHMAP_KEY_STRING) | `const char *` (NUL-terminated) | FNV-1a over the bytes | `strcmp == 0`, with pointer-identity fast path | Other key types (Bytes, custom structs, enum tags) are not supported at v1; codegen rejects `@form(hashmap)` with a focused diagnostic when the indexed-by field's resolved type doesn't map to one of these two tags. ### Growth policy - Initial: `cap=8`, slots calloc'd at locus birth via `lotus_hashmap_init`. - Growth: when `(len + 1) > 0.7 * cap`, double cap and rehash every live entry through the normal `set` path (the probe sequence changes with the new mask, so we don't copy raw bytes between tables). - Shrink: not implemented in v1. - Cap is always a power of two so hash-to-index folds to `& mask`. ### Deletion policy Backward-shift deletion (no tombstones). After clearing the target slot, the runtime walks forward through the cluster and shifts any entry whose natural position is "before" the freed slot in the probe sequence. The cluster boundary is the first empty slot encountered. This keeps probe chains tight and lets `find_slot` terminate correctly without a separate tombstone marker. ### Failure shapes - `lotus_hashmap_get` / `lotus_hashmap_remove` return 0 on contract break (missing key). Codegen wraps this into the `Ty::Fallible { success: S, payload: KeyError }` surface via the same machinery `@form(vec)` uses for `IndexError`, synthesizing the `KeyError { kind: "missing_key" }` payload at the call site (shipped v1.x-FORM-4 PR5). - `lotus_hashmap_set` OOM during the slot calloc / realloc routes through the substrate-trap → closure-violation channel per the two-channel rule. ## Form-ring-buffer runtime (v1.x-FORM-5) `@form(ring_buffer, cap = N)` lowers a pool capacity slot to an inline `lotus_ring_buffer_t` and synthesizes a fixed-capacity FIFO surface (push / pop / len / is_full). The cap is baked in at `lotus_ring_buffer_init` from the form annotation arg; the backing buffer is malloc'd once at locus birth and never grows. ### C struct layout ```c typedef struct { size_t cap; // fixed at init; never changes size_t head; // index of oldest element (next pop) size_t len; // current element count, 0..=cap size_t elem_size; // bytes per element char *buf; // cap * elem_size bytes } lotus_ring_buffer_t; ``` Codegen emits the matching LLVM inline struct on the locus's pool slot; the slot's struct field IS the ring buffer (no indirection). Element-size is `sizeof(T)` from the cell type's LLVM `size_of`. ### Primitive functions Defined in `crates/hale-codegen/runtime/lotus_arena.c` (v1.x-FORM-5): | Function | Behavior | |----------|----------| | `void lotus_ring_buffer_init(void *rb, size_t cap, size_t elem_size)` | `malloc(cap * elem_size)`; head=len=0 | | `int lotus_ring_buffer_push(void *rb, const void *src)` | 1=pushed, 0=full; wraps modulo cap | | `int lotus_ring_buffer_pop(void *rb, void *out)` | 1=popped, 0=empty; advances head | | `int64_t lotus_ring_buffer_len(void *rb)` | Current element count | | `int lotus_ring_buffer_is_full(void *rb)` | 1=full (len==cap), 0=not | | `void lotus_ring_buffer_destroy(void *rb)` | `free(buf)` at locus dissolve | ### Failure shapes - `push` returns 0 when full → codegen converts to Bool false at the language surface (`fn push(x: T) -> Bool`). - `pop` returns 0 when empty → codegen lazily allocates an `EmptyError { kind: "empty" }` payload on the err path, surfaced to the caller's `or` clause. - OOM during init (cap × elem_size too large to malloc) leaves `buf == NULL`; subsequent push/pop see a 0-cap buffer and refuse / fail. Routing OOM through the closure-violation channel is deferred to a future hardening pass — the v1 contract is "fixed cap; if init can't allocate, the buffer is permanently empty." ## Native codegen defaults What the compiler emits for a native `hale build`: - **Host-CPU tuning + O3 by default.** Native builds tune to the host CPU (`target-cpu`/`target-features` from the build machine) and run LLVM's aggressive (O3) pipeline — both the module passes and the backend codegen level. This unlocks autovectorization across all generated code (e.g. AVX-512 on a capable host). **Consequence:** a native binary is **not portable across microarchitectures** — it may use instructions absent on an older CPU. - **`--target-cpu native | baseline`.** `native` (default) is the host-tuned build above. `baseline` pins a portable **`x86-64-v3`** target (AVX2 + BMI2 + FMA) for **distributed artifacts** that must run on any modern x86-64 CPU. The emitted module self-describes its subtarget via per-function `target-cpu`/`target-features` attributes, so the choice is carried into bitcode (it survives LTO). - **`LOTUS_LTO=1` — opt-in full-LTO.** Read at *build time*. Emits the Hale module as LLVM bitcode and compiles the lotus C runtime TUs with `-flto`, so the final `clang -flto -O3 -fuse-ld=lld` link inlines the runtime hot paths (arena bump-allocator, string helpers, shm_ring framing) **across the TU boundary** into the Hale-generated callers — a boundary that's otherwise opaque. Worth a few percent on allocation/coordination-heavy code; neutral on already-vectorized loops (the host tuning is preserved under LTO via the function attributes above). **Off by default:** the LTO link is ~3–4× slower and requires `lld` on PATH. Native, non-sanitizer builds only; `wasm32` and sanitizer builds keep the ordinary non-LTO link. The `-Wl,--wrap` malloc/syscall shims (and the `LOTUS_ARENA_LOG_BIG_CHUNKS` / `std::diag::syscall_count` features that ride them) are preserved under LTO — `lld` resolves `--wrap` before LTO codegen. - **`wasm32` is unaffected** — it stays `generic`/O2 (the browser bundle is size/compat-sensitive). ## Build-time and toolchain environment Codegen reads no environment variable. Every knob that changes what a build emits is a field of `hale_codegen::BuildOptions`, and the one function that turns the process environment into those fields is `build_options_from_env` (`crates/hale-cli/src/build_env.rs`); every command that compiles (`hale build`, `run`, `test`, `replay`) starts from it, so a variable means the same thing to all four. A caller of the library (a test, another tool) sets the field and touches no environment, and must choose a `cache_dir`: `BuildOptions` has no `Default`, only `BuildOptions::new(cache_dir)`, because the runtime-object cache is the one setting with no right answer to guess; `crates/hale-codegen/tests/codegen_reads_no_environment.rs` fails if a read appears in the crate. A boolean variable is on for `1`, `true` or `TRUE` and off for anything else. A variable marked *set* is on by being set to any value at all. The rows with no field are read by the rest of the toolchain, not by a build. | Variable | `BuildOptions` field | Effect | Default | |---|---|---|---| | `HALE_DEV` (*set*) | `dev_profile` | The `--dev` profile: LLVM O1 module pipeline and Less machine codegen, trading runtime speed for build latency. | off | | `LOTUS_LTO` | `lto` | `1`, `true` or `full`: full LTO; `thin`: ThinLTO; anything else, off. Native, non-sanitizer builds only (see *Native codegen defaults*). | off | | `LOTUS_ASAN` | `asan` | Build with AddressSanitizer and skip the O3 module pipeline, so a leak or use-after-free report carries accurate frames. | off | | `LOTUS_TSAN` | `tsan` | Build with ThreadSanitizer for the runtime compile and the link; the `-Wl,--wrap` shims are left out. | off | | `LOTUS_UBSAN` | `ubsan` | Build with address and undefined-behavior sanitizers, aborting on the first UB (`-fno-sanitize-recover=all`). | off | | `LOTUS_DUMP_IR` (*set*) | `dump_ir_beside_output` | Write the pre-optimization LLVM IR to `.ll`. A library caller may instead name the path in `dump_ir`. | off | | `LOTUS_NO_BUS_DEVIRT` | `no_bus_devirt` | Force the all-dynamic bus lowering: every subject's dispatch plan is empty. The differential harness's control arm. | off | | `LOTUS_NO_OWNERSHIP_BUBBLE` | `no_ownership_bubble` | Force the pre-bubble ownership lowering: no bubble plans, no forwarding sets, no threading fields. | off | | `LOTUS_DISABLE_PREFETCH` | `disable_prefetch` | Compile the runtime without its prefetch hints. | off | | `LOTUS_DI_TRACE` (*set*) | `di_trace` | Narrate debug-location decisions on stderr. | off | | `HALE_DISPATCH_TRACE` | `dispatch_trace` | Print the flavor the bus dispatch plan chose for each subject, with its gate's `payload_flat` column, and the codec's flatness at each publish to a literal subject, on stderr. | off | | `HALE_LIFECYCLE_TRACE` | `lifecycle_trace` | The lifecycle trace: one line per obligation event on stderr (§ "The lifecycle trace"). A debug build; native host targets only. | off | | `HALE_TIME` (*set*) | `time_phases` | Print per-phase wall times of the build on stderr. | off | | `HALE_CC_WARNINGS` | `cc_warnings` | Let the runtime's C warnings through instead of `-w`. For work on the runtime itself. | off | | `HALE_NO_LLD` | `no_lld` | Link with the default linker even when `ld.lld` is on PATH (Linux only; lld is otherwise used). | off | | `HALE_NO_TS_SHIM` | `no_ts_shim` | Behave as if `libhale_ts_shim.a` was never built: a `std::ts` program gets the located refusal, other programs link without it. | off | | `HALE_TS_SHIM_A` | `ts_shim` | The tree-sitter shim staticlib to link, ahead of `` and the workspace `target/` dirs. | unset | | `HALE_ZIG` | `zig` | The `zig` binary a cross build (`--target`) compiles and links with. | `zig` on PATH | | `HALE_TARGET_GLIBC` | `target_glibc` | The glibc version a cross build's Linux gnu binary asks for. | `2.31` | | `HALE_TARGET_SYSROOT` | `target_sysroot` | The sysroot a cross build finds OpenSSL, zlib and the shim in. | `/hale/sysroot/` | | `XDG_CACHE_HOME`, `HOME` | `cache_dir` | Where compiled runtime objects are cached, content-addressed: `$XDG_CACHE_HOME/hale/runtime`, else `~/.cache/hale/runtime`. An empty value is skipped. | else `/hale-runtime-cache-`, a directory of that process's own | | `LOTUS_OPENSSL_PREFIX`, `OPENSSL_ROOT_DIR` | `openssl_prefix` | macOS: a Homebrew OpenSSL prefix (the first whose `include/openssl/ssl.h` exists) for the link. | the standard brew locations | | `LOTUS_NO_DEBUGINFO` | none: the CLI supplies no `debug` sources | Opt out of DWARF line tables for the Hale code (the runtime C always carries `-g`). | off | | `HALE_API_TOKEN` | none | The bearer `hale watch`, `admin` and `mcp --app` present to an HTTP or hub exposure when `--token` is not given; empty is unset. | no bearer | | `HALE_API_BEARER` | none | The bearer `hale api describe` and `hale api call` (and their short forms `hale describe`, `hale call`) present to an HTTP exposure when `--bearer` is not given; over a socket the caller is the peer's kernel credentials and the variable is ignored; empty is unset. | no bearer | | `HALE_BIN` | none | The `hale` binary a child process runs as its toolchain: `hale dna` sets it, to the binary it is running as, for the hosts and fixtures it starts. | the running binary | | `HALE_IMPORT_DEBUG` (*set*) | none | Trace import resolution on stderr, per call. | off | | `HALE_MCP_ROOT` | none | `hale mcp`: every path a tool call names must resolve under this directory. | unset: no restriction | | `HALE_MODEL_TRACE` | none | Print the model builder's and the fleet lowering's demand-proof line on stderr (`1` turns it on). | off | | `HALE_REPLAY_TEST_HOLD` | none | A test hook: `hale replay` waits, between admitting a recording and starting it, until this path exists, so a test can replace the recording at a chosen moment. | unset | | `HALE_SKIP_STALE_CHECK` | none | Any value but empty or `0` skips the check that the binary is not older than the workspace it was built from: every source of the identity-covered crates, the lock file and the ts-shim manifest (statted on each invocation, read only when one is newer than the binary), and the `dna/` tree it embeds (statted the same way, digested only when a file or its directory is newer than the binary or the file count is not the embedded set's). | off | | `HALE_STALE_DNA_ROOT` | none | The tree the stale-DNA check compares the embedded DNA against; the regression test's way to hand it a tree it may edit. | the workspace the binary was built from | | `HALE_TEST_JOBS` | none | How many workers `hale test` runs when `-j` is not given. | one per available core | | `HALE_TEST_KEEP_VAULT` | none | `1` keeps each test file's vault directory (mode 0700 under `/hale-test-vaults-`) after its run and prints where, instead of removing it. | remove | | `HALE_WARM_SKIP_IRIS` | none | `scripts/warm-dna-cache.sh` skips building the observer (`1`), for jobs that never run `hale iris`. | off | ## Diagnostic + tuning env vars Every variable the runtime (`lotus_arena.c`, `lotus_obs.c`) and the standard library read at run time, and the ones the CLI sets for the process it starts. Unset (the default) keeps the runtime quiet and its behavior as described in this document. | Env var | Default | Effect | |---|---|---| | `LOTUS_ARENA_LOG_BIG_CHUNKS=` | off | Logs every arena chunk + libc allocator (malloc / realloc / calloc / mmap) >= `` bytes to stderr with size, monotonic seqno, and 8-frame backtrace. Use `1` (= 1 MiB) as a shortcut; any positive decimal byte count works (e.g. `4096`). Each event labeled by source: `arena_big_chunk`, `malloc_big`, `realloc_big`, `calloc_big`, `mmap_big`. Note: only fires on the fresh-malloc path; chunks recycled from the per-thread pool bypass this hook — use `LOTUS_ARENA_LOG_CHUNK_ATTACH` for the full picture. | | `LOTUS_ARENA_LOG_CHUNK_ATTACH=` | off | Logs every chunk attachment to ANY arena — both fresh-malloc (`chunk_attach_malloc`) AND per-thread-pool-recycled (`chunk_attach_pool`) paths — when `cap >= N`. Use `1` for "log every chunk attachment". Each event additionally prints `arena= kind= label=`: `root` means the chunk attached to a top-level locus-lifetime arena (a leak class if it grows); `sub` means a subregion (method scratch / free-fn body) that will recycle to the pool on destroy. `label` walks the subregion→root chain and looks up the root in the residency registry — requires `LOTUS_ARENA_RESIDENCY=1` to populate the label map. Filter `kind=root label=` to isolate the actual arena-growers. Shares the `LOTUS_ARENA_LOG_BIG_MAX_EVENTS` cap with the big-chunks logger. | | `LOTUS_ARENA_LOG_BIG_MAX_EVENTS=` | 200 | Caps the log at `` events per process. Default 200. Set to 0 for unlimited (useful when watching low-rate sub-MiB allocation patterns over a long window). | | `LOTUS_CHUNK_POOL_STATS=1` | off | Dumps per-thread chunk-pool hit / miss / store / overflow counters to stderr at process exit. Diagnostic for "pool isn't recycling" symptoms — pairs hits vs misses, stores vs overflows. The atexit handler runs on the main thread; counters are `__thread` so the dump is that thread's view. | | `LOTUS_GLIBC_ARENA_MAX=` | unset (glibc's own) | Calls `mallopt(M_ARENA_MAX, )` at startup. Caps glibc's per-thread malloc arena count. `1` forces a single arena (max contention, min virtual-address fragmentation); higher `` trades contention for parallelism. Useful belt-and-suspenders against the per-thread arena heap-segment proliferation glibc default tuning can produce on long-running daemons. Unset keeps glibc's default. | | `LOTUS_BUS_PAYLOAD_ARENA_CAP=` | 64 MiB | Overrides the lazy-global bus payload arena's byte cap (default 64 MiB). When the cap fires, `lotus_arena_alloc` returns NULL and the existing alloc-fail paths (`empty_global` / `alloc_failed` violation) surface degraded service rather than OOM-killing the process. | | `LOTUS_DRAIN_GRACE_MS=` | 5000 | How long a SIGINT / SIGTERM drain may take before the runtime re-raises the signal with its default action, ending the process by it (default 5000). Only in a program that reads `self.draining`; see `semantics.md` § "Drain cascade (whole-process)" (GH #1039). | | `LOTUS_BUS_CALL_ARENA_STATS=1` | off | At exit, prints `[bus call arenas] opened=N live_bytes=L peak_bytes=P` to stderr: how many per-call arenas adapter `send`s and keyed adapter deliveries opened, the chunk bytes still held (0 unless a call is in flight), and the most held at once. The call arenas are not residency targets, so this is how a test sees them (GH #1038). | | `LOTUS_ARENA_RESIDENCY=1` | off | Registers every top-level arena (locus `__arena`s, `g_bus_payload_arena`, the program-wide global) into a side-table at creation time with a 24-frame construction backtrace. `std::process::dump_arena_residency()` walks the live set and emits one line per arena to stderr — bytes / chunks / parent / label, sorted by bytes desc — with the construction backtrace. Subregions (method scratch) are skipped; they destroy at method exit and don't accumulate residency. Atexit also dumps, but post-dissolve fires after all loci tear down — useful only for the global arena's final state. Long-running daemons should call `dump_arena_residency` from a heartbeat / checkpoint tick so locus arenas are sampled while still alive. | | `LOTUS_CHUNK_POOL_PREFILL=` | 32 | Per-thread chunk-pool pre-fill on first touch. Default 32 (= 2 MiB resident per scheduler thread). Set 0 to disable. Bumps the pool's steady-state floor so brief bursts don't drain to zero and miss into malloc; the trade-off is per-thread resident memory. | | `LOTUS_TSAN=1` | off (build time) | Read at *build time* (by `hale build`, not at runtime; see *Build-time environment*). When set, the emitted clang command passes `-fsanitize=thread` for both the C runtime compile and the binary link, and skips the `-Wl,--wrap=malloc/realloc/calloc/mmap` shim surface (TSAN intercepts malloc itself; the wrap'd `LOTUS_ARENA_LOG_BIG_CHUNKS` diagnostic is silently no-op under TSAN). The resulting binary runs ~5-15× slower; use only for race-hunting workloads. The C runtime embeds an empty `__tsan_default_suppressions` hook at link time so no external suppression file is needed; all originally-flagged substrate races (bus queue drain, arena destroy, coop pool worker, env-var lazy-init) have been fixed and the suppression list is empty. Opt-in tests live behind `#[ignore]` and the env var (see `crates/hale-codegen/tests/form_hashmap_lockfree_tsan.rs`). | | `LOTUS_LTO=1` | off (build time) | Read at *build time*. Opt-in full-LTO native build: the Hale module is emitted as bitcode and the lotus runtime TUs compile with `-flto`, so the `clang -flto -O3 -fuse-ld=lld` link inlines the runtime hot paths (arena / string / shm_ring) across the TU boundary into the Hale callers. A few percent on allocation/coordination-heavy code, neutral on vectorized loops (host tuning preserved via per-function `target-features`). Off by default — ~3–4× slower link, requires `lld`. Native non-sanitizer only; `--wrap` shims survive (lld resolves them before LTO codegen). See *Native codegen defaults* above. | | `LOTUS_BUS_LOG_UNMATCHED=1` | off | Surfaces silent no-key-match drops in `lotus_bus_local_dispatch_keyed` (Phase 3 routing keys). When set, each publish that matches no `where key == ...` subscriber for the topic emits a single stderr line citing subject, key, and the per-topic subscriber counts (specific vs unkeyed). Off by default — the silent-drop is correct for `on_unmatched: swallow` topics in steady state, but during bring-up the lack of any signal is load-bearing on debug cycles. Implied by `LOTUS_BUS_LOG_DROP=1`. | | `LOTUS_BUS_LOG_DESERIALIZE_DROP=1` | off | Surfaces silent drops in the udp:// reader thread when (a) no deserializer is registered for the inbound subject, or (b) the deserializer returns `<= 0` (size mismatch, bounded-read failure). Emits one stderr line per drop naming the subject, the payload size, and (when applicable) the deserializer's return value. Off by default; the silent-skip on cross-routed multicast noise is the correct steady-state behavior. Same env-gated pattern as `LOTUS_BUS_LOG_UNMATCHED` for keyed-dispatch misses. Implied by `LOTUS_BUS_LOG_DROP=1`. | | `LOTUS_BUS_QUEUE_CAP=` | 8192 | Caps the cooperative bus dispatch queue, each per-pinned-locus mailbox, and each cooperative pool's queue at `N` cells (default 8192; floor 64; rounded up to a power of two; read once). **v0.9.0 footprint change:** the pinned mailbox and cooperative-pool queues are now lock-free MPSC rings (Vyukov bounded ring + signal-only-when-parked wake), and a fixed-size lock-free ring **pre-allocates its cap up front** rather than growing to it — so each pinned subscriber mailbox and each cooperative pool now costs ~4.3 MB resident at the default cap (vs the prior grow-as-needed). With the typical handful of pinned loci / pools this is a few-to-low-tens of MB; **lower `LOTUS_BUS_QUEUE_CAP` for pinned-/pool-heavy programs** to shrink it (the rings honor it identically). When a producer hits the cap it *back-pressures* instead of growing without bound (GH #125) — every message is still delivered. The mechanism: a **single-threaded** producer on the cooperative queue **inline-drains** it to free space; a **cross-thread** producer to a full ring **blocks** (a fenced producers-waiting handshake) until the single consumer drains a slot; a handler self-publishing to its own full ring spills to a consumer-thread-local overflow list (it can't block on itself). The cross-*cooperative*-pool *shared* queue path (multiple drainers, no single consumer) is the remaining non-lock-free path — a follow-on. Lower the cap to tighten the bound / footprint; raise it to reduce drain bursts at the cost of resident memory. | | `LOTUS_UNIX_STREAM=1` | off (`SOCK_SEQPACKET` on Linux) | GH #231: forces the unix bus transport into framed `SOCK_STREAM` mode on Linux (the Darwin default — macOS has no AF_UNIX `SOCK_SEQPACKET`). Wire format per message: `[u64 LE payload len][u64 LE seq][payload]`; the seq is per-connection monotonic from 1, reset per accepted peer, and the receiver counts gaps (`seq_gaps` counter — GH #236's loss-computability primitive). Set for EVERY process on a socket: framed and SEQPACKET ends don't interoperate (a mismatch trips the 8 MB length sanity cap with a diagnostic naming the likely cause). Primary use: Linux CI/test coverage of the macOS code path. | | `LOTUS_BUS_COUNTERS_DUMP=1` | off | GH #236 (observability groundwork): prints one stderr line per remote binding at teardown — `[bus counters] subject=... kind=... role=... sent= delivered= bytes_sent= bytes_delivered= send_failures= dropped_lost= waits= rearms= reconnects=`. The counters are plain relaxed atomics bumped at the transport choke points (fanout send, serve-loop dispatch, re-arm, reconnect) and exist as the substrate for the iris observer; the dump is the operator/test surface until an in-process consumer ships. `dropped_lost` counts publishes made while a connect binding was in the lost/reconnecting window (GH #233 — drops the publish contract makes deliberate and visible). `waits` counts publishes that parked in `or wait` through that window instead (GH #255 phase 1). `buffered_early` / `dropped_early` (GH #468) count boot-window wire messages buffered before any matching subscriber registration existed, and the subset dropped (buffer eviction at the 64-msg/1 MiB per-binding cap, a deserialize failure at flush, or a subject that never gained a subscriber by teardown). Socket-buffer occupancy is intentionally not a counter: it's a poll-time `SIOCOUTQ` query against the live fd. | | `LOTUS_BUS_LOG_DROP=1` | off | Broad superset for diagnosing "publish appears to succeed but handler doesn't fire" symptoms. Implies `LOTUS_BUS_LOG_UNMATCHED` + `LOTUS_BUS_LOG_DESERIALIZE_DROP` AND covers additional silent-drop sites the narrower vars miss: `lotus_bus_dispatch`'s serialize-fn-returns-<=0 case, the local-fanout (`lotus_bus_dispatch_wire` + `lotus_bus_local_dispatch`) zero-matching-subscribers case, per-entry deserialize-returns-<=0 on the local-fanout path, and the no-post-target case (mailbox / coop_pool / global queue all NULL on a matched entry). Each line names the call site, subject, and relevant size / index info so a bus-heavy repro can identify exactly which silent-skip is firing. Reach for this first when investigating bus-drop friction; the narrower vars stay supported for their specific bring-up scenarios. | | `LOTUS_BUS_QUIESCE_MS` | 500 | GH #468: bound on the main-exit ingress quiesce (default `500`, `0` disables it). At every main-exit point, before pools join and loci dissolve, LISTEN binding fds half-close and their readers drain kernel-accepted data to true EOF through the still-intact registry; this bounds how long exit waits for a wedged reader. Exceeding the bound is loud (stderr names it) and drops whatever stayed undrained. Test-only siblings `LOTUS_BUS_TEST_BOOT_HOLD_MS` / `LOTUS_BUS_TEST_READER_STALL_MS` deterministically stretch the boot-registration window / the reader's descheduled window (the loss canaries in `binding_ingest_468.rs` use them); never set them in production. | | `LOTUS_ARENA_CHUNK_BYTES_OVERRIDE=` | 64 KiB | Overrides the arena chunk size (F.32-3): a power of two in [4 KiB, 16 MiB], anything else ignored; read once. Smaller chunks keep each locus's hot chunk in cache across pool rotations in a multi-locus-per-pool deployment. Overridden chunks bypass the per-thread chunk pool. | | `LOTUS_NO_CHUNK_POOL=` | off (on under an ASan build) | Any non-empty value not starting with `0` turns the per-thread chunk pool off, so a destroyed arena's chunks are freed instead of recycled with their bytes intact and a use-after-free reads memory a sanitizer can see (GH #816). | | `LOTUS_HUGE_PAGES=1` | off | F.32-4a: chunks of at least 2 MiB are mapped with `MAP_HUGETLB \| MAP_HUGE_2MB` instead of malloc'd. A value starting with `1`, `t` or `T` turns it on. | | `LOTUS_LOCK_MEMORY=1` | off | F.32-4c: `mlockall(MCL_CURRENT \| MCL_FUTURE)` at startup (Linux), to remove page-fault stalls on hot-path arena allocation. Needs `RLIMIT_MEMLOCK` or root; on failure it prints a diagnostic on stderr and runs unlocked. A value starting with `1`, `t` or `T` turns it on. | | `LOTUS_BUS_CONFIG=` | unset | The bus config file (the peer and route table `hale node` writes) that `lotus_bus_load_config` reads at startup. Unset or unreadable, the program is single-process. | | `LOTUS_BUS_UDP_RCVBUF=` | the kernel's | `SO_RCVBUF`, in bytes, for the udp bus readers. Ignored unless a positive `int`. | | `LOTUS_BUS_TEST_BOOT_HOLD_MS=` | 0 | Test only: stretches the boot-registration window of a listening binding (see `LOTUS_BUS_QUIESCE_MS`). Never set in production. | | `LOTUS_BUS_TEST_READER_STALL_MS=` | 0 | Test only: stretches the window in which a binding's reader is descheduled. Never set in production. | | `LOTUS_TEST_PINNED_START_NO_DRAIN=1` | off | Test only: disables the instantiating thread's queue drain while it waits for pinned or pool initialization. The startup request/reply negative controls use it to expose the resulting deadlock. Any non-empty value not starting with `0` enables it. Never set in production. | | `LOTUS_LIFECYCLE_SKIP=` | unset | Test only, and read only by a lifecycle-trace build (`HALE_LIFECYCLE_TRACE=1`; a release runtime has no such code): a comma list of steps a negative control removes, a kind (`PoolJoin`) or one event line (`Reclaim.Completed`). See *The lifecycle trace*. | | `HALE_MATRIX=full` | the sample | Test only, read by the test suite (`ownership_matrix.rs`, `lifecycle_matrix.rs`), never by a program: `full` runs every cell of the generated ownership and lifecycle matrices instead of the default deterministic sample. | | `LOTUS_OBS=1` | off | Native observation emission (iris): the process creates its observation segment and its probes emit. Implied by `LOTUS_OBS_RECORD` and `LOTUS_REPLAY`. See *Native observation emission*. | | `LOTUS_OBS_RINGS=` | 8 (64 when recording) | Rings in the observation segment, 1 to 64; a value outside that falls back to the default. | | `LOTUS_OBS_SLOTS=` | 4096 | Slots per ring: a power of two, at least 64; anything else falls back to the default. | | `LOTUS_OBS_WIRE=1` | off | Puts the `(origin, seq)` edge identity on the wire: the udp magic prefix and the framed transport's origin widening. That is a wire-format change a pre-header receiver cannot parse, so the whole fleet opts in together; it is what cross-process edges need (iris handoff-4 P16). | | `LOTUS_OBS_RECORD=` | unset | Lossless recording (GH #296): write the run's tape to ``; implies observation. See *Lossless recording mode*. | | `LOTUS_OBS_RECORD_DURABLE=1` | off | Synchronize every flushed drain sweep of the recording (`fdatasync` on Linux, `F_FULLFSYNC` on macOS); the default rides the page cache (survives a process crash, not power loss). | | `LOTUS_OBS_RECORD_ENV=full` | withheld | Record the value of every environment read, not just its name, existence and length; the artifact header says which policy applied. | | `LOTUS_REPLAY=` | unset | Replay a recording (`hale replay` sets it): the run is judged against the tape. | | `LOTUS_REPLAY_FEED=` | unset | Feed mode (`hale replay --feed`): the recording is input, not law. Mutually exclusive with `LOTUS_REPLAY`. | | `LOTUS_REPLAY_ALLOW_TRUNCATED=1` | off | Accept a recording with no clean-finalize trailer (a crashed writer); `--allow-truncated`. | | `LOTUS_REPLAY_ALLOW_UNVERIFIED=1` | off | Replay although the recording's model identity differs from this build's; `--allow-unverified`. | | `LOTUS_REPLAY_FEED_ALLOW_UNMATCHED=1` | off | In feed mode, unfed tape is a failure unless this is set. | | `LOTUS_REPLAY_STATUS=` | unset | Where the replayed process writes the verdict counters (journal misses, order divergences) the CLI reads. | | `LOTUS_REPLAY_AT=` | unset | Stop the replayed run (SIGSTOP) at the n'th consume; `hale replay --at `. | | `LOTUS_REPLAY_AT_CONSUMER=` | unset | With `LOTUS_REPLAY_AT`, count the n'th consume of that consumer instead of the process-wide n'th; `--at :`. | | `LOTUS_REPLAY_FD=` | unset | An already-open, validated descriptor for the recording, passed by `hale replay` so no path is re-resolved between admission and replay. | | `LOTUS_API_ROLES=` | the `table` param of the program's `std::api::StaticRoles` | Overrides a `StaticRoles` source's table at run time (`spec/stdlib.md` § `std::api`); with no table, every `requires` refuses until this says otherwise. | | `HALE_LOG=` | unset: no filtering | `std::log` drops events below this level (`debug`, `info`, `warn`, `error`, case-insensitive). Anything unrecognized, and an unset variable, filters nothing, so a typo cannot discard the logs it was set to see. | | `HALE_VAULT_DIR=` | `$XDG_CACHE_HOME/hale/vault`, else `~/.cache/hale/vault`, else `/tmp/hale-vault` | The local vault directory a `vault:` source of `std::secret` reads (a file per name). | | `HALE_VAULT_ADDR=` | unset | When set, a `vault:` source resolves over the vault's HTTP API at `/v1/secret/` instead of the local directory. | | `HALE_VAULT_TOKEN=` | unset | The bearer token for `HALE_VAULT_ADDR`; empty, the lookup fails closed. | Every top-level arena is created via `lotus_arena_create_labeled(name)` and carries an immutable human-readable label string. The codegen passes the locus name (e.g. `WsClient`, `__lib_metrics_metrics_MetricMap`); the program-wide global is labeled `lotus.arena.global`; `g_bus_payload_arena` labels itself. The label is the load-bearing identifier in the residency dump; backtraces resolve via `-rdynamic` for cases where the label alone isn't enough. The arena-chunk pool and -wrap=malloc family ship in every binary unconditionally; the env vars are zero-cost when unset (one int read + one branch per allocation). The `-rdynamic` link flag is similarly unconditional so backtrace symbols resolve without addr2line. ## Runtime size budget The runtime should be small enough that a hello-world program binary is < 1 MB statically linked, and < 100 KB if dynamic linking against libc. This is a target, not a guarantee. The framework's discipline enables this: no GC, no metadata overhead per allocation, region-based MM compiles to bump allocators. Comparable to C in size, with ergonomics closer to Erlang. ## Open questions for runtime - **Async / await integration.** Reserved keywords, no v0 semantics. The lifecycle state machine + cooperative yield points subsume most of what async is for; explicit async/await may not be necessary. - **FFI to existing languages.** Generic FFI in stdlib; team-specific bindings (e.g. domain-specific typed messages) live as third-party packages. Marshalling helpers in stdlib. - **Hot-reload of code (not just perspectives).** Erlang supports module-level hot reload. Lotus's perspective hot-reload is more granular and addresses most of the use case; full code hot-reload may not be needed. - **Determinism mode for tests.** Resolved for the single-pool case: no mode is needed, because single-pool execution is deterministic by construction, and that is now a stated, pinned guarantee rather than an accident — see `spec/testing.md` § Determinism. What remains open is deterministic *re-execution* of multi-pool programs, which is the record/replay track (GH #296) — reproducing a recorded per-consumer order, never constraining live scheduling. ================================================================================ ## Forms ## source: spec/forms.md ================================================================================ # Forms A **form** is a compiler-recognized annotation on a locus declaration that picks an efficient lowering for the locus's storage and synthesizes a standard method set. Forms are the mechanism Hale uses in place of parametric collection types (`Map`, `Vec`, etc.). See `spec/decisions.md` for The Design's grounding (F.0 form-before-parameter, F.22 capacity). This document specifies the form annotation system in general (syntax, contract, verification) and the `@form(vec)` contract in detail. Subsequent forms (`@form(hashmap)`, `@form(ring_buffer)`, `@form(lru_cache)`) get their own sections as they're committed. ## Annotation syntax ``` form_annotation = "@form" "(" form_name [ "," form_arg { "," form_arg } ] ")" form_name = LOWER_IDENT form_arg = IDENT "=" expression ``` A form annotation sits on the line above a `locus` declaration, like the existing `@projection` annotation: ```hale @form(vec) locus ItemList { capacity { heap items of T; } } ``` - **`form_name`** — the form identifier. Lowercase, single word. The v1 form library is fixed (see "v1 form library" below); user-defined forms are deferred to a future release. - **`form_arg`** — keyword arguments specific to the form. Used for tuning knobs that don't change storage discipline (e.g. `cap = 100` for `@form(lru_cache)` / `@form(ring_buffer)`). - **One form per locus.** Composition (`@form(vec) @form(ordered)`) is rejected in v1. Form-specific configuration that *does* change storage discipline goes on the capacity slot, not in the annotation arguments — see "`indexed_by` and slot clauses" below. ## Form contract Each form specifies three things the compiler verifies and implements: 1. **Required capacity shape.** What slots the locus must declare, of what kinds, holding what cell types. Verified at typecheck. 2. **Synthesized method set.** Names, parameter types, return types of methods the form provides. Injected at typecheck so call sites resolve normally. 3. **Lowering strategy.** What C-runtime substrate the compiler emits in place of the literal F.22 pool / heap lowering. If the locus's shape doesn't match the form's required capacity, the compiler emits a focused diagnostic and rejects the program. Example: ``` error[FORM-SHAPE]: @form(vec) requires exactly one `heap` slot; found `pool entries of CmdEntry` instead. --> registry.hl:3:1 | 3 | @form(vec) | ^^^^^^^^^^ 4 | locus Registry { capacity { pool entries of CmdEntry; } } | ---------------------------- | expected `heap items of T` ``` ## Synthesized methods The form *synthesizes* its standard method set. The user does not declare them; call sites resolve as if they were declared. ```hale @form(vec) locus ItemList { capacity { heap items of T; } // push, get, set, pop, len, is_empty come from @form(vec). } fn main() { let l = ItemListL_Int { }; l.push(42); let head = l.get(0) or raise; println(head); // 42 } ``` **The user CAN add additional methods** on top of the synthesized standard set. Naming a user method that collides with a synthesized method (e.g. user writes their own `push`) is rejected at v1 — override is deferred to v2. ## `@form(set)` — membership without a value A set is a hashmap whose value is carried but never surfaced: the cell IS the key. It reuses the hashmap slot and the whole `lotus_hashmap_*` runtime, sync disciplines included, so `@form(set, sync = striped)` works exactly as the hashmap does. ```hale type Item { key: String; } @form(set) locus Seen { capacity { pool items of Item indexed_by key; } } ``` The capacity shape is identical to `@form(hashmap)`. Only the synthesized surface differs: | method | signature | |---|---| | `insert(v)` | value struct in; the key comes from `indexed_by` | | `contains(k)` | `Bool`, not a fallible — "absent" is the ordinary answer to a membership question | | `remove(k)` | `fallible(KeyError)`, mirroring the hashmap's | | `len()` / `is_empty()` | as elsewhere | That surface is the reason the form exists. Membership through `@form(hashmap)` means writing `get(k) or false` at every call site — the value plumbing leaking back out of a container whose value nobody wants. ## `indexed_by` and slot clauses Form configuration splits between *slot clauses* and *annotation arguments*. The dividing line: - **Slot clause** — if the configuration changes how cells are laid out or accessed. A storage-discipline concern. - **Annotation argument** — if the configuration is a policy / tuning knob the form's runtime consults; the underlying storage shape is the same regardless. ```hale // Storage discipline — slot clause. @form(hashmap) locus CmdRegistry { capacity { pool entries of CmdEntry indexed_by name; } // ^^^^^^^^^^^^^^^ // slot clause } // Policy / tuning — annotation argument. @form(lru_cache, cap = 100) locus SessionCache { capacity { pool sessions of SessionEntry indexed_by id; } } ``` `indexed_by` is a slot clause because indexing IS a storage commitment — it changes the pool's layout and access path. ## Default lowering (no form annotation) A locus without `@form(...)` gets the **literal F.22 default lowering**: pool slots become `lotus_pool_t*` chunked free-list; heap slots become a `lotus_heap_t*` doubly-linked live list (individual O(1) alloc/free, wholesale-freed at dissolve — the contiguous doubling buffer is the `@form(vec)` specialization, not the default). The user's own methods run as written; no synthesis, no shape verification beyond the normal capacity-slot machinery. The form annotation is the user's opt-in to a specific efficient lowering. Without it, you get the predictable F.22 default. ## Form-annotated loci as application-layer storage substrate A `@form(...)` locus occupies a different position in The Design's taxonomy than a user-declared locus, and the distinction is load-bearing for the two-channel failure rule (`spec/semantics.md` § "Fallible call semantics" § "Where each channel lives"): - **User-declared loci** are substrate-facing — they participate in the locus tower's lifecycle (bus subscriptions, modes, contract reads, lifecycle methods). Their methods communicate failure structurally via closure assertions + `on_failure` routing. - **`@form(...)` loci** are application-layer storage substrate — they realize a substrate-honest *container* shape that application code uses to hold data. Their synthesized methods (`@form(vec).get`, `@form(vec).pop`, future `@form(...)` accessors) operate per-access and may be declared `fallible(E)`, addressing failure at the immediate caller's `or` clause. This is why the synthesized `@form(vec)` `get` / `pop` methods carry `fallible(IndexError)` while user-declared locus methods cannot. The `@form(...)` annotation is the declaration-site marker that "this locus is application- layer storage substrate, not a substrate-structural participant." The synthesized method surface gets the application-layer failure channel; the underlying form-vec locus still respects every other substrate invariant (arena ownership, dissolve cascade, capacity slot discipline). ## Perspectives and forms > **Perspectives reflect on structure, not on lowering.** The form annotation changes how the compiler lays out memory and synthesizes methods. It does not change: - The locus's name or place in the tower. - The set of fields declared in `params`. - The capacity slot declarations. - The `closure` / `on_failure` / `bus` blocks. Perspectives that reflect on a form-lowered locus see the *structural* view: the capacity slots, the params, the method signatures (synthesized or user-written, treated uniformly). They do not see the underlying C struct layout. ## Performance commitment The form machinery commits to three distinct perf shapes, distinguished because they measure different things and have different bands: > **(a) Tight-loop primitive cost.** A form-lowered primitive > (e.g. `@form(vec).push`) must run within **10% of a > hand-written equivalent in idiomatic C** on a microbench that > exercises the primitive in isolation. > **(b) Amortized workload cost.** A form-lowered workload that > mixes the form's primitives with real per-call work (the > shape real apps exhibit) must run within **2× of an equivalent > idiomatic C program**. > **(c) Per-op fallible-method cost.** A form-lowered fallible > primitive (e.g. `@form(vec).get` / `.pop` / > `@form(hashmap).get` / `.remove`) measured in isolation pays > the C-function-call boundary to the `lotus_*` primitive plus > the fallible-ABI plumbing. **No 10% commitment at v1**; > isolated-microbench numbers may show 10–50× behind C. The > contract is that fallible primitives are correct, predictable, > and competitive when amortized (the (b) band). > > **Update: the isolated gap is largely closed.** > `@form(vec)` `.get` / `.set` / `.pop` / `.push` are now inlined > directly at codegen — bounds-check + typed GEP + load/store, > no `lotus_*` C-call boundary. `.get` indexed by a counted-loop > variable (`for i in 0..v.len()`, the vec unmutated in the body) > additionally drops the bounds check entirely — it's provably > in-bounds — so the read vectorizes. The remaining cross-boundary > calls (arena allocation, etc.) inline under opt-in `LOTUS_LTO=1` > (see `runtime.md`). The "IR-level inlining or LTO" this band > once deferred to is shipped. These bands track the same underlying performance reality at different observer-perspectives (per The Design's form/parameter cut): (a) measures primitive layout correctness, (b) measures whether the substrate amortizes well at scale, (c) measures the codegen-pattern overhead per primitive call. `@form(vec)` is the canonical benchmark target (see "Bench protocol" under the `@form(vec)` section below). **Current standing (2026-06-28, vs Go at matched iteration counts):** | Bench | Hale vs Go | Band | Status | |---|---|---|---| | `form_vec_push` (500k push) | 4.83× | (a) | beats Go ✓ | | `vec_amortized` (push + fold, 200k) | 3.75× | (b) | beats Go ✓ | | `form_vec_get` (200k get) | 2.60× | (c) | beats Go ✓ | | `fn_scratch_work` (1k calls w/ work) | 7.05× | (b) | beats Go ✓ | `Hale vs Go` = Go time ÷ Hale time (> 1 → Hale faster). The earlier (2026-05-13) snapshot put `form_vec_get` at 0.026× and `vec_amortized` at 0.42×; those were a benchmark **iteration-count mismatch** — the Hale variants ran 20–25× more work than the `.go`/`.js`/`.py` siblings (see the bench repo's N-audit) — compounded by pre-optimization codegen. At matched N, with the `.get`/`.set`/`.pop`/ `.push` inlines + counted-loop bounds-check elimination + native-CPU/O3 defaults, Hale leads Go on all four. The formal within-10%/2×-of-**C** verification for bands (a)/(b) still awaits the C twins noted in the bench harness. If a form fails its applicable band, the lowering is redesigned before shipping more forms. The point of the form machinery is not to be clever — it's to be roughly as fast as the C the user would have written by hand, with all the locus tower's structural benefits on top. --- # `@form(vec)` A contiguous, growable buffer of `T`. The Hale analogue of `Vec` / `std::vector` / Go slices. First form committed for v1; canonical benchmark target for the 10% perf gate. ## Required capacity shape The locus MUST declare exactly one `heap` slot. Its cell type becomes the vec's element type `T`. ```hale @form(vec) locus ItemList { capacity { heap items of T; } } ``` Rules verified at typecheck: - Exactly one slot. Zero slots, more than one slot, or any `pool` slot is rejected. - The slot MUST be a `heap` slot. (`pool` is the unordered free- list shape; `vec` is the contiguous shape — they're different storage disciplines, so a `pool` declaration with `@form(vec)` is a contradiction.) - The slot name is user-chosen and is not part of the contract. The compiler finds the form's heap slot by *position*, not by name. Idiomatic spellings: `items`, `entries`, `bytes`, `xs`. The cell type `T` may be: - A primitive (`Int`, `Float`, `Bool`, `Decimal`, `Time`, `Duration`, `String`, `Bytes`). - A user-defined `type` (struct or enum). - A generic parameter (`heap items of T` inside a generic locus `ItemList`); monomorphization (m63) produces a concrete `@form(vec)` instance per binding. The cell type MAY NOT be a locus reference — vecs hold values, not loci. If you want a vec of child loci, use the F.22 `pool` projection-class machinery instead; that's the structural shape for parent-owns-children. ## Synthesized methods ``` fn push(x: T) -> () # infallible fn get(i: Int) -> T fallible(IndexError) fn set(i: Int, x: T) -> () fallible(IndexError) fn pop() -> T fallible(IndexError) fn len() -> Int # infallible fn is_empty() -> Bool # infallible fn sort() -> () # infallible; T in {Int, Float, String} fn sort_by(cmp: fn(T, T) -> Bool) -> () # infallible fn sort_desc_by(cmp: fn(T, T) -> Bool) -> () # infallible ``` The fallible methods return the locus-defined `IndexError` payload type: ```hale type IndexError { kind: String; # "out_of_bounds" or "empty" index: Int; # the requested index (0 for empty-pop) len: Int; # the vec's len at fail time } ``` `IndexError` is defined in the synthesized form preamble; the user does not declare it. The same type is shared across all `@form(vec)` instantiations (it's a flat record, not parametric over T). ### `push` ``` fn push(x: T) -> () ``` Appends `x` after the last element. Amortized O(1). The synthesized lowering grows the underlying buffer by doubling when capacity is exhausted; the realloc cost amortizes across N appends to O(N) total. `push` is **infallible**. OOM during the doubling realloc is a substrate-level concern: the C runtime traps malloc failure and re-raises as a closure violation, not as a `fallible(...)` return on `push`. From the language surface, `push` never errors. (See `spec/runtime.md` for the OOM trap convention.) ### `get` ``` fn get(i: Int) -> T fallible(IndexError) ``` Returns the element at index `i` (0-based). If `i < 0` or `i >= len()`, fails with `IndexError { kind: "out_of_bounds", index: i, len: self.len() }`. Idiomatic call sites: ```hale let head = vec.get(0) or raise; # bubble on empty let first = vec.get(0) or default_value; # substitute let nth = vec.get(i) or handle_oob(err); # custom handler ``` ### `set` ``` fn set(i: Int, x: T) -> () fallible(IndexError) ``` Overwrites the element at index `i` (0-based) with `x`. If `i < 0` or `i >= len()`, fails with `IndexError { kind: "out_of_bounds", index: i, len: self.len() }`. `set` does not extend the vec — index must be inside the current length; appending new elements uses `push`. ```hale vec.set(0, new_first) or raise; vec.set(i, x) or noop(err); # swallow OOB ``` > **Replaced-element reclamation (iris handoff P1, 2026-07-27).** > Vec elements are pointer-storage: each `set` deep-copies the new > value into the form owner's arena. The REPLACED element (and its > non-surviving String and Bytes fields, with aliasing dedup — the > hashmap retire-cell discipline) now retires onto the arena's reuse > freelist immediately, and the deep-copy allocation consults that > freelist — steady-state `set` churn ping-pongs between reused > blocks instead of growing the arena (~33 B/set and a > progressively slower containment walk, pre-fix). Bytes fields > since GH #1037: before it a struct's replaced Bytes stayed (~47 B > a set). A field that holds a block of its own — a nested record, a > tuple, a fixed array (a record's field of scalars is inline and owns > no block; inside a tuple or another array, even a scalar array is a > block of its own) — retires with everything it owns, at any depth: > before, the walk stopped at the top-level > String and Bytes fields, so a replaced element's nested record (its > block and its own String and Bytes) stayed in the arena for good > (~110 B a set for one nested record carrying a String and Bytes, > ~210 B for two levels). A block the new element still reaches > survives, wherever in it, and a block reached twice is freed once. > (`@form(hashmap)` cells retire through the same descriptor, deferred > to the activation boundary — see `@form(hashmap)` § Cell > reclamation.) > > **The vec owns its elements (GH #577).** `get` returns the > CALLER's copy of a heap-bearing element (a String, Bytes, a > struct with such fields), allocated in the caller's current arena; > `set` and `push` store the VEC's own copy, whatever arena the value > came from — a value read from the same vec, or one value pushed > twice, never leaves two slots sharing one struct. So a `get` result > stays whole across a later `set` of its slot, and the retire above > frees only what the vec held. Scalars and locus refs are copied by > value as before. (Before this, `get` handed back the slot's pointer > and a same-arena value passed through `set` uncopied; two items > swapped through `get` and `set` segfaulted.) > > The copy is an allocation in the caller's arena, freed with the > frame — the same cost and lifetime as a hashmap `get`. A frame that > reads a heap-bearing element millions of times before it returns > holds every copy until then; a hot walk uses `for x in v.items`, > which visits the elements in place without copying (and must not > `set` the vec it walks). ```hale,fragment ``` ### `pop` ``` fn pop() -> T fallible(IndexError) ``` Removes and returns the last element. If `len() == 0`, fails with `IndexError { kind: "empty", index: 0, len: 0 }`. Like `get`, `pop` returns the CALLER's copy of a heap-bearing element, allocated in the caller's current arena; the element the vec held — its block, its String and Bytes fields, and its nested records' blocks with what they own — is then freed onto the arena's reuse freelist, as a replaced element is on `set` (GH #1037). A vec used as a queue (push, then pop once handled) therefore holds its arena flat. (Before this, `pop` handed back the slot's own pointer and freed nothing: ~40 B a cycle for a bare String cell, ~62 B for a struct carrying one, for as long as the owner lived.) `pop` does not free the underlying buffer — capacity does not shrink. Buffer release happens at locus dissolution. ### `len` and `is_empty` ``` fn len() -> Int fn is_empty() -> Bool ``` `len()` returns the number of elements currently in the vec. `is_empty()` is sugar for `len() == 0`. Both are infallible and O(1). ### `sort` ``` fn sort() -> () ``` Sorts the vec in place in ascending order. The cell type T MUST be one of `Int`, `Float`, or `String`; any other T (struct, enum, bytes, etc.) is a typecheck error suggesting `sort_by(cmp)`. String comparison is lexicographic on the underlying byte sequence (i.e., the C `strcmp` ordering); Float comparison treats `NaN` as equal-to-anything to keep the ordering total (no panic on NaN-bearing inputs). `sort` is infallible. The substrate uses C `qsort` under the hood — average O(N log N), worst-case O(N²) on pathological inputs. ### `sort_by` and `sort_desc_by` ``` fn sort_by(cmp: fn(T, T) -> Bool) -> () fn sort_desc_by(cmp: fn(T, T) -> Bool) -> () ``` Sort the vec in place under a user-supplied strict-less-than comparator. The comparator's semantics: `cmp(a, b) == true` means "a should come before b in the result." This is the strict-`<` shape — `cmp(a, a)` SHOULD return `false` for any `a`; a reflexive `<=` produces an unstable ordering but does not panic. `sort_desc_by(cmp)` is equivalent to `sort_by(|a, b| cmp(b, a))` with the arg order swapped under the hood, so the same user predicate produces the reverse ordering. Provided as a convenience for the common "descending under the same key extractor" pattern. Both methods are infallible from the language surface. If `cmp` itself faults (e.g., raises via `or raise` on a fallible call inside the comparator body), the fault propagates through the sort and the vec is left in an unspecified but valid state (every element still present, ordering partially applied). ```hale fn by_x(a: Point, b: Point) -> Bool { return a.x < b.x; } points.sort_by(by_x); # ascending by x points.sort_desc_by(by_x); # descending by x — same cmp ``` The cell type T may be any sortable shape, including structs and enums. The substrate uses `qsort_r` with a per-(cell-type, direction) trampoline synthesized at codegen time; the cookie threads the caller's arena pointer through so the comparator's body can use stdlib calls that allocate. ## Lowering strategy `@form(vec)` lowers the heap slot to a three-field C struct: ```c typedef struct { size_t cap; // allocated capacity (elements) size_t len; // number of valid elements T* buf; // contiguous element array } lotus_vec__t; ``` - Initial capacity: `0` (no allocation at birth). First `push` allocates the initial buffer. - Initial buffer size on first push: `4` elements. Chosen as a small constant that avoids the malloc-per-element shape without over-allocating for short-lived vecs. - Growth policy: double `cap` on overflow. New buffer is malloc'd; old elements are `memcpy`'d; old buffer is freed. - Shrink policy: none. Capacity is monotonic in v1. (A `shrink_to_fit` method may be added later if a workload surfaces the need.) Element storage is by-value: a `@form(vec)` of `Int` is a contiguous `int64_t[]`; a `@form(vec)` of `type Pair { x: Int; y: Int; }` is a contiguous array of `{int64_t, int64_t}` records. No per-element heap allocation, no per-element header. For elements of pointer-shaped types (`String`, `Bytes`), the vec stores the pointer by value; the pointed-to bytes live in whatever arena they were allocated from. Dissolution of the vec frees the buffer but does not free the pointed-to bytes — those follow their owning arena's lifetime per the standard F.22 contract. ## Arena ownership `@form(vec)` is **not** a separate arena-allocated structure. The three-field `lotus_vec_*` struct lives inline in the locus's struct layout, the same way the literal `heap items of T` declaration would. The growable buffer (the `buf` field) is malloc'd from the *system allocator*, not from the locus's arena — this is the existing F.22 heap-slot contract, unchanged. Dissolution: when the locus arena is destroyed, the vec's `buf` is freed via the F.22 dissolve cascade (the synthesized destructor emits `free(self.items.buf)` for each formed heap slot). ## Interaction with the locus tower A `@form(vec)` locus is a locus in every other respect. It can: - Have `params { ... }` with defaults. - Have `birth`, `run`, `drain`, `dissolve` lifecycle bodies. - Declare `closure { ... }` invariants. - Route failures via `on_failure(child, err)`. - Participate in a `bus`. - Be projected by `perspective` declarations. These are orthogonal to the form annotation. The form *replaces* the literal F.22 heap-slot lowering; it does not replace any other locus mechanic. ## Bench protocol (FORM-3 gate) `@form(vec)` is the canonical benchmark target. The three perf bands above map to three benches that ship under `micro/` in the sibling `hale-lang/bench` repo: 1. **Tight-loop primitive (band (a), 10% gate).** `form_vec_push` — 1M `push` on a `@form(vec)` of `Int`, compared against an equivalent hand-written C program using `malloc` + doubling realloc and raw `int64_t[]` indexing. Wall-clock and peak RSS. **Status 2026-05-13:** 1.00× ratio vs Go (effectively at C parity); gate met after the `lotus_arena_create_subregion` elision for non-allocating fn bodies (`notes/form-perf-checkpoint.md` documents the path). 2. **Amortized workload (band (b), 2× gate).** `vec_amortized` — 200k push + 200k fold over the result, timed in one region. **Status 2026-05-13:** 0.42× ratio vs Go (2.4× behind) — outside the 2× band; investigation pending. 3. **Per-op fallible (band (c), advisory).** `form_vec_get` — 200k indexed reads via `vec.get(j) or raise`. **Status 2026-05-13:** 0.026× ratio vs Go (~38× behind isolated). Documented residual; advisory only at v1 — the gap is the C-function-call boundary to `lotus_vec_get` plus the fallible-ABI plumbing. Closing it requires either inlining the primitive's logic in IR at codegen time, or LTO. Both deferred until a workload measures the cost. 4. **App bench.** A representative app rewritten to use `@form(vec)` where it currently does explicit F.22 pool walks. Wall-clock and RSS compared before / after, with the form-lowered version targeted to be no worse than the F.22 baseline. Not yet wired. The microbench harness lives in the sibling `hale-lang/bench` repo; its `run.sh` resolves the `hale` binary via `$HALE_BIN` → `hale` on PATH → `../hale/target/release/hale`. Bench sources are sibling `.hl` / `.go` / `.js` / `.py` files under `micro/` and `app/`. If a bench fails its applicable band, the lowering is redesigned before further forms are added to the library. `@form(hashmap)` shipped via v1.x-FORM-4 without a parallel bench under this protocol (the band (a) win on `form_vec_push` was held to license the form-machinery extension); a `micro/form_hashmap_*` family in `hale-lang/bench` is the natural follow-up if perf becomes load-bearing for hashmap consumers. ## Anti-patterns ### Hand-rolling the contract on a form-annotated locus ```hale // WRONG — @form(vec) synthesizes push; user declaration // collides with the synthesized name. @form(vec) locus ItemList { capacity { heap items of T; } fn push(x: T) -> () { /* ... */ } // rejected } ``` The compiler rejects this at typecheck with `error[FORM-COLLIDE]: @form(vec) synthesizes `push`; user declaration shadows the synthesized method (override is deferred to v2).` ### Ignoring the fallible return ```hale // WRONG — `get` returns fallible(IndexError); the bare let // binding drops the error. let v = vec.get(i); // compile error: `vec.get` can fail (IndexError) and this call says nothing about it ``` ```hale // RIGHT — address the error with one of the three motions. let v = vec.get(i) or raise; ``` ### Treating the form annotation as syntactic sugar ```hale // WRONG — assumes @form(vec) is "just like" hand-writing the // methods over a literal F.22 heap. The lowering is different; // the storage layout is different; the perf characteristics // are different. ``` The form is a *contract*, not sugar. It commits to a specific lowering and performance shape. Code that depends on implementation details of the literal F.22 heap-slot lowering (e.g. memory addresses of individual cells across pushes) will not behave the same under `@form(vec)`. ## Open questions Spec-level questions not blocking the current `@form(vec)` contract; will be answered as workloads surface demand. 1. **Iteration surface — SHIPPED 2026-07-02.** `for x in v.items { ... }` iterates a `@form(vec)` (fully inline buf walk: len + buf loaded once, one GEP + load per element, no C calls — vectorizes for scalar cells) and `for e in m.entries { ... }` iterates a `@form(hashmap)` (cluster-aware slot-cursor walk via `lotus_hashmap_iter_next`: O(cap) for a full walk, where the index-based `key_at`/`entry_at` rescan from slot 0 per call — O(cap×len)). The loop variable is a per-iteration COPY for hashmap entries and a REFERENCE to the vec-owned cell for vec struct cells (scalars are copies by value). Mutating the form inside the body is unsupported (a grow rehashes/reallocs under the cursor). `break`/`continue` work. Ring-buffer iteration is still deferred (wrap-aware oldest-first walk). 2. **Bulk operations.** `extend(other: @form(vec))`, `clear()`, `truncate(n: Int)`. Useful but not foundational. Add after the core methods land. --- # `@form(hashmap)` A keyed associative store: each entry is a struct value `S` that carries its own key as one of its fields. The Hale analogue of `Map` / `std::unordered_map` / Go `map[K]V` — but *intrusive*: the value type S carries the key inside it rather than the map storing separate (K, V) pairs. Shipped as the second form in v1, following `@form(vec)`. ## Required capacity shape The locus MUST declare exactly one `pool` slot, with an `indexed_by ` clause naming a field of the cell type to serve as the key: ```hale type CmdEntry { name: String; handler: Int; } @form(hashmap) locus CmdRegistry { capacity { pool entries of CmdEntry indexed_by name; } } ``` Rules verified at typecheck: - Exactly one slot. Zero slots, more than one slot, or a `heap` slot is rejected. - The slot MUST be `pool`. (Hashmap recycles entry cells as inserts / removes flow — the `pool` discipline. `heap` is the growable-contiguous shape covered by `@form(vec)`.) - The slot MUST declare `indexed_by `. The named field must exist on the cell type. - The cell type MUST be a user-declared `type` struct. Primitives, enums, type aliases, and qualified paths are rejected — the substrate needs the cell's field layout to GEP the key out at insert time, which only resolves cleanly for struct cells. - The cell type MAY NOT be a locus reference. Cells are data; loci are managed entities. Storing an entity in a hashmap means the synthesized `.get(key)` materializes a stranger in the caller's scope — the same antipattern that `spec/semantics.md § Locus method dispatch` rejects at the user-declared layer. For keyed-children patterns, use the canonical alternatives: - **Parent-child**: declare `accept(c: ChildL)` on the parent locus. If name-based lookup is needed, pair with a parallel `@form(hashmap)` of cell type `type Index { key: String; child_idx: Int; }`. - **Bus topic**: publish commands keyed by name; the parent subscribes and dispatches to the right child. - **Delegation**: collapse the per-child operation onto the parent (`parent.inc_named(name)`). - The slot name is user-chosen and is not part of the contract. The compiler finds the form's pool slot by *position*, not by name. Idiomatic spellings: `entries`, `bindings`, `routes`. - `as_parent_for` on the slot is rejected — `@form(hashmap)` owns its slot's allocator, so the borrow mechanic from v1.x-4b doesn't compose. - `@form(hashmap, ...)` accepts one optional kwarg: `sync = X` (F.32-1; see "Cross-pool sync disciplines" below). All other kwargs are rejected. ## Cross-pool sync disciplines By default, `@form(hashmap)` is **single-pool only** — the runtime has no synchronization on the hashmap entry points (`lotus_hashmap_set` / `_grow` / etc), and cross-pool calls into a `@form(hashmap)` receiver whose discipline does not synchronize are typecheck-rejected (F.32-0). The opt-in path is the `sync = ` kwarg. A top-level map with no `sync =` argument gets the discipline sync inference picks from the pools each of its instances is called from (F.32-1∞), and `sync = none` keeps a map unsynchronized whatever inference would pick; see `semantics.md` § "A form's sync discipline" for the rule both follow. | Annotation | Discipline | Status | |---|---|---| | `@form(hashmap)` | inferred: none (single-pool only) unless one of its instances is called from several pools | shipped | | `@form(hashmap, sync = none)` | single-pool only; inference does not run | shipped | | `@form(hashmap, sync = serialized)` | per-map `pthread_mutex_t` (F.32-1α) | shipped | | `@form(hashmap, sync = striped)` | cell-level CAS + per-map `pthread_rwlock_t` for grow + cache-padded cells (F.32-1β2-v2) | shipped | | `@form(hashmap, sync = lockfree)` (optional `cap = N` initial-size hint) | cell-level CAS, no rwlock or mutex on the steady-state path (F.32-1γ-v1); + `remove` via tombstones (F.32-1γ-v2 session 1); + lazy grow with brief writer/reader stall during migration (F.32-1γ-v2 session 3) | shipped | **Discipline picker by workload:** - **Single-pool only** (no cross-pool calls): plain `@form(hashmap)`. Densest layout, zero sync overhead; cross-pool calls are typecheck-rejected. - **Cross-pool, write-heavy, cap unknown / dynamic**: `sync = serialized`. Per-map mutex; writers serialize but the path is short. Beats striped on 2-core / cheap-payload. **Reads are owned snapshots; replaced clones retire (2026-08-03, downstream handoff P1).** Every read path on a serialized map whose cell owns anything (`get`, `entry_at`, `for` iteration) copies it into the *caller's* arena — its String and Bytes fields, and each nested record, tuple or out-of-line array with what that owns — inside the same critical section that read the cell. (Until the map retired Bytes and nested blocks it copied String fields only; the rest was never freed, so a raw pointer to it could not dangle.) The value a reader holds is therefore a plain owned value — it stays intact regardless of later writes, and a reader never observes a writer's in-place mutations through a previously-read result. That ownership is what lets the map retire replaced clones exactly like a `sync = none` map (the anchor-retirement machinery in `memory.md`): previously a serialized map never retired, because a raw cell pointer held by an off-pool reader could outlive the writer's activation — the leak was the safety mechanism. A continuously-churned serialized map now holds steady-state RSS instead of growing per replace. `striped` and `lockfree` maps do **not** retire yet (their read paths bracket on rwlock / lf_enter rather than the map mutex, and their grow rebuilds slots concurrently — a separate audit); a churned String-bearing cell on those modes still accumulates. - **Cross-pool, read-heavy or per-op work is expensive**: `sync = striped`. Rwlock lets concurrent readers run in parallel; cache-padded cells avoid false-sharing between reader and writer cells. Slower than serialized on 2-core / cheap-payload writes (rwlock overhead > parallelism gain). - **Cross-pool, write-heavy, cap known approximately**: `sync = lockfree` (optional `cap = N`). Pure CAS on the steady-state hot path — no kernel-mediated sync. Fastest of the four on the `form_hashmap_false_sharing` bench (~1.3× faster than α serialized at 2 cores). Trade-off: when the load factor exceeds 0.6, a grow event briefly stalls all lockfree ops (~ms for typical caps) while the migration runs. Steady state outside of grow remains fully lockfree. `cap = N` is an optional initial-size hint (omitting it starts at `LOTUS_HASHMAP_INITIAL_CAP = 8` and grows on demand); supply 2-4× the expected peak so grows are rare. The `serialized` discipline wraps every public entry point in a per-map mutex. Throughput is bounded by lock contention (~5-10 M ops/s on a 4-writer workload); correctness is trivial. The map's `lotus_hashmap_t` struct grows by 12 bytes (int sync_mode + pointer mu); the pthread_mutex_t is heap-allocated at init and destroyed at dissolve. The `striped` discipline adds cache-line padding to the cell stride (rounds up to `LOTUS_CACHE_LINE`, 64B default) and uses a 3-state occupancy machine (EMPTY → CLAIMED → COMMITTED) with `__atomic_compare_exchange_n` for slot claim. A `pthread_rwlock_t` guards the grow path (set/get hold rdlock, grow holds wrlock). On the 2-core / cheap-payload bench striped measures ~1.87× slower than serialized — the rwlock overhead per op (~150 ns) exceeds α's mutex+memcpy (~90 ns) by more than the 2-core parallelism gain compensates. Striped's win materializes on 4+ cores or with heavier per-op work where the rwlock overhead amortizes. The `lockfree` discipline (F.32-1γ) drops the rwlock entirely. `cap = N` is an optional initial-size hint (was required pre-γ-v2 session 3 before grow shipped; now grows transparently when load factor crosses 0.6). Under γ-v1 `remove` was a no-op; under γ-v2 session 1 `remove` is supported via tombstones (4-state cell machine: EMPTY → CLAIMED → COMMITTED → TOMBSTONE). Pure CAS on the occupancy byte; no kernel-mediated synchronization on the hot path. The `form_hashmap_false_sharing` bench measures lockfree at ~1.30× faster than serialized and ~2.54× faster than striped on the 2-pool concurrent-write workload. Cap should be sized to 2-4× the peak expected entry count to keep linear-probe latency bounded and avoid early grows; the runtime rounds the user's `cap = N` up to the next power of 2 (needed for the `& mask` probe). Omitting `cap` starts at `LOTUS_HASHMAP_INITIAL_CAP = 8` and grows on demand. Tombstones in γ-v2 session 1 are *not* reclaimed in place — the probe advances past them, but inserts always land in the next EMPTY slot rather than reusing a TOMBSTONE. Session 3's grow path is where tombstones get compacted out: when the load factor (`live + tombstones / cap`) crosses 0.6, the table is rebuilt at double size and the new table omits tombstones entirely. Consistency on remove follows the lockfree model: a reader that observed COMMITTED before a concurrent CAS to TOMBSTONE returns the (now-stale) value — "the key was present at the moment we read." Grow under γ-v2 session 3 uses a simpler design than the full NBHM cooperative-helper migration: one writer wins a grow_phase CAS, spin-waits for in-flight ops to drain via a writers_in_flight counter, then runs the migration single- threaded (no SENTINEL state, no cooperative helping). All concurrent set/get/remove ops yield-spin during the migration window. The hot-path cost in steady state is one atomic load + branch-not-taken — measurably cheaper than the NBHM-style 5-state CAS-on-every-probe. The trade-off is tail latency on the writer that triggers grow (bounded by the migration's O(cap) walk, ~ms for caps up to ~100k) and brief stalls on all concurrent ops during that window. The OLD slots buffer is freed eagerly at the end of the migration (γ-v2 session 4). The drain-wait already guarantees no in-flight op holds a stale pointer to OLD when grow completes, so the use-after-free risk that the handoff doc's QSBR design was meant to solve doesn't exist in this single- grower variant — QSBR epoch tracking would be redundant. RSS post-warmup is bounded by the current table size plus the brief migration-window peak (OLD + NEW both alive for the ~ms duration of `lf_migrate`). Cross-pool method calls into a `@form(hashmap, sync = ...)` receiver whose discipline is `serialized`, `striped` or `lockfree`, written or inferred, are accepted without diagnostic — the discipline carries the substrate's safety contract; one into a `sync = none` receiver is rejected. Inside a single pool, all three sync modes pay only their respective uncontended-fastpath costs (~30 ns for serialized, ~10 ns for lockfree's CAS). See `notes/f32-cache-aware-delivery-plan.md` § F.32-1 for the per-discipline implementation strategy + trade-off analysis, and `spec/types.md` § "Single-threaded-method invariant (F.31)" for how cross-pool calls into form-bearing receivers interact with the placement system. The key type `K` is derived from the resolved type of the indexed-by field. At v1, K must be `Int` or `String`. Other field types parse and synthesize methods but reject at codegen with a focused diagnostic (the runtime ABI's `key_type_tag` only enumerates these two). ## Synthesized methods ``` fn get(key: K) -> S fallible(KeyError) fn set(value: S) -> () # infallible fn has(key: K) -> Bool # infallible fn remove(key: K) -> () fallible(KeyError) fn len() -> Int # infallible fn is_empty() -> Bool # infallible fn key_at(i: Int) -> K fallible(IndexError) fn entry_at(i: Int) -> S fallible(IndexError) fn bump(key: K) -> () # infallible; S must be {key + Int counter} ``` The fallible methods return the synthesized `KeyError` payload: ```hale type KeyError { kind: String; # "missing_key" — only kind at v1 } ``` `KeyError` is injected into the bundle scope by the form machinery alongside `IndexError`. The same type is shared across all `@form(hashmap)` instantiations. The key is not carried on the error because K varies per hashmap. Users who want key context construct it through the substitute motion (`or fallback(err)`), where `err: KeyError` is in scope and any of the call's local bindings — including the key arg — are available. ### `set` ``` fn set(value: S) -> () ``` Inserts or replaces. `set(v)` GEPs the indexed-by field from `v` to derive the key, then writes the whole struct at the hashed slot. If a previous entry shared the key, it is overwritten (`set` is unconditional — no error on duplicate). `set` is **infallible**. OOM during the doubling realloc is a substrate-level concern routed through the closure-violation channel, not a `fallible(...)` return. Same shape as `@form(vec)`'s `push`. ### `get` ``` fn get(key: K) -> S fallible(KeyError) ``` Returns the entry whose indexed-by field equals `key`. If no such entry exists, fails with `KeyError { kind: "missing_key" }`. ```hale let entry = registry.get(name) or raise; let entry = registry.get(name) or default; let entry = registry.get(name) or fallback(err); ``` ### `has` ``` fn has(key: K) -> Bool ``` `true` iff an entry with this key is present. Equivalent to "`get(key)` would succeed" but cheaper — no value copy. ### `remove` ``` fn remove(key: K) -> () fallible(KeyError) ``` Removes the entry whose indexed-by field equals `key`. If no such entry exists, fails with `KeyError { kind: "missing_key" }`. Idiomatic call shape: ```hale registry.remove(name) or raise; # bubble on missing registry.remove(name) or ignore(err); # swallow via Unit-returning handler ``` Hale doesn't surface `()` as a literal expression at v1, so swallowing the error requires a Unit-returning handler call (or guarding with `has` first): ```hale fn ignore(_e: KeyError) { } // later: registry.remove(name) or ignore(err); ``` `remove` does not shrink the underlying buffer; capacity does not decrease. Buffer release happens at locus dissolution. ### `len` and `is_empty` ``` fn len() -> Int fn is_empty() -> Bool ``` `len()` returns the entry count; `is_empty()` is sugar for `len() == 0`. Both infallible, O(1). ### `key_at` and `entry_at` ``` fn key_at(i: Int) -> K fallible(IndexError) fn entry_at(i: Int) -> S fallible(IndexError) ``` Hash-table-order iteration (added 2026-05-16). `key_at(i)` returns the i-th present key; `entry_at(i)` returns the i-th present full entry value. Order is hash-table order (deterministic for a given table state, but insertion-sensitive — adding entries between iterations may rearrange the sequence as the table rehashes). For a populate-then-iterate pattern the snapshot order is reproducible. `IndexError` payload matches the `@form(vec).get` shape (`kind: String`, `index: Int`, `len: Int`). `out_of_bounds` is the only `kind` produced; `index < 0` or `index >= len()` triggers it. Per-call cost is O(cap) — the substrate walks the slots array counting occupied entries until reaching the i-th. A full sweep is O(cap²). Fine at small/medium scale; agents iterating 100k+-entry tables should populate a parallel `@form(vec)` during inserts instead. Closes the wordfreq-corpus reinvention pattern where every program maintained a parallel keys vec to dodge the lack of iteration. ### `bump` ``` fn bump(key: K) -> () ``` Increment-or-init the Int counter field of the entry keyed by `key`. Collapses the canonical 6-line pattern: ```hale if m.has(k) { let prev = m.get(k) or raise; m.set(Entry { key: k, count: prev.count + 1 }); } else { m.set(Entry { key: k, count: 1 }); } ``` into: ```hale m.bump(k); ``` **Cell-shape requirement.** The entry type `S` MUST have exactly two fields: the `indexed_by` key field plus one `Int` field (the counter). Any other shape (zero Int fields, two Int fields, or extra non-Int fields) is a codegen error pointing at the manual pattern. The Int field's name is detected at codegen — "count", "n", "freq", "hits", etc. all work. `bump` is infallible. Generalizing to arbitrary update fns (`update_with(k, init, fn(S) -> S)`) is the natural follow-up once a use case surfaces. ## Lowering strategy `@form(hashmap)` lowers the pool slot to an inline six-field C struct holding open-addressing hashtable state: ```c typedef struct { size_t cap; // power-of-two slot count size_t len; // live entry count size_t key_size; // sizeof(K), set at init size_t value_size; // sizeof(S), set at init int key_type_tag; // 0 = Int, 1 = String char *slots; // cap * (1 + key_size + value_size) bytes } lotus_hashmap_t; ``` Each slot is `1 + key_size + value_size` bytes laid out as `[occupied: u8][key: K][value: S]`. `occupied = 0` means empty; the runtime uses **backward-shift deletion** (no tombstones) so probes terminate as soon as an empty slot is seen. - **Initial cap:** 8 slots, allocated at locus birth via `lotus_hashmap_init`. Power of two so hash → index folds to a single `& mask`. - **Growth policy:** double `cap` when `(len + 1) > 0.7 * cap`. Rehash every live entry through the normal `set` path (the probe sequence changes with the new mask). - **Shrink policy:** none. Capacity is monotonic in v1. - **Hash functions:** 64-bit Knuth multiplicative for Int keys (`k * 0x9E3779B97F4A7C15`), FNV-1a over the bytes for String keys. - **Probing:** linear with `& mask`. Backward-shift deletion walks the cluster forward, shifting any entry whose natural position is "before" the freed slot. Cluster boundary is the first empty slot. ### Key extraction at the codegen surface At each `set(value: S)` call site, codegen GEPs the indexed-by field offset on the value alloca to produce a pointer to the key, then passes `(slot_ptr, key_ptr, value_ptr)` to `lotus_hashmap_set`. The runtime memcpys `key_size` bytes from `key_ptr` into the slot's key region and `value_size` bytes from `value_ptr` into the value region. At `get`, `has`, `remove` sites, codegen lowers the key arg into an alloca matching `key_size` and passes its address. ## Arena ownership `@form(hashmap)` is **not** a separate arena-allocated structure. The `lotus_hashmap_t` struct lives inline in the locus's struct layout, the same way the literal F.22 pool-slot declaration would. The `slots` buffer is malloc'd from the *system allocator*, not from the locus's arena — matching the existing F.22 slot contract. Dissolution: when the locus arena is destroyed, the hashmap's `slots` buffer is freed via `lotus_hashmap_destroy` in the F.22 dissolve cascade. For elements of pointer-shaped types (`String`, `Bytes`) in the cell struct, the hashmap stores the pointer by value; the pointed-to bytes live in whatever arena they were allocated from. Hashmap dissolution frees the slots buffer but does not free the pointed-to bytes — those follow their owning arena's lifetime per the standard F.22 contract. ### Cell reclamation `set` stores the cell's own copy of everything the value points at (its String and Bytes fields, and each nested record, tuple or fixed array with what that owns — a record's field of scalars is inline and part of the cell itself, while an array inside a tuple or another array is a block of its own, as for `@form(vec)`), in the map locus's arena. When a `sync = none` or `sync = serialized` map replaces a cell (`set` of a present key) or removes one, everything the old cell owned is retired, at any depth, through the retire descriptor `@form(vec)` uses; a block the new cell still reaches survives, and a block reached twice is retired once. Retirement is deferred to the owner's activation boundary (anchor retirement, `memory.md`), so a value a plain `get` handed out stays whole for the rest of the activation; a serialized map's reads are owned copies (above). A churned map's arena therefore stays flat. Before, only top-level String fields retired: a replaced cell's Bytes and its nested records (their blocks and their own String and Bytes) stayed for good — a cell holding a nested record carrying a String and Bytes grew the arena from 64 KiB to 10.7 MiB over 100,000 sets. A nested block in the cell belongs to that cell alone. A value that carries a nested block from the same key's current cell, at the same field (`let e = m.get(k); m.set(Cell { k: k, big: e.big, ... })`), keeps it, so a get-modify-set allocates nothing for it; any other nested block — one read from another key's cell, one shared with a field — is copied. (Before, a nested block already in the map's arena was stored as-is whatever held it, so two cells could share one; and a cell whose only heap part was a nested record of scalars stored the pointer it was handed, into the setting method's scratch, which a later `get` read after the method had returned.) `striped` and `lockfree` maps retire nothing yet (see the discipline table above). ## Complexity | Operation | Expected | Worst case | |---|---|---| | `set` (no resize) | O(1) | O(N) on probe cluster | | `set` (with resize) | O(N) amortized over inserts | O(N) per resize | | `get` / `has` | O(1) expected | O(N) on probe cluster | | `remove` | O(1) expected | O(N) on shift | | `len` / `is_empty` | O(1) | O(1) | Load factor stays ≤ 0.7 by construction. Hash quality for Int keys (Knuth multiplicative) handles dense sequences such as consecutive IDs without all colliding on slot 0. ## Interaction with the locus tower A `@form(hashmap)` locus is a locus in every other respect — it can have `params`, lifecycle bodies (`birth` / `run` / `drain` / `dissolve`), `closure` invariants, `on_failure` routing, bus membership, and projection by `perspective` declarations. The form annotation *replaces* the literal F.22 pool-slot lowering and synthesizes the six methods; it does not replace any other locus mechanic. ## Bench protocol (future FORM-N gate) `@form(hashmap)` gets a bench family parallel to `@form(vec)`'s three bands once `hale-lang/bench`'s `micro/form_hashmap_*` is wired up: 1. **Tight-loop primitive (band (a)).** A `form_hashmap_set` microbench — 200k `set` calls on a hashmap of struct cells, compared against an equivalent hand-written C program using `malloc` + open-addressing tables of the same shape. Target: 10% of the C baseline. Expected to track `form_vec_push`'s shape closely since the subregion-elision work applies equally. 2. **Per-op fallible (band (c)).** A `form_hashmap_get` microbench — 200k `get(k) or raise` calls. Advisory; the same C-function-call-boundary residual `form_vec_get` shows will apply here. Closing it is the same work item. 3. **App bench.** A representative app rewritten to use `@form(hashmap)` where it currently does explicit registry walks; before / after comparison. Not gated on FORM-4 shipping (FORM-4 was held to the band (a) `form_vec_push` win for license); ships as a separate milestone after a hashmap consumer surfaces concrete demand. ## Anti-patterns ### Treating `set` as keyed insert ```hale // WRONG — set takes the whole value, not (key, value). registry.set("foo", entry); // type error: too many args ``` ```hale // RIGHT — value carries its key as a field; substrate extracts. registry.set(CmdEntry { name: "foo", handler: 1 }); ``` The intrusive shape means the type system catches this for you (`set` is synthesized with the single-arg signature `set(value: S) -> ()`), but the conceptual reflex from `HashMap` shaped languages is worth flagging. ### Ignoring the fallible return ```hale // WRONG — get and remove return fallible(KeyError). let v = registry.get(name); // compile error: the call says nothing about its failure registry.remove(name); // compile error: the call says nothing about its failure ``` ```hale // RIGHT — address the error via one of the three motions. let v = registry.get(name) or raise; registry.remove(name) or (); ``` ### Mutating the indexed-by field after `set` The intrusive shape means the key is the field. If user code keeps a reference to the value and mutates the indexed-by field, the hashmap's invariant breaks (the cell sits in the slot keyed by its *old* key, but `get` now looks up by its *new* key). The v1 surface doesn't expose stored cells by reference, so this isn't reachable from user code today. Future iteration APIs that surface entry references will need to gate against indexed-by-field mutation. > Forward-looking / deferred items for this area now live in the > decision log — see [`decisions.md` § Deferred & future > work](./decisions.md#deferred--future-work). ## Required capacity shape The locus MUST declare exactly one `pool` slot. The cell type is the element type `T`; the capacity comes from the annotation arg `cap = N`. ```hale @form(ring_buffer, cap = 64) locus RecentCmds { capacity { pool history of CmdEntry; } } ``` Rules verified at typecheck: - Exactly one slot. Zero slots, more than one slot, or a `heap` slot is rejected. (`heap` is the growable-contiguous shape covered by `@form(vec)`; ring buffer recycles fixed-capacity cells, which is the `pool` discipline.) - The slot MUST NOT declare `as_parent_for` or `indexed_by` — those clauses belong to other forms. - `@form(ring_buffer, cap = N)` requires `cap`, must be a positive integer literal. v1 doesn't const-evaluate expressions for form args. - The cell type T may be a primitive, a user-defined `type`, or a generic parameter. It MAY NOT be a locus reference — same restriction as the other forms. ## Synthesized methods ``` fn push(x: T) -> Bool # false when full fn pop() -> T fallible(EmptyError) fn len() -> Int # infallible fn is_full() -> Bool # infallible ``` The fallible `pop` returns the synthesized `EmptyError` payload: ```hale type EmptyError { kind: String; # "empty" — only kind at v1 } ``` `EmptyError` is injected alongside `IndexError` and `KeyError` by the form machinery; user-declared `EmptyError` wins per the existing idempotent-injection contract. ### `push` ``` fn push(x: T) -> Bool ``` Appends `x` after the last element. Returns `true` on success, `false` when the buffer is at capacity. Callers decide drop-vs-backpressure semantics by inspecting the result: ```hale let accepted = recent.push(entry); if !accepted { // backpressure: surface to caller, or drop, or evict-oldest // via pop()+push() in a separate path. } ``` `push` is intentionally Bool-returning rather than `fallible(FullError)`. The full-buffer state is a normal operational condition (the caller chose a bounded capacity), not a substrate failure — surfacing it as a Bool keeps the call shape ergonomic and avoids forcing every caller through `or`. This is the one place in the form library where infallible-but- returning-a-status is the right idiom; vec's `push` is truly infallible (OOM routes through the closure-violation channel), and hashmap's `set` is unconditional (replace-on-collision). The ring buffer's fixed cap makes "refused" a user-observable state. ### `pop` ``` fn pop() -> T fallible(EmptyError) ``` Removes and returns the oldest element (FIFO — the one inserted earliest among those still present). Fails with `EmptyError { kind: "empty" }` when the buffer is empty. ```hale let cmd = recent.pop() or raise; # bubble on empty let cmd = recent.pop() or default_cmd; # substitute let cmd = recent.pop() or fallback(err); ``` ### `len` and `is_full` ``` fn len() -> Int fn is_full() -> Bool ``` `len()` is the current element count, in `0..=cap`. `is_full()` is sugar for `len() == cap`. Both infallible, O(1). There is intentionally no `is_empty()` synthesized method on `@form(ring_buffer)` — `pop` is the natural empty-detection surface (the fallible return signals empty directly to the caller addressing the error). Adding `is_empty()` would create two redundant ways to ask the same question; defer until a workload demonstrates a real need. ## Lowering strategy `@form(ring_buffer)` lowers the pool slot to an inline five-field C struct holding head/tail and a pre-allocated backing buffer: ```c typedef struct { size_t cap; // fixed at init; never changes size_t head; // index of oldest element (next pop) size_t len; // current count, 0..=cap size_t elem_size; // bytes per element char *buf; // cap * elem_size bytes } lotus_ring_buffer_t; ``` - **Birth.** `lotus_ring_buffer_init` mallocs `cap * elem_size` bytes and pins them for the locus's lifetime. The init takes `cap` and `elem_size` as args; `cap` flows in from the form annotation, `elem_size` from the cell type's LLVM `size_of`. - **Push.** `lotus_ring_buffer_push` checks `len == cap`; if so returns 0. Otherwise computes the wrap index as `(head + len) % cap`, memcpys `elem_size` bytes from the caller-provided source into the slot, increments `len`, returns 1. - **Pop.** `lotus_ring_buffer_pop` checks `len == 0`; if so returns 0. Otherwise memcpys from `buf + head * elem_size` into the out-pointer, advances `head` modulo cap, decrements `len`, returns 1. - **No growth.** Once init runs, the backing buffer is fixed size. Push at capacity refuses; the spec contract is "fixed capacity" not "grows on demand." - **Dissolution.** `lotus_ring_buffer_destroy` `free`s the backing buffer at locus arena destroy. ## Arena ownership Same as `@form(vec)` and `@form(hashmap)`: the `lotus_ring_buffer_t` struct lives inline in the locus struct layout; the backing `buf` is malloc'd from the *system allocator*, not from the locus's arena. Dissolution frees `buf` via the F.22 dissolve cascade. For pointer-shaped element types (`String`, `Bytes`), the ring buffer stores the pointer by value; the pointed-to bytes live in their owning arena per the standard F.22 contract. ## Complexity | Operation | Cost | |---|---| | `push` (not full) | O(1) | | `push` (full → refuse) | O(1) | | `pop` (not empty) | O(1) | | `pop` (empty → fail) | O(1) | | `len` / `is_full` | O(1) | All operations are constant-time and allocation-free after locus birth. No realloc, no rehash, no compaction. ## Interaction with the locus tower A `@form(ring_buffer)` locus is a locus in every other respect. Same orthogonality as the other forms — `params`, lifecycle bodies, closure invariants, `on_failure` routing, bus membership, perspective projection all compose unchanged. ## Anti-patterns ### Forgetting to address `pop`'s fallible ```hale // WRONG — pop returns fallible(EmptyError); the bare let drops it. let v = recent.pop(); // compile error: the call says nothing about its failure ``` ```hale // RIGHT — address with one of the three motions. let v = recent.pop() or raise; ``` ### Treating `push` as fallible ```hale // WRONG — push returns Bool, not fallible(FullError). let v = recent.push(x) or raise; // type error: push isn't fallible ``` The full-buffer state surfaces as a Bool return, not a fallible payload. Inspect the Bool directly. ### Resizing expectations The ring buffer's cap is fixed at the annotation site. There is no `grow` or `shrink_to_fit`. Apps that need a growable bounded buffer should pick a generous cap up front, or use `@form(vec)` if growth is the right semantic. > Forward-looking / deferred items for this area now live in the > decision log — see [`decisions.md` § Deferred & future > work](./decisions.md#deferred--future-work). ## Required capacity shape The locus MUST declare exactly one `pool` slot with an `indexed_by ` clause over a user-declared struct cell — the same key surface as `@form(hashmap)` — AND the annotation arg `cap = N`. `lru_cache` is the one form that needs BOTH a key and a cap. ```hale type SessionEntry { id: Int; token: String; } @form(lru_cache, cap = 1000) locus SessionCache { capacity { pool sessions of SessionEntry indexed_by id; } } ``` Rules verified at typecheck: - Exactly one slot, of kind `pool`. Zero slots, more than one slot, or a `heap` slot is rejected. - The slot MUST declare `indexed_by `; the named field must exist on the cell struct and becomes the cache key type `K` (Int or String at v1). The cell struct is the value type `S`. - `cap = N` is required and must be a positive integer literal. v1 doesn't const-evaluate expressions for form args. - The slot MUST NOT declare `as_parent_for` — form-lowered slots own their own allocator. - The cell type MAY NOT be a locus reference — same restriction as the other forms. ## Synthesized methods ``` fn put(x: S) -> () # infallible; silent LRU evict fn get(k: K) -> S fallible(KeyError) # lookup + recency touch fn contains(k: K) -> Bool # membership, NO recency touch fn len() -> Int # infallible; count <= cap ``` `get`'s miss payload is the synthesized `KeyError` (shared with `@form(hashmap)`); no new payload type is introduced. ### `put` ``` fn put(x: S) -> () ``` Insert or update by the `indexed_by` key extracted from `x`. If the key is already present, its value is overwritten and its recency is refreshed. If the key is new and the cache is at capacity, the least-recently-used entry is silently evicted first. `put` is **infallible** — over-cap is a normal, silent operation (eviction), not a failure. This matches the "never flagged / bounded" contract: there is no `FullError`, unlike `@form(ring_buffer).push` which returns a Bool. Both insert and update mark the touched entry as recently used. ### `get` ``` fn get(k: K) -> S fallible(KeyError) ``` Looks up the value for key `k`. On a hit it returns the value **and** marks the entry as recently used (a recency touch) — this is what makes the policy LRU rather than FIFO: a `get` on an entry saves it from an eviction that a purely-oldest-inserted policy would take. On a miss it raises `KeyError`, addressed at the call site's `or` clause. ### `contains` ``` fn contains(k: K) -> Bool ``` Membership test. Returns `true` if `k` is present. Unlike `get`, `contains` does **NOT** touch recency — a `contains` on an entry leaves it exactly as recently-used as it was. This distinction is observable: after `contains(k)`, the entry `k` remains eligible for eviction if it was the LRU entry. ### `len` ``` fn len() -> Int ``` Current live entry count, always `<= cap`. Infallible. ## Lowering strategy `@form(lru_cache)` lowers the pool slot to an inline header struct plus a single heap-allocated open-addressed table, managed by the `lotus_lru_*` C runtime: ``` struct lotus_lru_t { size_t cap; // fixed live-entry cap (never grows) size_t len; // current live entries (<= cap) size_t key_size; size_t value_size; int key_type_tag; // Int / String key ABI (shared w/ hashmap) uint64_t tick; // monotonic access counter size_t table_cap; // power-of-two slot count (>= 2*cap) char *slots; // table_cap * (occupied + tick + key + value) } ``` It deliberately does **not** reuse the `lotus_hashmap_*` family: that family auto-grows and has no recency notion, both of which break the cap invariant. The table is sized to the next power of two `>= 2*cap` (load factor `<= 0.5`, so a probe always meets an empty terminator). Each slot carries an access tick; eviction removes the occupied slot with the minimum tick (the LRU entry) via backward-shift compaction (no tombstones), keeping the table reusable under unbounded insert/evict churn. Ticks are globally unique per access, so the LRU entry is unambiguous. The backing table is pre-allocated at locus birth (`lotus_lru_init`) and freed at dissolve (`lotus_lru_free`); the inline header lives in the locus struct and dies with the arena — the same inline-header / heap-buffer split as `@form(vec)`, `@form(hashmap)`, and `@form(ring_buffer)`. > Forward-looking / deferred items for this area now live in the > decision log — see [`decisions.md` § Deferred & future > work](./decisions.md#deferred--future-work). ================================================================================ ## 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`. ================================================================================ ## Operator precedence and associativity ## source: spec/precedence.md ================================================================================ # Operator precedence and associativity Operators are listed from **highest** precedence (binds tightest) to **lowest** (binds loosest). Operators on the same row have the same precedence; their associativity is given in the right column. | Level | Operators | Associativity | Notes | |-------|-----------|---------------|-------| | 14 | `()` `[]` `.` `::` | left | Call, index, field access, path | | 13 | unary `-` `!` `~` | right | Unary minus, logical not, bitwise not | | 12 | `*` `/` `%` | left | Multiplicative | | 11 | `+` `-` | left | Additive | | 10 | `<<` `>>` | left | Bit shifts | | 9 | `&` | left | Bitwise and | | 8 | `^` | left | Bitwise xor | | 7 | `\|` | left | Bitwise or | | 6 | `<` `>` `<=` `>=` | non-assoc | Ordering comparison | | 5 | `==` `!=` | non-assoc | Equality | | 4 | `~~` | non-assoc | Approximate equality (closure context only) | | 3 | `&&` | left | Logical and | | 2 | `\|\|` | left | Logical or | | 1 | `..` `..=` | non-assoc | Range (parsed everywhere; *use* restricted to `for x in lo..hi` at typecheck) | | 1 | `or` | right | Fallible disposition (v1.x-FORM-1; contextual). RHS is `raise` / `discard` / `fail ` / another expression. | | 0 | `=` `+=` `-=` `*=` `/=` `%=` `&=` `\|=` `^=` | right | Assignment | | -1 | `<-` | non-assoc | Bus send (statement-shape only) | ## Notes ### `&&` and `||` short-circuit Both operands are `Bool`. They are evaluated left to right, and the right operand is evaluated only when the left does not already decide the result: `a && b` evaluates `b` only when `a` is `true`, and `a || b` only when `a` is `false`. A call, a division or a slice on the right therefore does not run when the left rules it out (`d != 0 && n / d > 1` never divides by zero). Chains follow precedence, `&&` binding tighter than `||`: `a || b && c` is `a || (b && c)`, and `b && c` is not evaluated when `a` is `true` (GH #643). ### Comparison and equality are non-associative `a < b < c` is a parse error. Use `a < b && b < c`. This avoids the chained-comparison surprises common in C and matches Rust's choice. ### Approximate equality binds at level 4 The `~~` operator is permitted **only inside a `closure` block's assertion clause**. Elsewhere it is a parse error. It is non-associative; `a ~~ b ~~ c` is invalid. A closure assertion has the form: ``` expression ~~ expression within expression ``` The `within` clause is part of the assertion syntax, not an operator at this precedence level. See `grammar.ebnf` for the production. ### Generic arguments shadow comparison The lexer emits the same tokens for `<` `>` regardless of context. The parser disambiguates: when a `<` follows an identifier in a type-expression context (after `:`, `->`, in a generic-args position), it begins a generic-args list. Otherwise it is a comparison. This is the same approach Rust takes (with the famous turbofish disambiguator `::<>` reserved for ambiguous expression contexts). v0 does not provide turbofish; we'll add it if needed. ### Member access vs. path - `.` accesses a field or method of a runtime value: `foo.bar`, `book.bid_side()`. - `::` accesses a name through a path: module, type, perspective: `messages::Book`, `Strategy::default()`. ### Fallible disposition (`or`) binds looser than everything except assignment The `or` keyword is the fallible-disposition postfix (v1.x- FORM-1). It is **contextual** — recognized only when it appears as a postfix on an expression that the typechecker has marked `Ty::Fallible`. Outside that position, `or` is an ordinary identifier (so `let or = 5;` stays admissible). It is **right-associative** so a chain reduces step by step: ``` a() or b() or raise // parses as: a() or (b() or raise) ``` The RHS of `or` is one of: - the contextual keyword `raise` (diverges by re-entering the enclosing `fallible(E)` fn's error-return path — the value-error channel, orthogonal to closure tests; past every fallible frame it root-panics via `lotus_root_panic`), or - `discard` (swallow the error, substitute Unit; rejected at typecheck when the success type is non-Unit), or - `fail ` (diverge like `raise`, but with a fresh payload of the enclosing fallible fn's declared error type), or - any expression of the success type (the substitute path; `err` is implicitly bound to the payload in this scope). ### Bus send is a statement, not an expression `<-` does not nest in expressions. It appears only at statement position: ``` "subject" <- value; ``` The left side is a string-literal subject (or an identifier bound to a publish handle in a future revision); the right side is any expression. The construct produces no value — there is no `x = ("s" <- v)`. The level −1 row in the table is bookkeeping: `<-` binds looser than every expression operator, so the parser recognizes the leading expression in full before deciding the statement is a send. ### Recovery primitives are statements, not operators `restart`, `quarantine`, `bubble`, `violate`, etc. are statement-level keywords; they do not participate in the precedence table. They appear in the form: ``` restart(child); restart(child) for 3; quarantine(child); bubble(err); violate fatal_io; // F.27, v1.x-VIOLATE violate fatal_io with detail; // optional payload ``` `violate` is divergent (same type-level shape as `fail` / `bubble`); the typechecker treats it as `Never`. Its closure- name argument is a bare identifier (not a parenthesized argument list), and the optional `with ` trailer carries a user-shaped payload onto the synthesized `ClosureViolation`. ### Lifecycle and locus member declarations are not expressions `birth`, `accept`, `run`, `drain`, `dissolve`, `on_failure`, `mode`, `closure`, `contract`, `params`, `bus` introduce declaration-level constructs inside a locus body and never appear in expression position. ## Examples ``` a + b * c // a + (b * c) -- level 12 binds tighter than 11 a == b && c == d // (a == b) && (c == d) -- level 5 over level 3 a < b == c // parse error: < is non-assoc with == -a.field // -(a.field) -- access binds tighter than unary minus foo(x) // generic instantiation, not (foo < T) > (x) ``` ## Range and reserved precedence levels - `..` / `..=` (range) — level 1, non-assoc. These **parse in any expression position**; v1 restricts their *use* to `for x in lo..hi`, so a range elsewhere is a typecheck error, not a parse error. `..` is exclusive, `..=` inclusive. Future operators expected to land at specific levels: - `??` (nil-coalesce) — level 2, right-assoc `??` is a reserved token; using it in v0 is a parse error. Note: `?` (try-propagation) was previously reserved at level 13 but has been **cut** from the roadmap. The fallible- disposition operator `or` covers the same use case at the expression level without re-introducing a parallel upward-propagation mechanism at the value layer. See `spec/design-rationale.md`. ================================================================================ ## Projects ## source: spec/projects.md ================================================================================ # Projects A Hale project is a directory tree of `.hl` source files plus anything those files vendor or reference. This document covers the on-disk shape: how the directory tree composes into one or more compiled binaries, how files within a directory share scope, and how a project reaches into vendored libraries. Two language-level commitments drive the shape: - **F.19 — per-directory seed model.** Every `.hl` file in one directory compiles as one seed; all top-level decls share scope. - **F.25 — cross-seed imports.** A library is a directory (or single file) of `.hl` source; the importer names a namespace alias and the resolver finds the source by path. The recursion is in the import graph, not the directory tree. The file system is presentation — convenient grouping of bits that compose into a logical structure at parse time. Two projects with identical import graphs can ship totally different on-disk layouts; the locus shape lives in the code. See `spec/decisions.md` F.19 and F.25 for the design rationale. ## Project shapes Three shapes are idiomatic at v1; pick the smallest that fits. ### Single-file script For one-off programs and tiny utilities. One file, no directory: ``` script.hl ``` Build: `hale build script.hl` → `./script` binary. Imports work — `import "../shared/foo" as foo;` resolves relative to `script.hl`'s directory — but most scripts have no imports. ### Single-app project For an app that's developed and shipped as a unit: ``` myapp/ # the project — one seed ├── main.hl # AppL declaration + fn main() ├── .hl # sibling concerns (F.19; same seed) ├── ... ├── lib/ # vendored sub-loci (F.25) │ ├── moa/ │ └── / ├── README.md # what the app does └── FRICTION.md # per-app friction log ``` Build: `hale build myapp/` → `myapp/myapp` binary (next to source; directory's basename becomes the binary name). `hale build myapp/ -o ` writes the binary at exactly `` instead — its directories are made, no extension is added, and nothing is written beside the source; a wasm build puts its `.mjs` loader next to the `.wasm` at ``. `-o` belongs to `build` alone: `run` and `replay` execute their binary and refuse it. The center of the project is one named locus declared in `main.hl` (per the apps-are-loci rule in `spec/styleguide.md`). Sibling `.hl` files decompose by concern; they share top-level scope under F.19. Vendored libraries live under `lib/` (by convention — see "Disk is presentation" below). There is no `src/` wrapper, no project metadata file, no build-output directory. The directory IS the package; the binary lands next to the source. ### Workspace (multi-binary) For multiple related apps that share vendored libraries: ``` project/ # workspace root ├── apps/ │ ├── fitter/main.hl # one app, one seed │ └── applier/main.hl # another app, another seed └── lib/ # workspace-level shared libs ├── moa/ └── shared/ ``` Each app under `apps/` is its own seed; `hale build apps/fitter` produces `apps/fitter/fitter`. Imports written inside an app like `import "lib/shared" as shared;` resolve via F.25's workspace-root fallback — the entry-relative search misses (no `apps/fitter/lib/`), and the resolver walks upward to find a `Cargo.toml` and tries `/lib/shared/`. The workspace root is identified by walking upward from the entry source looking for `Cargo.toml`. A future milestone may add an `.hale-workspace` sentinel for non-Cargo trees; until then, projects shipped outside this monorepo can still use entry-relative imports but lose the workspace fallback. ## Seeds — the per-directory model (F.19) **A directory of `.hl` files compiles as one seed.** Every top-level decl (locus, type, free fn, perspective, const, interface) declared in any file in the directory is visible to every other file in the same directory, in one shared scope. `hale build `, `hale run `, and `hale check ` accept directory targets and bundle every `.hl` file under them. `hale build ` keeps working for single-file targets (scripts, one-off cases). **File order in the merged bundle is alphabetical by filename** (deterministic). Resolution within the seed is **order-free** — the typechecker flattens all top-level decls into one bundle scope before name lookup, so a fn declared in `z.hl` is callable from `a.hl` without ceremony. **No per-file visibility.** There is no `pub`, no Go-style uppercase-exported convention. Anything declared at the top level of any file is visible to every other file in the seed. Decompose by *concern* (one file per concern, helpers grouped with their callers); don't try to encode visibility through file boundaries. **No subdirectories.** A subdirectory inside a seed is NOT part of the seed — it's a separate seed. To reach into it, import it as a library. This keeps "what's in scope at a given file" answerable by reading the seed's directory; subdirs would either silently inject decls (confusing) or require their own import mechanism (which is what cross-seed imports are for). ## Cross-seed imports (F.25) Cross-seed imports are how one seed reaches into another. The imported seed's decls become available under a user-chosen alias. ### Syntax ``` import "" as ; ``` - **``** is a string literal naming the library to import. The path is resolved per "Resolution order" below. - **``** is an identifier naming the namespace at the import site. It is **required** — bare `import "";` is a parse error. Cross-seed references in the importing seed read as `::Name`. The alias-required rule is the same forcing-function discipline v1.x-3 enforces for `: projection recognition` (no default sub-mode) and v1.x-FORM-2 enforces for the two-channel rule (substrate-facing surfaces — lifecycle, mode, closure assertions, bus handlers — can't declare `fallible(E)`; user-declared `fn` members and free fns can): the user names the commitment at the surface so a downstream reader doesn't have to reconstruct the namespace from the path. Imports appear at the top of a file, before any top-level declaration. Multi-file seeds may declare imports in any file; the build merges every file's imports into one set against the seed's directory + workspace root. ### Examples ```hale import "lib/finance" as fin; import "../shared-helpers" as helpers; fn main() { let q = fin::Quote { symbol: "ABC", price: 10 }; let h = helpers::Formatter { }; } ``` ### Resolution order The compiler tries three locations in order; the first hit wins: 1. **`/.hl`** — single-file library. 2. **`//`** — directory bundle. Every `.hl` file in the directory is one library seed (per F.19's per-directory model). File order in the merged bundle is alphabetical; resolution within the seed is order-free. 3. **`//`** — workspace fallback. The workspace root is the first directory found by walking upward from the importer that contains a `Cargo.toml`. If none of the three locations resolve, the build fails with a **located** diagnostic at the import's path literal — the file, line and column of the `import` that names nothing, with the three search paths as the message body (2026-09-20, GH #860). It is a finding about the program like any other, so every channel carries it: a record under `hale check --json` / `hale verify --json`, the located line with a caret under the path in text mode, and the same located line from `build`, `run` and `test`, which have no machine-readable channel. (Before #860 it was a sentence on stderr plus a bare non-zero exit, so `--json` answered an unresolvable import with an empty stream.) A path that DOES resolve — to a directory with no `.hl` files in it, or to a file that will not open — is an `"io error"` record about that path instead: the import was resolved, and what failed was reading what it named. **A library's identity is its seed directory.** `main.hl` is a seed's entry file, not a library of its own, so a rule-1 hit on a `main.hl` resolves as rule 2 against the directory around it: `import "../lib/main"` and `import "../lib"` name the **same** library — one file set, one `lib_id`, one set of mangled symbols — and an importer that uses one spelling in one of its files and the other spelling in another gets one library under both aliases. Any other single file is a library of its own under rule 1, bringing in that file and nothing else. The one exception is a `main.hl` in the importer's own directory, which stays a rule-1 single-file library: a seed does not import itself. **A file belongs to exactly one library.** So a rule-1 single-file import of a file that a directory import somewhere in the same build also takes is **refused**, with a located error at the single-file import's path literal naming the library that already holds it and where that import is written: ```text /tmp/app/other.hl:1:8: type error: `../lib/helper` is already part of the library imported as `a` at /tmp/app/main.hl:1; a single file of a directory-imported library is not a library of its own — reach its declarations as `a::` and drop this import import "../lib/helper" as h; ^^^^^^^^^^^^^^^ ``` The two identities are genuinely different — one file against the whole seed — and they are not reconcilable the way the two spellings of one library are: resolution parses each file once, so `helper.hl` cannot carry two manglings. Before the refusal the resolver's file set was global and whichever identity resolved second was handed only the files the first had not taken, so half of one alias's names resolved to nothing with no diagnostic anywhere (2026-09-20, GH #820). The refusal is order-free: it names the file spelling whichever of the two resolved first, and the directory import is left alone. Both spellings of the SAME library (`../lib` and `../lib/main`) are unaffected — they are one `lib_key`, not two. ### Mangling scheme Each imported library's top-level decls are rewritten with a flat prefix so they never collide with the importer's symbols. The mangled form is: ``` __lib_____ ``` Every part is encoded into identifier characters by one rule, the **component encoding**: an ASCII letter or digit is kept, except an `x` that starts the component; a `_` is kept when it does not start the component and is followed by a letter or digit other than `x`; every other byte is an escape, `xHH` (two lowercase hex digits) at the start of the component and `_xHH` after it. So an encoded component starts with a letter or digit, never ends with `_` and never holds `__`, and the tuple reads back unambiguously: a run of two underscores is a joiner, a run of three the directory mark followed by a joiner, and no two declarations of a build — of one library or of two — share a name. - **``** is a function of the library's own path and nothing else: its canonical path relative to the entry's workspace root (the nearest ancestor directory of the entry containing `hale.toml` or `Cargo.toml`) when the library lies inside the workspace, otherwise relative to the entry seed's directory (with leading `..` segments). Two consumers of one workspace importing the same lib produce the same `lib_id` regardless of which alias each consumer chose, which of the two spellings of "Resolution order" above each wrote, the order of their imports, or what else they import; a workspace moved or cloned as a whole keeps every `lib_id`. Each path segment is one encoded component and segments are joined by `__`. A single-file library drops its `.hl`, and a directory library's `lib_id` ends in one `_` (a single-file library's never does). So `shared/messages/` is `shared__messages_`, `../one/util.hl` is `x2e_x2e__one__util`, `lib-a/` is `lib_x2da_`, and `xml.hl` is `x78ml`. - **``** is the basename of the source file the decl lives in, sans `.hl`, encoded. So two files in the same library can share a decl name without colliding, and a stem is never read as part of a path: `b__util.hl` in library `a/` is `__lib_a___b_x5f_util__Tag`, and `util.hl` in library `a/b/` is `__lib_a__b___util__Tag`. - **``** is the decl name as written in source, encoded: `s__who` is `s_x5f_who` and `_who` is `x5fwho`. Example: `/shared/messages/messages.hl` declaring `type Order { ... }`, imported by app A as `msgs` and by app B as `m`, both produce `__lib_shared__messages___messages__Order` in the merged program. The shared identity is the natural shape for DTO seeds exchanged on a bus — both apps see Order as symbol-identical, and the wire bytes match by construction. One shape is kept as it always was, unencoded. In a single-file library named by its file name alone (see the compatibility boundary below) whose file stem is letters and digits, a declaration whose name neither starts with `_` nor holds `__` is `__lib___` — `util.hl` declaring `make_err` is `__lib_util_util_make_err`. That shape never holds `__` after the prefix and the encoded one always does, and its stem ends at the first `_`, so the two never meet. Mangling is recursive: every reference to an imported decl inside the imported seed itself — bare names in fn bodies, struct literals, type expressions, capacity-slot element types, `as_parent_for` clauses, etc. — is rewritten through a unified rename map built across the whole library. Locals (let bindings, fn params, lifecycle params, for-loop vars, pattern bindings, generic params) shadow top-level names per ordinary lexical scope rules; the mangler tracks scope so a local named `Greeter` inside an imported fn body does NOT rewrite. The user never writes the mangled form. Their import-site references go through a per-build path-rename table that maps `::` → `__lib_____`, analogous to the static `STDLIB_PATH_RENAMES` and `MOA_PATH_RENAMES` tables. The codegen's `Cx::mangled_for_path` method consults all three tables in order: static stdlib, static moa, per-build imports. The `` is the importer's local namespace choice (used only at the call-site reference layer); the `` is the lib's canonical identity. Collision avoidance: two different libs of one build have different paths relative to the same anchors — a path inside the workspace never starts with `..` and one outside it always does — and the encoding is injective, so they get different ``s whatever aliases their importers picked and in whatever order they are imported. There is no fallback name and no collision to resolve. (Until 2026-10-01 a lib outside any workspace was named by its file name alone, and the second of two such libs with one file name took a digest of its absolute path: which lib kept the plain name depended on import order, and a clone of the tree got other names.) The compatibility boundary: exactly these names are unchanged from before 2026-10-01. A single-file library whose path from its anchor is its own file name — `import "util"` beside an entry outside any workspace, or a `util.hl` at the workspace root — whose file stem is letters and digits keeps the name of each declaration that neither starts with `_` nor holds `__` (`__lib_util_util_who`, `__lib_util_util_make_err`). Every other name changed: a declaration of that library starting with `_` or holding `__` is encoded (`__lib_util__util__x5fwho`); a library beside the entry whose stem holds a `_` takes the encoded shape (`__lib_my_util__my_util__who`), since `a.hl` declaring `a_a_b` and `a_a.hl` declaring `b` would otherwise share `__lib_a_a_a_a_b`; and every other library takes the encoded relative path, joined to the encoded stem and name by `__`: a workspace library's `/` separators became `__` (`shared_messages` is now `shared__messages_`), a directory library gained its trailing `_`, and a library outside the workspace is named by its path from the entry seed's directory rather than by its file name. The mangling shape mirrors the existing hand-spelled `__StdLangMorpheme` / `__MoaBraidId` prefixes the bundled stdlib and moa seeds carry; cross-seed imports extend the same discipline automatically. ### An alias and a value may share a name An import alias lives in its own namespace: it may coincide with the name of a fn, const, locus or topic the seed declares. ```hale import "../core" as core; fn core(args: String) -> String { return "[" + args + "]"; } fn go() -> String { return core::greet("mid") + " " + core("x"); } ``` Both references resolve. A **qualified path** (`core::greet`) resolves its head against the seed's import aliases — the head of a two-segment path is never a value. A **bare name** (`core("x")`, `core`) resolves against the seed's own declarations. Locals still shadow both, per the scope rules above. The one head that is not an alias is a **type**: `Color::Red` is an enum-variant path, so a seed declaring both `import "…" as Color;` and `type Color = enum { … }` resolves `Color::…` against its own enum, and the alias is unreachable in path position from that seed. Aliases are conventionally lower-case for exactly this reason. This holds identically whether the seed is compiled directly or reached through an import: the import rewrite renames a path head only when the head names one of the seed's own **type** decls, so an alias head survives mangling and the per-build path-rename table resolves it as it does at the import site. (GH #714: the rewrite used to rename any head that matched a seed decl, so `core::greet` became `__lib__main_core::greet` — a path through the free fn's mangled symbol. `hale check` passed, since it resolves the alias, and the build failed. A seed that ran on its own became unbuildable the moment someone imported it.) ### Scoped imports (A4) If library A imports library B, B's decls become reachable **inside A's body only** under the alias A chose for B. A's own importers (apps or other libraries) do NOT see B unless they import it themselves. The mechanism: the resolver recurses into each imported library's `import` directives with the library's own directory as the new importer dir, so relative paths resolve the way the library author wrote them. This is the per-library-scoped-import shape called out as future work in the v1 IMPORT milestone — A4 lifts the v1 strict barrier to unblock the `pond/_util/*` retrofit (libraries that share internal helper libs without forcing every consumer to vendor them). "Reachable inside A's body" is full reach, including **expression and return position**: A may name a B type as a field/param/return type, call B's free fns, *and* instantiate B's types and loci by qualified literal — `b::Thing { ... }`, `b::SomeLocus { ... }` — inside A's own fns, even when A is itself only reached two hops down (`app → A → B`) and across A's multiple files. (This is the "G34" shape; verified at HEAD, WS3.4 2026-06-11. The re-export rule below still holds — the *app* cannot name `b::Thing` unless it imports B itself.) **A qualified type is checked like a local one.** In a WHOLE program — one where every `import` is resolved, which is what `hale check `, `build`, `run` and `test` hand the checker — `lib::Thing` in any annotation position (`let`, fn parameter, fn return, struct field, `params` field, a `capacity` slot, an alias target) denotes the imported declaration and is typed as it. The annotation constrains what may fill it, the same way a locally declared type does: `let t: lib::Thing = "x";` is a located type error naming `lib::Thing`, and field and method access through the binding resolves against the imported declaration. Two aliases for one library are one type, because the declaration they name is one declaration. **A call into an imported seed is checked like a local one.** In a whole program, `lib::f(..)` is typed with the library's own signature: too few arguments (fewer than its params without a default), too many, an argument of the wrong type, `or` on a fn that cannot fail, and the type of the value it returns are the same located errors a same-seed call gets, naming the callee as written (fn `lib::add3` takes at least 3 arguments, got 2). The value an imported factory returns is typed too, so a method called on it is held to its signature. (GH #1028: the path was typed as unknown, and a call with an argument too few passed `hale check` to fail in `hale build`.) An imported fn is a value like a local one: `apply(lib::add3)`, `let f = lib::add3;` and a record field holding it all carry the library's fn, typed as that fn (GH #1082: codegen resolved the path only in call position, and a value of it failed `hale build` with "unresolved path" after passing `hale check`). A check of a single FILE of a multi-file seed is held to this too: the file target loads that file and its own imports, as `hale build ` does, so a path whose `import` line lives in a sibling names nothing in either, and both refuse it. (GH #833: until then the annotation typed as unknown, so nothing was checked against it and the mismatch surfaced at build, unlocated, or not at all. The single file kept that tolerance until 2026-10-11.) **An unresolvable qualified name is a located error.** The other half of the same rule. In a whole program, a qualified path that resolves to *nothing* is an error located at the path, in every position a path can stand in — an annotation, a call, a struct or locus literal, a const, an enum variant. Two shapes, two messages: ```text main.hl:4:13: type error: `zz::f`: `zz` is not an import or a type of this seed main.hl:6:13: type error: `b::Greeting` is not declared by the library imported as `b`; `b` provides: Mood, Tick, hello ``` The first is a head that names nothing — not `std::`, not an import this build resolved, not a declaration of this seed. The second is a head an import *does* answer, with a name behind it that the library never declared; it carries a did-you-mean when a spelling is close and the library's own surface when none is. A `bindings { }` entry needs no separate rule: a topic nothing declares has always been a located error there, qualified or not. A `subscribe` or `publish` subject is the bus position of the same mistake, and is worded as the resolver words a local unknown topic, naming the alias and the missing topic: ```text main.hl:5:19: type error: subscribe references unknown topic `b::Nope` (the library imported as `b` declares no `topic Nope`) main.hl:6:19: type error: subscribe references unknown topic `zz::Ping` (`zz` is not an import of this seed, so no `topic Ping` declaration is in scope) ``` (Until then such a subscription checked clean and failed at `hale build`: the path reached lowering as a subject with no topic and no payload, and a subscription that spelled its payload with `of type` was refused, since a topic reference carries its own.) Permissive, because the declaration is genuinely absent: an import the bundle never RESOLVED (a consumer holding one seed without its libraries; the language server is not one — it loads the seed and every import as `hale check ` does). A check of a single FILE of a multi-file seed is not permissive: it refuses what the build of that file refuses. `std::` is exempt because the stdlib tables answer a `std::` path and report their own typos, and a head that names a declaration (`Color::Red`, a `type C2 = Color;` alias) is not an import at all. So are `time::sleep` and `time::monotonic`, the two paths the compiler still lowers without the `std::` prefix — the rule refuses nothing the build accepts. Every *other* unprefixed stdlib path is a dropped prefix, and says so where it stands: "`env::args_count` is unresolved — did you mean `std::env::args_count`?", which is the message the build has always given for it. (GH #803: until then every one of these passed `hale check` and `hale verify` and died in codegen — `path call zz::f in expression position`, `unknown qualified name b::Nope`, `qualified type zz::T not in stdlib path-renames table` — late, from another layer, and with no source location.) **No re-exports.** B's decls are not visible to A's importers unless they declare their own dependency on B. The `` in B's mangled prefix is derived from B's canonical path, NOT from any importer's alias — so a util library reached through two different importers gets ONE shared identity in the merged program (path-deduplicated by canonical path in the visited set). 2026-05-22 changed this from the original per-importer mangling, removing the "DTO seed across two apps" sharp edge where the same source produced different symbols. **Cycles.** The CLI's canonical-path `visited` set bounds the walk; a lib that imports itself or two libs that mutually import each other resolve once each and stop. **An alias binds in its own seed only, through the rename table too.** Two seeds in one build may choose the same alias for *different* libraries: if `a` says `import "../libx" as u;` and the app says `import "../liby" as u;`, `u::f()` in `a` is `libx`'s `f` and `u::f()` in the app is `liby`'s. Each reference resolves against the aliases of the seed it is written in, and the seed's files share one alias namespace exactly as they share one declaration namespace. The build holds that guarantee through the per-build path-rename table (`alias::Name -> mangled symbol`), which is keyed by the alias as written. When two seeds bind one alias name to different libraries the compiler gives each binder a scoped head of its own and rewrites that seed's own references to match, so the two cannot be confused; the alias the author wrote is what diagnostics show. Until GH #746 the table was flat: the last binding registered won, both seeds resolved to one library, and nothing reported it (`hale check` passed and the binary computed the wrong value). Each alias and declaration contributes one rename row, even when many files import the same library. Rows follow the load's stable order: each alias's first binding, then declaration name within that library. Scoped aliases retain their canonical binder order. Diagnostic demangling indexes these rows once per editor snapshot and shares that index between publications. It preserves the longest-name-first replacement order and the first alias's spelling when several aliases name the same declaration (F.40 phase 3, X3). **A reference must name an alias its own seed declares.** The guarantee above holds in both directions, so a qualified path whose head is an import alias that some *other* seed of the build declares — and this one does not — is a check error located at the path, naming the seed that does declare it. This closes the other half of the flat-table hole: a seed that imported nothing at all could write `u::f()` and have the one table answer it out of an importer's row, so a library silently called whatever library its app happened to spell `u` (and the same library, compiled from a different app, called something else). Until GH #762 that was accepted in silence whenever the alias was uncontested, and reported only when two seeds contested it. Exempt from the rule: `std::`, the bundled namespace no seed imports, and a head naming one of the seed's own declarations (`Color::Red` is an enum variant, not an alias). A head NO seed in the build declares is not this rule's — nothing resolves through it in either direction — and is refused by the unresolvable-qualified rule above, at the path. One seed whose own files disagree — the same alias bound to two libraries inside a single namespace — resolves to one of them, as it always has; the compiler does not (yet) reject that shape. ### No `pub` / `export` Every top-level decl in an imported seed is exported. There is no visibility modifier in v1. The whole imported seed becomes available under the alias. Adding `pub` doubles the design surface (every decl picks a visibility; users author the modifier; the typechecker enforces it); v1 declines that complexity until a workload demonstrates a real need for export control. ## Disk is presentation The on-disk hierarchy is decoupled from logical identity. Three layers to keep straight: - **Library identity** = the alias the importer assigns (`foo`) plus the decl names inside the lib (`Bar`, `Greeter`). Stable across moves. References in user code — `foo::Bar`, `let g = foo::Greeter { ... }` — never change when a lib moves on disk. - **Library location** = the string in the import line (`"lib/moa"`, `"../vendor/moa"`). Per-importer, mutable. Moving a lib costs N edits to import lines (one per importer) but zero edits to actual code. - **Library convention** = the fact that vendored libs typically live under `lib/` rather than `vendor/` or `petals/`. The `lib/` prefix is style, not semantics. The resolver does not privilege `lib/`; it walks entry-relative then workspace-root for whatever path the importer wrote. The locus shape — the recursion tower of a project — lives in the **import graph**, not the file tree. Parse-time resolution + merging + mangling is where the recursion materializes. The filesystem just stores the bits in some convenient grouping; two projects could have identical import graphs and totally different disk shapes. This is by design. Refactoring the on-disk layout (moving a lib from `lib/` to `vendor/`, splitting a monolithic lib into two, consolidating two libs into one) only needs to update import lines — the bulk of the code that references those libs stays unchanged. ## Workspace root caveat Workspace-root detection walks upward looking for `Cargo.toml`. For Hale programs living inside this Rust monorepo (apps/, examples/, etc.) the walk hits the workspace's top-level Cargo.toml and the fallback works as expected. Standalone-shipped Hale binaries — sources not under a Cargo.toml — won't have a workspace root to fall back on. They can still use entry-relative imports (the single-file and directory shapes above); only the workspace-fallback path is unavailable. A future milestone may add an `.hale-workspace` sentinel for non-Cargo trees. ## `hale run` interaction `hale run` and `hale build` share the same codegen path and, as of GH #904, the same build OPTIONS: one parser, one `BuildOptions`, and `run` fingerprints what it built with rather than the defaults (see *Build flags + environment* above). As of WS3.3 they also share the same *import* path: both the single-file form and the directory form (`hale run ./dir`) resolve `import "..." as ...;` directives, build the per-build path-rename table, and rewrite qualified `alias::Name` references identically. A directory `hale run` now produces the same merged-and-resolved program as `hale build ./dir` — it execs it instead of writing a binary. (Previously the directory `hale run` form bundled the directory's files *without* threading the path-rename table, so cross-seed `alias::Name` references — and a topic decl referenced from a sibling file — failed under `run` though they worked under `build`. That gap is closed; the two commands no longer diverge on imports. `hale check` had the last surviving divergence: a multi-file seed with no imports was kept as per-file programs, and a pre-pass resolved each file alone — so a `topic` declared in one file and subscribed from a sibling reported "unknown topic" under `check` while `build` and `run` resolved it. Closed 2026-08-11: a multi-file seed merges before checking, exactly like the import-bearing path.) ### What `hale run` starts, `hale run` ends `hale run` is a foreground wrapper, and everything it starts is bounded by it: the compiled program, and under `--observe` the iris session beside it and the `hale build` that materializes the observer. On Linux each of them is spawned with a parent-death signal (`PR_SET_PDEATHSIG`, SIGTERM) and stays in `hale`'s process group, so a `hale` that dies — a CI cancel, a SIGKILL — takes them with it, and a group-directed signal (a shell's Ctrl-C, `timeout` without `--foreground`) still reaches them as it always did. Nothing is left running that only a human knows to kill. `hale` does not die first on the signals a program drains on (GH #1039, `semantics.md` § "Drain cascade (whole-process)"): once the program is running, `hale` ignores SIGINT — a Ctrl-C reaches the program through the process group, and `hale` waits for the drain — and forwards a SIGTERM sent to its own pid (`timeout`, `kill $pid`) to the program, then keeps waiting. Either way it reports how the program ended: exit 0 for a drain that finished, "killed by SIGTERM" for one that outlived its grace. The observed session also gets its OWN stdout and stderr, which `hale` relays to its stderr. The program's stdout is the command's output: an observer must not interleave its chatter into it, and — the reason this is a rule rather than a preference — a caller reading that stdout through a pipe must see EOF when `hale` exits. Until 2026-09-20 (GH #905) it did not: the session inherited the descriptor, so `hale run --observe prog.hl | cat` never returned. `hale` reaps the `hale iris` it started, but fuse-hl UNDER that `hale iris` was left running with the pipe's write end and nothing was left to close it — the program ending was enough to hang the caller, and a `hale` killed before it could reap anything left the whole session behind. `hale iris` names the way its fuse-hl ended (GH #578): a nonzero status is printed, and a fuse-hl killed by a signal is named and exits 128 plus the signal, where it used to exit 1 without a word. ## Git-based dependency fetching (`hale fetch`) A project may declare git dependencies in an `hale.toml` manifest at the repo root; `hale fetch` clones each into `vendor//` and pins resolved commit SHAs in `hale.lock`. The cloned source is then picked up automatically by the import-resolution order above (path 1 of the resolver looks at `//`, which is exactly where the fetcher places `vendor//`). `vendor/` is toolchain-managed and distinct from `lib/` (hand-maintained, never touched by the fetcher). Both paths work identically through the import resolver but keeping them physically separate prevents `hale fetch` from clobbering hand-vendored source on a name collision. See `spec/packages.md` for the full surface — manifest format, lockfile shape, pin semantics, fetch command behavior, and library-author conventions. ## What's NOT shipped (v1 boundaries) Explicit non-features of the v1 project / import system. A future milestone may relax some of them when concrete friction demonstrates the need. - **Deduplication is by canonical path, not content.** A library reached through two different importers gets ONE shared identity — its `` is derived from its canonical path and deduplicated in the resolver's visited set (the 2026-05-22 change; see § "No re-exports" above), so the same source yields the same symbols across consumers. What's *not* done is content-level unification: two libraries vendored at different paths are distinct symbols even if byte-identical. - **No registry / version ranges / semver.** Dependency pins are exact git refs. See `spec/packages.md` § "What's NOT in v1" for the full list of package-management non-features. - **No `pub` / `export` keywords.** Everything top-level in an imported seed is exported. - **No `src/` wrapper.** Source files live at the project root. - **No build-output directory.** The binary lands next to source. ## Implementation entry points The project / import surface lives in three places: - `crates/hale-frontend/src/frontend.rs` — `find_workspace_root`, `resolve_import`, `collect_target_files`, `resolve_imports`, `collect_ap_files`; `crates/hale-frontend/src/snapshot.rs` — `load_whole_seed`, the one load every verb runs (a file target stands for its seed). The frontend does file resolution + mangling + merging once per snapshot; the snapshot's lowering view carries the per-build path-rename table into `hale_types::resolved::resolve_rewritten`, which lowering reads through `hale_codegen::build_resolved`. - `crates/hale-types/src/mangle.rs` — `mangle_program`, `build_seed_renames`, `mangle_with_renames`. The AST walker rewrites decl sites and use sites with a scope-aware shadowing stack. - `crates/hale-codegen/src/codegen.rs` — `build_executable_with_imports`, `Cx::mangled_for_path`, the `import_renames` field on `Cx`. End-to-end coverage lives in `crates/hale-codegen/tests/cross_seed_imports.rs` using `tests/fixtures/lib-toy/` (two-file library) and `tests/fixtures/import-toy-consumer/main.hl` (consumer with `import "../lib-toy" as toy;`). ## Build flags + environment A flag may stand on either side of the target: the first argument that is not a flag IS the target, so `hale build --dev app.hl` and `hale build app.hl --dev` are one command (2026-09-20, GH #861; before it `build`'s flag parsing started at argv[3] and a flag in front of the target was read as the target itself). Value-taking flags — `--link`, `--csrc`, `--target`, `--target-cpu`, `--target-cache` — take the next argument as their value, so that argument is never mistaken for the target. `hale run` follows the same rule up to the target and then stops: everything after the target is the PROGRAM's argv, which is why `run`'s own `--observe` goes in front of it. `--observe=` is `--observe` with the session serving on `` — the `[port]` of `hale iris`, so `0` is a port the kernel picks — in place of iris's default 8787. `run` takes the same build options `build` does, from the same parser (2026-09-20, GH #904; before it, `run` compiled with `BuildOptions::default()` no matter what it was given, so every build option was first read as the target and then — GH #900 — named and refused, and the documented spot-check `hale run prog.hl` could exercise neither a dev build nor an FFI program). So does `hale replay`, which recompiles the program it admits a recording against. Both refuse, by name, the flags that report ON a build rather than change it — `--locality-report`, `--target-cache`, `--strict`, `--wrap-main` — and `run` refuses a target it cannot exec (`--target wasm32`, or a program whose `target wasm { }` declaration selects wasm32 — ffi.md § *The effective target*), since it compiles in order to execute. The options are part of the execution identity (runtime.md § *Replay*): a recording made under `hale run --dev` is admitted by `hale replay --dev` and refused by a default `hale replay`, with the identity message. The three commands compute that identity's options one way — their flags, `--env`'s role table, which `replay --env` resolves as `run --env` does, and the `[ffi]` surface of the imported packages — and debug information is not among them, so a recording made by a binary `hale build` emitted from a file replays against that file, and one from a directory build against the directory's entry file: the identity names each source by its source-map path, the same whichever target loaded it. | Surface | Effect | |---|---| | `hale build --dev` / `hale run --dev` / `hale replay --dev` / `HALE_DEV=1` | Latency mode: LLVM O1 pipeline + Less machine codegen instead of the O3/`target-cpu=native` release default. For edit-build-run loops. | | `hale check --json` | NDJSON diagnostics on stdout, one object per line (`file`/`line`/`col`/`severity`/`kind`/`message`, plus `related`: an array of `{file, line, col, note}` secondary locations, present only when a diagnostic has them — e.g. a duplicate name's previous declaration; 2026-08-11) — editor/LSP consumption. EVERY finding that fails the command is a record, including a lexical or syntactic one: a file that does not parse — the target's own or any file reached through an `import` — emits one record per diagnostic, `"kind":"parse error"` (or `"lex error"`), at that file's own line and column (2026-09-19, GH #777; before it the parse path printed text to stderr and left `--json` empty, so a gate saw a non-zero exit with nothing explaining it). An input that could not be READ is a record too, `"kind":"io error"`, `"file"` the path it is about and `"message"` the OS error, at `"line":0,"col":0` — no position, because there is no text to have a position in: a target that does not exist, a `.hl` file of the seed that will not open, a file of the import graph that will not open (2026-09-20, GH #806; these printed a sentence on stderr and left the stream empty, an environment failure wearing the same shape as a crash). An `import` that resolves to NOTHING is a located record like any other finding, `"kind":"type error"` at the line and column of the path literal that names nothing, with the three search paths tried in its `message` (2026-09-20, GH #860; it was the last failure on this path still reported only as a sentence on stderr). A finding whose position is inside the **embedded stdlib** — the witness leaf of a violated effect assertion, which the analyzer reached by walking into a Hale-source stdlib body — is a positionless record too, `"file":""` at `"line":0,"col":0`, with the stdlib file and line in its `message` (`in the standard library, io_tcp.hl:118:18`); the same holds for a `related` entry raised there (2026-09-20, GH #856). The stdlib parses at base 0 in its own coordinate space, so such an offset is not a position in any file of the seed, and a `file` field is a join key: before this it named whichever seed file the number happened to fall inside. An empty stream therefore means a clean seed, and nothing else does. `hale verify --json` is the same stream under the stricter gate. | | `hale lsp` | Publishes `hale check `'s diagnostics for the open file's seed, read through the editor's buffers, in two stages: first every file of the seed with what needs no model (the scope's and the type check's findings, the build rules, the allocation survey), then, when the laws' verdicts add a finding, each file they add to again with its whole list, which the first publication prefixes. A file's last publication is what `hale check` reports for it. A publication for a document the editor has changed again since is not sent; the next check replaces it. The first stage is incremental by declaration: after an edit that changes only what fn, method and hook bodies do, the type check and the secret-reveal rule run again for the changed declarations and the declarations the call graph (over every body the check reads, an `on_failure` handler and a params initializer among them), the ownership and bus graphs, the placement table and the flow rows say read them, and keep the previous findings for the rest; every other part of the stage runs whole, an edit to what a declaration declares (a signature, a field, a member, a declaration added or removed) checks the whole seed, and the result is the full check's either way. The laws stage always runs whole. | | `HALE_TIME=1` | Per-phase build wall times on stderr (resolve, front-end+codegen, llvm-passes, obj-emit, emit+link). | | `--no-warn-unbounded-alloc` | Opts a run out of the default-on memory-bound survey (see verification.md). | | `hale check --report-unbounded-use` | One stdout line per `@unbounded` or `@bounded_by` fn or hook of the program, imported seeds' included, and of the stdlib source it reaches: `unbounded-use:`, the path relative to the working directory (a stdlib decorator's `crates/hale-stdlib/hl/`), the line the decorator's, sorted by path and line. Each count is what removing that decorator would add (see verification.md § the memory-bound survey). The diagnostics and the exit code are the check's (GH #1490). | | `hale check --sealable` | Reports which loci could take `@sealed` and what it would cost: per locus, the sites outside it that read or write its `params`. Empty means sealing is a no-op. The survey reads the param-access rows the check recorded (every access through a locus-typed receiver, imports resolved), the rows the sealed rule judges, so it cannot disagree with the checker; an imported seed's locus reached through its qualified name counts. | | `hale check --flows` | Reports which locus types are flows (reclaimed when their `run()` completes) and, for each, every `release(c: T)` clause that makes it one: its declaring locus, file and line, imported seeds included, as the declaring seed spells it. It keys each type as codegen does (a qualified type by its whole path), so a report and a binary agree; "none" means every accept'd child is a resident (GH #736). | | `hale check --units` | Reports the unit dialect's witnesses on stdout (spec/units.md § The witness report): per scalar declaration of the program, its kind, its denomination and the declaration whose `in` fixed it, its `round:` policy and where that is written, a point's origin, the headroom of an `Int` at its denomination, and what a declared range fits in (`u8` … `u64`, or `i64`); per narrowing the check recorded, its factor or its range, a literal's compile-time count, and the policy that discharged it, at the site (`or …`) or from the target type's `round:`. A query over the unit rows and the typed bodies' conversions, never a re-check, outside the hashed model half; the text is stable (declarations in program order, narrowings by site) so it can be recorded and diffed, and `--json` prints the same fields as one object. A location's file is relative to the checked seed's directory, with `..` for a file outside it (`../common/main.hl:3:1`), never absolute. With nothing to report it is the one line `units: no quantity is declared` (GH #1076, U5). | | `hale check --strict-secret` | The fail-closed `@secret` walk: every branch, alias propagation through `let` and tuple destructuring, and `uncertified` for anything it cannot follow. Opt-in, and loud by design; the default `@secret` pass is a lint (see verification.md § Secrets). | | `--target native\|wasm32\|` | The target to build for; `hale --list-targets` names every triple with its tier as seen from this host. `native` (default) and the host's own triple build and link the host platform; `wasm32` builds from any host. Another host's native triple is a cross target (GH #970; before it, GH #969, such a triple silently built the host): codegen uses the target's own backend, triple and a generic CPU. A **Linux** triple — gnu (`--target x86_64-unknown-linux-gnu` on a Mac, or the other architecture on Linux) or musl (`x86_64-unknown-linux-musl`, `aarch64-unknown-linux-musl`: linked static, one file that runs on any Linux, a cross target from every host; no `async_io` pools — musl has no ucontext, so the check refuses `where async_io` for it as it does for macOS) — is then **linked** here: the lotus runtime is compiled and the program linked with `zig cc -target` (zig on PATH or `HALE_ZIG`; glibc pinned at 2.31, `HALE_TARGET_GLIBC` overrides), against a **target sysroot** holding OpenSSL and zlib as static archives and, when built, the tree-sitter shim — `scripts/target-sysroot.sh ` builds one under `/hale/sysroot/` — OpenSSL and zlib from pinned source tarballs, compiled with zig against the same glibc floor (a distribution's archives are built against its own newer glibc and cannot be linked at the floor), `HALE_TARGET_SYSROOT` names another. The emitted gnu binary depends on the target's glibc alone; a musl one on nothing. Cached runtime objects are keyed by the compiler, so one target's never serve another. A missing zig or sysroot fails before the link, naming the piece and how to get it; sanitizers are host-only and refused. A **Darwin** triple from anywhere else has no toolchain here and is emitted as a relocatable object, `.o`, with a note. `run` and `test` refuse every foreign triple, as they refuse `wasm32`. A Windows triple is refused at argument parsing as not buildable yet (GH #445). The check judges target questions — `where async_io` — against the named target, not the host. With no `--target`, a written `target wasm { }` declaration selects wasm32, for the check and the artifact alike, and a `--target` it contradicts is refused at the declaration (ffi.md § *The effective target*); `hale check` takes the same flag. | | `--target-cpu native\|baseline` | Backend CPU tuning (native = host, default; baseline = portable x86-64-v3). | ================================================================================ ## Package management ## source: spec/packages.md ================================================================================ # Package management Hale's v1 package-management surface is small and explicitly git-based. A project declares its direct dependencies in `hale.toml`; running `hale fetch` clones each one into `vendor//` and records resolved commit SHAs in `hale.lock`. `import "vendor/" as alias;` directives pick the cloned source up via the standard import resolution order (see `spec/projects.md`). The fetched tree lives at `vendor/` — toolchain-managed, distinct from `lib/` which stays for hand-maintained sources the user vendors directly. Both paths work identically through the import resolver (each is just an `//` hit on path 1), but keeping them physically separate prevents `hale fetch` from clobbering hand-maintained source on a name collision. The single design commitment driving the shape: - **F.26 — vendoring is the v1 dependency primitive.** Hale ships no registry, no version-solver, no transitive resolution. Each project lists its direct dependencies and the consumer vendors any transitive ones explicitly. See `spec/decisions.md` F.26 for the rationale. ## Manifest (`hale.toml`) The manifest lives at the project root — the same directory that hosts the top-level `.hl` sources and (after `hale fetch`) the `vendor/` directory. `hale init [dir]` writes this skeleton (plus a hello-world seed, a first `tests/*_test.hl`, and a `.gitignore`; strictly non-destructive — existing files are kept, only missing ones are filled in; 2026-08-11). It is a TOML file with one required section, `[deps]`: ```toml [deps] helpers = { git = "https://github.com/me/helpers", rev = "abc123" } finance = { git = "https://github.com/me/finance", tag = "v0.1.0" } ui = { git = "https://github.com/me/ui", branch = "main" } ``` Each entry's key is the local namespace the consumer will use to import the dep. The value is a table with one required field and up to one optional pin: | Field | Required | Description | |----------|----------|--------------------------------------------------------| | `git` | yes | The clone URL. Any scheme git understands works. | | `rev` | no | Pin to a specific commit SHA. | | `tag` | no | Pin to a named tag. | | `branch` | no | Track a branch (lockfile still pins the resolved SHA). | Setting more than one of `rev` / `tag` / `branch` is a manifest error — the spec must be unambiguous. Setting none uses the remote's default branch. There is no `[package]` table, no top-level metadata, no authors / description / license fields. A project is identified by its directory name and its source. ### Pin semantics - `rev = ""` — the consumer pins to that exact commit. Triggers a full (non-shallow) clone, because git's `--depth 1 --branch ` form rejects raw SHAs. After cloning, `git checkout ` lands the working tree. - `tag = ""` — shallow clone of the tag's commit. Idempotent re-fetches don't update; the lockfile pins the SHA the tag pointed to when fetched. - `branch = ""` — shallow clone of the branch tip at fetch time. The lockfile still pins the SHA. To pick up upstream changes on a tracked branch, delete the lockfile (or just the lockfile entry for that dep) and re-run `hale fetch`. ## Lockfile (`hale.lock`) Auto-written by `hale fetch`. Pins every declared dep to a resolved commit SHA so re-cloning is reproducible across machines and across time: ```toml [[dep]] name = "helpers" git = "https://github.com/me/helpers" sha = "abc1234567890abcdef..." [[dep]] name = "finance" git = "https://github.com/me/finance" sha = "deadbeefcafef00d..." ``` The lockfile is intended to be committed alongside the manifest. A consumer running `hale fetch` on a fresh checkout re-clones every dep at the locked SHA, producing the same `vendor/` contents the author worked with. If a dep listed in the manifest has no entry in the lockfile (new dep), `hale fetch` resolves it freshly and appends to the lockfile. If a dep is removed from the manifest, its lockfile entry is dropped on the next fetch (the lockfile is re-emitted from the current manifest, not edited in place). The `hale.lock` shape is owned by the toolchain — manual edits will be overwritten on the next `hale fetch`. To upgrade or downgrade a dep, edit the manifest and re-run `hale fetch`. ## The `hale fetch` command ``` hale fetch [repo-root] ``` `repo-root` defaults to the current working directory. The behavior, per dep: 1. **First fetch (no `vendor//`).** Clone the URL into `vendor//`, checking out the requested ref (`--depth 1` for tag / branch / default-branch; full clone + `git checkout` for `rev`). 2. **Re-fetch, lockfile SHA matches current HEAD.** No-op — no network call. 3. **Re-fetch, lockfile SHA differs from current HEAD.** Run `git fetch --tags --prune origin`, then `git checkout` the requested ref. Updates the lockfile with the new resolved SHA. 4. **Re-fetch, no lockfile entry for the dep.** Same as case 1 from the consumer's perspective — the dep is new to this project even if `vendor//` was somehow already present. 5. **Collision with a hand-maintained directory.** If `vendor//` exists but has no `.git/`, `hale fetch` errors and refuses to overwrite it. Move or delete the directory and re-run. This guards against silently clobbering sources the user vendored by hand (e.g. before adding the dep to `hale.toml`). After processing every dep, `hale fetch` writes a fresh `hale.lock`. The write is whole-file (no in-place editing) so a partial / failed fetch never leaves a corrupt lockfile. Exit codes: - `0` — every dep resolved cleanly. - `1` — a manifest error, a lockfile parse error, a network failure, or a `git` invocation returning non-zero. ## Resolution order interaction The compiler's import resolver doesn't know about `hale.toml` or `hale.lock`. It only knows that an `import "vendor/" as alias;` (or `import "lib/" as alias;`, or any other path) directive looks for source on disk at the paths described in `spec/projects.md` § Resolution order. `hale fetch` puts the source at `/vendor//`, which is exactly where path-1 of the resolver looks first when the import string is `"vendor/"`. This separation is deliberate: the fetcher is a small, optional tool that produces an on-disk tree. The compiler treats that tree the same way it treats hand-vendored source under `lib/`. A project that already vendors its libraries (committed into `lib/`) can ignore the package manager entirely; a project that uses `hale fetch` exclusively gets the same compile behavior with less manual maintenance; and a project that does both keeps the trees physically separate so the toolchain never overwrites work it didn't put there. ## Library author conventions A library is just a git repository whose root directory holds one or more `.hl` files. From the consumer's perspective the clone lands at `vendor//`, which becomes one Hale seed (per F.19). What this implies for library authors: - **Source goes at the repo root**, not under `src/`. Nested directories are NOT crawled — they exist for the author's organization only. - **Include `hale.toml` in a library only if it has FFI bindings.** As of 2026-05-22, a library that declares `@ffi("c")` functions ships an `hale.toml` with an `[ffi]` section listing `link = [...]` (C libraries the consumer's link line should pull in) and `csrc = [...]` (C glue files compiled alongside the runtime). The consumer's build reads the section automatically at import time — no manual `--link` / `--csrc` flags needed. See [`spec/ffi.md`](./ffi.md) for the FFI contract and [`agents/binding-packages.md`](../agents/binding-packages.md) for the authoring brief. Pure-Hale libraries (no `@ffi`) have no reason to ship `hale.toml`; their consumers resolve them through the standard import lookup. v1 still has no transitive `[deps]` resolution: a library declaring its own `[deps]` won't have those auto-fetched by the consumer's `hale fetch`. The consumer vendors every dependency themselves. Document any indirect requirements in your README. - **Tag releases** if you want consumers to pin via `tag = "vX.Y.Z"`. The toolchain doesn't enforce semver — the tag is just a git ref — but consumers will read your tag history when deciding what to pin to. - **Keep top-level decl names short and namespace-friendly.** Consumers will see your decls as `alias::Name`; the alias is theirs to choose, so design decls that read fluently under any short prefix. See `spec/styleguide.md` for naming rules. ## What's NOT in v1 Explicit non-features. A future milestone may relax some of these when concrete friction demonstrates the need. - **No registry.** All deps are git URLs. - **No transitive deps.** A library's own dependencies are not followed. The consumer must vendor them too. - **No version ranges or semver.** Pins are exact strings (a rev, tag, or branch name). There is no highest-compatible-version solver. - **No `hale publish`.** Distribution happens via `git push` to a hosting service of the author's choice. - **No checksum verification beyond git's own.** A pinned SHA is the integrity guarantee — git's content-addressing makes swapping content under a fixed SHA infeasible. - **No build scripts or post-fetch hooks.** A library is source only; no arbitrary code runs during `hale fetch` beyond the `git` invocations the toolchain controls. ## Implementation entry points - `crates/hale-cli/src/pkg.rs` — `Manifest`, `DepSpec`, `Lockfile`, `LockedDep` types (serde) and the `fetch()` entry point. - `crates/hale-cli/src/main.rs` — the `hale fetch` dispatch arm. - `crates/hale-cli/tests/pkg_fetch.rs` — integration tests that exercise a real `git clone` against a `file://` URL. The compiler's import-resolution path (which consumes the cloned source) lives in `hale-frontend`'s whole-seed load (`resolve_import` / `find_workspace_root`) — see `spec/projects.md` § Implementation entry points. ================================================================================ ## Standard library ## source: spec/stdlib.md ================================================================================ # Standard library Bundled with the toolchain, no separate install required. This document describes the **current** stdlib surface. Milestone / phase history lives in [`../CHANGELOG.md`](../CHANGELOG.md). ## Path resolution `.hl` source references stdlib symbols by fully-qualified path: ```hale let p = std::process::pid(); let contents = std::io::fs::read_file("config.toml"); std::io::tcp::Listener { host: "127.0.0.1", port: 8080 }; ``` The parser tokenizes `::` as a path separator. The type checker resolves `std::*` paths that name Hale-source stdlib declarations to their real nominal types (GH #470 — literals, fields, methods, and interface coercions check like user code, and a `std::` literal that matches nothing is an error); remaining namespaced paths (Rust-implemented builtins) type permissively, with their path-call names validated against the stdlib surface registry. A path-call carrying a row in that registry's signature table also checks its arity, its argument types and its RETURN type, and every function the compiler lowers natively or through a named Hale body has one: the signature its lowering enforced (F.40 phase 4, S6; before, 118 had none, so any arguments passed `hale check` and a wrong count or type failed at build, without a location). The seven `std::io::mirror` cursor primitives are signed with the arguments their lowering reads, so a call with more is refused by `hale check`, where lowering ignored the rest. A function the rename table maps onto a Hale body is checked against that body's own signature. The return may itself be a stdlib struct (GH #771): a `std::json::string_field(...)` is a `std::json::JsonString`, so a misspelled field on the result is a located error naming the public path, and passing the result where a `String` is expected is refused. A row also says whether the function can fail, and every function lowering lowers only under an `or` has a row that does (F.40 phase 4, S5): the `std::io::tcp` and `std::io::tls` timeout and option setters (`set_recv_timeout`, `set_send_timeout`, and tls's `set_nodelay` and `set_rx_timestamps`) and the `__` primitives the stdlib's own `File`, udp and `Child` wrappers call (`std::io::file::__open` / `__seek` / `__write_bytes`, `std::io::udp::__bind` / `__send` / `__recv`, the seven `std::process::__*` primitives) all fail with `IoError`, so a bare call of one is the located bare-fallible error from `hale check` (spec/semantics.md § "A bare fallible call is an error"), where lowering used to refuse it without a span or answer "not implemented". Lowering keeps no list of its own: whether it refuses a stdlib call is that same row. The check refuses a bare call of a function whose row can fail, and an `or` over one whose row cannot; the one such call that still reaches the build is an `or` over one of those unsigned functions, or over one whose success the table cannot state (`std::bytes::builder::__view` / `__text_view`, a view; the check types both permissively), which the build refuses with "is not a fallible call — remove the `or` clause" (some used to read "`or` over unknown path call"). A stdlib function written in Hale (a body the stdlib's rename table names, like `std::process::adopt` or the `std::io::file` wrappers) is called like any fn: a statement drops what it returns, and one that returns nothing can be called as a statement and only there (F.40 phase 4, S5: before, only `adopt` could, through a branch of lowering's own). An ARGUMENT position names a stdlib type the same way (GH #829): `recv_into`'s `buf` is a `std::bytes::BytesBuilder`, the one type its lowering accepts, so `std::io::tcp::recv_into(fd, 0, 64)` is a located error at the argument instead of a spanless codegen refusal. Only a path-call with no row at all — or a row whose parameter is deliberately polymorphic, like the user `@form(vec)` destination of `std::str::split_into` — types permissively, as does an argument whose own type the checker cannot see; incompleteness therefore degrades to the historical tolerance, never to a false error. Lowering resolves a `std::*` path through the function's row (the `stdlib_surface` family): the row names an intrinsic id, a Hale body, a rename, or nothing, and one exhaustive `match` on the id lowers the call at each position. Some operations are bare **builtins** rather than stdlib functions (`len`, `to_string`, `abs`, `min`, `max`, the printers — grammar intrinsics, see [`types.md`](./types.md)). A path that is a conventional spelling of one of those — `std::str::len`, `std::string::length`, `std::math::abs`, `std::cmp::min`, `std::io::println` and their siblings — is answered with that builtin's call shape (``the length of a String is the builtin `len(s)` ``) rather than an edit-distance guess, as is the member spelling `s.len()` / `s.length` / `s.size` on a `String` or `Bytes` (GH #722). Every other unknown name keeps the plain unknown-function or unknown-namespace diagnostic: an operation with no builtin equivalent is never pointed anywhere. There is **no general module system** at v1 — no `use` statements, no user-defined modules, no multi-file `.hl` packages via the std-style mechanism. `std::*` is the only recognized prefix. Adding a new stdlib function means: one row in `SURFACES` (`crates/hale-types/src/stdlib_surface.rs`: its signature, effect classes, fallibility and how it lowers), its `IntrinsicId` variant, an arm in `lower_std_intrinsic` and one in `lower_std_intrinsic_fallible` (the compiler refuses a missing arm), its libc backer in `declare_builtins` and the C function, a program that calls it (the coverage gate fails without one), the table-answers golden, this document's row and the book's. Cross-binary user code uses the F.25 cross-seed-imports mechanism (`import "path/to/lib" as alias;`) — distinct from the `std::*` magic path; see [`decisions.md` § F.25](./decisions.md). ## Design principles - **Batteries included.** Go's approach: if a typical Hale program needs it, it ships. A new Hale user shouldn't need third-party packages for table-stakes coordinator-system work. - **One canonical implementation.** Per Go's "one obvious way": one `std::collections::Map`, not seven. Multiple options live in third-party packages. - **Framework-aware.** Stdlib types use the language's projection classes, modes, and closure tests where appropriate. The stdlib is itself disciplined. - **Replaceable.** Anything in stdlib can be replaced by a third-party module; nothing in stdlib is tied into the compiler. `std::*` is the curated path for ships-with-the-compiler bindings (libc + OpenSSL only at the link floor). User-extensible C-ABI bindings live outside this surface — see [`spec/ffi.md`](./ffi.md) for `@ffi("c")` declarations, the mechanism by which library authors land bindings to third-party C libraries (raylib, sqlite, curl, ...) in community repos like pond. The stdlib's narrow link surface is preserved exactly because user code can extend the FFI surface without touching the compiler. ## Module surface | Namespace | Surface | Source | |---|---|---| | `std::process` | `pid() -> Int`, `uid() -> Int` (the process's real uid), `exit(code: Int)`, `rss_bytes() -> Int`, `dump_arena_residency() -> Int` (no-op unless `LOTUS_ARENA_RESIDENCY=1`; writes per-arena residency snapshot to stderr), `dump_pool_residency() -> Int` (F.35, 2026-05-28; writes one stderr line per cooperative pool — name, async_io vs blocking mode, parked-coro count, pending cell-queue depth — for ops embedding in heartbeat ticks), `run(argv) -> ProcessOutput fallible(IoError)`, `spawn` / `wait` / `kill` / `write_stdin` / `read_stdout` / `read_stderr` over `Child`. argv is **newline-separated**, not shell-split; when run/spawn fails ENOENT and argv[0] contains a space, the IoError's `path` label carries a hint naming that mistake (2026-07-27, hale-bun handoff item 4) instead of reading as "binary not on PATH". **`try_wait(c) -> Int fallible(IoError)`** (2026-07-17) — non-blocking reap via `waitpid(WNOHANG)`: `-2` = still running (the retryable sentinel shape `recv_into` uses; poll again), `0..255` = exited, `-1` = killed by a signal; ECHILD (already reaped) surfaces `kind="not_found"`. The supervisor idiom: a periodic tick polls `try_wait` per child without ever parking its pool — closes the styleguide §7 "daemons can't non-blocking-reap" gap. **`signal(c, sig) -> () fallible(IoError)`** (promoted from pond/subprocess) — arbitrary POSIX signal to the child's pid (15=TERM, 9=KILL, 1=HUP, …); the TERM→KILL escalation remains `kill`'s job; ESRCH surfaces `kind="not_found"` (usually benign post-exit — `or discard`). Both honor the manual-`Child` convention: `pid <= 0` answers exited-with-0 / no-ops. **`adopt(dest, src) -> ()`** (GH #716, 2026-09-19) — move a spawned handle into a `Child` the caller already owns, so a service can retain its subprocess past the method that started it. `self.child = std::process::spawn(…)` is refused (a locus-typed field takes only a locus LITERAL — see spec/semantics.md § "Reassigning a locus-typed field"), and `adopt` is not a locus store: it transfers the handle's STATE into the instance the field already owns, so there is never a second owner. Three steps, in this order: `dest`'s outgoing handle is released first (fds closed, process TERM→KILL→reaped) while its descriptors are still open and therefore still uniquely its own — the kernel cannot have reissued those numbers to `src`, so a re-spawn into the same field never closes the new child's pipes; then `src`'s pid + fds move in; then `src` is **disarmed** to the not-spawned sentinels, so its own teardown closes nothing. Infallible (the release is best-effort teardown, like dissolve), so it needs no disposition. `adopt` **terminates** the handle `dest` held — `wait`/`try_wait` first if the outgoing child's exit code matters. Adopting an empty `Child { }` — what the `or` disposition on a failed `spawn` hands back — is the documented way to leave the field empty, so a spawn failure never leaves a half-built handle. Adopting the handle already in place (`adopt(x, x)`) is a no-op. **Ownership:** exactly one `Child` owns a given pid + fd triple, and it is the one whose dissolve closes them; a handle whose fields were copied out by hand is a second owner and double-closes. A Child that `spawn` RETURNED has no owner at all — a factory-returned locus is never reclaimed (GH #383), so its dissolve never runs and a `let`-bound spawn leaves its child running at program exit; `adopt` into a field is what gives the handle an owner that tears it down. | path-call dispatch + C primitives | | `std::env` | `args_count()`, `arg(i)`, `arg_or(i, default)`, `var(name)`, `var_exists(name)` | path-call dispatch + main-prelude argv stash | | `std::cli` | `Resolver` locus — layered config resolution with precedence **CLI argv > env var > fallback**. Params: `env_prefix: String = "HALE_"` (each `get(key, …)` looks up `` in the process env — `prefix="HALE_"`, `key="dir"` → `HALE_DIR`) and `argv_keys: String = ""` (newline-separated positional keys — first line maps to `argv[1]`, second to `argv[2]`, …; a blank line doesn't shift positions; a key absent from `argv_keys` skips the CLI layer). Methods: `get(key, fallback) -> String` (the highest *populated* layer wins; empty/unset at a layer falls through) and `get_int(key, fallback) -> Int` (same precedence; a non-parseable value falls through to `fallback` rather than crash). No birth/run/dissolve lifecycle — the params *are* the configuration, so re-prefixing the Resolver retargets it without touching the body. | `runtime/stdlib/cli.hl` | | `std::time` | `monotonic() -> Duration`, `monotonic_ns() -> Int`, `sleep(d: Duration)`, `now() -> Int`, `time_from_unix(n: Int) -> Time`, **`current() -> Time`**, **`iso8601(t: Time) -> String`**, **`parse_time(s) -> Time fallible(ParseError)`**, **`unix(t: Time) -> Int`**, **`nanos(t: Time) -> Int`**, **`from_nanos(n: Int) -> Time`** | `clock_gettime` + EINTR-retrying `clock_nanosleep`; **on a `where async_io` pool `sleep` PARKS the coroutine on a deadline** (timer-only park, no fd — Crumb batch-5, 2026-07-28), yielding the shared worker so N sleeping coros overlap instead of serializing and a sleeping handler never holds the pool against unrelated work; off async pools `sleep` slices the request into ≤100ms intervals and folds in a cooperative bus drain after each slice, so a long keep-alive sleep doesn't starve main-pool handlers (see `spec/runtime.md` § "`time::sleep` drain semantics"); `now()` is `CLOCK_REALTIME` seconds and stays; **`current()` is the same clock as a `Time` (nanoseconds; journaled and replayed like `now()`)**. `Time` is i64 nanoseconds since the epoch (GH #607): `time_from_unix(n)` is `n × 10⁹`, `iso8601` renders it (the fraction only when not zero; `to_string` and `println` do the same), `parse_time` is `parse_iso8601` yielding the instant, `unix` floors to seconds, `nanos` / `from_nanos` are the integer view. **`Duration` and `Time` are this module's declarations** (GH #1076, U4): its seed (`hale-stdlib/hl/time.hl`) declares the time catalogue (`unit ns; unit us = 1_000 ns; unit ms = 1_000 us; unit s = 1_000 ms; unit min = 60 s; unit h = 60 min; unit day = 24 h;`), `type Duration = quantity Int in ns;` and `type Time = point Duration;`: a time literal's unit is one of these, and the unit dialect's algebra types the two (spec/units.md § The stdlib's time catalogue); every function above takes and returns them as the nanoseconds they always were. **`parse_iso8601(s) -> Int fallible(ParseError)`** with the probe sibling **`can_parse_iso8601(s) -> Bool`** (2026-08-03) — the inverse of `time_from_unix`, returning unix seconds. Formatting was never missing (`time_from_unix` already yields ISO-8601 text, which is why `println` on a `Time` renders a date); parsing had no counterpart, so a timestamp could be emitted and never read back. UTC only: a timezone database is megabytes against the wasm target, and local time additionally reads `TZ`, making it an `env` effect rather than a pure computation — it can arrive later as a distinct, effectful call. A trailing offset such as `+01:00` is REJECTED rather than ignored, because an hour-wrong timestamp that never announces itself is worse than a failure | | `std::decimal` | `to_float(d: Decimal) -> Float` `format(d: Decimal, places: Int) -> String` (GH #230, 2026-07-22): render with exactly `places` fraction digits (0..=9 clamped), round half-up — the fixed-places money-display surface; default printing still trims trailing zeros (declared precision is not stored in the scale-9 repr). | Direct i128 → f64 conversion at scale 9 (`mantissa × 10^-9`) — skips an ASCII round-trip | | `std::str` | `parse_int(s) -> Int fallible(ParseError)`, `parse_float(s) -> Float fallible(ParseError)`, `parse_decimal(s) -> Decimal fallible(ParseError)`; predicate siblings `can_parse_int` / `can_parse_float` (`can_parse_decimal` is NOT dispatched — listed here historically; implement or drop, tracked in notes/typecheck-m3.md); range-bounded variants `range_eq(json, start, end_exclusive, expected) -> Bool` / `range_parse_int(json, start, end_exclusive) -> Int fallible(ParseError)` / `range_parse_decimal(json, start, end_exclusive) -> Decimal fallible(ParseError)` (2026-05-26 — operate on byte ranges within an existing `String` without materializing a substring, paired with `std::json::iter_find_*_range` for allocation-free JSON walks); `byte_at_unchecked(s, i) -> Int` (2026-05-26 — direct byte access at offset i with NO bounds check; caller must guarantee 0 ≤ i < len(s); used by stdlib scan helpers (JSON walkers) where the bound is externally known and a per-access strlen / bytes_from_string would tank perf; misuse → UB); **the `ByteView` byte-scanning surface (GH #720, 2026-09-19): `bytes_view(s) -> ByteView` (one strlen, kept in the view's `n` field), `byte_at(v, i) -> Int` (the byte 0..255, or -1 outside `[0, n)`), `slice(v, lo, hi) -> String` (clamped into `[0, n]`, no strlen), and the length-taking primitive `range_copy(s, n, lo, hi) -> String` they are built on** — the SAFE linear-time way to walk a large String a byte at a time; see [§ Linear byte scanning](#linear-byte-scanning--the-string-cost-model) for the cost model and why the obvious loop is quadratic; `index_of`, `lower` / `upper`, `trim`, `substring(s, lo, hi)`, `replace`, `repeat`, `pad_left` / `pad_right`, `from_bytes`, `clone(v) -> String` (deep-copy a `StringView` to an owned blob; identity on a `String` for generic callers); `builder_new() -> Bytes` / `builder_append(b: Bytes, s: String) -> Bytes` / `builder_len(b: Bytes) -> Int` / `builder_finish(b: Bytes) -> String` (String-builder primitives, the builder carried as a `Bytes` handle and `builder_append` handing back the same one — for binary-safe accumulator use `std::bytes::BytesBuilder`); **the everyday predicates `contains` / `starts_with` / `ends_with` -> Bool** (2026-08-03 — the first two existed in the runtime with `memory(read)` attributes long before they were reachable from Hale; `ends_with` is new, and its absence is why the trio was unusable as a set); **`split_into(s, sep, target: @form(vec))`** (2026-08-03 — writes into caller-supplied storage rather than returning a sequence, following `text::tokenize_words_into`, because Hale has no sequence VALUE to return: arrays are fixed-size and growable collections are locus-owned. That is also the allocation-visible shape — the caller owns the storage, so the cost lands in the caller's budget and a `@hot` handler can reuse one vec. Empty fields are preserved: `"a,,b,"` is four); **`join(v: @form(vec) of String, sep) -> String`** (2026-08-03 — note this RETURNS where split writes: a String is already a value, so joining never meets the sequence question. One arena allocation sized in a first pass, not repeated concatenation); **UTF-8 accessors `cp_count(s)` / `cp_at(s, byte_off)` / `cp_size(s, byte_off)`** (2026-08-03 — `String` stays byte-oriented; these let a caller walk code points deliberately rather than pretending bytes are characters. Invalid UTF-8, and a byte offset landing mid-sequence, both yield -1 rather than U+FFFD, so corruption cannot be mistaken for content. Normalization, case folding beyond ASCII, grapheme segmentation and locale collation are each a separate commitment with megabytes of tables against the wasm target, and are deliberately NOT provided) | `lotus_str_*` C runtime primitives | | `std::bytes` | `at(b, i) -> Int fallible(IndexError)`, `slice(b, lo, hi) -> Bytes`, `from_string(s) -> Bytes`, `from_int(v) -> Bytes`, `concat(a, b) -> Bytes`, `clone(v) -> Bytes` (deep-copy a view to an owned blob). **Word-scan + masked-XOR** (2026-06-13, fast-protocol-I/O #4): `find_byte(b, off, needle) -> Int` returns the first index `>= off` whose byte equals `needle` (low 8 bits) or `-1` (non-fallible; `memchr` word-at-a-time scan — the length/delimiter-framing primitive for HTTP CRLF etc.). `read_*` / `at` / `find_byte` also accept a **`BytesMut`** raw `{ptr,len}` window (a `MirrorRing.readable()` window or a `Topic.write` slot) — read directly via their `_raw` siblings (length is the window length, no `[i64 len]` prefix), so a mirror-ring parse is zero-copy. `BytesBuilder.xor_mask(src: Bytes, key: Int)` (and its primitive `std::bytes::builder::__xor_mask_into(handle: Int, src: Bytes, key: Int) -> Int`) appends `src` XOR'd with a repeating 4-byte key (`masked[i] = src[i] ^ key[i % 4]`, key bytes packed little-endian) in one reserve + word-at-a-time pass — the WebSocket masking primitive, replacing a per-byte `from_int` + `append` loop. **Binary-pack readers** (2026-06-06, shm-ring-interop Proposal A): `read_u8` / `read_u16_{le,be}` / `read_u32_{le,be}` / `read_u64_{le,be}` and the signed `read_i8` / `read_i16_{le,be}` / `read_i32_{le,be}` / `read_i64_{le,be}` (sign-extended), each `(b, off) -> Int fallible(IndexError)`; plus `read_f32_le` / `read_f64_{le,be}` `-> Float fallible(IndexError)`. Fixed-width scalar reads at a byte offset, bounds-checked (`[off, off+width)` past the buffer raises `IndexError { kind: "out_of_bounds", index: off, len }`, same error as `at`). Endianness is explicit (`_le` is the x86-native common case); a `u64` with the top bit set wraps to a negative `Int` (i64). **Binary-pack writers** (2026-06-08, shm-ring-interop A1): the mirror `write_u8` / `write_u16_{le,be}` / `write_u32_{le,be}` / `write_u64_{le,be}`, signed `write_i8` / `write_i16_{le,be}` / `write_i32_{le,be}` / `write_i64_{le,be}`, and `write_f32_le` / `write_f64_{le,be}`, each `(buf, off, val) -> Int fallible(IndexError)` — a fixed-width scalar write at a byte offset into a **`BytesMut`** raw window (a `Topic.write` slot or a `MirrorRing.writable()` window), bounds-checked identically to the readers (`[off, off+width)` past the window raises `IndexError`), returning the offset past the write. These back the `Topic.write(max) { … }` zero-copy ring producer and the `repr:`-tagged `Type::set_field`. **The `BytesBuilder` primitives** (`std::bytes::builder::__*`, called by `bytes_builder.hl`; the handle `h` is an `Int`): `__new(cap: Int) -> Int`, `__append(h, chunk: Bytes) -> Int`, `__append_str(h, s: String) -> Int`, `__append_slice(h, src: Bytes, lo: Int, hi: Int) -> Int`, `__append_scalar(h, v: Int, width: Int, big_endian: Int) -> Int`, `__append_f32` / `__append_f64(h, v: Float, big_endian: Int) -> Int`, `__append_pad(h, to_align: Int) -> Int`, `__len(h) -> Int`, `__clear(h)` / `__free(h)` / `__shift_front(h, n: Int)` (each `-> Int`, the handle back), `__finish(h)` / `__snapshot(h) -> Bytes`, `__view(h) -> BytesView` and `__text_view(h) -> StringView`; `std::bytes::__is_alloc_fail(b: Bytes) -> Int` answers `1` for the allocation-failure sentinel. Growing-buffer accumulator surface lives on the `BytesBuilder` locus — see [§ Builders vs Bytes](#builders-vs-bytes--the-recv-loop-pattern) | `lotus_bytes_*` C runtime primitives | | `std::regex` | `matches(pattern, text) -> Bool` (full match), `find(pattern, text) -> Int` (leftmost byte offset, `-1` when absent), `valid(pattern) -> Bool`. Syntax: literals, `.`, `*`, `+`, `?`, `|`, grouping, character classes with ranges and negation, `\` escapes. **No backreferences, no lookaround** — the engine class is forced, not chosen: backtracking's exponential worst case cannot be bounded, so it is incompatible with `@budget` and `@hot`, and a linear-time Thompson NFA is the only option. Classified PURE; the match path allocates nothing beyond fixed state lists sized from the pattern, so a match is countable against a budget. `valid` exists so a malformed pattern is reported rather than silently matching nothing | `lotus_regex_*` C runtime (Thompson NFA over a state set) | | `std::text` | `md_to_html(md: String) -> String`, `base64::encode` / `base64::decode` / `base64::url_encode` (RFC 4648 §5 URL-safe, unpadded — for JWT/JWS, OAuth, webhooks), `Sink` interface + `StdoutSink` / `StringSink` / `FileSink` loci, byte-class predicates (`is_alpha` / `is_digit` / `is_alnum` / `is_whitespace` / `is_word_char`), `tokenize_words_into(s, target_vec)` | `runtime/stdlib/text.hl` + C runtime | | `std::io::fs` | `read_file(path) -> String`, `write_file(path, s) -> ()`, `write_file_append(path, s) -> ()` (in the `or` form; the bare legacy call returns an Int status, 0 ok / -1 error — GH #535 corrected the earlier "returns bytes appended" and the `Int` success type the checker advertised for the `or` form), `read_bytes -> Bytes`, `write_bytes(path, b: Bytes) -> ()` (binary-safe write — the companion `std::compress`/`std::tar` output needs; `write_file` ships String content via strlen and truncates at the first NUL), `file_size -> Int`, `mkdir`, `rename`, `unlink`, `mktemp(prefix, suffix) -> String` (creates the file `prefix` + six random characters + `suffix` with mode 0600 through `mkstemps(3)`, closes it and returns its path, which the caller removes; a directory goes in the prefix, `mktemp("/tmp/run-", ".log")`, and a failure's `IoError.path` is the template), `list_dir_count -> Int`, `list_dir_at(path, i) -> String` — all `fallible(IoError)`. (`list_dir` is listed in older notes but not dispatched — use list_dir_count + list_dir_at.) `file_exists(path) -> Bool` is a predicate (non-fallible). One-shot path-call surface: each call opens, does the op, closes. For held-open shapes use `std::io::file::File`. | `lotus_fs_*` C runtime primitives | | `std::compress` | GH #254 (2026-07-27). One-shot compression over `Bytes`, all `fallible(IoError)`: `gzip(b) -> Bytes` (gzip container, zlib level 6), `gunzip(b) -> Bytes` (accepts gzip OR bare zlib streams — windowBits 15+32 auto-detect), `zstd(b) -> Bytes` (zstd frame, level 3), `unzstd(b) -> Bytes`. Error kinds: `invalid` (corrupt / truncated / unsupported input, incl. a zstd frame with no declared content size — streaming frames are the v2 path), `not_found` (**zstd only**: libzstd not installed — see below), plus EFBIG-backed kinds when decompressed output would exceed the **1 GiB one-shot guard** (zip-bomb / corrupt-length protection; streaming is the path for bigger). Linkage: gzip rides zlib (`-lz`, universally present — distro zlib1g / the macOS SDK); zstd is **dlopen'd at first use** (`libzstd.so.1` / Homebrew dylib paths on macOS), so neither `hale build` nor emitted binaries carry a link-time libzstd dependency — a program that never calls the zstd pair runs anywhere, and one that does gets a clean fallible `not_found` on machines without the library. Compression levels are fixed at v1 (no level parameter yet). | `lotus_compress_*` in `runtime/lotus_compress.c` (own TU, same rationale as lotus_tls.c) | | `std::tar` | GH #254 (2026-07-27). One-shot ustar archives over `Bytes`, all `fallible(IoError)` (kind `invalid` = malformed archive / index out of range). Read side is indexed (the list-then-extract shape; each call re-walks headers — O(n), fine for tool-sized archives): `entries(b) -> Int`, `entry_name(b, i) -> String` (ustar prefix field honored: `prefix/name`), `entry_size(b, i) -> Int`, `entry_type(b, i) -> String` (`file` / `dir` / `link` / `symlink` / `other`), `entry_data(b, i) -> Bytes`. Write side is append-style: start from empty Bytes, `pack(archive, name, data) -> Bytes` appends a regular file (mode 644), `pack_dir(archive, name) -> Bytes` a directory (mode 755), `finish(archive) -> Bytes` appends the two terminating zero blocks. Names over 100 bytes are split into the ustar prefix field (≤ 155 + `/` + ≤ 100; ENAMETOOLONG otherwise); mtime is written as 0 for reproducible archives; octal sizes only (no GNU base-256). Compose with `std::compress::gzip` for `.tar.gz` and `std::io::fs::write_bytes` to ship to disk — the output is accepted by system `tar` (pinned by `compress_tar.rs`). | tar section of `runtime/lotus_compress.c` | | `std::io::file` | `File` locus (held-open fd with auto-dissolve close). `open(path, mode) -> File fallible(IoError)`; `read_line(f) -> String` (returns "" at EOF or error — pair with `at_eof`); `at_eof(f) -> Bool`; `write_bytes(f, b)`, `write_line(f, s)`, `seek(f, offset)` all `fallible(IoError)`. Mode strings `"r"` / `"w"` / `"a"` / `"r+"` / `"w+"`. Returned Strings live in the bus payload arena. The locus's primitives take the fd as an `Int`: `__open(path: String, mode: String) -> Int`, `__seek(fd, offset: Int) -> ()` and `__write_bytes(fd, b: Bytes) -> ()`, each `fallible(IoError)`; `__read_line(fd) -> String`, `__at_eof(fd) -> Bool` and `__close(fd) -> Int` (`0` or `-1`). | `lotus_file_*` C primitives + `runtime/stdlib/file.hl` | | `std::io::stdin` | `read_line() -> String`, `read_line_status() -> Int` (status `-1` = EOF/IO error; `0` = OK including empty-line). `read_byte(timeout_ms: Int) -> Int` — `poll` up to `timeout_ms` then a 1-byte `read`; returns `0..255` = the byte, `-1` = timeout, `-2` = EOF/error (sentinel; a timeout is a control outcome, not an error). `timeout_ms <= 0` is a pure poll. For interactive raw-mode input paired with `std::term::RawMode`. | POSIX `getline` / `poll`+`read` + payload-arena copy | | `std::io::stdout` | `write_bytes(s: String) -> Int` — `fflush(stdout)` then a raw `write(1, ...)` that bypasses the prelude's `_IOLBF` line-buffering, so a multi-line frame isn't flushed per newline. Returns bytes written, `-1` on error (sentinel). The `fflush` keeps output ordered consistently with buffered `println`. | `lotus_term_write_stdout` C primitive | | `std::term` | `is_tty(fd: Int) -> Bool` (POSIX `isatty` — probe whether an fd is a terminal, e.g. so a logger can decide on color without vendoring an FFI shim). `RawMode` — an RAII guard locus: `let r = std::term::RawMode { };` puts stdin in raw mode (no echo/canon/ISIG — unbuffered byte input, Ctrl-C as byte `0x03`) at birth and restores the saved termios at dissolve (scope exit). Soft-fails (runs unstyled) when stdin isn't a tty. `RawMode` also registers a runtime `atexit` termios restore, so a panic / unhandled error (which `exit()` through `atexit`) restores the terminal too — no FFI glue needed for terminal hygiene. `size() -> TermSize` (record `{ cols: Int; rows: Int }` via `ioctl(TIOCGWINSZ)`; `{ 0, 0 }` when stdout isn't a tty — poll per frame, no SIGWINCH handling). POSIX-only; non-tty / non-POSIX degrades soft. The primitives `term.hl` calls: `__raw_enable() -> Int` / `__raw_disable() -> Int` (`1` ok / `0` failed) and `__size_packed() -> Int` (`(cols << 16) | rows`). | `lotus_term_*` C primitives + `runtime/stdlib/term.hl` | | `std::io::tcp` | `Listener` locus (a listener of the TCP connection substrate calling an `on_connection` callback per connection: § The TCP connection substrate), `Stream` locus with `send` / `send_bytes` (Unit success) / `recv` / `recv_bytes` — all `fallible(IoError)` since #209 (EOF and recv-timeout return empty, only genuine errors fail; see § Stream methods below), `recv_into(fd, buf: std::bytes::BytesBuilder, max_bytes) -> Int` (caller-provided builder destination). Path-calls `listen_socket`, `connect`, `accept_one` are `fallible(IoError)`. `connect` accepts dotted-quad hosts directly and falls back to hostname resolution via `getaddrinfo(AF_INET)`. **A refused connect is an answer (GH #1030):** `connect` makes one attempt, and a connect nothing listens for fails at once with kind `connection_refused` (it used to retry for ~1 s, which made a client of a store that was down pay a second per dial). A caller racing its peer's `listen()` asks for the wait: `connect_wait(host, port, wait: Duration) -> Int fallible(IoError)` retries a refused connect every 5 ms until `wait` has passed, or the process drain begins, then fails with `connection_refused`; any other error ends it at once. `std::io::tls::connect` dials through `connect`, so it fails fast too. The bus transports' own CONNECT role (`unix://`, `LOTUS_BUS_CONFIG`) keeps its ~1 s retry, for a peer process starting at the same moment. **Exclusive bind (2026-07-15, downstream handoff item 4):** `listen_socket` sets `SO_REUSEADDR` (so a restart can rebind a port still lingering in `TIME_WAIT`) but deliberately **not** `SO_REUSEPORT` — so a second live process binding the same host:port fails with `EADDRINUSE` instead of silently sharing the port with the kernel round-robining connections between two divergent-state servers (a split-brain foot-gun). Intentional multi-process load balancing across a shared port is not the default; it would need an explicit opt-in if a workload ever wants it. Send/recv timeouts: `set_recv_timeout(fd, d: Duration) -> () fallible(IoError)` wraps `SO_RCVTIMEO`; `set_send_timeout(fd, d: Duration)` wraps `SO_SNDTIMEO`. `d = 0` disables (blocking default). After `set_recv_timeout(fd, 100ms)` a `recv_bytes` on a quiet socket returns ~100ms instead of blocking forever — unblocks recv loops that need periodic silence-detection / heartbeat / watchdog work. **Sub-millisecond timeouts aren't real:** `SO_RCVTIMEO` is rounded to the kernel's scheduling tick, so a `set_recv_timeout(fd, 50us)` parses but an idle recv still returns after ~1–1.5ms, not 50µs. Poll loops that need finer cadence must amortize their probes rather than lean on a sub-ms deadline (the async_io timed park inherits the same floor — its deadline comes from the same `SO_RCVTIMEO` value). Shares the `sock_set_timeout_ns` helper with the udp siblings (P4). **async_io parking (2026-07-14, downstream handoff):** on a `where async_io` pool, `recv_into` / `recv_stamped_into` / `recv_bytes` / `Stream.recv` park the coroutine on EPOLLIN until the fd is readable or the fd's `set_recv_timeout` deadline expires — `-2` (or `recv_bytes`'s empty return) means the *deadline expired*, never an instant would-block; with no timeout set they park indefinitely. The park deadline mirrors the socket's `SO_RCVTIMEO` (read back via `getsockopt` at call entry), so timeout semantics are identical on every pool type and liveness machinery built on the `-2` sentinel (pond/websocket ping-pong) composes unchanged. **`Stream.release_fd() -> Int`** (2026-07-19): flips `owns_fd` off (dissolve becomes a no-op) and returns the fd — the ownership hand-off primitive behind `std::http` takeover and any live-connection transfer to a longer-lived owner. **`send_fd(fd, b: Bytes) -> () fallible(IoError)`** (2026-07-27, hale-bun handoff item 4b): public raw-fd send — the write-side takeover companion to `close_fd` / `recv_into` / `set_recv_timeout`, for a handler that keeps a released fd without adopting `Stream`'s close-at-scope-exit lifecycle. Same contract as `Stream.send_bytes` (Unit success; genuine I/O errors fail); previously the only persistent-write path was the internal `__send_bytes`. Nagle control: `set_nodelay(fd, on: Bool) -> () fallible(IoError)` sets `TCP_NODELAY` on a connected fd — `on = true` disables Nagle so small writes hit the wire immediately instead of waiting up to ~40ms to coalesce, the first thing a latency-sensitive request/response or market-data socket reaches for. Previously impossible from Hale (`std::io::tcp` had no setsockopt surface); the generic udp `set_option_*` / `get_option_int` work on any fd if a less-common TCP option is needed. Kernel RX timestamps: `set_rx_timestamps(fd, on: Bool) -> () fallible(IoError)` enables `SO_TIMESTAMPNS` once at setup; `recv_stamped_into(fd, buf: std::bytes::BytesBuilder, max_bytes) -> Int` is the timestamped sibling of `recv_into` (identical `>0` / `0` EOF / `-1` fatal / `-2` retryable contract) that issues one `recvmsg(2)` capturing the kernel's wire-arrival timestamp alongside the bytes — no extra syscall on the hot path. Read the stamps with `last_recv_kernel_ns() -> Int` / `last_recv_user_ns() -> Int` immediately after the call (errno-style thread-local, same idiom as `udp::last_source_*`). `last_recv_user_ns` is a `CLOCK_REALTIME` stamp taken at `recvmsg` return; `last_recv_kernel_ns` is the kernel's `SCM_TIMESTAMPNS` value, or **`0` when the platform/path delivered none** — notably loopback TCP and any NIC without RX software/hardware timestamping (it never returns garbage, so `>= 0` is the contract). The cmsg is parsed defensively (first control message only, length-validated, no `CMSG_NXTHDR` walk — some libcs loop forever on a zero-length cmsg). The `Listener` / `Stream` loci's primitives take fds as `Int`s and answer status the sentinel way (`-1` on failure, the cause in `__last_io_status() -> Int` / `__io_error_kind(errno: Int) -> String`): `__listen_socket(host: String, port: Int) -> Int`, `__accept_one(listen_fd) -> Int` (the connection's fd; `-1` when the wait ended on purpose — the socket shut by `stop` / `shutdown()`, including a Unix one on an `async_io` pool, which answers a non-blocking accept `EAGAIN` once shut — or the socket is no listener; `-2` when the failure ended one connection and not the listener: out of descriptors or buffers, or a network error the kernel passed up from a connection that failed before it was taken), `__connect(host: String, port: Int) -> Int`, `__recv(fd, max_bytes: Int) -> String`, `__recv_bytes(fd, max_bytes: Int) -> Bytes`, `__send(fd, msg: String) -> Int`, `__send_bytes(fd, b: Bytes) -> Int`, `__set_recv_timeout_ns(fd, ns: Int) -> Int`, `__close_fd(fd) -> Int` and `__shutdown_listen_socket(fd) -> Int`; `__parks() -> Int` is `1` where a blocking socket call parks the calling coroutine (an `async_io` pool's worker) and `0` where it holds the thread, and `__on_pool() -> Int` is `1` on any cooperative pool's worker (where a `yield` in a loop that never returns runs none of the pool's queued cells), which is how the TCP connection substrate picks its connection shape; `__name_listener(name, fd) -> Int` and `__stop_listener(name) -> Int` keep the process's listeners by name, behind `stop(name: String) -> Bool` (§ Stopping a server). The substrate's loci and handle — `Acceptor`, `Connections`, `Accepted`, `ConnHandler`, and the `Listener` built on them — are in § The TCP connection substrate. `__listen_socket` / `listen_socket` listen with the kernel's largest backlog (`SOMAXCONN`, clamped to `net.core.somaxconn`; it was 16, and a burst of clients larger than that lost SYNs and paid TCP's 1 s retransmit). | `lotus_tcp_*` C primitives | | `std::io::h2` | GH #1417 (R7). An HTTP/2 server over a parked socket, built on nghttp2 (MIT, vendored under `crates/hale-codegen/runtime/third_party/nghttp2`) compiled into the runtime by its cc step and linked statically into a program that reaches it. The library is sans-I/O and owns no thread, socket or blocking call: `runtime/lotus_h2.c` is bytes in (`__feed`), bytes out (`__drain`), a queue of events (`__poll`, `__ev_stream`, `__ev_code`, `__ev_bytes`: 1 a request opened with its headers, 2 data, 3 end, 4 reset, 5 GOAWAY, 6 stream closed) and `__respond` (headers, body, trailers), `__reset`, `__goaway`, `__alive`, `__open`, `__close`, all on an `Int` handle, all computation on the calling thread. `Listener` and `Conn` (`io_h2.hl`, appended to a program that serves over `grpc::Rpc` or names one of them) accept on the `async_io` pool and own a socket and a session each — the `Listener` is a listener of the TCP connection substrate (§ The TCP connection substrate), named `host:`, whose consumer starts a `Conn` per accepted connection as a flow child on its pool, so a connection nothing takes is closed and `std::io::tcp::stop(name)` closes it while its connections are served on: a connection feeds each read to its session, writes what it drains and raises the session's stream events (`Event`, subject `__api.h2.event`) for a transport, which asks it to answer or reset a stream, or say GOAWAY (`Cmd`, `__api.h2.cmd`). SETTINGS, PING and the flow-control windows are the library's; a connection ends alone on an EOF, a failed write, a protocol error or the peer's GOAWAY once its streams are done. The compiler's own expansion places the listener by its stdlib name `__StdIoH2Listener`; a param spelled `std::io::h2::Listener` and placed on a pool does not have its `run()` posted to that pool (observed in R7, not changed there). Refused on wasm32. | | `std::io::unix` | GH #1106. AF_UNIX stream sockets with the fd-level shape `std::io::tcp` has: `listen_socket(path) -> Int fallible(IoError)` unlinks a stale socket file, binds and listens (with the kernel's largest backlog, `SOMAXCONN`, as a TCP listener; it was 16, and a burst of clients larger than that waited in `connect(2)`); `std::io::tcp::Acceptor { path }` is the substrate's listener on one (§ The TCP connection substrate); `__release_path(path, fd) -> Int` removes the socket file the listener on `fd` bound at `path` only while the path still names that file (its device and inode recorded at bind, and any record of the path retired by a later bind of it in the process; 1 when removed), called before `fd` is closed — a shut listener's path may meanwhile have been taken as stale by a replacement (a shut Unix socket refuses connects, and the stale check steals it rather than refuse a path whose listener has stopped), whose file the old listener's cleanup must not delete; `connect(path) -> Int fallible(IoError)` is one attempt (a missing or refusing socket fails at once); `connect_wait(path, wait: Duration) -> Int fallible(IoError)` retries a missing or refusing socket until `wait` has passed, for a caller racing its peer's listen. The fd is address-family agnostic: wrap it in `std::io::tcp::Stream { conn_fd }`, and accept, recv, send, shutdown and close are the tcp primitives. The api binding's socket side is built on these (`spec/semantics.md` § "The api binding"). `peer_uid(fd)`, `peer_gid(fd)`, `peer_pid(fd) -> Int` (GH #1108) answer the peer's credentials on a connected socket, -1 when the kernel will not say; the api binding's caller is built from them. `user_id(name) -> Int`, `group_id(name) -> Int` (GH #1109) resolve a name per the host's account database (`getpwnam` / `getgrnam`), -1 when it has none — the static role table's `user:` / `group:` spellings stand on these, once at birth; `peer_groups_count(fd) -> Int`, `peer_group_at(fd, i) -> Int` are the supplementary groups the kernel holds for a connected peer (`SO_PEERGROUPS` on Linux, the first 256; 0 and -1 elsewhere), read once per connection so no account lookup runs per request. | | `std::api` | GH #1108. `Principal { mode: String = "local"; name: String = "local"; uid: Int = -1; gid: Int = -1; pid: Int = -1; groups: String = ""; via: String = ""; }` (`groups`, GH #1109: the supplementary groups the kernel holds for the connection, comma-separated; not on the receipt) is who is calling as the exposure established it (`local` for a call that never crossed a transport, `unix` for a peer on a `unix::Rpc` socket named `uid:`, `bearer` for a token on an HTTP, MCP or hub transport, named as the program's `BearerSource` answers) — the shape DNA carried by hand as `principal_mode` / `principal_name`. `Context { caller: Principal; role: String = ""; request_id: Int = 0; via: String = "local"; }` is what a served handler that declares a second parameter receives (`spec/api.md` § Serving); `local_context() -> Context` is the one a call that never crossed a transport carries. Both are ordinary structs: constructible in a test, forwardable in a payload. GH #1109: `interface RoleSource { fn holds(p: Principal, r: String) -> Bool; }` is the membership source an exposure consults at its `requires` — the direct question only; the exposure walks `includes` itself — handed to the transport instance as `roles: self.`; `StaticRoles { table: String; known: String }` is the stdlib's implementation, its table `role=member,member;role=` passed as the `table` param and overridden by `LOTUS_API_ROLES`, `known` the roles the program declares; at birth a table naming another role, or a member outside `uid:`, `gid:`, `user:` / `group:` / `bearer:` (a name: letters, digits, `.`, `_`, `-`, `@`) and `*`, refuses to start, saying which entry. `*` matches any authenticated caller: a socket peer (uid ≥ 0) or a bearer principal its source named (`mode: "bearer"`, a non-empty `name`). `bearer:` matches a bearer principal whose `name` is exactly `` (never resolved: the name is the bearer source's) and never a socket peer; `uid:` / `gid:` / `user:` / `group:` match socket peers and never a bearer principal. A `gid:` member matches the peer's primary group or one of `Principal.groups`; `user:` / `group:` resolve once at birth per the host's account database (an unknown name is reported to stderr and holds nobody); an unauthenticated principal (a socket peer with uid -1, a bearer principal with an empty `name`) holds nothing. GH #1417 (`spec/api.md`, the contract; R2a ships the runtime, R2b to R6 the transports): `api::serve(Surface, transport, as: "name", receivers: { … }, bound: N, on_full: refuse)` puts a surface on a transport instance and returns the handle `main` stops. R2a: `Rpc` is the transport interface (`attach`, `frame`, `reply`, `close_connection`, `stop_listening`; the stdlib's are `std::api::test::Rpc`, the in-process fixture, and (R2b) `std::api::unix::Rpc`, a Unix stream socket carrying one JSON object per line whose principal is the peer's kernel credentials, served from the main locus (its listener, `std::api::unix::Listener`, is a param the serve expansion adds to it, placed on the `async_io` pool `__api_unix`); `std::api::ws::Hub` and `std::api::udp::Hub` (R5, `hale_stdlib::API_HUB_SOURCE`, appended after the runtime to a program that binds a topic to a hub: the hub is a main-locus param whose address is bound at birth, its listener a param the compiler adds, placed on the `async_io` pool `__api_ws`; a topic bound to it is a stream, an adapter binding with the topic's JSON codec; it admits a subscription against the row's `requires`, delivers from a queue of `bound` frames shedding under `on_full`, ends a subscription at the credential's expiry and at a role-source revision, sends `closed` at `stop()`, answers `GET /.description`, and may be the transport a surface is served over; its methods are `events()`, `subscribers()`, `connections()`, `announce(revision)` and `stop()`); `http::Rpc` (R3) and `mcp::Rpc` (R6: JSON-RPC over `POST /mcp`, built on the HTTP listener) are shipped too, and `grpc::Rpc` (R7, `hale_stdlib::API_GRPC_SOURCE`: unary gRPC calls over the HTTP/2 server of `std::io::h2`, `hale_stdlib::API_H2_SOURCE`, whose listener is a main-locus param on the `async_io` pool `__api_grpc`; both are appended only to a program that names `grpc::Rpc`)); `Request { kind; member; bytes; digest; correlation; credential; peer; reason }` is what `frame` returns, `Outcome` (`Result(String)`, `HandlerError(String)`, `Refusal(kind, reason, extra)`, `ServerError`) what `reply` is handed; `Exposure` is the locus the runtime builds per serve site (admission, dispatch, the pending table that completes each request once, `stop()`), `Handle { fn stop(); }` what a serve returns; `Surface` is the rows the exposure reads (generated per surface); `Grants { roles; revision }`, `RevisedRoleSource { fn grants(p) -> Grants; }`, `RevisedStaticRoles` (the static table's pair) and the wire subject `__api.roles.revision` (`Revision { source; revision }`) are a role source's grants-and-revision pair, `ExpiringBearerSource { fn expiry(token) -> Int; }` a bearer credential's expiry (Unix nanoseconds, 0 is none), `ServedContext` is `Context` with the `exposure` and `generation` of a served call (`Context` is unchanged); the runtime is `hale_stdlib::API_RUNTIME_SOURCE`, appended to a program that serves or names it, not part of the bundled stdlib; `test::Rpc` is the fixture transport (`call`, `call_as_peer`, `call_digest`, `describe`, `lose`, `outcome`, `line`, `await_outcome`); `Context`, `Principal`, `RoleSource` and `BearerSource` stay, the two sources named on the transport instance (`principals:`, `roles:`). | | `std::io::udp` | `bind(host, port) -> Int fallible(IoError)` (`host=""` → INADDR_ANY); `send(fd: Int, host: String, port: Int, msg: String) -> () fallible(IoError)` (the message is text: a `Bytes` is refused), `recv(fd, max_bytes)`, `recv_into(fd, buf: std::bytes::BytesBuilder, max_bytes)` (`>0` bytes / `0` empty datagram / `-1` fatal / `-2` retryable — a `set_recv_timeout` expiry or async_io park deadline; **2026-07-14 behavior change**: EAGAIN previously fell into `-1` fatal, and on `where async_io` pools `recv_into` now parks until readable or deadline like the tcp sibling), `close(fd) -> Int` (and its primitive `__close(fd: Int) -> Int`). Multicast (2026-05-26 P1): `join_group(fd, group, iface) -> () fallible(IoError)` (iface=`""` → INADDR_ANY), `leave_group(fd, group, iface)`, `set_multicast_ttl(fd, ttl)` (0..255), `set_multicast_loop(fd, enabled: Bool)` (whether the sender receives its own packets), `set_multicast_iface(fd, addr)`. Transparent setsockopt pass-through (P2): `set_option_int(fd, level, name, value)`, `set_option_bool(fd, level, name, enabled)`, `get_option_int(fd, level, name) -> Int` — paired with `std::io::sockopt::()` named constants below for the `level` / `name` args. Source-bearing recv + timeouts (2026-05-26 P4): `recv_with_source(fd, max_bytes) -> Bytes fallible(IoError)` populates a thread-local source cache; `last_source_host() -> String` / `last_source_port() -> Int` read the cache from the most-recent recv_with_source on the current thread (errno-style; read immediately after recv). `set_recv_timeout(fd, d: Duration) -> () fallible(IoError)` / `set_send_timeout(fd, d: Duration)` wrap `SO_RCVTIMEO` / `SO_SNDTIMEO` (they take a struct timeval so can't ride `set_option_int`); `d = 0` disables (blocking default). Datagram boundaries preserved. **async_io parking (2026-07-15, downstream handoff item 3):** the Bytes-returning `recv` / `recv_with_source` (like `recv_into` before them) park the coroutine on EPOLLIN on a `where async_io` pool — bounded by the socket's `set_recv_timeout` deadline, or indefinitely when no timeout is set — instead of blocking the single pool worker inside `recvfrom`. This is what lets N reader loci, each parked on its own socket, share one async pool concurrently: previously the first blocking `recv` pinned the worker and every reader queued behind it on the same pool never started (with no timeout, never at all). Off async pools it stays a plain blocking `recvfrom`, and a park-deadline expiry surfaces the same "no datagram" result (NULL → the fallible error path) a blocking `SO_RCVTIMEO` timeout already produced. **`std::io::udp` is the raw-socket primitive, not a bus transport** — its `recv` / `send` calls don't carry the bus's typed-payload-dispatch contract. For UDP-as-bus see the `std::bus` row's `udp://host:port` substrate transport (shipped 2026-05-26): single URL scheme covers IPv4 unicast and multicast, dispatch goes through the same `LOTUS_BUS_CONFIG` route as `unix://`, lossy delivery (publisher-side "sendto returned" durability; subscribers best-effort). **`Reader` handle (2026-07-16) — the ergonomic event-driven ingest default.** `std::io::udp::Reader { addr, port, cap }` bundles a bound socket + a single reused `BytesBuilder` and exposes `next() -> BytesView fallible(IoError)`: it binds lazily on the first call (so a bind failure propagates through `next()`'s fallible channel), clears + refills the buffer in place per call (no per-datagram allocation), and returns a **zero-copy `BytesView`** aliasing the buffer — valid until the next `next()`. On a `where async_io` pool `next()` parks on EPOLLIN (kernel-woken, no busy-poll, no `SO_RCVTIMEO` quantum); on a classic pool it blocks the worker in `recvfrom`. It parks until a datagram arrives, so its only failures are a bind failure (first call) or a fatal recv error — both genuinely exceptional, so `or raise` is the disposition (`or discard` doesn't apply — `next()` yields a value). This is the hand-rolled "bind + BytesBuilder + `recv_into` + `.view()`" fast path baked into one handle so it's the path of least resistance; unlike the allocating `recv` it copies no per-datagram payload (the view aliases the buffer). `dissolve()` closes the socket; the nested `BytesBuilder` frees with it. | `lotus_udp_*` C primitives | | `std::io::sockopt` | Named-constant getters returning the platform's numeric value for each setsockopt level / name. Use as the `level` / `name` args to `std::io::udp::set_option_*` / `get_option_int`. Each is a zero-arg fn `() -> Int` (`std::io::sockopt::SO_RCVBUF()` etc.) so the value tracks the kernel headers; cross-platform without hardcoding. Shipped: `SOL_SOCKET`, `IPPROTO_IP`, `IPPROTO_IPV6`, `IPPROTO_TCP`, `IPPROTO_UDP`, `SO_REUSEADDR`, `SO_REUSEPORT`, `SO_RCVBUF`, `SO_SNDBUF`, `SO_RCVTIMEO`, `SO_SNDTIMEO`, `SO_BROADCAST`, `SO_KEEPALIVE`, `SO_LINGER`, `SO_PRIORITY`, `SO_BINDTODEVICE`, `IP_TTL`, `IP_TOS`, `IP_MULTICAST_TTL`, `IP_MULTICAST_LOOP`, `IP_MULTICAST_IF`, `IP_ADD_MEMBERSHIP`, `IP_DROP_MEMBERSHIP`, `IP_PKTINFO`. PMTU surface added 2026-05-27: `IP_MTU_DISCOVER` + `IP_PMTUDISC_DONT` / `IP_PMTUDISC_WANT` / `IP_PMTUDISC_DO` / `IP_PMTUDISC_PROBE` (Linux-only; returns -1 on platforms missing the constant — caller can detect and skip). TCP option added 2026-06-13: `TCP_NODELAY` (use with `IPPROTO_TCP` as `level`; or reach for the `std::io::tcp::set_nodelay` convenience). | `lotus_sockopt_` C getters | | `std::io::mirror` | The primitives behind the `std::io::MirrorRing` locus, called by `mirror_ring.hl` (not a user surface): `__new(cap: Int) -> Int` maps the double-mapped ring and returns its handle; `__recv_into(handle: Int, fd: Int, max: Int) -> Int` reads into the free window (`> 0` bytes / `0` / `-1` fatal / `-2` retryable). The cursor primitives `__free`, `__commit`, `__consume`, `__readable`, `__writable`, `__len` and `__capacity` take the handle (and a byte count for `__commit` / `__consume`) and have no signature: the compiler does not count their arguments. | `lotus_mirror_ring_*` C primitives | | `std::io::tls` | Client-side TLS via system OpenSSL. `connect(host, port) -> Int fallible(IoError)` does the TCP connection + TLS 1.2+ handshake with SNI + system-trust-store cert verification. **Socket upgrade** (2026-07-14): `upgrade(fd: Int, host: String, verify: Bool) -> Int fallible(IoError)` wraps an *already-connected* TCP fd (from `std::io::tcp::connect`) in a client TLS session — the STARTTLS-style path a protocol takes after speaking a plaintext prologue on the socket (e.g. pond/pq sends the pgwire `SSLRequest`, reads the `'S'` byte, then upgrades the same fd). The returned handle is fully interchangeable with `connect`'s across `send_bytes` / `recv_bytes` / `recv_into` / `close` / `set_nodelay` / etc. SNI (`SSL_set_tlsext_host_name`) is **always** sent. `verify = true` authenticates the peer: hostname-checked against the cert via `SSL_set1_host` against the system trust store (identical to `connect`). `verify = false` gives **sslmode=require semantics** — encrypt *without* authenticating the peer (`SSL_VERIFY_NONE` per-connection override, no hostname check), for endpoints whose CA is not in the system trust store (e.g. AWS RDS); SNI is still sent. **FD-ownership asymmetry (important):** `upgrade` does **not** close `fd` on handshake failure — the caller already owned the fd (it came from `tcp::connect`, not from this call), so teardown stays with the caller (`std::io::tcp::close_fd(fd)` in the `or` handler). This is the *opposite* of `connect`, which dials its own fd and therefore closes it on any failure. On success the returned handle owns the fd (`close` closes it), same as `connect`. **Caveats:** pass a *hostname*, not an IP literal, for `host` when `verify = true` — `SSL_set1_host` treats the value as a DNS name for certificate hostname matching, and using an IP address as SNI is discouraged (some servers reject or mismatch it). Do **not** `upgrade` the same fd twice — each returned handle assumes sole ownership of the fd at `close` time, so a double-upgrade produces two handles that both believe they own the fd, leading to a double-close. **STARTTLS over-read:** before calling `upgrade`, read *exactly* the plaintext prologue and no further (pond/pq reads the single `'S'` byte and stops). Any bytes consumed past the prologue are gone from the kernel receive buffer, so the peer's TLS `ServerHello` — which follows immediately on the wire — would be handed to the caller as plaintext and the handshake would then read a truncated, corrupt stream (the classic STARTTLS plaintext-injection footgun). The current design is safe because `upgrade`'s recvmsg BIO and the `tcp::recv_*` primitives share **no** read buffer — every read pulls straight from the socket, so there is nowhere to accidentally buffer post-prologue bytes — but any future read-ahead/buffered `tcp` reader would break this property, so a consumer must keep its prologue reads unbuffered and byte-exact right up to the `upgrade` call. **`Int` is overloaded — a raw fd and a TLS handle are not interchangeable even though both are typed `Int`.** TLS handles are small table indices (0, 1, 2, …) that numerically alias real fd numbers, so the type system cannot catch a mix-up: passing a TLS *handle* to `upgrade` (which expects a raw fd) runs `SSL_connect` on whatever kernel fd happens to share that number (e.g. fd 0/1/2 = stdio), and passing a raw *fd* to `tls::send_bytes` / `recv_bytes` indexes the handle table with a value that isn't a handle — **both are silent misuse** (no error raised, just corrupt/garbage I/O), not a type error. Only ever pass `upgrade` an fd from `tcp::connect`, and only ever pass the `tls::*` send/recv/close primitives a handle returned by `tls::connect` / `tls::upgrade`. `connect` is now internally `dial + upgrade(fd, host, verify=1)`, so the `connect` behavior is unchanged and its network tests double as regression coverage for the shared upgrade path. `send_bytes` / `recv_bytes` / `recv_into` / `close` over the handshaked connection. **`send_bytes(handle, b: Bytes) -> Int` answers a status, not an error:** `0` once the whole buffer is written, `-1` on a failure (errno set; an `SSL_write` failure is also printed to stderr) — never a byte count, and not `fallible`, so an `or` after it is refused ("\`std::io::tls::send_bytes\` is not fallible (it returns \`Int\`); drop the \`or\` clause"). That is the asymmetry with `std::io::tcp`, whose `Stream.send_bytes` and `send_fd` are `-> () fallible(IoError)`: a TLS writer tests the `Int`. `recv_bytes(handle, max) -> Bytes` answers empty on a failure as on EOF. Send/recv timeouts: `set_recv_timeout(handle, d: Duration) -> () fallible(IoError)` / `set_send_timeout(handle, d: Duration)` wrap `SO_RCVTIMEO` / `SO_SNDTIMEO` on the connection's underlying socket fd (the handle-aware siblings of the `std::io::tcp` ones, which take a raw fd). With a recv timeout set, `recv_into` returns the `-2` "timed out, retryable" sentinel (not `-1`/fatal) when `SSL_read` yields `SSL_ERROR_WANT_READ` — so a long-lived client can bound a blocking read and run connection-liveness work (ping/pong) instead of hanging forever on a half-open connection. On a `where async_io` pool, `recv_into` / `recv_stamped_into` park the coroutine on the raw fd (EPOLLIN, or EPOLLOUT on a renegotiation `WANT_WRITE`) until readable or the recv-timeout deadline — same 2026-07-14 parking semantics as `std::io::tcp`; `-2` means the deadline expired, never an instant would-block. **Fast-path siblings**: `set_nodelay(handle, on: Bool) -> () fallible(IoError)` (TCP_NODELAY on the underlying fd — reuses the tcp primitive) and `recv_stamped_into(handle, buf: std::bytes::BytesBuilder, max) -> Int` with `last_recv_kernel_ns() -> Int` / `last_recv_user_ns() -> Int`, the TLS siblings of the `std::io::tcp` versions. `set_rx_timestamps(handle, on: Bool) -> () fallible(IoError)` enables `SO_TIMESTAMPNS`. The kernel timestamp rides the *socket* recvmsg but `SSL_read` sits in front of the socket, so the TLS connection uses a custom BIO whose read does `recvmsg` + the defensive `SCM_TIMESTAMPNS` cmsg walk, capturing the stamp on the socket fill that feeds `SSL_read` — `last_recv_kernel_ns` is the last segment's kernel RX stamp (0 when the path delivered none, e.g. no NIC RX timestamping), `last_recv_user_ns` is a `CLOCK_REALTIME` stamp at `SSL_read` return. (The recvmsg cmsg is the path that carries `SO_TIMESTAMPNS`; the `SIOCGSTAMPNS` ioctl reads `sk_stamp`, which that option does not populate.) Process-global `SSL_CTX` runs with `SSL_MODE_RELEASE_BUFFERS` — OpenSSL releases its read/write buffers between records so long-running TLS clients don't accumulate ~32 KiB per idle connection. **Allocation (audit, fast-protocol-I/O #6):** `recv_into` is zero-alloc on the Hale side — `SSL_read` decrypts straight into the caller's reserved buffer, no per-record malloc in the binding (the `tcp`/`udp` `recv_into` siblings likewise; pinned by `crates/hale-codegen/tests/recv_zero_alloc.rs` via the `std::diag` counter). The only per-record TLS allocation is OpenSSL-internal: with `SSL_MODE_RELEASE_BUFFERS` set, a released read buffer is re-malloc'd on the next record — a deliberate memory-vs-malloc tradeoff. An always-busy latency-critical connection that prefers zero per-record malloc over frugal idle memory would clear that mode (retain the buffers); that knob isn't exposed yet (no consumer). The `lotus_tls.c` TU compiles separately so helper tests linking `lotus_arena.c` directly don't drag in libssl/libcrypto. | `lotus_tls_*` in `runtime/lotus_tls.c` | | `std::shm` | In-band record-header field delivery for a foreign ring (2026-06-13, shm-ring-interop). When a `layout:`-bound subscriber's `ring_layout` declares `record_header_bytes` with in-band header scalars (a per-record fixed header before the payload — e.g. a sequence number and a producer wire-arrival timestamp), `last_record_seq() -> Int` / `last_record_kernel_ns() -> Int` / `last_record_user_ns() -> Int` read the header fields of the record currently being delivered, called from inside the handler (errno-style thread-local, the same read-immediately idiom as `tcp::recv_stamped` — the value is the most-recent record's, valid for the duration of the handler). Each returns `0` when the bound layout declares no corresponding header field. The names map to the layout's declared header scalars by role; the layout's `recheck post_copy` guard ensures a torn header isn't surfaced. | `lotus_shm_*` in `runtime/lotus_shm_ring.c` | | `std::diag` | Test-time gate counters. `heap_alloc_count() -> Int` returns the cumulative number of heap allocations (malloc / realloc / calloc / mmap) the runtime has made; `syscall_count(name: String) -> Int` returns the cumulative count of a wrapped I/O syscall (`"recv"`, `"recvmsg"`, `"read"`, `"write"`, `"send"`, `"sendto"`). Read a counter before and after a steady-state region and assert the delta — the runtime/test-time complement to compile-time `--warn-unbounded-alloc` ("this loop did zero heap allocs" / "exactly one read per poll"). Both return `-1` when the counting shim is absent (sanitizer builds — TSan/ASan interceptors collide with the `-Wl,--wrap` shim), so a caller can distinguish "gate unavailable in this build" from a real `0`. Counters are process-wide and monotonic; `syscall_count` returns `-1` for an unrecognized name. The wrap shim is compiled into every default (`-O2`) build at the cost of one relaxed-atomic increment per allocation / wrapped syscall — only the runtime's own calls are routed (libc- and libssl-internal I/O is untouched). | `lotus_diag_*` + `__wrap_*` in `runtime/lotus_arena.c` | | `std::http` | `Request` + `Response` types (`Response.headers: String` carries CRLF-joined user-supplied headers — no trailing CRLF — for Set-Cookie / CORS / custom headers); `parse_request`, `write_response(s: std::io::tcp::Stream, resp: Response)` (a statement); case-insensitive symmetric `header(receiver, name: String) -> String` lookup (the receiver is a `Request` or a `Response`, evaluated once, and its type picks the reader: the stdlib table's row names one Hale body per receiver type, F.40 phase 4, S5; it used to be evaluated twice); `Handler` interface (`fn handle(req: Request) -> Response`); `Server` locus with `shutdown()` and `std::io::tcp::stop(name)` (see [§ Stopping a server](#stopping-a-server--stdiotcpstopname-and-servershutdown)) and optional `ready_signal: String` for piped oracles. **Request reassembly** (2026-07-14, downstream handoff): the per-connection loop reads until the `\r\n\r\n` header terminator and then until `Content-Length` body bytes arrive, so clients that split headers and body across TCP segments (python urllib et al.) are served whole. Guards: 1 MiB total-request cap (a declared `Content-Length` over the cap answers `413`; overflow before a complete header block closes without a response) and a 5s recv timeout (bounds a stalled client wherever it reads: on an `async_io` pool the read parks only that connection's coroutine and the deadline still ends it). **Concurrency follows placement** (see [§ Server concurrency](#server-concurrency--a-coroutine-per-connection-on-async_io)): placed `cooperative(pool = X) where async_io`, the Server serves its connections concurrently, a coroutine each, at most `max_connections: Int = 256` at once; anywhere else (main, `pinned`, a classic pool) one at a time. The Server is a listener and a consumer of the TCP connection substrate (see [§ The TCP connection substrate](#the-tcp-connection-substrate)); `name: String` (left empty, `host:`, unique even for `port: 0`; the name it goes by is its `listener` field; an explicit one another listener holds is the `name_taken` violation at birth) is its address for `std::io::tcp::stop(name)`; birth binds, so a restart binds afresh. Keep-alive remains unsupported (`Connection: close` hardcoded); `Transfer-Encoding: chunked` is not parsed. **Per-connection region** (GH #578): each connection is served in a child of the Server that dissolves when its response is written (inline, or on `async_io` a flow child reclaimed as its `run()` returns), so nothing a request allocates — the reassembled bytes, the parsed `Request`, the handler's `Response` and its body — outlives its response; a Server's memory does not grow with the requests it answers (it used to keep every response, a 450 KB body growing the process 450 KB a request). The handler locus itself is the Server's param and keeps its own state across requests as before. **Connection takeover / Upgrade (2026-07-19):** `Request.conn_fd` carries the live connection fd into the handler (`-1` outside a Server), and `Response { takeover: true }` makes the Server write ONLY the status line + the response's `headers` + a blank line — no Content-Type/Content-Length/`Connection: close`, no body — and return WITHOUT closing the fd (`Stream.release_fd()` disarms the per-connection scope close). From that moment the handler owns the connection: stash `req.conn_fd` (typically publish it to a session locus on its own pool) and drive it through the raw-fd tcp surface (`send_fd` / `recv_into` / `close_fd`) or a borrowed `Stream { conn_fd: fd, owns_fd: false }`. Status-agnostic (101 for WebSocket-class upgrades — `__status_phrase` knows `101 Switching Protocols` — or a CONNECT tunnel's 200). Caveats: the conn loop's 5s recv timeout is still armed on the fd (clear via `set_recv_timeout(fd, 0)`), and a handler that sets `takeover` without stashing the fd leaks it — the accept/release daemon warn class. This is the surface WebSocket promotion was blocked on. **Raw takeover (Crumb batch-3, 2026-07-28):** `Response { takeover_raw: true }` transfers the fd with **nothing written** — no status line, no headers. For the deferred-response shape: the handler returns before the answer exists (a promise resolved later, a bus reply from another locus), so whoever ends up owning the fd writes the entire response — status line included — via the raw-fd surface (`send_fd`). Also the CONNECT-tunnel / server-initiated-protocol shape. `status`/`headers`/`body` are ignored; takes precedence over `takeover`; same timeout + fd-leak caveats. **Router** (promoted from pond/router, 2026-07-17): `Router` locus — `add(method, pattern, h)` registers `METHOD /path/:capture` patterns against `RouteHandler` loci (`fn handle(ctx: Context) -> Response`; first match wins, register specific-before-general; method matching is case-insensitive at register time), `add_fn(method, pattern, f)` registers a bare `fn(Context) -> Response` — no handler locus; the fn pointer is stored in the route entry itself, sharing one list and one precedence order with locus routes (2026-08-11, downstream request), `use(m)` registers `Middleware` (`before(ctx)` forward / `after(ctx, resp)` backward — onion order), `dispatch(req)` runs the chain, and `handle(req)` satisfies the `Handler` interface so `Server { handler: router }` plugs in directly. `add` and `use` **keep** their argument: the router holds the handler or middleware as a borrow and never reclaims it, so it must outlive the router — a field of the locus that owns the router, or a router built and used in one frame; `hale check` refuses a literal from a frame the router outlives (`spec/semantics.md` § "A handle a method keeps is the same borrow", GH #1048). **Direct dispatch** (2026-08-14): `build_context(req) -> Context` builds the per-request bundle the Router would (query string split into `params.qs`, captures empty) and `is_route(ctx, method, pattern) -> Bool` runs the Router's own matcher against one method + pattern — `:name` captures fill `ctx.params` on a hit; **any** miss (method or pattern) leaves the captures cleared, so an `if`-ladder of `is_route` checks in a locus's own `handle` is first-match-wins in written order with no stale-capture bleed. Method compare is case-insensitive on the argument side (like `add`); both fns are pure. This is the one-locus-many-endpoints shape: endpoint methods are direct `self` calls, so shared per-instance state (a db handle) needs no Router and no per-endpoint handler loci, and under `pinned(..., replicas = K)` placement that state is per-replica by construction. `Context` bundles the parsed `Request` (`ctx.req`, raw target incl. query string) with `RouteParams` (`ctx.params`); `path_param(params, name)` / `query_param(params, name)` return the capture / `k=v` value or `""` (sentinel shape; values NOT URL-decoded at v1). Patterns bound at 8 captures (`bounded[String; 8]` — exceeding it raises at register-authored routes); the 404 default is the overridable `not_found: RouteHandler` param. Trailing-slash tolerant on both sides; no implicit wildcard suffix. **Client** (promoted from pond/http/client, 2026-07-17): one-shot free fns `get(url)` / `post(url, body, content_type)` / `request(req)` — all `fallible(HttpError)`, `Connection: close`, read-to-close — plus the pooled `Client` locus (`user_agent` / `timeout_ms` / `max_retries` / `max_body` params; same fallible method surface; opt-in `keep_alive: true` switches to framed reads — Content-Length or `Transfer-Encoding: chunked` — over a 4-slot per-host:port connection pool with retry-and-backoff; the pool is deliberately hand-rolled, NOT `@form(lru_cache)`: an fd-owning cache needs an eviction hook and take-semantics the form doesn't offer). Client-side types are distinct from the server side on purpose: `ClientRequest` (`method` / `url: Url` / packed `headers` / `body: Bytes`) and `ClientResponse` (`status` / packed `headers` / `body: Bytes` — Bytes for binary safety, embedded NULs survive); `parse_url(s) -> Url fallible(HttpError)` decomposes scheme/host/port/path (query rides in `path`; no userinfo/fragments; no URL-decoding). `HttpError` kinds: bad_url / unsupported_scheme / connect_failed / send_failed / recv_failed / bad_response / too_large / retries_exhausted. https rides `std::io::tls` — **placement caveat**: TLS recv blocks the worker thread (no async_io park yet), so loci making https calls belong on `pinned` or a classic cooperative pool. Not implemented at v1: redirects (3xx returns as-is), proxies, compression. | `runtime/stdlib/http.hl` | | `std::json` | `Builder` locus (streaming output assembly — see [§ json::Builder](#stdjsonbuilder--streaming-output-api)); `escape_string` / `unescape_string` (RFC 8259 — `unescape_string` decodes \uHHHH to its scalar, combines surrogate pairs, and answers U+FFFD for an unpaired surrogate or \u0000; both helpers and the Builder are linear in output bytes — see [§ String escaping](#string-escaping--escape_string--unescape_string)); `find_string_field` / `find_int_field` / `find_bool_field` (flat-object lookup — PERMISSIVE by contract: a non-String value comes back as its raw scalar spelling, so `"owner": null` reads as the String "null"); `string_field(json, name) -> JsonString` (the TYPED read, GH #719 — `{kind, text}` where `kind` is one of `"string"` / `"null"` / `"missing"` / `"number"` / `"bool"` / `"array"` / `"object"` / `"invalid"` and `text` carries the decoded string only for `"string"` — see [§ Typed field access](#typed-field-access)); `find_field_raw(json, name) -> String` (the raw value of the named TOP-LEVEL member — rebuilt on the object cursor 2026-07-28 (Crumb batch-4): matches key POSITIONS only, so key text recurring inside an earlier string VALUE no longer shadows the real key (on a real npm packument the old text-scan lost 12 of 35 version keys). Depth-aware and string-safe; nested walks re-feed the returned object/array substring — the documented chaining contract, now actually enforced); `ArrayIter` + `array_first` / `array_next`, and the span-bearing `ArrayIterSpan` cursor `array_first_span(json) -> ArrayIterSpan` / `array_next_span(it, json) -> ArrayIterSpan` (carry the element's byte range rather than an owned substring — the allocation-free array-walk sibling of the object cursor below). Range-bearing iter family: `iter_find_field_range(it, json, name) -> JsonFieldRange` and `iter_find_string_field_range(it, json, name) -> JsonFieldRange` return `{ok, start, end_pos}` instead of an owned-String substring (the owned siblings: `iter_find_string_field(it, json, name) -> String`, `iter_find_int_field(..) -> Int`, `iter_find_bool_field(..) -> Bool`, `iter_find_field_raw(..) -> String`, `iter_substring(it, json) -> String`, and the bounded `find_field_raw_in(json, name, start: Int, end_exclusive: Int) -> String`; the byte scanners under them are `next_non_ws` / `next_quote_or_bs` / `next_struct_or_quote(json, from: Int, len: Int) -> Int`); paired with the `std::str::range_*` family for fully allocation-free per-element walks on large arrays (the high-throughput workload class where per-field allocation dominates arena pressure). No nested-tree shape at v1 — re-feed substrings into the same surface for nested walks. Single-pass object member cursor: `object_first(json) -> ObjectIterSpan` / `object_next(it, json)` walk `{...}` members once, with `obj_key_eq(it, json, name) -> Bool` / `obj_key_len(it) -> Int` for key dispatch, `obj_key_string(it, json) -> String` for unknown-key iteration (the key-side sibling of `obj_value_string`, incl. escape decoding — hand-slicing `key_start..key_end` silently skips it) and `obj_value_int(it, json) -> Int` / `obj_value_bool(it, json) -> Bool` / `obj_value_float(it, json) -> Float` / `obj_value_string(it, json) -> String` / `obj_value_raw(it, json) -> String` reading the current value from its source range (no per-field rescan; nested objects/arrays on unmatched keys are skipped whole by the depth scan). This is the substrate a compiler-generated, schema-specialized parser drives — and the seam a future SIMD structural index slots under. Strict counterparts to all of the above: `valid(text) -> Bool` (one well-formed RFC 8259 value and nothing else) and `valid_object(text) -> Bool` (…and a top-level object with unique, unescaped keys) — see [§ Validation](#validation--valid--valid_object). | `runtime/stdlib/json.hl` | | `std::test` | `assert(cond: Bool, msg: String)`, `assert_eq_int(actual: Int, expected: Int, msg: String)`, `assert_eq_str(actual: String, expected: String, msg: String)` — statements, returning nothing | `runtime/stdlib/test.hl` | | `std::log` | `Logger`, `LogEvent`, `StdoutSink` (subscribes `log.**`). **Sinks promoted from pond/logfmt (2026-07-18):** `FileSink` — appends every event to `path`, rotates by size (`max_size_bytes`, `keep_files`; chain shifts via atomic `rename(2)` overwrite, oldest evicted, active file recreated on next append), I/O failures captured in the `last_error_kind/errno/path` scratch triple (the styleguide-2.7 convention), also wears the `std::text::Sink` shape (write/line/newline with the same rotation). `ConsoleSink` — dim HH:MM:SS + colored width-5 level badge + dim path + message; WARN/ERROR on stderr (StdoutSink's lane split); color AUTO (tty probe on stderr; FORCE_COLOR/CLICOLOR_FORCE override; NO_COLOR always wins; `color: false` = never). pond's OtlpSink stays in pond (vendor-protocol integration is app-tier). **Structured logging (GH #469):** `LogEvent` carries `fields` (logfmt text) and `ts` (unix seconds, stamped at the PUBLISH site, not the render site — they differ under a queued or bridged sink and under `hale replay`). `kv(key, value)` renders one pair, quoting the value when it contains a space, a quote or an `=`; join pairs with a space. Each level has a `_kv` variant (`info_kv`, `warn_kv`, …). Fields are text rather than a map because a map is a locus and a locus cannot be a payload; the flat record crosses every transport the bus supports. **Level filter:** `HALE_LOG=error|warn|info|debug` sets a threshold; `min_severity` on a `Logger` or any sink pins it in code (0=trace 1=debug 2=info 3=warn 4=error — a severity ORDER distinct from the wire level constants, which are historical). Filtering is at the PUBLISHER, so a suppressed call emits no event at all; sinks filter too, for the case where two sinks want different levels. | `runtime/stdlib/log.hl` | | `std::metrics` | Prometheus-shaped metrics (promoted from pond/metrics, 2026-07-18). `Registry` locus (`namespace` prefix; **owns its storage** — the `MetricMap` (`@form(hashmap, sync = serialized)`) and `HistogramList` (`@form(vec)`) are param-default children, so `Registry { namespace: "app" }` is the whole construction and a Registry returned from a builder fn keeps its series alive; explicit `store:`/`histograms:` overrides remain accepted but a let-bound override dissolves at the constructing fn's scope exit — prefer the owned default). Factory free fns `counter(reg, name, labels)` / `gauge(reg, name, labels)` / `histogram(reg, name, bounds, labels)` are **idempotent on (name, labels)** — a repeat call returns a handle to the same series without resetting it; handles reference the storage slots directly (resolve once at boot, cache the handle as a field, mutate from the hot path — styleguide S12). `Counter` (`inc` / `add`, monotonic by convention), `Gauge` (`set` / `add` / `sub` / `inc` / `dec`), `Histogram` (`observe(v)` — cumulative buckets + implicit `+Inf` + `sum` + `count`; bucket bounds passed as a space-separated ascending String `"0.005 0.01 0.05"`, parsed once at registration, max 32 buckets with over-cap clamping). Labels via `labels_empty()` / `labels_one(k, v)` / `labels_two(...)` / `labels_append(l, k, v)`. `render()` emits Prometheus text exposition (`# TYPE` lines, `ns_name{k="v"} value` samples, histogram `_bucket{le=...}` / `_sum` / `_count`), every value to the digit: an integral one below 2^53 as its integer, any other in fixed point to nine decimals with its trailing zeros cut (one too small for that in exponent form, never `0`), and `NaN` / `+Inf` / `-Inf` as the exposition spells them (GH #988: it was `to_string`'s six significant digits, so epoch seconds and any counter past 999999 were rounded); `Endpoint { registry: reg }` satisfies `std::http::Handler` and answers any request with the rendering under `Content-Type: text/plain; version=0.0.4`, so `std::http::Server { handler: Endpoint { ... } }` is a complete /metrics scrape target. The MetricMap is `sync = serialized` because the canonical topology scrapes from one pool while handlers write from another. | `runtime/stdlib/metrics.hl` | | `std::secret` | GH #436 (2026-08-08). Key material an application never holds. `Signer` — HMAC over a key the caller never sees: `sign(msg) -> Bytes` (HMAC-SHA256, 32 bytes), `sign_512(msg) -> Bytes` (64), `verify(msg, mac) -> Bool`, `ready() -> Bool`. `Credential` — an opaque token/password: `matches(candidate) -> Bool`, `ready() -> Bool`, `fingerprint() -> String` (first 8 bytes of SHA-256, hex — a stable non-reversible handle that is safe to log and correlates across processes), `reveal() -> Bytes` / `reveal_text() -> String` (GH #989 — the resolved credential in the open, for the one statement that puts it on a wire or compares it: the checker refuses a reveal outside a locus method, and one whose value is not consumed in its statement by a wire write's payload, a comparison of the whole value (`matches` is the constant-time one) or a `@secret` parameter — bound, stored, returned, used to decide a branch, or handed to `std::process` — `spec/semantics.md` § "`@sealed` and a revealed secret"; a `Signer`'s key never leaves at all), and `write_private(path) -> Bool` (the credential written on the sealed side, nothing revealed, for a tool that reads a secret from a file: `path` is a NEW file created mode 0600 by `openat(2)` itself — `O_CREAT|O_EXCL|O_NOFOLLOW`, never chmod after — in a directory the process's effective user owns with no group or world access, that is not itself a symlink, and outside any git work tree (no `.git` in it or an ancestor); every check and the create go through one descriptor of the directory, so no path component can be swapped between them; `false` when it refuses, when there is no credential, or when the write fails, a partial file removed. The reveal rule follows values, not files: the file is the tool's to read, and removing it is the caller's). Both are **`@sealed`**, so their `params` are readable only from inside them: `self.signer.key` from a holder is a compile error naming the method to call instead. Both take the **name of a source**, never the bytes — `env_var:` (read via `std::env::var`) or, for `Signer`, `key_file:` (`std::io::fs::read_bytes`) — and load during `birth`, so the material exists only inside a sealed locus from the moment it enters the program and there is no construction site at which the caller held it. `Signer` also takes an optional `decode:` transform (`""` = the source's bytes verbatim, `"base64"` = decode first): a venue that issues an HMAC secret base64-encoded requires the key to be the DECODED bytes, and a signer pointed at such a credential without `decode:` keys under the text of the base64 — valid-looking MACs under the wrong key, silently rejected downstream. An unavailable source, undecodable input, or an unrecognized transform yields an empty key (fail closed), never a key that isn't the one the source names. Check `ready()` at startup: a missing source yields an empty key rather than a failure. `Credential` also takes `vault:` (GH #989), a name resolved against a vault instead of an env var or a file — and resolved DIFFERENTLY from the other two: `env_var`/`key_file` resolve once, at `birth`, into the sealed value; `vault:` resolves FRESH on every privileged call, never cached, because a vault's whole point is that a value can change without restarting every process that reads it. `env_var` wins if both are set, same precedence `Signer` already has between `env_var` and `key_file`. Local mode (`HALE_VAULT_ADDR` unset) reads a directory of files keyed by name — `HALE_VAULT_DIR`, or the per-user cache root the toolchain already uses (`std::secret::vault_local_dir()`: `$XDG_CACHE_HOME/hale/vault`, `~/.cache/hale/vault`) — filesystem permissions as the only access control, no server, no token, never a system path a process cannot write without root. `HALE_VAULT_ADDR` set makes it an HTTP `GET /v1/secret/` instead, authenticated with the per-host token in `HALE_VAULT_TOKEN` as a bearer credential. Either way: fail closed exactly like the other two sources — a missing directory, a missing file, a missing token, a non-200, an unreachable address are all empty, never a partial or stale value. Rotation and provisioning a real vault are out of scope for this cut (a real vault is provisioned out of band; `vault:` only reads it). Every privileged operation carries the `secret_use` effect class, declared by the module and travelling with the import, so an application constrains it with claim forms that already exist (`forbid reaches(plugins, effects(secret_use))`, `bound secret_use <= 1 on paths from handlers`). **Scope:** confinement, not information flow — the MAC `sign` returns is derived from the key and is NOT tracked, a `verify` verdict is observable and may be published, the compare is not a constant-time primitive, and zeroization is out of scope (the bytes live in the locus arena for the process lifetime). See `spec/verification.md` § Secrets. | Hale source in `crates/hale-stdlib/hl/secret.hl` over `std::crypto` / `std::env` / `std::io::fs` / `std::http` | | `std::math` | `sqrt`, `exp`, `log`, `floor`, `ceil`, `pow`, `tanh`, `nan`, `is_nan`, `inf`, `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`, `int_to_float`, `float_to_int`, `round`, `trunc` | path-call dispatch into libm (`nan`/`inf`/`is_nan` are IEEE 754 sentinels; trig added 2026-05-23 for spatial / animation code). `int_to_float`/`float_to_int` are named `sitofp`/`fptosi` conversions — round-toward-zero, callable in any expression position; see `spec/types.md` § Explicit numeric conversions. `round(Float) -> Int` / `trunc(Float) -> Int` are the Float→Int siblings that return `Int` directly: `round` is round-half-away-from-zero (`3.7 → 4`, `2.5 → 3`, `-2.5 → -3`), `trunc` is round-toward-zero (an alias of `float_to_int`). Both lower to pure LLVM (`fptosi`, plus a compare/select half-shift for `round`) — **no libm symbol**, so unlike `floor`/`ceil` (which stay libm and return `Float`) they need no host import on the `wasm32` target. | | `std::crypto` | `sha1(b) -> Bytes` (20-byte), `sha256(b) -> Bytes` (32-byte), `hmac_sha256(key, msg) -> Bytes` (32-byte), `sha512(b) -> Bytes` (64-byte) / `hmac_sha512(key, msg) -> Bytes` (64-byte) (the 64-bit-word SHA-2 sibling — FIPS 180-4 SHA-512 + RFC 2104 HMAC over a 128-byte block; same non-fallible shape as `hmac_sha256`, hand-rolled, no libcrypto; added 2026-06-25 for venue order-entry auth, which sign with HMAC-SHA512), `crc32(b) -> Int` (4-byte IEEE 802.3 checksum returned as Int; reversed polynomial `0xEDB88320`, init `0xFFFFFFFF`, final XOR `0xFFFFFFFF` — the zlib / Python `binascii.crc32` variant; added 2026-05-27). `ecdsa_p256_sign(key, message) -> Bytes fallible(CryptoError)` / `ecdsa_p256_verify(pubkey: Bytes, message: Bytes, sig: Bytes) -> Bool` (ES256 — ECDSA over NIST P-256 + SHA-256; `key` is a PEM EC private key, SEC1 or PKCS#8; `pubkey` is PEM SPKI; signature is raw `r‖s`, 64 bytes, the JWS/COSE form JWT wants; added 2026-06-03 for venue/JWT auth). `ecdsa_p256_sign` has one mode, fallible (F.40 phase 4, S5: the bare call that returned an empty Bytes on failure is a compile error now, the bare-fallible law's; write `or b""` for that substitute), so `let sig = std::crypto::ecdsa_p256_sign(key, msg) or raise;` propagates a structured `CryptoError { kind: String, detail: String }` (`kind` = the op tag `"ecdsa_p256_sign"`; `detail` = the failure reason) — read it via `or handler(err)` / `or fail err` / `or ` exactly like `IoError` / `ParseError`. The hashes + crc32 are hand-rolled (no libcrypto); ECDSA is OpenSSL-backed (rides the libssl/libcrypto link TLS already pulls). | `lotus_crypto_*` (hashes in `runtime/lotus_arena.c`; ECDSA in `runtime/lotus_tls.c`) | | `std::os` | `getrandom(n: Int) -> Bytes fallible(IoError)` (CSPRNG; `getrandom(2)` with `/dev/urandom` fallback) | `lotus_os_getrandom` C primitive | | `std::rand` | `next_int(max: Int) -> Int` — a uniform-ish integer in `[0, max)` drawn from a shared xorshift64\* generator; `seed_from_time()` re-seeds that generator from the wall clock. **Not cryptographic** (deterministic PRNG, process-shared state) — for security-sensitive randomness use `std::os::getrandom`. | `lotus_rand_*` C runtime | | `std::ts` | Tree-sitter parse substrate (m96 — the `std::ts::*` routes back the higher-level `Lang` locus). `parse_go(src: String) -> Int` parses Go source and returns an opaque **tree handle** (`Int`); `root_node(tree) -> Int` returns the root **node handle**. Node navigation (all handles are `Int`): `node_child_count(node) -> Int` / `node_named_child_count(node) -> Int`, `node_child(parent, i: Int) -> Int` / `node_named_child(parent, i: Int) -> Int`, `node_is_named(node) -> Int` (`0`/`1`). Kind, text, spans: `node_kind(node) -> String`, `node_text(node) -> String`, `node_start_byte(node) -> Int` / `node_end_byte(node) -> Int`. Go is the only bundled grammar at v1; the tree-sitter shim staticlib is linked into the build (gating the link on actual `std::ts` use is future work). **The shim is a build requirement, and a missing one is a build error, not a link error** (GH #808): `std::ts` is the one stdlib namespace whose implementation ships as a separate artifact (`libhale_ts_shim.a`, from the `hale-ts-shim` crate), and a toolchain built without it refuses any program that reaches `std::ts::*` with a located diagnostic naming the artifact and `cargo build --release`. Programs that don't reach `std::ts` — including those that merely merge the stdlib loci that use it — build normally against a toolchain that lacks the shim. | `lotus_ts_*` + tree-sitter shim | | `std::bus` | `__StdBusAdapter` interface (contract for user-supplied bus transports — a single `fn send(subject: String, bytes: Bytes)` method); `__local_dispatch(subject: String, bytes: Bytes) -> Int` primitive (the `Int` is always `0`) lets an adapter relay received wire-bytes into the local handler set. **Substrate transport URL schemes** (resolved at runtime by `lotus_bus_load_config` from `LOTUS_BUS_CONFIG=`): `unix://` (AF_UNIX SEQPACKET; m58); `udp://:` (2026-05-26 — IPv4 UDP, single scheme covers unicast and multicast: addresses in `224.0.0.0/4` trigger `IP_ADD_MEMBERSHIP` on the subscribe side, everything else takes the plain unicast bind/sendto path; lossy delivery — publishers get "sendto returned" durability, subscribers best-effort; gap recovery is a deployment concern via app-layer repeaters, MoldUDP-style). Each LOTUS_BUS_CONFIG line: `subject = :` where role is `listen` or `connect`. A well-formed route that cannot be *opened* fails the boot via `lotus_bus_binding_fail` (the publish contract, spec/semantics.md — no requested route may silently not exist); malformed lines warn-and-skip. Other protocol-layer transports (NATS, MQTT, raw-TCP-with-framing) come in via user adapters (the `__StdBusAdapter` route above). **Payload size**: the substrate handles bus payloads up to ~64 KB (`LOTUS_PAYLOAD_MAX`, sized to UDP datagram max). Mailbox cells inline payloads ≤ 512 B (`LOTUS_PAYLOAD_INLINE` — zero malloc on the hot path); larger payloads route through a per-cell `malloc` that the drain path frees after the handler returns. The UDP transport's wire buffer is sized at LOTUS_PAYLOAD_MAX; the kernel-side socket receive buffer takes its size from `SO_RCVBUF` (default ~208 KB on Linux, raise via `LOTUS_BUS_UDP_RCVBUF=` env). UDP `sendto` failures log once per errno class to stderr (`EMSGSIZE` typically means path-MTU mismatch; see `std::io::sockopt::IP_MTU_DISCOVER` for the DF-bit knob). | `runtime/stdlib/bus.hl` + `lotus_bus_*` C runtime | Hale doesn't use parametric stdlib collection types (`Map`, `Vec`, `Set`, etc.). Storage is locus-shaped via the `@form(...)` annotation machinery — see [`forms.md`](./forms.md). v1 ships `@form(vec)` (contiguous-buffer), `@form(hashmap)` (intrusive open-addressing, String / Int keys), and `@form(ring_buffer)` (fixed-capacity FIFO). ## Builders vs Bytes — the recv-loop pattern `Bytes` and `std::bytes::BytesBuilder` are deliberately distinct types because their runtime ABIs are **incompatible**: - **`Bytes` blob.** Single contiguous allocation: `[i64 len][u8 data[len]]`. The handle points at the length prefix. `lotus_bytes_len(b)` reads `*(int64_t*)b`. `lotus_bytes_at(b, i)` reads `((u8*)b)[8 + i]`. Stable across the value's lifetime. - **`BytesBuilder` locus.** Owns a `lotus_bytes_builder_t` header `{cap, buf, mutation_epoch}` whose body lives in a separately malloc'd region pointed to by `buf` and can move on realloc (the header is stable; the body is not). The two ABIs cannot be unified without giving up stable handles (the body has to be relocatable; the Bytes blob layout doesn't tolerate that). Lifting the builder into its own locus type turns `std::bytes::at(builder, i)` into a typecheck error rather than the silent footgun it was when the builder shared the `Bytes` static type. The discipline that follows: 1. **Pick one role per binding.** A binding is either a `BytesBuilder` (long-lived growable buffer with methods `append` (chunk: Bytes) / `append_str` (s: String — append the string's bytes verbatim in one strlen + memcpy; `String` only, a non-NUL-terminated `StringView` must be materialized first) / `len` / `shift_front` / `clear` / `snapshot` / `finish` / `view` / `text_view`, plus the binary-pack writers below) or a `Bytes` (immutable length-prefixed blob with functions `at` / `slice` / `len` / `concat`). The typechecker enforces this; no implicit coercion between them. **Binary-pack writers** (2026-06-06, shm-ring-interop Proposal A — the inverse of `std::bytes::read_*`): `b.append_u8(n)`, `b.append_u16_{le,be}(n)` / `u32` / `u64`, the signed `b.append_i8`/`i16_{le,be}`/`i32`/`i64` (identical byte pattern — width is what matters), `b.append_f32_le(x)` / `b.append_f64_{le,be}(x)` (x: Float), and `b.append_pad(to_align)` (zero-fill to the next `to_align` boundary). Each appends the low `width` bytes in the named endianness; a realloc failure routes through `violate alloc_failed` like `append`. 2. **Cross between roles via explicit calls.** `BytesBuilder → Bytes` is either `b.snapshot()` (copies into the bus payload arena — stable across mutations) or `b.view()` (no copy, aliases the builder's buffer — valid until the next mutation; the right choice for parser passes). `Bytes → BytesBuilder` is `let b = std::bytes::BytesBuilder { ... }; b.append(bytes)` (copies). The Builder → Bytes direction has a zero-cost path via `view()`; the reverse does not. `snapshot()` and `finish()` are `-> Bytes fallible(ClosureViolation)` (F.42): a failed allocation violates `alloc_failed`, the owner's `on_failure` runs, and the call fails, so each call carries an `or` (`b.snapshot() or raise` where the activation should not go on). `view()` and `text_view()` allocate nothing and are not fallible. 3. **Long-lived accumulators live as locus state.** Either a method-body `let`-binding (dissolves at scope exit) or a param-typed field on the owning service locus (dissolves via the F.29 cascade at the parent's dissolve). Method-body `let` is simpler when the lifetime fits in one method call; field-typed is needed when the buffer must outlive a single call (e.g., state held across bus subscription callbacks). 4. **Read syscalls write directly into the builder.** The `recv_into` family takes a `BytesBuilder` as `buf` and writes into its tail. Combined with `b.shift_front` after each peeled frame (streaming) or `b.clear()` at message boundaries (per-message accumulator), the steady-state recv loop is zero-alloc against `g_bus_payload_arena`. Canonical pattern, a held-open socket locus that accumulates inbound frames: ```hale locus FrameClient { params { sock: Int = -1; recv_chunk: Int = 4096; } run() { let rx_buf = std::bytes::BytesBuilder { initial_cap: 4096, }; loop { let got = std::io::tcp::recv_into( self.sock, rx_buf, self.recv_chunk); if got <= 0 { break; } // peel complete frames off the front via rx_buf.len() / // rx_buf.shift_front(consumed); for a per-frame snapshot // use rx_buf.snapshot() only at the point of handoff to // logic that needs a Bytes view (parsers that read via // std::bytes::at / slice). } // rx_buf dissolves here → buffer freed, no explicit cleanup } } ``` Try writing `std::bytes::at(rx_buf, 0)` inside that loop — it fails at typecheck (`at` expects `Bytes`, got `__StdBytesBytesBuilder`). The discipline is mechanical, not documentary. The same pattern with the builder held as locus state (per F.29), for cases where the accumulator must survive across multiple method calls (bus callbacks, message state held between handler firings): ```hale locus WsClient { params { sock: Int = -1; recv_chunk: Int = 4096; rx_buf: std::bytes::BytesBuilder = std::bytes::BytesBuilder { initial_cap: 4096 }; last_msg: std::bytes::BytesBuilder = std::bytes::BytesBuilder { initial_cap: 4096 }; } fn read_one() { let got = std::io::tcp::recv_into( self.sock, self.rx_buf, self.recv_chunk); // ... peel frames, append payload bytes into self.last_msg // via self.last_msg.clear() + .append(...) between message // boundaries — no per-frame allocation. } // No dissolve() needed for rx_buf / last_msg — the cascade // fires their dissolve when WsClient itself dissolves. } ``` Consumer reads `self.last_msg` via a contract that exposes `b.view()` — zero-copy across the F.14 interface. ## Linear byte scanning — the `String` cost model A `String` is a NUL-terminated blob with no stored length, and the operations a hand-written parser reaches for first are the O(n) ones. The costs below are the shipped ones, measured on a 1 MiB input (GH #720): | operation | cost | notes | | --- | --- | --- | | `len(s)` | O(n) — `strlen` | The call is `memory(read)`, so LLVM hoists it out of a loop whose body writes no memory. Put an allocating call in that body — a slice, a concat, a builder append — and the hoist fails and every iteration re-walks the input. Measured: the same 1 MiB slice loop took 13.2 s with `len(s)` in the condition and 7.7 s with the length in a local. | | `s[lo..hi]`, `std::str::substring(s, lo, hi)` | O(n) + O(hi-lo) | The clamp does its own `strlen`, so the cost does not depend on how few bytes were asked for. One-byte slices in a bounded loop are the quadratic shape: 1 Mi of them cost 7.7 s. | | `std::str::byte_at_unchecked(s, i)` | O(1) — one load | No bounds check at all. Correct only while the caller's bound is; an out-of-range `i` is an out-of-bounds read. | | `std::str::bytes_view(s)` | O(n), once | One `strlen` for `n`, plus one copy of the text into the caller's arena (a `String` field of a returned struct is deep-copied at the fn boundary). Hoist it out of the loop that uses it. | | `std::str::byte_at(v, i)` | O(1) — compare + load | Checked against the view's `n`. | | `std::str::slice(v, lo, hi)` | O(hi-lo) | Clamped into `[0, n]`; no `strlen`. | | `std::str::range_copy(s, n, lo, hi)` | O(hi-lo) | What `slice` is built on, for a caller that already tracks its own length. | So the natural bounded-parser loop is quadratic: ```hale let mut i = 0; while i < len(s) { // hoisted, but see above let c = s[i..(i + 1)]; // O(n) EVERY iteration i = i + 1; } ``` and the view is the linear form of the same loop, with the bound kept in a register and every read checked against it: ```hale let v = std::str::bytes_view(s); // one strlen + one copy let mut i = 0; while i < v.n { let c = std::str::byte_at(v, i); // 0..255, or -1 if out of range if c == 44 { … } // 44 = ',' i = i + 1; } ``` Measured on the same 1 MiB input: **13.2 s → 29 µs** for the scan (the loop vectorizes, because the bound is loop-invariant), and the whole program including construction runs in 2 ms. At 1 / 2 / 4 MiB the scan costs 29 / 57 / 114 µs — linear, pinned by `byte_view_scan_scales_linearly` in `crates/hale-codegen/tests/stdlib_str.rs`. Three properties the surface commits to: 1. **Refusal, not UB.** `byte_at` answers -1 for any `i` outside `[0, n)` and `slice` clamps into `[0, n]`, so a truncated frame or an attacker-supplied length field yields short data rather than a read past the end. A length taken from untrusted input needs no validation before it reaches `slice`. 2. **Bytes, not characters.** `n` is a byte count, `byte_at` returns one byte, and `slice` can cut a multi-byte sequence in half. That is the right default for protocol and format work (every delimiter, digit and quote is one byte in UTF-8, and a continuation byte is never mistaken for one). When the unit of meaning is a character, walk with `std::str::cp_at` / `cp_size`, which refuse mid-sequence offsets with -1. 3. **The view's `n` is the bound.** It comes from `bytes_view`, which is why the checks can be one compare. A view fabricated by hand with an `n` larger than its text is the one way to get past them (the `byte_at` byte-is-NUL guard still stops a sequential scan at the real end). `std::str::byte_at_unchecked` stays for the case where the bound is already proven by the surrounding scan — the stdlib JSON range-walkers use it — and keeps its "misuse is UB" contract. ## `~~std::panic~~` — not a thing Hale doesn't have `panic(msg)`, `assert(cond)`, or any other value-level "bail from this function" primitive. Failure is structural, not parametric: 1. Declare a **closure** in the relevant locus asserting the invariant you want enforced. 2. When the assertion fails at the closure's epoch, the runtime constructs a `ClosureViolation` and routes it to the parent's `on_failure` handler per F.9. 3. The parent picks one of `restart` / `restart_in_place` / `quarantine` / `reorganize` / `bubble`, or absorbs the violation by returning without calling any of them. 4. A violation that bubbles past `main` exits the process non-zero with the violation report on stderr. That covers every legitimate use of `panic`. "Impossible state" becomes "a closure asserting state is possible." "Bail from this function" is a category error in Hale — functions return values; failure lives at the locus level. Writing `panic("…")` anyway is an ordinary unbound callee: `hale check ` reports ``call to `panic`: no free fn, generic fn or fn-pointer binding with that name is in scope`` at the call. Until GH #800 the name sat in the checker's bare-builtin exemption table, so `check` accepted it and only `hale build` objected, at lowering, without a source location. ## Form-synthesized error types Beyond the explicit `std::*` namespace, the resolver injects form-specific error payload types into the top scope when any locus in the bundle uses the corresponding form. These behave like ordinary user types after injection — they can be the target of `fallible(...)`, declared as fn parameters / fields, or pattern-matched in `match`. They are NOT importable via `std::*` (they're not in a namespace); their names live at the top level. | Form / source | Synthesized type | Fields | |---|---|---| | `@form(vec)` | `IndexError` | `kind: String`, `index: Int`, `len: Int` | | `@form(hashmap)` | `KeyError` | `kind: String` | | `@form(ring_buffer)` | `EmptyError` | `kind: String` | | `std::io::fs` / `std::io::tcp` | `IoError` | `kind: String`, `errno: Int`, `path: String` | | `std::str::parse_int` / `parse_float` / `parse_decimal` | `ParseError` | `kind: String`, `input: String` | Idempotency: if a user / library declares a type with the same name, the user declaration wins. The injection only runs if the target name isn't already in scope. ### `IoError` `std::io::fs::*` (except `file_exists`) and the path-call surface of `std::io::tcp::*` (`listen_socket`, `connect`, `accept_one`) return `fallible(IoError)`. Agents address failures uniformly: ```hale let s = std::io::fs::read_file(path) or raise; let n = std::io::fs::file_size(path) or 0; std::io::fs::mkdir(out_dir) or show(err); ``` The `kind` tag is errno-derived — `"not_found"`, `"permission_denied"`, `"is_dir"`, `"already_exists"`, `"would_block"`, `"connection_refused"`, `"timeout"`, `"host_unreachable"`, `"broken_pipe"`, `"interrupted"`, etc., with `"io"` as the catch-all for unmapped codes. `errno` carries the raw platform errno for callers that want it; `path` carries the file path / connection target / empty string for socket-fd ops without a useful path label. `Stream.send` / `Stream.send_bytes` / `Stream.recv` / `Stream.recv_bytes` are *locus methods* on `std::io::tcp::Stream`, all `fallible(IoError)` since 2026-07-15 (#209; they previously used a legacy -1/0-sentinel shape predating open-question #24 lifting the "no fallible on locus methods" restriction). Semantics: `send(msg: String)` / `send_bytes(b: Bytes)` succeed with Unit (the old Int return was only ever a 0/-1 status), so fire-and-forget callers write `s.send(x) or discard;` and strict ones `or raise`. `recv(max) -> String` / `recv_bytes(max) -> Bytes` **fail only on a genuine I/O error** (connection reset, broken pipe, bad fd, …): a clean EOF and a `set_recv_timeout` expiry both return the empty value as before — timeout is a liveness signal, not an error. The error path is built from the errno the primitive records in a thread-local (`__last_io_status` / `__io_error_kind`, the same taxonomy the fallible path-calls use). `IoError` itself is now declared in the stdlib seed (`io_tcp.hl`), so user code can construct / `fail` it directly. `std::io::stdin::read_line` keeps its sentinel shape (path-call pairing with `read_line_status` for EOF-vs-error distinction). **`Stream` fd ownership** (`owns_fd: Bool = true`). By default a `Stream` *owns* its `conn_fd` and closes it on dissolve — the contract the `Listener` / `http::Server` accept-loop helpers rely on for per-connection cleanup (`__handle_one_connection` wraps the accepted fd in a Stream whose scope-exit dissolve closes it). Set `owns_fd: false` to *borrow* an fd owned elsewhere: a transient `Stream { conn_fd: self.conn_fd, owns_fd: false }` built only to call `send`/`recv` against a long-lived connection. A borrowed Stream's dissolve is a no-op (no `__close_fd`, no `close` LogEvent), so building one per operation against a persistent connection — e.g. a WebSocket conn locus that wraps its fd per frame — no longer closes the shared fd out from under the next operation. Owning a fd from two live Streams at once is still a double-close bug; `owns_fd: false` is precisely the opt-out for the borrow case. ### `ParseError` `std::str::parse_int(s)` / `parse_float(s)` / `parse_decimal(s)` return `fallible(ParseError)`. The non-fallible siblings exist only as `can_parse_*` predicate spellings; every parsing call site must address the failure with `or`. `ParseError` carries: - `kind: String` — `"empty"` (s was `""`), `"trailing_chars"` (s parsed a prefix and had junk after), `"invalid"` (no leading numeric prefix), `"overflow"` (`parse_int` only — magnitude exceeds Int range). - `input: String` — the original `s` (truncated to a reasonable preview if very long), for diagnostic surfaces. ```hale let n = std::str::parse_int(s) or 0; let n = std::str::parse_int(s) or raise; let n = std::str::parse_int(s) or self.report(err); ``` Reach for the predicate sibling `can_parse_int(s) -> Bool` only when you genuinely want to branch *before* parsing. In most cases `or` is shorter. The qualified form `std::str::ParseError` resolves to the same struct as bare `ParseError` — useful in projects that also declare a local error type with the same name. ## `std::process::rss_bytes()` — observability Returns the calling process's **peak** resident-set size in bytes via `getrusage(RUSAGE_SELF)`. There's no syscall for *current* RSS that doesn't go through `/proc/self/statm`; for alarm thresholds peak is usually what matters anyway. Returns 0 if `getrusage` rejects (vanishingly rare). On Linux the underlying value is reported in KiB and we multiply by 1024. ```hale let bytes = std::process::rss_bytes(); println("rss=", to_string(bytes)); ``` **A spawned process inherits its parent's peak.** `execve` does not reset `ru_maxrss`: `fork` hands the child a copy-on-write duplicate of the parent's address space, so the pre-exec child's RSS high-water mark *is* the parent's RSS, and the exec folds that mark into the new image's `ru_maxrss` for the life of the process. A program started by a large parent therefore reports at least that parent's RSS forever after — measured, one unmodified binary reads 4.7 MB standalone and 411 MB when spawned from a parent holding 400 MB. `rss_bytes()` is a peak for a process that owns its own history (a service started by an init system, a CLI run from a shell); it is not a way to measure a child you spawned, and a supervised worker's reading carries its supervisor's footprint. For the *current* RSS — and for any measurement that must be the program's own — parse `/proc/self/statm`'s line one field two via `read_file` (size-tolerant for synthesized files); that file is read from the post-exec address space and carries none of the inherited history. Both surfaces ship; pick by use case (peak for alarms on a top-level process, current for heartbeat gauges and for anything spawned). ## Stopping a server — `std::io::tcp::stop(name)` and `Server.shutdown()` A listener — a `std::http::Server`, a `std::io::tcp::Acceptor` or a `std::io::tcp::Listener` — is stopped by shutting its listening socket down (`shutdown(SHUT_RDWR)`): its blocked or parked `accept` returns an error, the accept loop ends, and `dissolve()` does the `close()`. The wake has to be the syscall: a `pinned` listener blocked in `accept(2)` services no mailbox, so a stop delivered over the bus would wait for the next connection. `std::io::tcp::stop(name: String) -> Bool` stops the listener the process knows by `name` (its `name` param; a listener enters its name when it is born and leaves when its socket is closed, 64 listeners a process). **A listener's name is unique.** A listener binds in its `birth()` and then takes its name: the `name` it was given, or, left empty, the host and the port the socket is actually bound to (`127.0.0.1:`, the kernel's pick for `port: 0`, so two `port: 0` servers are two names and two routes). The name it goes by is its `listener` field. An explicit `name` another live listener holds is the listener's `name_taken` violation at birth, with its socket closed before anything runs. A listener whose birth failed holds no subscription that matches a live route: a Server builds its consumer in `birth()`, on the route of its final name, only once the name is its own, and its relay closes nothing until then — so a refused server can never shadow another listener's catch-unmatched close. Because birth binds, `restart(server)` binds afresh (a supervisor that corrected `host`, a port that came free), closing the socket a previous birth left. It is a free call, pool-neutral, so **a checked program stops a listener on another pool with it** — where a method call across pools is refused (F.31's single-threaded-method invariant, `spec/types.md`). `true` when a listener had the name: ```hale main locus App { params { web: std::http::Server = std::http::Server { port: 8080, name: "web", handler: Routes { } }; } placement { web: cooperative(pool = web) where async_io; } run() { while !self.draining { std::time::sleep(20ms); } std::io::tcp::stop("web"); } } ``` `Server.shutdown()` does the same from the Server's own thread (a locus on its pool, or a test harness that runs no checker). The accept loop treats any negative `accept_one` return as a clean shutdown signal, so even degenerate fd closes (external fd-closes, etc.) terminate gracefully. **Shutdown is a drain, never a cut.** Once the Server's accept loop ends — `stop`, `shutdown()`, or `max_accepts` reached — its `run()` returns only after every connection it accepted has been answered, so `dissolve()` never tears down a connection mid-request. Inline (one at a time) that is the connection being served; on an `async_io` pool it is every connection in flight, each finishing its handler and its write. A connection is bounded by its 5 s recv timeout while it reads and by the handler's own time after that. The drain is the Server's: a program that ends (its main locus's `run()` returns, the pool workers are joined) while connections are still in flight ends them with the process, unanswered — wait for them before returning when they must be answered. (Under SIGTERM the process drain also ends the sleeps a handler was in when it began, `spec/runtime.md`, so a sleeping handler answers early.) ## Server concurrency — a coroutine per connection on `async_io` `std::http::Server` is a listener and a consumer of the TCP connection substrate (below), both in the Server's own placement. It serves concurrently exactly where a blocking call parks rather than holds the thread: on a cooperative pool declared `where async_io`. ```hale main locus App { params { web: std::http::Server = std::http::Server { port: 8080, handler: Routes { } }; } placement { web: cooperative(pool = web) where async_io; } } ``` There the accept loop parks on the listening socket between connections and relays each one to the Server's consumer, which serves it in a child of its own (an accept'd flow child) whose `run()` reads the request, calls the handler and writes the response. A park anywhere in a connection — the request's recv, a `std::time::sleep`, a socket read or an outbound plain-HTTP call in the handler, a send to a slow client — yields the pool's worker to the other connections and to the accept loop. Five concurrent requests to a handler that sleeps a second take one second, not five; fifty take one second. At most `max_connections` (default 256) are served at once; the next connection waits for a slot (a parked coroutine holding its descriptor), as many again may wait, and past that a connection is closed unserved — which bounds the descriptors and the 64 KiB coroutine stacks a burst holds. Each connection's region is reclaimed as its `run()` returns. Anywhere else — `fn main()`'s thread, the main pool, a `pinned` thread, a classic cooperative pool — a blocking call holds the thread, so the Server serves one connection at a time, inline, as it always has: a handler that takes a minute holds every other request for that minute. (On main and a pinned thread the accept loop yields after each relay, which runs the consumer's delivery on the same thread before the next accept; on a classic pool, which runs its queued cells only between cells, the Server hands the connection to its consumer directly.) Concurrency is not the default because it cannot be: the main pool cannot be `async_io`, placement is written only on the main locus, and the Server's consumer is a param of the Server, so it inherits the Server's thread like any nested locus (`spec/runtime.md` § "Nested-instantiation inheritance") — making the consumer a separate locus does not let the statement form `std::http::Server { … };` in `fn main()` place it elsewhere, and moving the user's handler to a thread its program did not name would change what that handler may assume. macOS and musl targets have no `async_io` pools at all. The one switch is the Server's placement entry; a program that wants the listener and the serving apart places a `std::io::tcp::Acceptor` and `std::io::tcp::Connections` itself (below). **The handler's state.** Every connection calls the same handler locus. On an `async_io` pool its `handle` runs inside the connection's coroutine, on the pool's one worker thread (`spec/runtime.md` § "One worker thread per named cooperative pool"), so two calls never run in parallel: each runs uninterrupted until it parks, and while it is parked another connection's `handle` may run and change the handler's `params`. A value a handler read before a park may be stale after it; state that must hold across a park is re-read, or kept in the request's own locals. Off `async_io` every call runs to completion before the next begins. Either way the handler's code runs on the consumer's thread — the Server's; a handler locus that is a field of a locus on another pool is entered from that thread, not its own. ## The TCP connection substrate Every server has the same shape, and the substrate writes it once (`crates/hale-stdlib/hl/io_tcp_serve.hl`): a **listener** that only binds, accepts and relays each connection over the bus as an owned handle, and **consumers** that take a handle and serve its connection. The syscalls are stitched by placement: a listener placed `pinned` blocks in `accept(2)` on its own thread, one on a `where async_io` pool parks there; a consumer on an `async_io` pool serves each connection on a coroutine of its own, and anywhere else one at a time. - **`std::io::tcp::Acceptor { host, port, path, name, shards, max_accepts, ready_signal }`** — the listener. Binds at birth (a socket it cannot bind is its `listen_failed` violation; a restart binds afresh), enters its name (`name`, or `host:`, or `unix:`; one taken is `name_taken`) for `stop`, prints `ready_signal`, and accepts until stopped or `max_accepts`, relaying each connection. It serves nothing. **Two address families, one substrate:** with `path` set it binds a Unix stream socket there in place of `host:port` (a stale socket file is replaced, a path a live process holds is `listen_failed`) and removes the file when it dissolves — only while the path still names the file it bound (`std::io::unix::__release_path`): a listener stopped by name keeps its fd until it goes, a shut Unix socket refuses connects, so a replacement may take the path as stale and bind its own file there, and the old listener's cleanup leaves that file alone; accept, read, write and close are the same calls for both, so the relay, the handle and the consumers do not know which family a connection is. A failed accept nobody asked for (out of descriptors, a connection that failed between the handshake and the accept: `__accept_one`'s `-2`) ends that connection and never the listener, which accepts again 20 ms later; a requested wake (`stop`, `shutdown()`, the pool's shutdown) is `-1` and ends the loop. `shards: K` (default 1) deals connections round-robin over K routes. Place it `pinned` or `where async_io`; on a classic pool its accept holds the pool, and consumers on that pool are never served. It is on the checker's long-running list, like the Server. - **`std::io::tcp::Accepted { listener, route, fd, seq }`** — the handle: the listener's name, the route it was sent on (`#`), the descriptor and the listener's count from 1. **Whoever takes it owns `fd`** and closes it. The handle itself lives for its delivery: a long-lived consumer keeps nothing of the connections it takes (spec/memory.md § "Every wire delivery takes the wire cell"; it used to keep the two names, ~34 B a connection). - **`__TcpAcceptedT`** — the substrate's topic, on the wire subject `__tcp.accepted`, `keyed_by route`, `on_unmatched: fallback`. A consumer subscribes `where key == `; every listener holds the topic's catch-unmatched subscription (`where key == _`) and closes a handle that is its own. **An accepted connection is served or closed — never leaked, never dropped in silence**, so a program may state it: `require no_silent_loss(topic __TcpAcceptedT)` holds (`spec/verification.md`), and `cover keys` is not available (the key is a String). - **`std::io::tcp::ConnHandler`** — `fn serve(c: std::io::tcp::Accepted)`: what serves one connection, on its consumer's thread. - **`std::io::tcp::Connections { listener, shard, handler, max_connections }`** — the consumer: subscribes to the route `#` and serves each connection with `handler` in a child of its own — a flow child on an `async_io` pool (concurrent, bounded and drained as above, reclaimed as its `run()` returns), an inline child anywhere else (one at a time). `refused` counts the connections closed unserved past its bound. Two consumers on one route would both take every handle, so routes are one consumer each: replicas share a listener through its shards. - **`std::http::Responder { handler }`** — the HTTP `ConnHandler`: reads one request (reassembled, 1 MiB cap, 5 s recv timeout), asks `handler`, writes the response (takeover included). - **`std::io::tcp::stop(name)`** — above. - **`std::io::tcp::Listener { host, port, max_accepts, on_connection, max_connections }`** — the m83 listener, on the substrate: one locus holding both ends, as the Server does, whose consumer's handler calls `on_connection: fn(std::io::tcp::Stream)` with a `Stream` over each connection and closes the connection when the call returns. `max_accepts` (default 1; negative = until stopped) bounds the accepts, and `run()` returns once each accepted connection has been served. Placed `where async_io` it serves its connections concurrently, a coroutine each (at most `max_connections`, 256); anywhere else one at a time. Its name is its bound address (`listener`, `host:`), for `stop`. A socket it cannot bind is no violation, as before the substrate: it accepts nothing and `run()` returns. Birth and dissolve print `__StdIoTcpListener.birth host=… port=…` and `__StdIoTcpListener.dissolve host=… port=…`; the default `on_connection` prints `__default_on_connection fd=`. Two replicas behind one pinned listener, each on an `async_io` pool of its own, the listener stopped by name from the main locus: ```hale main locus App { params { zero: std::io::tcp::Connections = std::io::tcp::Connections { listener: "web", shard: 0, handler: std::http::Responder { handler: Api { } } }; one: std::io::tcp::Connections = std::io::tcp::Connections { listener: "web", shard: 1, handler: std::http::Responder { handler: Api { } } }; acceptor: std::io::tcp::Acceptor = std::io::tcp::Acceptor { port: 8080, name: "web", shards: 2 }; } placement { zero: cooperative(pool = zero) where async_io; one: cooperative(pool = one) where async_io; acceptor: pinned; } claims { handed_over: require no_silent_loss(topic __TcpAcceptedT); } run() { while !self.draining { std::time::sleep(20ms); } std::io::tcp::stop("web"); } } ``` Consumers are declared before the listener, so they are subscribed before it accepts. `std::http::Server` is one locus that holds both ends: its `relay` (the listener's end of the bus) and its `conns` (a `Connections` on its own route, `handler: std::http::Responder { … }`). **The api runtime's socket transports are substrate listeners** (spec/api.md § The Unix JSON transport, § The HTTP transport, § gRPC, § Streams). `std::api::unix::Listener`, `std::api::http::Listener` (the latter `mcp::Rpc`'s too), `std::api::ws::Listener` (a `ws::Hub`'s) and `std::io::h2::Listener` (`grpc::Rpc`'s) hold both ends as the Server does: a `relay` that accepts and relays, and a `conns` on the route of the listener's name (`unix:`, `host:`), built in `birth()` once the name is entered, whose `ConnHandler` starts the transport's connection locus (the peer that reads requests, publishes them as the exposure's ingress and writes the replies; the hub's WebSocket peer, which upgrades the connection and carries its subscriptions and calls; the HTTP/2 `Conn`, which owns the connection's nghttp2 session) as a flow child of its own — accepted and released, on the listener's `async_io` pool, and reclaimed with its region when the connection ends. A per-connection locus that subscribes to the bus has to be a flow child rather than a local of `serve`: a locus born in a method body and gone at its end has its deliveries queued for the main thread, which would answer the connection from another thread than the one reading it. What is the api's own is the policy around the substrate: an address it cannot bind or a name it cannot enter is the boot's failure (`api: could not listen on
`, exit 2), nothing is accepted until the exposure (the hub's core, the gRPC transport) is subscribed, and the exposure's `stop()` shuts the socket (the accept returns and the listener releases it, the Unix socket file with it) and ends its connections as its transport does (a hang-up; the hub's `closed` frame; HTTP/2's `GOAWAY` once the streams in flight are answered). Every api listener closes a handle on its own name's route that no consumer takes, and `stop(name)` reaches it: the socket is shut and released, and the connections it accepted are served on until they end or the exposure stops (a drain, never a cut). The substrate is source the program carries only when it names it: `std::http::Server`, `std::http::Responder` or one of `std::io::tcp::{Listener, Acceptor, Connections, Accepted, ConnHandler}` appends it (and its topic) to the program before the check, as the api runtime is (`hale_stdlib::TCP_SERVE_SOURCE`), and `std::io::tcp::Listener` appends the Listener after it (`hale_stdlib::TCP_LISTENER_SOURCE`, `io_tcp_listener.hl`); every other program carries none of it. It is stdlib source in every judgment: its loci start where the program names them, a scope of it holds dynamic sites in the placement table only once the program reaches it, the cooperative-pool blocking advisory does not read its bodies, the resource budget counts none of its descriptor sites, and a capability it needs is refused at the program's literal (`--target wasm32`), with the witness down into it. **Not yet.** The peer address: `accept` is called without one, so a consumer cannot be keyed by peer. The api listeners' consumers start each connection's locus and return, so `max_connections` does not bound them: an api listener's connections are as many as clients open, as they were before the move. ## `Server.ready_signal` — synchronization for piped oracles `std::http::Server` accepts an optional `ready_signal: String = ""` param. When non-empty, the server emits it via `println` from `birth()` immediately after `listen_socket` succeeds and before the accept loop begins. Test harnesses, oracles, and shell scripts that pipe the server's stdout (`./bin | grep -m1 READY`) key off this line: ```hale std::http::Server { port: 8080, handler: Routes { }, ready_signal: "READY" }; ``` Pair with the line-buffered stdout setup (the prelude installs `setvbuf(stdout, NULL, _IOLBF, 0)`) so a single `println` is flushed even under pipes. ## `std::json::Builder` — streaming output API `Builder` is a `@form(...)`-less locus that accumulates a JSON document into an internal buffer. It tracks scope state in a single context stack (one char per open scope: `O`/`A` for object/array with at least one value already emitted, `o`/`a` for empty). The Builder inserts separators (`,` between siblings, `:` between key and value) automatically. Methods, grouped: - **Scopes:** `begin_object()`, `end_object()`, `begin_array()`, `end_array()`. - **Object entries (key + value in one call):** `field(name, value)` for the common string case; `string_field`, `int_field`, `bool_field`, `null_field` for explicit typing. - **Array entries / bare values:** `value(v)` (string), plus `string_value`, `int_value`, `bool_value`, `null_value`. - **Nested scopes inside an object:** `begin_object_field(name)` / `begin_array_field(name)` — emit `"name":` then open the sub-scope. - **Finish:** `result() -> String` returns the assembled buffer. ```hale let b = std::json::Builder { }; b.begin_object(); b.field("name", "alice"); b.int_field("age", 30); b.begin_array_field("tags"); b.value("admin"); b.value("ops"); b.end_array(); b.end_object(); let out = b.result(); // {"name":"alice","age":30,"tags":["admin","ops"]} ``` The flat-object readers (`find_*_field`, `array_first/next`) are the input side of the same v1 commitment: JSON is a wire format, not a tree value type, and the API surface reflects that. ### String escaping — `escape_string` / `unescape_string` `escape_string(s)` emits RFC 8259 §7: `\\` and `\"`, the four short forms `\b \f \n \r \t`, and `\u00XX` for the remaining control bytes. Every other byte passes through, so UTF-8 in the input is UTF-8 in the output. `unescape_string(s)` is the inverse, and decodes `\uHHHH` to the scalar it names (GH #709 — before 2026-09-19 every `\uHHHH` decoded to `?`, so an escaped literal never compared equal to its plain form): | input | result | | --- | --- | | a scalar, `\u0031` / `\u00e9` / `\u4e2d` | its UTF-8 encoding, 1–3 bytes | | a surrogate pair, `\ud83d\ude00` | the astral scalar it denotes, 4 bytes | | an unpaired surrogate, `\ud83d` or `\udc00` | U+FFFD | | `\u0000` | U+FFFD | | four digits that are not all hex, `\uZZZZ` | the six source bytes, verbatim | | any other unknown escape, `\q` | the two source bytes, verbatim | The two refusals are deliberate. A native `String` is NUL-terminated, so U+0000 has no representation in one: writing the raw byte would truncate the value and let a prefix compare equal to the whole, which is worse than a visibly wrong character. An unpaired surrogate is not a Unicode scalar and has no UTF-8 encoding at all. Both answer U+FFFD rather than failing, because these helpers read untrusted wire bytes and the rest of the document is still worth decoding. ### Bounded memory `escape_string`, `unescape_string` and every `Builder` emitter accumulate into one growing byte buffer and materialize a `String` once, so each is linear in the bytes it produces and the transient cost is bounded by the output size (GH #708). This is a contract, not an optimization: a `String` is immutable, so the earlier `out = out + piece` form copied the whole accumulated prefix per write AND left every prefix behind in arena storage until the call returned — quadratic in both time and retained memory. A downstream handoff generating a document with a 600,000-byte shared value and 512 references reached 23–24 GiB of scratch and exhausted swap. `result()` copies the buffer, so a snapshot taken mid-document is a stable `String`: the Builder keeps accumulating after it, and the earlier snapshot does not change. ### Validation — `valid` / `valid_object` `valid(text)` is true when `text` is one well-formed RFC 8259 value and nothing else: objects, arrays, strings, numbers, `true` / `false` / `null`, with JSON whitespace (space, tab, LF, CR) permitted around every token. Leading and trailing whitespace is fine; trailing anything else is not, and `""` is not a document. The number rules are the grammar's, so `01`, `1.`, `.5`, `1e` and `+1` are all refused while `-0`, `1e5` and `-1.25e-3` are values. `valid_object(text)` is `valid` plus: the top-level value is an object, its keys are unique, and none of them is written with an escape. That is the shape a gate in front of a record wants. A duplicate key is syntactically valid JSON — last-wins is a convention, not the spec — so `valid` accepts it and two readers of the same bytes can disagree about the record; an escaped key spelling (`"\u0061"` for `"a"`) compares unequal to the plain spelling in every `find_*` lookup in this module, so admitting one lets a field be present and unfindable at the same time. Keys of NESTED objects are checked for syntax only. Three refusals go past the grammar. Each is a value this runtime cannot carry, and each is the same call the escape helpers make: | refused | why | | --- | --- | | text that is not well-formed UTF-8 | including a surrogate encoded directly as three bytes (ED A0..BF) and anything above U+10FFFF | | a lone `\uXXXX` surrogate | denotes no scalar; `unescape_string` answers U+FFFD for it, and an admission gate should refuse rather than hand on a replacement character | | `\u0000` | a native `String` is NUL-terminated, so U+0000 would truncate the value and let a prefix compare equal to the whole | `valid` is linear in the input. Both are **bounded**: - **Depth 64.** Nesting deeper than that is refused rather than scanned, so a hostile `[[[[…` costs a constant walk. The walk is iterative — one `Int` holds a bit per open level saying whether that level is an object — and 64 is where one machine word of that mask runs out. The bound is also the reason the scan is not a recursive descent: a coroutine stack is 64 KiB, and 64 frames of one is a real fraction of it. - **64 top-level fields, for `valid_object` only.** Uniqueness is decided by walking the object's earlier members and comparing keys where they already live, so the check needs no table and no accumulator — and the width bound is what keeps that walk from being the quadratic cost it would otherwise become (uniqueness is at most 64 passes over the object). A wider object answers false. `valid` has no width bound, so it is still checkable as JSON — only the unique-key guarantee stops at 64, and a caller that needs both on a wider object can `valid` it and walk the keys with `object_first` / `obj_key_eq`. Both are classified **pure**, and both allocate nothing at all: bytes are read with `std::str::byte_at_unchecked` after explicit bounds checks, whitespace runs skip through the SIMD `next_non_ws`, and nothing intermediate is materialized — no substring, no key set, no accumulator. That is a contract, not an accident: these fns are meant to run on every inbound message, so a per-call scratch buffer would land in the caller's arena and be retained there until the caller returned. Cost is one pass for UTF-8, one for the grammar, and for `valid_object` up to one per field over the top-level keys — measured at ~10 ms per MiB, against 10.6 s for the per-character checked-slice shape (`text[p..p + 1]`, which allocates per byte) that hand-rolled validators reach for first (GH #720). String bodies are deliberately NOT scanned with the sibling `next_quote_or_bs` jump: it cannot see the raw control bytes RFC 8259 §7 forbids inside a string, and accepting those would make `valid` weaker than the validators it exists to replace. These are the strict counterpart to the `find_*` family, which stays permissive on purpose (`find_string_field("{\"x\":1", "x")` answers `1`). Validate at the boundary, then scan. ### Typed field access `find_string_field(json, name)` answers with a `String` whatever the value was. That is its contract and it does not change — but it means eight different documents give one answer, and an optional field cannot be read safely: | document | `find_string_field` | `string_field(…).kind` | | --- | --- | --- | | `{"owner": "Ada"}` | `"Ada"` | `"string"` | | `{"owner": null}` | `"null"` | `"null"` | | `{"owner": "null"}` | `"null"` | `"string"` | | `{"owner": ""}` | `""` | `"string"` | | `{}` | `""` | `"missing"` | | `{"owner": 7}` | `"7"` | `"number"` | | `{"owner": true}` | `"true"` | `"bool"` | | `{"owner": []}` | `"[]"` | `"array"` | | `{"owner": {"a": 1}}` | `{"a": 1}` | `"object"` | | `[1, 2]` (not an object) | `""` | `"invalid"` | `string_field(json, name) -> JsonString` is the typed read (GH #719). `JsonString` is `{ kind: String; text: String; }`: - `kind` is one of `"string"`, `"null"`, `"missing"`, `"number"`, `"bool"`, `"array"`, `"object"`, `"invalid"`. - `text` is the DECODED string content — surrounding quotes stripped, escapes resolved through the same `\uHHHH` path as `unescape_string` — and only when `kind` is `"string"`. Every other kind carries `""`, so a caller that ignores `kind` gets an empty name rather than a plausible wrong one. - `"missing"` means `json` IS an object with no such member; `"invalid"` means `json` is not an object at all, or the member's value is not a JSON value the scanner can name (an unterminated string, a bare token like `NaN`). - A key repeated within one object resolves to the FIRST member with that name — the same answer `find_string_field` gives, since both walk the top-level object cursor and stop at the first match. The reader is not string-specific: an `Int` or `Bool` caller can gate the coercion on `string_field(json, name).kind == "number"` / `== "bool"` before trusting `find_int_field` / `find_bool_field`, which coerce the same way `find_string_field` does. The result types in this namespace — `JsonString`, `JsonFieldRange`, `ArrayIter`, `ArrayIterSpan`, `ObjectIterSpan` — are nominal types the CHECKER knows, not opaque values (GH #771). `string_field(body, "owner").knd` is a type error naming `std::json::JsonString` with a did-you-mean for `kind`; so are a wrong arity, a swapped `(json, it)` argument pair on the span cursors, and handing one of these structs to a `String` parameter. `let r: std::json::JsonFieldRange = …` is the same type as the call's inferred one, so the annotation is optional rather than lossy. ```hale let owner = std::json::string_field(body, "owner"); if owner.kind == "string" { node.adopt(owner.text); } else if owner.kind == "null" || owner.kind == "missing" { // a root: parentless, and NOT a node named `null` } else { return "owner must be a string or null"; } ``` ## `read_file` for synthesized files `std::io::fs::read_file(path)` uses a growing buffer internally (4 KiB initial, doubling, 64 MiB cap) rather than pre-sizing from `fstat`. Synthesized files under `/proc` and `/sys` report `st_size=0` from `fstat`, so a fstat-then-read approach would return an empty String for `/proc/self/statm`, FIFOs, sockets, and similar synthetic sources. The growing-buffer shape reads real bytes from all of them. The 64 MiB cap is a runaway guard, not a memory budget — real `/proc` / config files are 4–64 KiB. Callers hitting the cap probably want a streaming API; the cap surfaces as `IoError { kind="io", errno=EFBIG }`. ## What's not in stdlib (third-party territory) - ML / learning libraries - Database drivers (Postgres, etc.) - Web frameworks beyond basic HTTP - Image / audio / video processing - Cloud SDKs (AWS, GCP, etc.) - GUI / TUI frameworks - Cryptography beyond TLS + SHA-2 basics - Compression formats beyond ones used in stdlib These live in the Hale package ecosystem (per [`packages.md`](./packages.md)). ## Open decisions 1. **Module organization** — flat (`std::collections`, `std::string`) vs hierarchical (`std::collections::Map`). Probably the Go-style middle ground (`std/collections/map.go`). 2. **What's exported by default vs deep-imported.** `import std;` for everything? `import std::time;` only? Probably the latter: explicit per-module imports. 3. **API stability commitments.** Go's stdlib is famously stable. v0 stdlib is unstable; v1 marks specific APIs as `stable`; only stable APIs survive long-term. 4. **Versioning.** Stdlib versioned with the language? Or independently? Probably with the language for v0; consider independent versioning when stable. ## Why batteries-included - **Lower adoption barrier.** New users don't need to evaluate third-party packages for table-stakes functionality. - **Discipline propagation.** Stdlib uses framework primitives correctly; new code following stdlib examples inherits the discipline. - **Ecosystem trust.** When the language ships a `std::crypto`, it's vetted; trust transfers to programs that use it. - **Cross-language consistency.** Programs from different teams share the same vocabulary because they share the same stdlib. Cost: stdlib is permanently load-bearing once shipped. Bad decisions are hard to undo. Discipline at design time matters more here than in third-party. ================================================================================ ## spec/ffi.md — Foreign-function interface (`@ffi("c")`) ## source: spec/ffi.md ================================================================================ # spec/ffi.md — Foreign-function interface (`@ffi("c")`) User-extensible bindings to external C-ABI libraries. Library authors declare extern symbols in `.hl` source via an `@ffi("c")` annotation; the compiler emits LLVM `declare` for the signature and the linker resolves against C source files supplied at build time. No stdlib expansion is required to bind a new library. ## Syntax ```hale @ffi("c") fn raylib_init_window(w: Int, h: Int, title: String) -> (); @ffi("c") fn raylib_should_close() -> Bool; @ffi("c") fn raylib_clear_background(c: Color) -> (); ``` Grammar: ``` ffi_annotation ::= '@' 'ffi' '(' STRING ')' ffi_fn_decl ::= ffi_annotation 'fn' Ident '(' params ')' ('->' type_expr)? ';' ``` The annotation precedes the `fn` keyword. The fn body MUST be absent — the declaration terminates with `;`. The compiler synthesizes an empty body internally so downstream passes keep the same `FnDecl` shape; user code MAY NOT write a `{...}` block. The ABI string is the literal `"c"` (native C-ABI binding) or `"js"` (a WASM host import — see [§ WASM host interface](#wasm-host-interface)). Any other ABI string is rejected at parse time. ## Position `@ffi("c")` is valid only on **top-level free fn declarations**. The annotation is rejected on: - Locus methods (`locus L { fn ...; }`). - Mode bodies (`mode bulk { ... }`, `mode harmonic { ... }`, ...). - Perspective method signatures. - Interface method signatures. - Closure declarations. The position restriction matches the substrate's expectation that the C-ABI boundary crosses at top-level program scope only; locus and perspective methods carry implicit Hale-side context (`self`, scratch arena, lifecycle hooks) that doesn't translate to C. ## Restrictions An `@ffi("c")` fn declaration MUST NOT be: - **Generic.** Type parameters require monomorphization; the C-ABI boundary is monomorphic by definition. Declare separate `@ffi` fns per type if needed. - **Fallible.** `fallible(E)` is an Hale internal channel; C functions report failure via error sentinels in the return value, and the Hale wrapper above translates to `fallible(E)` if exposed to user code. - **Defaulted.** Parameter defaults are not portable across the C-ABI boundary; the wrapper layer applies defaults before the call. The parser rejects all three with a diagnostic at the annotation or marker position. ## Type marshalling The typechecker validates `@ffi("c")` parameter and return types against a portable subset. LLVM lowers each Hale type to a matching C-ABI representation at the call boundary: | Hale type | LLVM type | C type | Notes | |---|---|---|---| | `Int` | `i64` | `int64_t` | 64-bit signed throughout. | | `Float` | `double` | `double` | 64-bit IEEE 754. | | `Bool` | `i32` | `int32_t` | Hale's i1 zero-extends to i32 at the call, truncates back at the return. Avoids C `_Bool` cross-platform ambiguity. | | `String` | `ptr` | `const char *` | NUL-terminated. Caller owns; callee MUST NOT retain past the call. A `StringView` does **not** implicitly coerce to a `String` parameter — it isn't NUL-terminated, so a `char*`-expecting callee would `strlen` past its end. Pass a view as a `StringView` parameter (→ `lotus_view_t`, length-carrying) or materialize it first via `std::str::clone`. | | `Bytes` | `ptr` | `void *` (header) | Points at Hale's `[int64 len][payload]` header — callee uses `lotus_bytes_len(p)` / `lotus_bytes_data(p)` (declared in `lotus_arena.h`) to inspect. Caller owns. | | `BytesView` / `StringView` | `{ ptr, i64 }` (struct by value) | `lotus_view_t` | 16-byte F.30b view layout. C glue MAY use `lotus_view_data` to recover the payload pointer + length. | | `Duration` / `Time` | `i64` | `int64_t` | Both are 64-bit nanosecond counts under the hood. | | `()` (unit) | `void` | `void` | Return-position only — declared as `-> ()` or omitted entirely. Empty-tuple return type accepted but normalized to `()`. | | User struct (`type T { ... }`) | `ptr` | `const T *` (param) / `T *out` (sret return) | Passed by pointer at the boundary; struct returns use a hidden sret first arg (see User-type structs section below). Layout match is the library author's responsibility. | Reserved at Stage 1 (typecheck rejects with a clear diagnostic): - `Decimal` — i128 mantissa with platform-variable ABI. Marshal as `Int` (raw mantissa) or `Float` (lossy conversion) at the Hale side; the wrapper handles the scale. - `Uint` — Hale-internal type; declare as `Int` at the FFI signature. - Projections / fixed-size arrays / tuples — no portable C struct layout for these v0 shapes. - `fallible(E)` — internal channel; see Restrictions above. - Function-pointer types — wrap as a struct/handle at the C side. - `LocusRef`, `Cell` — Hale-internal. ### User-type structs User-type structs (`type Color { r: Int = 0; ... }`) are passed **by pointer** at the C-ABI boundary, not by value. The Hale side already stores user structs as heap pointers, so the natural mapping is `ptr` at the LLVM level. C glue authors write: ```c // Param-position: const T * (or T * if the callee mutates). void raylib_clear_background(const Color *c) { ClearBackground((::Color){ (uint8_t)c->r, (uint8_t)c->g, (uint8_t)c->b, (uint8_t)c->a, }); } ``` Struct returns use **sret-style**: Hale allocates the return slot in the caller's arena and passes a pointer as a hidden first argument. The LLVM-level fn signature is `void foo(T *out, )`; the C glue writes the struct into `*out`: ```c // Return-position: hidden T *out first param, returns void. void vec3i_scale(Vec3i *out, const Vec3i *v, int64_t k) { out->x = v->x * k; out->y = v->y * k; out->z = v->z * k; } ``` The Hale-side call expression ```hale let scaled = vec3i_scale(v, 10); ``` sees the sret slot's pointer as its result — same value-shape as any other struct-returning expression. The sret transformation is hidden from user code; only the C glue author sees it. **Why pointer + sret instead of by-value:** SysV / Win64 / aarch64 all classify struct-by-value differently based on size. A portable implementation would need a per-platform ABI-lowering pass. The pointer convention sidesteps that entirely — every target lowers `ptr` the same way — at the cost of one dereference per arg on the C side. For the workloads Hale is shaped for (locus methods, bus dispatch, FFI to system libraries), that cost is negligible compared to the portability win. **Layout contract:** the Hale struct's field order + types must match the C struct on the other side. The library author guarantees this. Future spec iteration may add a compile-time layout-assertion mechanism (`@ffi_layout("c")` on the `type` decl); today the contract is documented but not machine-checked. ## Calling convention `@ffi` fns differ from regular Hale free fns at the LLVM ABI level: - **No implicit `__caller_arena` first parameter.** Regular free fns receive the caller's `current_arena_ptr()` as an implicit prefix; `@ffi` fns do not. - **No fallible sret slots.** `@ffi` fns can't be `fallible(E)`, so the sret-pair the substrate emits for fallible returns is absent. - **No monomorphization.** `@ffi` fns can't be generic. The LLVM symbol name is the literal Hale fn name as written. There is no `__std_*` mangling, no per-import alias prefix, no generic-instantiation suffix. The library author's C glue exports a function with that exact name; the linker resolves directly. ## Lifetime rules The Hale-side caller of an `@ffi` fn owns every pointer it passes. The C-side callee MUST: - NOT retain `String` / `Bytes` / view pointers past the call boundary. If C needs persistent storage, it must copy into its own malloc'd memory. - NOT free or write through any pointer received from Hale. Arena-owned pointers are read-only at the C side. If a C function needs to RETURN heap-allocated `String` or `Bytes`, the convention matches stdlib primitives that allocate return values: call `lotus_arena_alloc(lotus_caller_arena_or_global(), size, align)` to land the storage in the caller's arena, then return the pointer. The caller's arena outlives the C-side function frame, so the returned pointer survives. Exceptions MUST NOT cross the FFI boundary. C code that fails returns an error sentinel (NULL, -1, etc.); the Hale-side wrapper translates to a `fallible(E)` shape if the error needs to propagate. ## Build surface The `hale build` CLI accepts repeatable flags that thread the library author's C glue + link surface through to clang: ``` hale build mydir/ --link raylib --csrc pond/raylib/glue.c \ --link curl --csrc pond/curl/glue.c ``` - `--link ` — appended as `-l` to the clang link line. The system's dynamic linker resolves at runtime. - `--csrc ` — passed directly to clang as a translation unit compiled alongside the C runtime. The library author's `.c` glue file goes here. May be repeated for multiple files. Both flags are optional; programs that don't use `@ffi` declarations don't need either. ### `hale.toml [ffi]` auto-pickup (Stage 2) When `hale build` resolves an `import` against a directory that contains an `hale.toml`, it reads the file's `[ffi]` section and appends those values to the build's link surface automatically. `hale run` and `hale replay` build with the same options, from the same function, and `hale test` and `hale bench` apply the same pickup. Library authors ship: ```toml # pond/raylib/hale.toml [ffi] link = ["raylib"] csrc = ["glue.c"] ``` Consumers then just `import`: ```hale // myapp/main.hl import "vendor/raylib" as ray; fn main() { let w = ray::Window { width: 1280, height: 720 }; ... } ``` `hale build myapp/` reads `vendor/raylib/hale.toml`, picks up `link=["raylib"]` + `csrc=["glue.c"]`, and threads them through to the clang invocation. The CLI flags from the prior section still work as additive overrides (CLI first, then toml- sourced). A flag naming what an imported package's `[ffi]` names — a `--csrc` with the canonical path of a package's `csrc`, a `--link` of a library a package links — is a no-op, not an error: the package's entry stands and the flag's is dropped, so the C file is compiled once and the options (and the execution identity) are the flagless invocation's. This holds for `hale build`, `hale run` and `hale replay` alike. Single-file imports (`import "helpers"` → `helpers.hl`) have no companion toml and contribute nothing. De-duplication: a lib referenced under two aliases or via multiple files in the same seed contributes its FFI flags once per unique resolved directory. Transitive FFI is NOT walked at Stage 2: only the entry's top-level imports are scanned for `hale.toml`. If a directly- imported lib itself imports another `@ffi`-using lib, the transitive lib's `[ffi]` must be re-declared (or surfaced via manual `--link` / `--csrc`) at the entry. Resolved if a workload surfaces the need. ## Library-author surface A binding library typically ships: 1. A `.hl` file with `@ffi("c") fn ...;` declarations + the user-facing Hale wrapper (locus, types, idiomatic signatures). 2. A `.c` file exporting the C-side symbols declared in the `.hl`. Often a thin shim from Hale's snake_case to upstream C naming. 3. (Stage 2) An `hale.toml [ffi]` section declaring `link = [...]` and `csrc = [...]`. Example skeleton (pond/raylib): ```hale // pond/raylib/raylib.hl @ffi("c") fn raylib_init_window(w: Int, h: Int, title: String) -> (); @ffi("c") fn raylib_close_window() -> (); locus Window { params { width: Int = 1280; height: Int = 720; title: String = ""; } birth() { raylib_init_window(self.width, self.height, self.title); } dissolve() { raylib_close_window(); } } ``` ```c // pond/raylib/glue.c #include #include "raylib.h" void raylib_init_window(int64_t w, int64_t h, const char *t) { InitWindow((int)w, (int)h, t); } void raylib_close_window(void) { CloseWindow(); } ``` ```toml # pond/raylib/hale.toml (Stage 2) [ffi] link = ["raylib"] csrc = ["glue.c"] ``` ## Diagnostic surface Parser errors: - `expected ; (an @ffi fn declaration has no body), got LBrace` — body block written after the signature; convert to `;`. - `unsupported FFI ABI "" — Stage 1 accepts only "c"` - `\`@ffi\` fn must not be generic — the C-ABI boundary is monomorphic` - `\`@ffi\` fn must not be \`fallible(...)\` — C functions return an error sentinel, the Hale wrapper above translates to \`fallible(E)\` if needed` - `expected \`fn\` after \`@ffi(...)\` annotation` - `expected \`fn\` after \`@export\` annotation` - `\`@export\` and \`@ffi\` are mutually exclusive — an FFI import is not a module export` Typecheck errors: - `\`@ffi\` fn \`\` parameter \`

\` has type Decimal — Decimal (i128) has platform-variable ABI; marshal as Int/Float at the Hale side instead` - `\`@ffi\` is only valid on top-level free fns at Stage 1, not on locus methods` Codegen errors: - `@ffi fn \`\` parameter \`

\`: type is not yet wired for FFI codegen at Stage 1` — user-type structs, arrays, etc. fall here. - `@ffi fn \`\`: parameter defaults are not supported across the C-ABI boundary` - `@export fn \`\`: fallible exports are not supported yet (wasm entry-inversion v1)` ## WASM host interface On the `wasm32` target (`hale build --target wasm32`, or a program that declares `target wasm { }`) the foreign boundary is the JavaScript host rather than a C library. The same `@ffi` machinery serves the inbound direction, and a dual annotation `@export` serves the outbound direction. ### Package `[ffi]` under wasm32 A package's `[ffi] csrc` translation units **are compiled for wasm32** and linked into the module (#213, 2026-08-03). Before that they were silently skipped: `link_wasm` was invoked without the build options, so every `@ffi("c")` symbol a package defined in C surfaced as an undefined `env` import, `--allow-undefined` swallowed it, and the generated JS loader stubbed unknown imports with `() => 0`. The build reported success and every such call returned 0 forever. Two constraints follow from the wasm build being **freestanding**: - **No libc sysroot.** The runtime is compiled with bare `--target=wasm32` against a forward-declared shim, not wasi-sdk. There are no system headers, so a translation unit that `#include`s `` will not compile for this target. That is now a build error naming the file, rather than a silently missing symbol. - **`[ffi] link = [...]` is rejected.** It names a system dynamic library, and wasm has neither a dynamic linker nor system libraries. Dropping it silently would reproduce the same failure one level up, so it is an error that points at `csrc` as the alternative. It is a property of the program, the configuration and the target, never of the machine: `hale build` reports it before any tool is looked up (a machine without clang meets this refusal, not a missing compiler), and `hale check --target wasm32` reports it too, as a record against the package manifest's `link` line (``lib/glue/hale.toml:5:1: error: `[ffi] link = ["m"]` cannot be satisfied on wasm32 — …``). A `--link ` flag, which `hale check` takes as `hale build` does, is the same input, refused naming the flag. One sharp edge worth stating, because linking the C is what exposes it: **Hale's `Int` is 64-bit, so a C declaration must use `long long`, not `int`.** A mismatch links, and then traps at call time with a wasm `signature_mismatch`. That is louder than the native ABI, which would quietly truncate — and far louder than the previous behaviour, where the symbol was a stub and the mismatch could not be observed at all. ### The `target` declaration + stdlib gating The program opts into the wasm backend with a top-level `target` declaration whose name is **`wasm`** (or the alias **`browser_js`** — both select the same backend and gating): ``` target wasm { } ``` **Top-level is part of the rule, not a habit.** A `target` declaration inside a `module { }` is a parse error (GH #901, `spec/semantics.md` § "Declarations inside `module { }`"): every consumer of a target reads the program's own item list, so one declared at depth used to be accepted and then ignored — the same program that this section gates reported `ok` with its `target wasm { }` one brace deeper. **The effective target.** `hale check`, `hale build` and the editor act on one target, for analysis and for the artifact alike: - an explicit `--target` (on `hale build`, and on `hale check`, which takes the same flag) is the effective target; - with no `--target`, a written `target wasm { }` / `target browser_js { }` selects **wasm32**: `hale build` of such a program emits the wasm module and its loader, exactly as `--target wasm32` does; - with neither, the host. An explicit `--target` of another class than a written declaration's is refused at the declaration, on `hale check` and `hale build` alike: ``this program declares `target wasm`, and is being checked for ``: build it with `--target wasm32`, or drop the declaration``. The editor takes no `--target`, so it shows what `hale check` and `hale build` without one compute. `hale run` and `hale replay` execute what they build, and a declared program builds a module this host cannot execute, so they refuse it, as they refuse `--target wasm32`. The portable stdlib (`std::str`, `std::bytes`, `std::json`, `std::math`, `std::text`, …) works unchanged. The **POSIX-backed namespaces are rejected at typecheck** under this target — the browser sandbox has no syscalls — with the diagnostic ``error: `std::...` is unavailable under `target wasm`: `` (``under `--target wasm32` `` when the configuration, not a declaration, put the program under wasm32). The gated set is exactly the table below, rendered from the compiler's capability matrix (`hale_types::capability`). **Every use, not every spelling.** The refusal covers every way a program reaches a gated namespace, read off the resolved program (`hale_types::capability::uses`), not the text of its calls: - a call spelled with the stdlib path, refused at the call with the wording above; - a construction of a stdlib locus (`std::io::tcp::Listener { … }`, in a body or a params initializer): its lifecycle runs, so it is refused at the literal; - a method on a handle (`conn.recv(64)` with `conn: std::io::tcp::Stream`), resolved through the receiver's type; - a call into another seed through its import alias (`c::stamp()`), refused at that call when the seed's fn reaches the namespace; - a construction of another seed's locus (`lib::Kid { }`), refused at the literal when what its existence runs reaches the namespace: its lifecycle, its params initializers and its `on_failure` handler — the same requirements that refuse the locus declared in the program's own sources at the use inside it. A use reached through a chain is worded ``error: `std::` is unavailable under `target wasm`: — witness: `` → … → `` ``, naming each link. The program's own sources are the horizon: a fn the program writes is judged at the use in its own body, once, and the calls to it carry nothing; a fn of an imported seed or of the stdlib is judged at the call that crosses into it, and a locus of one at its construction. The horizon relocates a refusal; it never erases a requirement. A type that only names a gated namespace — a parameter, field or return typed `std::io::tcp::Stream` — is not a use. A call through a local bound by `let` to a fn or a stdlib path is that fn's use, the local a link of the witness (`let f = pid; f()` → `` `f` → `pid` → `std::process::pid` ``). In a fn's, a hook's or a method's body, a call through a function value the local does not name — a function-typed parameter, a computed callee, a local bound to anything but a fn (`let f = self.g; f()`) or reassigned (a local a `while` or `for` loop reassigns anywhere in its condition or body is unresolved for the whole loop and after it, since a call ahead of the assignment runs, on the next iteration, the value it stored) — reaches the program's function values of its type (spec/verification.md, "An indirect call reaches the program's function values"), each that fn's use, the local a link of the witness (`` `lib::via` → `f` → `lib::pid` → `std::process::pid` ``). A call whose requirements cannot be established (a method on a receiver whose type is not known; such a call no function value of the program can be; and, in the program's own or another seed's params initializer or `on_failure` handler, any call through a local function value bound to anything but a fn, or reassigned, by the same loop rule) is refused under wasm32 as ``cannot establish what `` requires on wasm32: ``, since an unknown requirement is never an admission there; on the native targets it is admitted. | Rejected path | Browser substitute | |---|---| | `std::io::tcp` | a WebSocket bus adapter (`ws://`) | | `std::io::udp` | (no raw UDP in the browser) | | `std::io::tls` | the browser does TLS transparently for `wss://` / `https://` | | `std::io::fs`, `std::io::file` | `fetch` via an `@ffi("js")` host import, or a bus message | | `std::io::h2` | a WebSocket bus adapter (`ws://`), or an `@ffi("js")` host import | | `std::io::stdin`, `std::io::stdout` | `println(...)` (the loader routes it to the host console) | | `std::term` | (no terminal in the browser) | | `std::process` | (no OS process control) | | `std::http` | (server is built on raw TCP) | | `std::env` | configuration handed in through an `@ffi("js")` host import or an `@export` fn's arguments | | `std::io::mirror` | (no shared memory in the browser) | | `std::io::sockopt` | (no sockets in the browser) | | `std::io::unix` | a WebSocket bus adapter (`ws://`), or an `@ffi("js")` host import | | `std::ring` | (no shared memory in the browser) | | `std::time` | a host clock (`performance.now`, `Date.now`) or timer through an `@ffi("js")` host import | | `std::ts` | parsing on the host, through an `@ffi("js")` host import | The **in-process typed bus is fully available** under `target wasm`: `topic` declarations and `bus { publish … }` / `bus { subscribe … }` across loci lower the same way they do natively — a `Subject <- payload` is delivered to every matching in-module subscriber's handler, payload-copied through the synthesized `__serialize_T` / `__deserialize_T` wire codec. Those codecs follow the `lotus_serialize_fn` / `lotus_deserialize_fn` ABI (`ssize_t(const void *, …, size_t)`), whose `ssize_t` / `size_t` widths are **target-pointer-width** — i32 on wasm32, i64 on the native 64-bit targets — so the runtime's `lotus_bus_dispatch` indirect call matches the codec on both. The *cross-process / network* transports need syscalls the sandbox does not have, and the module runs on its host's one thread, so under wasm32 the check refuses every `bindings { }` entry (`unix`, `shm_ring`, an adapter) at the binding, and every placement that asks for a thread of its own or a pool's — `pinned`, `cooperative(pool = X)` with X other than `main`, `where async_io` — at the placement entry. (Before, a `unix` or `shm_ring` binding and a pool were admitted and never ran, and a `pinned` locus or an adapter failed late in wasm-ld.) Reach the outside world through `@ffi("js")` host imports and the inbox/state seam below instead. ### `@ffi("js")` — host imports (host → into Hale's callees) `@ffi("js") fn name(...);` declares a function the **JS loader** provides at instantiation (a wasm `env` import), e.g.: ``` target wasm { } @ffi("js") fn console_log(msg: String); @ffi("js") fn draw_line(x1: Float, y1: Float, z1: Float, x2: Float, y2: Float, z2: Float); ``` Marshalling: `Float` passes directly as a JS `number` (f64). `Int` and `Duration` are i64 internally, but at the **`@ffi("js")`** boundary they marshal as **f64 (JS `number`), not i64 (which crosses as a JS `BigInt`)** — the host handler receives a plain number, with no `Number(x)` dance, and an `Int`-returning host import accepts a plain JS number back (the runtime `sitofp`s before the call and `fptosi`s the return). The trade-off is f64's 53-bit integer range: an `Int` whose magnitude exceeds 2^53 loses precision across this boundary — pass such values as a `String`/`Bytes` payload instead. (This is **only** `@ffi("js")`. `@ffi("c")` keeps i64 — on wasm those resolve to linked runtime C symbols that genuinely expect i64.) `String`/`Bytes` pass as a pointer into wasm linear memory (the loader reads them with a `TextDecoder` over the module's `memory`). The generated `.mjs` loader supplies a built-in `console_log` plus the libm set (`sin`/`cos`/`tan`/`sqrt`/… mapped to JS `Math.*`, so `std::math` works under wasm with no app glue); an app wires its own imports through `run(glue)`. Position and the generic / defaulted restrictions are the same as `@ffi("c")`. Only the wasm32 loader supplies a `js` import. On a native target an `@ffi("js")` declaration is refused at the declaration, called or not (the declaration is the use): ``` `@ffi("js")` fn `name` is a host import of the wasm32 loader, and this program is built for ``: … ```, where it used to become an undefined symbol at the native link, and only once something called it. ### What a wasm32 module imports The link keeps every symbol nothing defines as an `env` import (`wasm-ld --allow-undefined`). The generated loader supplies an import from its writers (the libc output functions `println` lowers to, `console_log` and the libm set) or from the app's `run(glue)`, and stubs any other with `() => 0`. The runtime's thread, pool, mailbox and transport paths a wasm32 module cannot run (the target refuses `pinned`, every pool but `main` and every transport binding) are compiled out of it, the runtime's own `fprintf(stderr, …)` messages are no-ops there, and codegen emits no observation probe for a target that is neither recorded nor replayed. So a module imports the loader's writers and the program's declared `@ffi("js")` names, and today two more, from generated code: - `dprintf`: what `eprint` and `eprintln` lower to, and the report of a violation no handler absorbs; - `fflush`: the flush of stdout before that report. The loader has no stderr writer, so it stubs both with `() => 0`: on wasm32 `eprintln` writes nothing, and an unabsorbed violation's message is dropped before its `exit(1)` traps the module. An `@ffi("c")` name no `[ffi] csrc` defines is imported too, and runs as `() => 0` under the generated loader. A host that instantiates the module with its own imports object supplies every one of these names. `crates/hale-codegen/tests/wasm_import_backstop.rs` holds every module the wasm tests build to this list. It checks linkage only: an operation the runtime lowers to an inline stub imports nothing, so passing it does not show the operation does anything. ### `@export` — exports (Hale → callable by the host) Two forms; both are wasm-only (a **no-op on the native target**) and both produce a wasm module export the host calls by its literal name. **`@export fn name(...) { ... }`** — a top-level free fn. Unlike `@ffi` it has a real Hale body. It is valid only on top-level free fns (same position rule as `@ffi`), is **not** `@ffi` (mutually exclusive — an import is not an export), and is **not** `fallible(E)` (v1 — the host has no error channel). **`@export locus L { ... }`** — the persistent singleton "app." At most one per program. It is instantiated **once** (birth runs; it is never dissolved), and each of its non-fallible `fn` methods becomes a wasm export the host calls (`inst.exports.()`). State lives in the locus's params — ordinary Hale fields that survive across calls because the singleton persists. The locus **must not define `run()`** (it is host-driven via its methods, not a cooperative run loop): under wasm32 the check refuses the `run()` it writes with ``@export locus `L` must not define `run()` — a wasm singleton is host-driven via its `@export` methods, not a cooperative run loop``; natively the locus is an ordinary one. `fallible` methods stay internal (not exported). ### Entry-inversion run-model A program built with `@export` runs **inverted**: instead of a blocking `main`, the host drives the exports. The compiler synthesizes and exports **`_hale_start()`**, which creates a **persistent** program arena (and bus queue) that is *not* torn down — and, for an `@export locus`, instantiates the singleton there and stashes its pointer. The generated loader calls `_hale_start` once at instantiation and then the host calls the exports (e.g. one per `requestAnimationFrame`). A program with no `fn main` is valid when it has any `@export` and its effective target is wasm32; on a native target its entry is its `fn main`, so the check refuses it at its first `@export`: ``a program with no `fn main` is an export-only module, which needs wasm32: declare `target wasm { }` or build with `--target wasm32` ``. If `_hale_start` is present the loader does **not** call `main` (its create-then-destroy of the arena would clobber the persistent one). **`--wrap-main` (browser-playground entry synthesis).** A bare `fn main` program is not the `@export` shape a wasm build needs. The `hale build … --target wasm32 --wrap-main` flag synthesizes it *on the parsed AST*: when the program has a top-level `fn main()` and no `@export` entry, it replaces `fn main` with an `@export locus __Main { birth() { } }` (routing the body through the `_hale_start` path) and injects a `target wasm { }` gate if absent. Because it operates on the AST — not the source text — every diagnostic keeps the user's original line/col (no offset) and a `{`/`}` inside a string or comment can't mis-wrap it. It is **wasm-only and opt-in**: a hard error unless the effective target of the written sources is wasm32 — `--target` in any wasm32 spelling, or a written declaration; the declaration the flag injects never selects the target — since there is no native entry-inversion to wrap. It is never implied by the target (a wasm program may legitimately keep a bare `fn main` exported as `main`), and a no-op when an explicit `@export` entry already exists (prefer-explicit). Holding state across calls: - **`@export locus` (preferred):** state is the locus's fields, mutated in one method and read in another — plain Hale, no marshalling. This is the natural shape for a browser client. - **`@export fn` (lower-level):** each call's allocations are released on return, so cross-call state goes through the runtime **host seam** — `@ffi("c") fn lotus_wasm_state_set(b: Bytes);` / `lotus_wasm_state_get() -> Bytes;` deep-copies a packed `Bytes` blob into its own arena so it survives. Inbound messages use the seam in either model: `lotus_wasm_alloc(n)` / `lotus_wasm_set_inbox(len)` (wasm exports the host calls to write bytes in) + `@ffi("c") fn lotus_wasm_inbox() -> Bytes;` (Hale reads them and parses with `std::json` / `std::bytes`). ## Cross-references - `notes/ffi-design.md` — design memo capturing the agreement the Stage 1 surface graduated from, plus the Stage 2/3 staging plan still pending implementation. - `spec/stdlib.md` — `std::*` paths are NOT the only way to bind C libraries; this spec is the user-extensible alternative. - `spec/runtime.md` — the C-runtime helpers (`lotus_bytes_*`, `lotus_arena_alloc`, `lotus_caller_arena_or_global`, etc.) that library authors typically call from C glue. - `docs/src/systems/webassembly.md` — the pedagogical companion to the WASM host interface above (the browser-client walkthrough: loader `run(glue)`, the inbox, the `@export locus` game loop). ## C→Hale re-entry: `@export fn` on native (Crumb batch 2, 2026-07-27) The inverse direction of `@ffi`: an `@export fn` free fn is emitted as an **unmangled C-ABI symbol** on native targets (the same annotation wasm entry-inversion already used; the internal arena-ABI implementation is renamed `__hale_impl_` and the literal name is claimed by a C-callable wrapper). C code — an engine registering host functions, a callback-taking library — links the symbol and calls straight into Hale. **Marshalling** is the same FFI-portable set as imports, in the same C shapes: `Int` ↔ `int64_t`, `Float` ↔ `double`, `Bool` ↔ `bool`, `String` ↔ `const char*`, `Bytes` ↔ lotus blob pointer (`lotus_bytes_len`/`lotus_bytes_data` are linkable for C-side access). Typecheck rejects non-portable params/returns, defaulted params (fixed C arity), and fallible exports (no C error channel). **The v1 contract is same-thread re-entry**: the callback must fire while Hale is inside an in-flight `@ffi` call on that same thread (the engine-host-function shape — JS calls a host fn during `crumb_js_run_file`). Codegen publishes the call site's arena in the caller-arena TLS around every `@ffi` call (save/set/restore, nesting-safe); the export wrapper's prologue picks it up, so the re-entered body composes with the established context — bus publishes dispatch, eager loci instantiate and dissolve, all on the thread that owns them. Entry from a foreign thread (or outside any in-flight `@ffi` call) aborts with a pointed diagnostic naming this section; cross-thread entry is the documented follow-up. **Invariants the re-entered code inherits** (spelled out per the Crumb request): re-entry runs on the calling thread's context — the same single-threaded interiority as the locus/fn that made the `@ffi` call. Publishes enqueue exactly as they would from that caller; a re-entered export must not assume it runs on main unless the `@ffi` call site does. `JSValue`-style by-value foreign structs never cross — trampolines convert to the marshalling set C-side (the pond/sqlite handles-in/scalars-out pattern). ================================================================================ ## Static verification surface ## source: spec/verification.md ================================================================================ # Static verification surface This page is the canonical catalog of the compile-time **checks** the toolchain runs beyond ordinary type-checking — the structural and semantic guarantees a program earns at `hale build` / `hale check` time. It describes shipped behavior; the verification roadmap that drove these checks — now delivered — is recorded in GitHub issue #18 (closed). Two severity levels exist: - **error** — fails the build (`Diag::is_error()` is true). - **warning** — surfaced but non-fatal; the only non-error diagnostic Hale emits. Used where the flagged shape is a real smell but can be legitimate, so the call is left to the author. Most checks run in the bundle-level passes of `crates/hale-types/src/check.rs` (`check_bundle`); a few resolve-time ones run in `crates/hale-types/src/resolve.rs`; cell slot-of-origin is a codegen-time check. Each entry names the enforcing pass. **Every bundle-level check applies inside `module { … }`, at any nesting depth** (GH #825, 2026-09-20). A module is a NAMESPACE, not an analysis boundary — the resolver registers a module's declarations under their bare names, so a fn, locus, topic or `bindings` entry one brace deeper is an ordinary member of the bundle. Findings carry the same message, the same span and the same severity as the identical declaration written at the top level, and whole-bundle facts (the at-most-one-`main` count, the pool map seeded from the `main` locus's placement block, the transport-bound topic set, the `@form` sync-discipline index) count module-nested declarations too. The behavior summary the effect, allocation and certificate analyses read collects module-nested bodies as well (F.40 phase 3), so a module-nested fn is never an unanalyzed body: a call into one resolves, its effects are in its callers' sets, and a law through it can be certified. ## Concurrency & placement safety The bus + cooperative-pool model is the substrate; these checks keep a program's placement coherent with how the runtime dispatches. | Check | Catches | Severity | Enforced by | |---|---|---|---| | **Single-threaded-method invariant** | a *direct* cross-pool method call (`self.field.method()` where `field` is placed on a different pool) — it would run the callee's method on the wrong thread | error | `check_placement_single_thread` | | **Dead bus receiver** | a non-`main` cooperative locus that subscribes to the bus *and* makes a blocking call in `run()` — the blocking call monopolizes the pool thread so the dispatch never delivers and its handlers never fire | error | `check_cooperative_pool_blocking` | | **Blocking call on a cooperative pool** | a blocking `run()` (`recv`/`accept`, a stdin or file read, an `http` request, `process::run`) on a pool that isn't `where async_io` — it holds the pool's OS thread and stalls co-scheduled loci. Follows the call graph: blocking reached through a helper fn or `self.method` is flagged too | warning | `check_cooperative_pool_blocking` | | **Cooperative pool starvation** | two or more loci on one cooperative pool (not `where async_io`) whose `run()` bodies statically never return (terminal `while` with no exit — `while true`, `while !self.draining`, or a never-assigned Bool flag) — the pool runs each `run()` to completion in birth order, so the later `run()` bodies never start. Covers fields with no placement entry (they default to pool `main`) and the main locus's own `run()`, which begins only after params-init | warning | `check_pool_starvation` | | **Nested long-running child** | a non-`main` locus with a `run()` body of its own holding a params field of a locus type whose `run()` is long-running (a body of its own, whether or not it returns: `spec/runtime.md` § Typecheck enforcement) — the canonical fix is hoisting it to a `main` sibling with its own placement | error | `check_nested_long_running_child` | | **Unowned subscriber locus** | a bus-subscribing locus instantiated inside another locus's bus handler with no accepting ancestor in the ownership graph on every construction path of the handler's locus (the paths the placement table records, `fn main`'s included; a hole proves no owner) — it dissolves when the handler returns, so its subscription can never fire; judged by declaration identity, and silent where the graph cannot decide (`spec/semantics.md` type-check rule 20; overridable with `--allow-unowned-subscriber`) | error | `check_unowned_subscriber_locus` | | **Pinned placement in a loop** | a locus whose `placement { }` pins a field, instantiated inside a loop body — the pinned thread's join record is one slot per instantiation site, so every iteration but the last is orphaned with its arena live. A loop that *calls a fn* holding the literal is fine (each call joins its own thread) | error | `lowering_laws::pinned_root_in_a_loop` | | **Unconsumed placement entry** | a `placement { }` entry whose field is initialised by anything but a locus literal (a factory call, a conditional, a reference to an instance built elsewhere) — the entry rides an override the next literal takes, so nothing consumes it and the stated placement is silently dropped. Checked against the init at the instantiation site when the literal supplies one, and the `params` default otherwise | error | `lowering_laws::placement_entry_consumed` | The dead-receiver error is deliberately **direct-call-only** (its call-graph surface is not widened), while the blocking *warning* is interprocedural — the high-stakes diagnostic stays precise. See `spec/semantics.md` type-check rules 7–8 and `docs/src/services/concurrency.md`. **The warning's horizon.** The helpers that block are read off the effect rows: a fn blocks when its own `block` leaf holds the worker (below), or when any fn it calls blocks, transitively over the rows' resolved call targets — a module's fns by their bare names, a qualified cross-seed call, a stdlib body behind a handle method and another locus's method included. The `run()` walk consults that set at two call shapes only: a bare call `pump()` (a free fn) and `self.pump()` (a method of the locus's own). A handle method or a `self.field.method()` hop written in `run()` itself is not looked at; the same call inside a helper that `run()` calls makes the helper block. **What counts as "blocking" for both** is the effects registry's `block` classification — the same rows the effect assertions and the `.hale.effects` manifest read (`stdlib_surface::SURFACES`) — and not a list kept beside it. So `io::stdin::*`, `io::file::read_line`, `io::udp::recv*`, `std::http::*`, `tcp::connect` / `accept_one`, `tls::connect` / `upgrade` and `process::read_stdout` / `read_stderr` are blocking leaves here because they are `block` rows there; classifying a new stdlib leaf `block` puts it in this lint the same day. One `block` row is carved out, because on a cooperative pool it does not hold the worker for the wait: **`std::time::sleep`**, which the lowering chunks into ≤100 ms slices and drains the pool's bus queue between them. That slicing is what makes "handlers plus a `time::sleep` loop" the prescribed event-driven shape — the shape both diagnostics above name as the fix — so counting it would have the lint flag its own advice. This is a *different* subtraction from the async_io park exemption below (`ASYNC_IO_PARKING`, GH #791). That one is the async_io-pool rule: those leaves swap the coro out, which needs the pool's event loop. `check_cooperative_pool_blocking` never looks at an async_io placement — it skips the entry on the `where async_io` constraint before reading a single call — so on every placement it does look at, a park-capable leaf takes its blocking path and really does hold the thread. ## Bus-graph property checks The bus topology is a typed directed graph in the source; these walk it. (GitHub issue #18 item 4.) | Check | Catches | Severity | Enforced by | |---|---|---|---| | **Orphan topic / subject** | a declared `topic` or literal subject wired to only one end — published with no subscriber, subscribed with no publisher, or used by neither | warning | `check_bus_graph` | | **Queued bus cycle** | a publish→subscribe→publish loop spanning ≥2 loci, or an *unconditional* one within one locus with a send the intra-locus rewrite leaves on the bus — the cell hops via the cooperative queue and can spin / livelock | warning | `check_bus_cycles` | | **Intra-locus re-entrant cycle** | an *unconditional* self-republish loop within one locus whose every send the intra-locus rewrite turns into a direct synchronous call — it recurses on one thread without bound (stack overflow) | error | `check_bus_cycles` | | **Bus backpressure** | a publish inside an unbounded `while true` loop with no flow-control or exit point (`yield` / `sleep`/`tick` / an input-pacing blocking call / `break`/`return`) — floods the bus without bound | warning | `check_bus_backpressure` | | **Subject type-mismatch** | two sites on the same literal subject string declaring different `of type` payloads — a subscriber would decode the wrong type | error | `check_bus_subject_types` | | **Routing-key fallback rules** | an `on_unmatched: fallback` topic with no `where key == _` subscriber, or a `where key == _` filter on a non-fallback topic | error | `check_phase3_fallback_subscribers` | | **Topic parent-chain cycle** | a topic hierarchy that loops (`topic A : B; topic B : A`) | error | `finalize_topic_chain` (resolve) | Orphan detection is **closed-world gated** (it runs only when a `main` locus is present), and suppressed by transport bindings, `**` wildcard coverage, cross-seed (`alias::Topic`) references, and self-pub/sub — so library seeds and external peers aren't falsely flagged. The intra-locus cycle error counts only *unconditional* sends as edges: a self-republish guarded by `if`/`match`/loop is a terminating state machine, not unbounded recursion, and is left alone. The orphan, cycle and dead-receiver checks read one bus graph (`build_bus_graph`): its subjects compared by wire subject, its edges keyed by the declaration that wrote the handler, and a subject it cannot resolve recorded as a hole that no check fires on. See `spec/semantics.md` type-check rules 7, 9 and 10. The backpressure check's "input-pacing blocking call" is the *same* leaf set as the pool-blocking lint above — the registry's `block` rows minus `std::time::sleep`, which it counts separately as a throttle along with `std::time::tick`. So a `while true { let line = std::io::stdin::read_line(); … <- … }` loop is paced by its input and is not flagged, on the same footing as a loop driven by a blocking `recv`. ## Claims — domain requirements as checked sentences (GH #382, phase 1) Bundle-level, **named** sentences over the program graph, declared in the main locus and evaluated in `hale check` as **errors** — never advisories (an advisory claim reads as law and doesn't bind, the #354 fail-open shape). The motivating property is multi-tenant isolation: "no path from domain A to domain B" as one declaration with a name a contract can cite, instead of per-fn `@effects` contracts scattered across every position with completeness by hope. ``` group delta_wing = { delta::*, DeltaStore }; group gamma_wing = { gamma::Research }; main locus Org { params { ... } claims { iso_dg: forbid reaches(delta_wing, gamma_wing); iso_calls: forbid reaches(delta_wing, gamma_wing) via { calls }; no_spend: forbid reaches(gamma_wing, effects(money)); } } ``` - **Groups are declared vocabulary, never patterns.** A `group` names a set of declared program elements (loci, free fns, imported decls). An unknown member is an **error, not an empty set** — the misspelt-effect-class lesson applied at the group layer — with a did-you-mean. An empty group is a **vacuity error** unless it opts out with `may_be_empty`: a `forbid` trivially satisfied by an empty quantification domain is a fail-open wearing formal clothing. The only glob is `alias::*` — trailing-only enumeration of an imported seed's decl set via the same rename table codegen resolves `alias::Name` through, deliberately mirroring the trailing-`**` rule for bus subjects. Qualified members (`alias::Name`) canonicalize at the mangle stage (#334's path), never by name-suffix matching. - **`forbid reaches(SRC, DST)`** — absence under closure: no path from any element of SRC to any element of DST. Evaluation is fn-grained: a locus member projects to all of its methods, lifecycle hooks, and modes, which only ever *adds* sources and sinks (the conservative direction). Edges are the resolved call graph (stdlib bodies merged, handle-method calls resolved) and the declared bus graph (a publish site composes with every subscriber of its subject, wildcard subscribers included). `via { calls }` / `via { bus }` restricts which relations compose; the default is the **full composition** — more edges, conservative. `bus` composes publish sites in the visited fn's own body; the default (with `calls`) is the sound transitive closure. - **`effects()`** in target position: the declared carriers of an effect class (an `@effects(is: {…})` frontier entry or a classified leaf), composed-class masks included. An undeclared class in claim position is an error with a did-you-mean. - **Witnesses.** A violation renders a minimal countermodel path in author spelling — `` `delta::Triage::on_task` -(publishes "org.metrics")-> `gamma::Research::on_metric` `` — cross-seed symbols demangled. One witness per claim (the minimal countermodel, not an enumeration). **Provenance** (#392): the witness also says where to edit, as secondary diagnostics in the effect system's root + leaf shape — the callsite that crosses the boundary (or the publish site and the subscription decl for a bus hop) and the forbidden destination's declaration. Spans are emitted only for bundle decls: stdlib bodies parse in their own offset space, and a span from there attributed to a bundle file would name the wrong source, so a stdlib-interior hop renders by name alone. - **Unknown ⇒ violation.** An indirect call (#353) no function value of the program can be, or a computed publish subject, on a path from a `forbid` source cannot be certified and is reported as a violation, exactly as `@no_syscall` treats the same shapes. (A call through a local bound by `let` to a fn name or path is not indirect: the graph follows the binding through the body's scopes, and `let f = pid; f()` is a call of `pid`. A reassigned binding is not followed, and one a `while` or `for` loop reassigns is not followed anywhere in the loop or after it; a call through one, like a call through a function-typed parameter, reaches the program's function values of its type — § Default-on & opt-in analyses, "An indirect call reaches the program's function values".) And the **unresolved-callee backstop**: EVERY method call on a receiver the summarizer cannot type (a struct-literal receiver, a chained `self.a.b` field, a call result, a branch value) fails closed in any judgment that traverses calls — without it, `forbid reaches(A, B)` certified while the forbidden path executed (found by the #382 soundness audit), and a name-keyed version was still blind to WRAPPERS reaching the target transitively (found by the follow-up review). No name comparison is sound; the edge itself is the uncertainty, and the artifact records it (`untyped_receiver_call:`) inside the hashed model half. Synthesized form/builtin methods (`counts.set`) carry a known receiver type and are exempt. The rule is one shared predicate applied by every judgment that traverses calls — claims, effect inference, effect certificates, `@budget`, and the quantitative dims — so fn-level certificates and bundle-level claims always agree (#392 closed the `@budget` guard, which had the message but not the test). - **Interface dispatch fans out.** A method call on an interface-typed value (`route.handler.handle(ctx)` — the stdlib router's own shape) is resolved by closed-world enumeration (#392): the summarizer fans the one written edge out to every conforming locus. Conformance here is structural name + arity over the declarations — a superset of the checker's typed conformance, safe because over-approximation only adds edges. Reachability and effect judgments walk every alternative; counting judgments (`bound`, `@budget`, the quantitative dims) take the **max** over the alternatives of one dispatch site, because one invocation dispatches to exactly one target — a sum would count phantom calls no execution performs. An interface NO locus conforms to has no values in a closed world (an interface value only arises by coercing a conformer), so its call sites are dead: they contribute nothing to any judgment, and the artifact records each (`uninhabited_interface_call:.`) inside the hashed model half so a conformer appearing later changes `shape_hash`. A call through a *field* of interface type narrows the fan-out to the impls the program stores into that field (GH #540): every `Locus { field: Impl { } }` literal at any instantiation site (a parameter default, a struct field default and a closure assertion included: each is built where its fn is called, so it is read as a site), and the default literal when some literal of the locus omits the field (or none is seen), intersected with the conformers. Only a struct literal of a locus narrows. A write the pre-pass cannot name — a literal field value that is a parameter, a call result, a local or a conditional, a default that is not a literal, or any assignment to a field of that name (the receiver is untyped, so it counts for every locus with such a field) — keeps every conformer. A literal of an imported seed's locus (`alias::Name { }`) is read through the bundle's import renames, and a bundle that closes no world (it declares no `main locus`) narrows nothing: a library's loci are built by whoever imports it, in literals the library's own check never reads. Slots of standard-library loci, and interface-typed parameters, locals and results, are never narrowed. - **Placement.** `claims { }` is only legal inside `main locus` (parse error elsewhere): main is the closed-world gate, so bundle-wide claims cannot be evaluated anywhere earlier, and one-main-per-bundle makes the claims root unique. Claim names are the contract-of-record and must be unique. The remaining verbs (#382 phases 2–5): - **`only edges A -> B { publish T; subscribe T; }`** — isolation with an exhaustive grant enumeration: every DIRECT edge from A to B must match a granted line, and every un-granted edge is reported (the grant list is the review surface, so the full diff matters). `publish T` and `subscribe T` admit the same bus edge — the verb names which end's declaration is the reviewable line. Call edges are never grantable: a direct call across the boundary is always an un-granted edge. Transitive paths through third parties are `forbid reaches` territory; a subscriber outside B (the `log.**` sink shape) is not an A→B edge and needs no grant. - **`bound C <= N on paths from G`** — `@budget`'s per-call semiring behind a claims surface: total sites of user class C reachable per invocation (a call-tree SUM, exactly `@budget(C = N)`'s semantics — two calls to a carrier are two sites). A recursion cycle, loop-nested carrier, indirect call, or computed publish subject is unbounded and violates. The witness carries the count and a representative chain. Built-ins keep their `@budget` spellings. - **`require subscribes|publishes(some G, topic T)`** — existence over the DECLARED bus ends (the `bus { }` blocks — "wired" is a declaration property). - **`require attributed(all C)`** (GH #436) — every user fn that **directly** performs an operation of built-in class `C` carries at least one **user-declared** effect class. `require attributed(all syscall)` says every place the program touches the OS names a purpose. **Orthogonal to interposition.** `forbid reaches(app, effects(syscall)) avoiding gate` constrains WHERE a boundary is crossed and says nothing about what any crossing is FOR; this constrains attribution and says nothing about location. Neither implies the other: all I/O can funnel through one `write(path, bytes)` everyone calls for everything (interposed, unattributed), or forty loci can each touch the OS while every one names its purpose (attributed, un-interposed). A hybrid wants both. It is a universal over the whole closed world, where `avoiding` is scoped to a group — so a locus outside the group, including one added later, is covered without editing the claim. A **direct site** is: a classified frontier path call, an `@ffi` declaration (the declaration itself, not its caller — it is the application-owned boundary and can carry the purpose), a resolved callee the author does not own whose effects include the class, a syntactic site (publish, allocation), or the fn's own `@effects(is: {C})`. That last covers a method that declares itself a carrier and calls nothing classified — the shape a privileged operation takes. A built-in in `is:` establishes that the operation exists; it is not its purpose, so a user class is still required. An **indirect or opaque call** the checker cannot resolve leaves the claim `uncertified` unless the enclosing fn already names a purpose — the same refusal-to-certify posture as the rest of the stack. **DIRECT, not transitive**, and that is load-bearing: transitively, every caller downstream of one attributed fn inherits the label and passes, which would make the claim nearly vacuous. The attribution point is the first **application-owned** fn crossing out — a frontier path call, an `@ffi` declaration, or a Hale-source stdlib body whose own effects include the class. An ordinary application callee is judged on its own row instead, which is what keeps this direct. Attaching only to frontier path calls would have made coverage depend on whether an API happens to be a path call or a stdlib locus method — not a stable boundary to hang a security claim on. A built-in in `is:` does not count — it restates what the compiler already infers, and the claim asks for a purpose the author supplied. The class must be a built-in; a user class there would be trivially true while reading like a contract. - **`require sealed(all G)`** (GH #436) — every locus in `G` is declared `@sealed`. A **universal** over the group's members, which is why the quantifier is `all` rather than the `some` the other `require` forms take: those ask whether an endpoint exists anywhere, this asks whether every member holds. Reports every unsealed member in one diagnostic — a baseline is adopted once and the reader wants the whole list. Without it, sealing is per-locus discipline, and one unsealed member of a vault group is the whole hole. - **`require no_silent_loss(topic T | all G)`** (GH #1327 § 2) — no modeled boundary on a named route is configured to discard silently. A route is a wire subject; `all G` takes every route a member publishes or subscribes (a group touching none is an error); a wildcard endpoint contributes every modeled address its pattern matches (`wildcard_match`, the address read as literal send text — a topic, send or binding address spelled with `**` included) plus the pattern itself for the unmodeled remainder, and on a pattern route endpoints and holes are matched by subject-set overlap. Read from the model at every boundary on the route: the topic's `on_unmatched: swallow`, a subscription's `drop_old` / `drop_new` shed policy, a send's `or discard`, a binding whose loss behavior is `drop`. A refusal the caller observes (`on_unmatched: fail` or `on_full: fail` with `or raise` / `or wait`) is not a silent discard. `violated` lists every silent setting; `uncertified` (never `holds`) when a send routes its refusal into a custom handler, when a connect-side binding's link loss is supervision policy and not every send waits, when a hole hides the route's endpoints, or when an unresolved publisher may reach the route — a function-grain `ComputedSubject` hole whose locus's declared publish patterns overlap it (no declared pattern: every route), any other function-grain hole hiding PUBLISHES, or stdlib absorption residue that can address it. A silent setting outranks an unproven one. Policy only: accepted-message accounting and durability across process failure are out of scope. - **`cover topic in seed(a): subscribed_by(some G)`** — bounded universal: every topic the seed imported as `a` declares has a subscriber in G. Every uncovered topic is named. A seed with no topics is an error at the claim (an empty coverage domain holds vacuously). - **`count publishers|subscribers(topic T) ==|<=|>= N`** — the cardinality family over distinct loci; `== 1` is the invariant behind every single-writer pattern, and a violation names the competing writers. - **`cover keys(topic T [in LO..=HI]): delivered_to(exactly_one G)`** (GH #1327 §1) — routing coverage and exclusivity. For every permitted key `k` of the `keyed_by` topic `T`, the registrations of group `G` that receive `k` number exactly one: `|R_G(k)| = 1`. A *registration* is one instance of a subscribing locus — three arranged replicas of one `subscribe` are three. Recipients outside `G` are not counted (an audit subscriber does not break a worker group's uniqueness), but they take part in routing: a `where key == _` fallback hears only the keys no filter, in any group, matched. The *permitted keys* are the stated integer interval `in LO..=HI` (inclusive; written `-3..=3` with a sign), or, without it, what the topic's publish sites can produce — the whole `Int` range, `{false, true}` for `Bool`, every value of any other key type. The recipient rule is the one the `@budget(fanout)` account uses (`key_routing`): one query, two callers. Keys are decided symbolically — the permitted domain is cut by the registrations that exist into the keys some filter names and the intervals (or "every other value") none does — so an `Int` key costs a handful of cases, not 2^64. A violation names every uncovered key or interval, or the registrations (handler, filter, instance path) that overlap on a key. **Fail closed:** where the graph has an unknown the claim is `uncertified` with the reason — a filter whose value is not statically known (`where key == self.id`) on a locus that has instances, a locus that can also be born outside the arrangement (an incomplete population), a hole hiding subscriptions, instance counts or key filters on the topic, a topic with no publish site and no stated interval, and — with no stated interval — a publisher the model could not resolve that may send on the topic's wire (a send to a computed subject whose locus declares a covering `publish` pattern, an unanalyzed body, a stdlib interior; the model records these at function grain, not on the topic, so they are asked for by wire through `model_query::unresolved_publishers`): such a publisher may send keys outside the domain its publish rows give, and the diagnostic names it. A stated interval is the domain whatever such a publisher sends. Where the statement is malformed it is `invalid`: an unkeyed topic, a key interval for a non-`Int` key (the `keyed_by` field's declared type decides, whether or not a publish site or literal filter exists), a group with no locus members. Never `holds`. **What it proves:** in the modeled arrangement, each permitted key selects exactly one registration of the group. **What it does not:** a different arrangement (a replacement, a restart or a runtime birth changes the live registrations — preserving uniqueness across them needs a stronger invariant over live registrations and incarnations); exactly-once *processing* (that needs request identity, retry, persistence and acknowledgement semantics); that a publisher produces only permitted keys (an out-of-interval key is outside the claim; the topic's `on_unmatched` policy governs it); or delivery of a cell the queue sheds or a binding drops (loss policy is a separate claim family). - **`during P`** on `forbid` — restricts sources to the named phase of each source locus (`during birth` is the quiet-boot claim), evaluated against the model's **phase relation** (#392): lifecycle hooks (`birth`, `accept`, `release`, `run`, `drain`, `dissolve`) and modes (`bulk`, `harmonic`, `resolution`) are hook-phases the runtime drives; an ordinary method is its own source-slice phase. The relation is exported in the topology artifact, which is what makes a `during` row independently re-derivable. A phase naming nothing in the group is an error, not a vacuously-holding claim. - **`avoiding G`** on `forbid` — masks G's vertices out of the walk, which makes it the interposition form: "every path from A to B passes through the gate" is `forbid reaches(A, B) avoiding gate`. **Indexed effect families** (#382 phase 3): `domain wing = { delta, gamma };` declares a closed index domain; `effect knowledge(wing);` declares a family. Every instantiation `knowledge(delta)` interns as an ordinary declared class and `knowledge(*)` as an auto-populated composed class over all of them — the whole feature is a reduction onto shipped machinery (masks, `only:` complements, cross-seed remap, did-you-mean), so a misspelt index is an undeclared-class error and a domain member added later lands OUTSIDE every existing `only:` contract (#354's fail-closed, inherited rather than re-derived). The domain must be declared earlier in the same file as the family. Companion: `@budget( = N)` bounds calls to declared carriers of a class along any path, with the same loop/indirect unboundedness rules as every per-call dimension. The value all of this is derived FROM — the typed, provenance-bearing `ApplicationModel`, its holes and capabilities, the laws it must satisfy, and the discipline a new judgment family follows — is specified in [`spec/model.md`](./model.md). This document specifies what is CHECKED and what is SERIALISED; that one specifies the IR sitting between them. **The topology artifact** (#382 phase 2; schema 1.17): `hale check --dump-topology` emits a PROJECTION of the model (lossy by construction; see [`spec/model.md`](./model.md)) — sorts (loci, fns, topics), relations (calls with **weights**: loop nesting, unbounded-loop membership, interface-dispatch tags; publishes; subscribes), the through-stdlib **contracted** edges (`calls_via_stdlib`: user→user paths whose interior is stdlib bodies, collapsed to their endpoints with a conservative loop flag, so reachability over the artifact matches reachability as evaluated — the flag is the MODEL's, true whenever any contracted path is loop-nested; schema 1.13 adopted that reading over the pre-model walk's, which never revisited a stdlib body a looped path reached second, and a program where the two differ takes a new `shape_hash` and one re-record), the declared **groups**, the effect **labels** (declared carriers), the **phase relation**, the **seed sort**, the compiler-**derived** per-fn effect sets, the **supervision** relation (schema 1.10, downstream handoff — one row per `on_failure` handler: supervising locus, supervised child + error types, the recovery ops the body invokes, and a literal retry bound when one is written; a policy change moves `shape_hash`, and `provenance.supervision` carries the spans, so the observer's live RESTART/SUPERV_TRANS stream finally has declared policy to anchor to), and the **unknowns** (fns with indirect calls, untyped-receiver calls, dead uninhabited-interface dispatch, or computed publish subjects), all in author spelling — plus every named claim's normalized form and result, under a schema version and a `shape_hash` (FNV-1a/64 over the canonical model half; claim RESULTS are excluded, so one topology under different law keeps one shape). Schema 1.11+ (GH #476 Change 6) adds three unhashed, digest-covered typed sections: **`law`** — every lowered `ClaimIr` row with its ordinal, origin (`main` / `constitution:` / `library` / `annotation`), judgment **family** (`reachability` / `boundary` / `endpoint` / `bound` / `causes` / `depends` / `budget` / `certificate` / `unmigrated` / `fleet`; `unmigrated` is now unreachable and kept only so an older artifact decodes), machine **verdict**, a TYPED **`law` payload** (one tagged object per `ClaimIr` variant carrying the law's operands — each reference as `{"name": , "display": , "resolved": bool}`; the raw half is the machine join key, the same deliberate raw/display duality the `topics` section carries), per-certificate **`certs`** evidence keyed by certificate ordinal for the certificate family, and source provenance, plus `law_digest` (the law table's semantic digest) and `inputs_digest` (the analysis-inputs digest) — the ties a consumer checks before trusting external evidence against this artifact; **`capabilities`** — the model's positive completeness account, typed (`exact_cardinality` is derived: endpoint counts come from a closed-world enumeration and are exact unless a hole says otherwise; `exact_costs`, added by Change 5h, vouches the per-call COST account — allocation sites, frame estimates and blocking points, carried site-grained in `relations.costs` because a per-call budget is a statement about one invocation and the loop flag is what turns a finite count into an unbounded one. The three call-hole kinds REQUIRE the COSTS bit: a call whose target the caller chooses is exactly where a quantitative law must not certify through); and **`adequacy`** — per migrated judgment family, `exact` when capabilities vouch every relation family that judgment consumes, else `degraded` (the certificate family consumes PUBLISHES — a publish-set contract cannot prove a computed subject in-set). As of schema 1.17 every family is judged over the model: `causes:` (Change 5f), `depends:` (5g) and `@budget` (5h, through the evidence sidecar) import no outside verdict. No non-passing law row can coexist with a `clean` document verdict. The emitted model half — and `shape_hash` — come from the `ApplicationModel` projection (`project_model_half`), and so do the unhashed `sources`, `provenance`, and `topics` sections (`project_unhashed_tail`); the legacy gathering is gone: Change 10 deleted the evaluator, and the corpus differentials it was the comparison arm of are now committed snapshots of its final answers. Bus selectors in the `law` payload serialize their resolved CANDIDATE sets (the topic identities and wire patterns the selector matched) plus the selector's own source location; `capabilities` carries `exact_publishes` and `exact_subscribes` as INDEPENDENT flags, and per-family `adequacy` reads the positive account — a family is `exact` only when capabilities vouch every relation family its projection consumes (holes are the validation cross-check, never a source of positive knowledge). A payload contract carries a structural `opaque` discriminant — `opaque` is not a reserved word, so a struct field literally named `opaque` keeps its structural shape. The `law` section also carries the CANONICAL catalogs: **`fn_universe`**, **`loci`**, **`groups`**, and **`topics`** as `{name: , display}` pairs (topics add their wire `subject`), **`subjects`** (the wire-subject pattern universe), and **`effect_classes`** (each class with its `declared` / `cyclic` status). `fn_universe` covers every function the model knows — wider than the legacy `sorts.fns`, which holds the summarized ones (a failure handler is not). A resolved reference must match one exact `(name, display)` pair — the raw half is the machine join key and is anchored, not merely cross-row-consistent — and the catalogs are cross-tied to the `topics`/`groups`/`sorts` sections other consumers join on. Bus selectors keep their candidate sets, and admission RECOMPUTES each set from the catalogs with the compiler's own matching rule (`bus_ref_matches`) — a candidate swap under an unchanged selector name is refused. Legacy `claims` rows carry the law `ordinal` they project from. **One shared admission** (`validate_law_account`) runs for Track A rendering AND fleet composition: it decodes every payload against the closed per-kind shape (unknown fields refused), resolves every `resolved: true` reference against the artifact's own catalogs (loci, groups, topics, phases, seeds, effect classes, `fn_universe`), refuses a `holds` verdict over any unresolved operand, re-renders each certificate's expected form from the typed operands and requires the row's `certs` to match it (an operand swap cannot keep the old certificates), requires the `lowered` section to project ONE-TO-ONE from the typed account — every lowered row carries its law `ordinal` (and certificate `cert` ordinal where applicable) and must match the form, result, and subject re-rendered from the typed operands, and every generated expectation must be met, so deleting law rows orphans their evidence even in an annotation-only artifact — recomputes per-row and document verdicts from the evidence, checks the one-to-one claims-to-law projection in both directions, requires the `law.legacy` report — the old engines' verdicts for rows that are still `unmigrated`, keyed by ordinal and by a form FINGERPRINT re-rendered from the typed operands, so an operand mutation orphans the entry. As of schema 1.17 NO family is unmigrated, so that report is empty for every program; the section survives only so an artifact written by an older toolchain still decodes. Every row that renders a compatibility form must STATE it, and admission re-renders it from the typed payload — that is the operand binding for a judged row, which imports no outside verdict at all. A `causes:` row naming an undeclared or cyclic class still cannot hold — refuses fleet-family rows outright (an application artifact does not own a fleet account — that is Change 7's), and recomputes `adequacy` from `capabilities`. Static invalidity DOMINATES (round 7): an unresolved operand or an undeclared/cyclic effect class makes `invalid` the ONLY admissible verdict — a replayed engine result is never an alternative — and otherwise a certificate row's verdict is EXACTLY its recomputed evidence severity. A subject outside the legacy analyzable universe carries no certificates and judges `uncertified` with its residue on the row (round 8): `sorts.fns` for fn subjects, the `analyzable` flag on `law.loci` rows for phase contracts — an unanalyzed body is residue, not invalidity. An implicit lifecycle phase with no hook body gets a SYNTHETIC `Holds` certificate from the evidence layer (no hook performs no effects), so `@phase_effects(birth: {})` on a hook-less locus is `holds`, not a missing report. Beyond the statically decodable invalidities, the claims evaluator's other legitimate `Invalid` outcomes (an operand outside a verb's domain, projection vacuity, an empty `during` slice, `avoiding` overlap, …) admit by RETAINING the judgment's explanation — an `invalid` row must carry either a decodable invalidity or its judgment's evidence, and an unanalyzed `uncertified` its residue. Machine-`invalid` rows may still PRESERVE an old engine's report (`law.legacy`, keyed budget `lowered` rows) — bound by fingerprint as optional evidence, never demanded and never overriding the machine verdict, so an older artifact's cyclic-class rows still admit. Nothing the current compiler emits populates `law.legacy` any more. The catalogs are CLOSED: unique in both halves and in exact bijection with the `topics` / `groups` / `sorts.loci` sections (`fn_universe` covers `sorts.fns`), so selector recomputation cannot be widened underneath a certificate. The artifact carries a typed **`endpoints`** section (round 8) — every bus endpoint at wire-subject grain with its verb and `via: site | declaration`, including a DECLARED publisher with no send site, which the V1 site-grained relations never show — and `law.subjects` must equal exactly the subjects the endpoint and topic sections carry, in both directions. The endpoint section is NOT a second authority (round 9), and endpoint identity is TYPED (round 10): every row carries its wire subject and, when a declared topic covers the end, the topic identity (`declared_topic` is the model's syntactic fact — a literal address whose text collides with a topic display stays a literal, never inferred from strings; a topic-typed row's subject must equal the topic's wire subject). Site rows are LOSSLESS (rounds 11–12): each carries its owning fn/handler, authored site ordinal, and AUTHORED SPAN, with exactly one occupant per (verb, owner, site). They project onto the V1 relations at (owner, name) grain under their OWN declared identity — a topic-covered end by the topic display, a literal end by its text — and their per-owner SPAN MULTISETS must equal the span-grained provenance section one-to-one (the V1 rows dedup; the provenance spans do not, so a typed site row cannot disappear behind a collision). Declaration rows come from the typed **`declares_publish`** relation section and keep the OWNING LOCUS in the compared identity — a declaration cannot move between loci while a `require publishes` verdict rides on the original owner. The relation's canonical identity is `(locus, subject, declared_topic)` (round 13): a literal declaration whose text equals a topic's wire subject and the typed topic declaration are distinct endpoint facts, and BOTH survive regardless of declaration order — the model schema represents them separately, exactly as `BusSubject::canonical` and the endpoint judgment distinguish them. Closures never participate in locus analyzability: they are invisible to the certificate machinery at every scope (a top-level closure-only locus already certifies synthetically), so the builder and admission share one membership rule — only members that produce function entities the engines could walk count. The residual is CLOSED as of schema 1.12 (Change 7's versioned shape transition): the canonical endpoint identity — verb, owner, source-order site ordinal, byte-exact wire subject, and declared topic — is part of the HASHED model half (`endpoint_identity`), and the unhashed endpoint sections must agree with it exactly. Substituting a colliding literal's content now contradicts the hashed half; rewriting the hashed half changes the program's shape identity, which is what replay admission and recorded baselines compare. Shape hashes changed for every bus-carrying program at this transition — re-record baselines and `.halerec` recordings once. Analysis coverage is FUNCTION-grained with THREE typed states (rounds 10–11): `analyzed` (the body was walked), `summarized` (a behavior-summary row exists — this set IS the legacy `sorts.fns`, the hashed anchor), and whether a certificate engine emitted a report (the evidence account). Failure handlers carry the typed `FunctionKind::FailureHandler` — no consumer infers handler-ness from a display prefix. The coverage laws — `analyzed ⇔ summarized` (both directions: a summarized body was walked and a walked body is summarized, anchoring `analyzed` to the hashed `sorts.fns`); failure handlers never analyzed; the legacy fn sort equals the summarized set; an unanalyzed body RETAINS its `UnanalyzedBody` residue and an analyzed body carries none; and (rounds 14–15) `LocusDecl::analyzable` DERIVES from the CLOSED ownership account: every function carries its canonical `owner` (`Some` for methods, hooks, modes, and failure handlers; `None` for free functions), `member_of` must be a total, exclusive partition agreeing with it exactly (a free fn appears in no row; every other kind in exactly one, at its owner), and a locus is analyzable iff every relevant owned member (all kinds except failure handlers) is analyzed, an empty set vacuously so — so a membership row can be neither deleted nor moved to launder coverage or group projection — and ownership is anchored to the ENTITY IDENTITY itself (round 16): `owner = Some(l)` requires the function's raw name and display to encode `l` as their prefix (`Hidden::poke` cannot canonically be owned by `App`), with the owner id range-checked directly, so even a fully coordinated repoint (owner + row + both analyzability flags) is refused. Ownership is coverage-bearing and folded into the evidence coverage digest. The artifact's `law.fn_universe` rows carry the typed owner (present iff the kind is non-free, display-anchored, resolving to a cataloged locus), and admission's locus coverage recomputes from IT — never from display-prefix recovery. All of these laws live in ONE shared validator (`ApplicationModel::validate_coverage`), called by `ApplicationModel::validate` and by `EvidenceTable::validate` alike: the same coverage state is lawful (or refused) at the model, the sidecar, and the artifact. Coverage is BINDING in the sidecar API too: `EvidenceTable::validate` categorically refuses a certificate payload for a subject or phase whose typed coverage says no report exists — fn eligibility requires BOTH `analyzed` and the hashed `summarized` anchor, and locus eligibility recomputes the member coverage from the typed `member_of` relation rather than trusting the flag (round 14) — a matching digest proves the sidecar repeated the model's bits; this proves its evidence obeys them. `law.fn_universe` rows carry all three facts, and the SUMMARIZED subset must equal `sorts.fns` exactly. `law.loci`'s `analyzable` flag recomputes from the member coverage: a locus with executable members is analyzable iff all its non-`on_failure` members are analyzed, and a MEMBERLESS locus is vacuously analyzable (no body to walk — every phase contract holds by absence), so the flag is recomputable in both directions. An implicit lifecycle phase's certificate must be exactly the synthetic `holds` with no diagnostics. Coverage also participates in EVIDENCE IDENTITY: the sidecar carries a coverage digest (`TopologyShapeV1` deliberately excludes coverage for recording compatibility), and a judgment refuses a sidecar derived beside different coverage as stale. The **`law.issues`** account (round 9) serializes every table-level law-selection failure — lowering issues (unknown or cyclic constitutions, illegal adoption, collisions) and the judgment pre-pass (duplicate claim names) — with source locations; it participates in `law_digest` (the canonical fingerprint covers `{issues, rows}`) and in the document verdict (a non-empty account is `law_failed`), admission validates each entry like any diagnostic, and the duplicate-name case is recomputed from the rows themselves — no claim error disappears between checking and artifact projection. The evidence engine's `ANALYSIS_SEMANTICS_VERSION` is 8. It moves whenever the producer's RESULTS move, because `validate` compares digests and never the implementation: v3 was round 8's synthetic implicit-phase certificates and report-less subjects judging `uncertified`; v4–v6 are GH #476 Change 5h, where `@budget` joined the evidence pipeline and its quantitative results changed (fan-out became a per-scenario execution count, cross-seed calls resolve, an unknown subscriber population stopped reading as an absent one); v7 is GH #533, where a call through an interface-typed field stopped resolving against the field's default literal and fans to every conformer, so a constructor override can no longer hide a carrier; v8 is GH #540, where that fan-out narrows to the impls the program stores into the slot (see *Interface dispatch fans out* in the claims chapter) and stays at every conformer when a write is one the pre-pass cannot name. Evidence produced under an earlier version cannot share an `inputs_digest` with this one. Both digests are RECOMPUTED at admission: `law_digest` is the canonical-JSON fingerprint over the law rows (serde-canonical rendering, fnv1a64 — a row edit under a stale digest refuses), and `inputs_digest` must equal the consuming binary's analysis-inputs digest (evidence produced under a different stdlib/analysis snapshot is refused — re-dump). Evidence is VALIDATED, not presence-checked: every diagnostic carries a non-empty message and paired provenance resolving to a known source, a migrated `violated` / `uncertified` row must retain its judgment's evidence (the countermodel / the residue), and a violated certificate must retain its diagnostics. Evidence locations are source-space-honest: a diagnostic whose span lives in a FOREIGN offset space (stdlib parse space, another seed) is never re-resolved against bundle sources — the projection carries a per-diagnostic discriminator, so numeric overlap with a bundle file cannot misfile stdlib evidence as application code. No row bridges to an old engine: every family is judged over the model, and a subject the judgment cannot start from — a body outside the analyzable universe — is `uncertified` WITH its reason rather than silently absent. The legacy `claims` / `lowered` string rows remain, now PROJECTED from the canonical model path; `semantics` bumps to 2 because the machine verdicts are stricter in two documented places (a certificate naming a cyclically-defined or undeclared effect class is `invalid`, never a vacuous `holds`; `require attributed` over an unanalyzable body is `uncertified`, never a fail-open `holds`), and the document `verdict` follows the machine. **One authority (GH #476 Change 9).** Those verdicts are also what `hale check` reports. Until Change 9 the checker ran a second evaluator that re-derived reachability, boundary, endpoint and bound from source while the artifact read the judgment engines over the canonical model; a corpus differential could hold the two equal, but only one of them could be right about a program the differential did not contain. The checker now judges over the model, so a document and the compiler that produced it cannot disagree about a law. Two consequences are user-visible: * the stricter rules above now apply at check time too — a `require attributed` claim over a body with an indirect or opaque call is `uncertified`, and a build that passed on the old fail-open now fails; * an `only edges` grant naming an undeclared topic is refused as an invalid law (with a did-you-mean hint) instead of being silently dropped, which had the effect of evaluating a weaker claim than the one written. Changes 5f–5h then migrated the last three families — `causes:`, `depends:` and `@budget` — and Change 10 DELETED the second evaluator (about 1900 lines). Its corpus differentials, which could only ever hold two implementations equal, are now committed snapshots of its final answers: a diff in `claim_diags_snapshot.txt` or `law_rows_snapshot.txt` is a user-visible change to what `hale check` says or what the artifact records. The lowered-certificate comparison stays a live differential, because the certificate ENGINES were never what was being migrated. A law that cannot be certified is now LOUD in every family. The checker appends diagnostics, not verdicts, so an `uncertified` row with no diagnostic used to compile clean while the artifact marked the document `law_failed` — the same disagreement this change exists to remove — and left the row with no evidence for admission to find. Every non-holds row states its reason. What the claim surface still owns is law SELECTION — which laws exist at all: clause enumeration, constitution adoption and identity, group resolution, the library/world tier rule. Selection is not judgment, and it has exactly one implementation, consumed by BOTH the checker and the artifact lowering: a selection refusal appears in the document's law-issue account, so the two cannot report opposite answers about one program. It does not consult the bus graph: deciding which laws exist is a question about clause text, adoption and membership. Selection's verdict on each group declaration is CARRIED with the lowered laws, in four states — resolved; intentionally empty (`may_be_empty` on a group whose every selector resolved); selector failed; declaration refused (declared twice, or empty without saying so). Every GROUP OPERAND is a domain — the source and destination sets, `only edges`' two ends, `bound`'s `from`, an endpoint family's group, and `forbid reaches`' `avoiding` gate, whose members become the mask that removes paths from the walk. A law quantifying over a group in either invalid state, in ANY of those positions, is **invalid**, never a vacuous `holds`: a verdict over a domain the compiler rejected has no witness and describes no program. Only the intentionally-empty state holds vacuously — that is what declaring `may_be_empty` is for, and it authorizes an empty group, not a misspelled member. The status is carried rather than re-derived because the two are not the same question. An unresolved selector leaves nothing behind in the model, so a member-count test reads `{ Missing } may_be_empty` as intentional, `{ Worker, Missing }` as whole, and a name declared twice as fine — while the model keeps the LAST declaration and selection keeps the first. Each judges a law against a domain selection refused. Judgment runs only over a program that denotes a valid model. The model is a description of a checked program, and some parser-valid, checker-invalid programs derive deliberately unlawful models (a key filter on an unkeyed topic mirrors the checker's own refusal). The checker therefore judges claims only once the resolver and the type checker agree; claim errors themselves do not gate it, since a program whose only failures are broken laws still has a valid model, and hiding the rest of its laws behind one failure would be its own fail-open. The artifact's own identity is pinned by a committed baseline of `origin -> shape_hash` over the corpus rather than by a rival serializer: a model change that would move an artifact hash — and with it the replay admission of every existing recording of that program — fails the baseline gate with a regenerate hint, so the move is a decision in a diff. A **provenance** section carries per-edge and per-decl source spans as bundle-global byte offsets; it is excluded from the hash on purpose — moving code must not change the shape identity. A **`topics`** section (unhashed, #399) carries the per-topic OBSERVATION identity: the wire subject, the canonical payload shape, and `payload_hash` = FNV-1a/64 over `wire_subject ++ ':' ++ shape` — byte-identical to the value the native emitter registers in the runtime observation manifest (`lotus_obs.c`; the shared implementation is `hale_types::topic_identity`, which codegen also calls, so binary and artifact cannot drift). This is the reconciliation ruling for the compiler-side vs runtime-side identities: they stay separate (payload field shape does not affect claim evaluation, so it is not part of the model `shape_hash`), and the artifact is the JOIN document — a recording/WAL segment carrying `(name, shape_hash)` matches a row here and thereby names the exact checked topology it ran under. The `subject` field stays raw (it is the join key); a subject-less topic registers under its declared — possibly mangled — local name, so shared topics should declare `subject:` to fuse across binaries. The exact definition and test vectors live in iris PROTOCOL.md §4. `--check-topology-shape ` gates the model identity alone — `shape_hash` — so claim renames and source motion pass while a changed graph fails. `--check-topology ` diffs against a committed baseline and fails with a regenerate hint — the `.hale.effects` precedent: an unreviewed topology or law change fails CI the way an API break does. Both gates compare the artifact of the model the check judged the laws over; a program that does not typecheck has no model, so they refuse it as `--dump-topology` does, naming the error kind, and exit 1. v2 scope: every claim verb replays independently over the exported relations — `forbid`/`only edges` including through-stdlib reachability, `require`/`count` cardinality, `cover` via the seed sort, `during` via the phase relation, `bound` over user classes via labels + weights (dispatch alternatives group by (from, interface, method) and fold with max). Remaining compiler-certified: `bound` over built-in classes (site counting through the stdlib interior, deliberately not serialized) and any walk past the step ceiling. The derivation (source → model) remains the trust root. **§8 — one schema of record** (#392): every fn-grained certificate — each `@effects` assert, each `@phase_effects` phase contract, each `@budget` in both families — is REPORTED as the claim form it is pointwise sugar for, with its verdict, in the artifact's `lowered` array (`forbid reaches({F}, effects(money))`, `bound alloc <= N on paths from {F}`, `only effects {…} on {L} during birth`, …). Rows come from the same evaluations that gate the build, so the report and the build cannot disagree. The traversal substrate is already one engine (the shared call-graph walker plus the shared summary and the shared fail-closed predicate); the annotation voices keep their diagnostics. **Library-tier claims** (#392 thread 2): a TOP-LEVEL `claims { }` block — legal outside any locus — is the LIBRARY tier: a seed swears about **itself and its own boundary**, the block travels with the import, and it re-evaluates in every closing build over the merged world. Checked standalone, a library's claims evaluate over its own world; at close, the same sentences quantify over everything the merge added — a second subscriber the app wires onto a library topic violates the library's own `count subscribers(topic T) <= 1`, reported with seed attribution (`pay::single_settle`, never a mangled symbol) and pointing at the library's own claim line. World-quantification stays main's: a seed that declares `main locus` states its law inside that locus, and its writing the top-level form is a check error. That tier split is the enforcement surface for "a dependency may not brick downstream builds with world-claims" — a library can only NAME what it can see (its own decls and its own imports), and its traveling block is marked at the mangle stage, which only ever touches imported seeds. Group and topic references inside a traveling block canonicalize to mangled decls exactly as group decls do (#334); claim names are never mangled. **The world is every `main locus` the bundle declares.** World law is read from a column of the entry row (`EntryRow::world`): the entry's `claims { }` block, a module-nested main's, and an imported application's, each in declaration order. The column is wider than the entry on purpose. Which declaration a seed runs is the entry's question (`spec/semantics.md` § Bundle-wide rules: an imported `main locus` is never the entry); whose law binds the world it closes is this one, and an application's inline law keeps binding when another seed imports it, whether or not the importer has a `main locus` of its own. A bundle with any `main locus` closes a world, so a top-level `claims { }` block of the closing seed's own is refused there. **An imported main's inline claims keep their own seed's groups** (GH #733). A `main locus`'s `claims { }` block is world law, and an application that another seed imports still carries that block through the merge: an importing test seed, or a fleet composing the application, closes a world in which the imported main is the only main, so its clauses are the law evaluated there. The group names in those clauses are resolved in the seed that WROTE them — the same import-rename table a traveling library block and a `constitution` resolve through — so a claim means one thing whether its seed is checked directly or through an import, and an importer's group of the same name is never substituted for the declaration the claim was written against. Resolution never *widens*: a name no declaration answers is still an unknown-group error, never an empty set — and (GH #774) an undeclared group is an error in the seed that WROTE the claim regardless of what an importer declares. A group reference nothing in its own seed answers is bound to that seed at the merge, so an importer's same-named group cannot answer it and the error cannot disappear downstream; the diagnostic names the group the author wrote. This holds for an inline main-locus claim, for a library-tier block (both of which swear about their own seed's boundary), and for a `constitution` declared by a seed that itself declares `main locus` — such a constitution can be adopted by nothing but that main. A POLICY seed's constitution is the one exception, and by design: it closes no world, so its group vocabulary is the adopting entrypoint's to declare (below, "Groups are not implied"). ## Constitutions — one authored claimset, many closed worlds (GH #409) A **constitution** is a named claimset declared outside any main and adopted by entrypoints with `adopt NAME;` inside a main-locus `claims { }` block. It is not a new quantification horizon: every clause is evaluated in the adopting main's own closed world, exactly as if written there. Authoring is shared, evaluation is not. - **Placement.** `constitution` is top-level only. `adopt` is legal only in a MAIN-LOCUS `claims { }` block — adoption is what fixes which world the clauses are evaluated against, and a library seed closes none. `adopt` inside a `constitution` is a parse error; claimsets compose with `extends`. - **Composition is union.** `extends A, B` contributes A's and B's clauses. A derived constitution may ADD and may not REPLACE: a claim name declared by two constitutions in one adopted set is an error naming both origins, as is a local main clause shadowing an adopted one. This is what makes weakening *unexpressible* — a stricter bound is a second named claim that coexists with the inherited one, and both are checked. Deciding whether a replacement strengthens or weakens would require proving implication between claims, which fails open when it is wrong. - **Duplicates within one constitution are an error.** Diamond duplication is resolved by constitution-level traversal, so a repeated `(origin, claim name)` can only be two clauses declared under one name — silently keeping the first would leave law the author wrote unchecked. - **Diamonds dedup by ORIGIN.** Two constitutions extending a common base contribute the base once. Dedup is by originating constitution, never by claim name: deduping by name would swallow the genuine two-origin collision the union rule depends on. - **Cycles** in `extends`, unknown constitution names (with a did-you-mean), and duplicate constitution declarations are errors. - **Groups are not implied.** A constitution names group vocabulary, and every adopting entrypoint must DECLARE those groups. An undeclared name is an unknown-name error, not an empty set; `may_be_empty` applies only to a group that is declared and resolves to zero members. An entrypoint lacking a component therefore writes `group thing = { } may_be_empty;` rather than omitting the declaration. This is what a POLICY seed is — a seed that declares no `main locus`, so its constitutions are adopted elsewhere and their open names are the adopter's to answer. A constitution declared by a seed that DOES declare `main locus` can be adopted by nothing but that main, so its vocabulary is its own seed's and an undeclared name there is that seed's error wherever it is compiled from (GH #774, above). ### Identity A constitution's **display name** is flat and unmangled so diagnostics cite it as written. Its **identity** is the digest of its normalized closure — its own `(name, rendered form)` pairs, sorted, plus its **deduplicated** bases' digests, recursively. `extends A, A` and `extends A` evaluate identically, so they must digest identically. Two declarations are the same constitution iff their closures agree. Identities come from the adoption **traversal**, not from inspecting which claims were emitted: a constitution that contributes no clause of its own (`constitution Dev extends Base { }`) has a meaningful closure and must still be compared. An evaluation reports both its `roots` — the constitutions named directly, by source `adopt` or by environment binding — and the `closure` those roots reach. The distinction is load-bearing across entrypoints: two seeds may each declare `Core` with different clauses, so a binding by bare name proves only that each entrypoint had *some* constitution called `Core`, not that all were evaluated against one claimset. ### Deployment environments `hale.toml` binds constitutions to deployment targets: ```toml [claims] base = "Core" # every environment carries this [environments.prod] constitution = "Prod" # …and prod adds this entrypoints = ["apps/riskgw"] [environments.dev] source_only = true # explicit: this environment adds none entrypoints = ["apps/riskgw"] ``` `--env ` injects the base and the environment's constitution as if the source had written `adopt`. Binding here rather than in source is what lets one entrypoint satisfy different claimsets in different environments: it cannot write two conflicting `adopt` lines, but it can be checked twice. `[claims] base` is what makes "an environment may add law, never drop it" true of the mechanism rather than only of `extends` — every evaluation carries the base by construction. A workspace declaring environments must decide about a base explicitly: either `base = "…"` or `no_base = true`. An absent `[claims]` section is indistinguishable from a misspelled one, and the intended baseline would vanish while every environment still looked valid. An environment naming no constitution must say `source_only = true` for the same reason, and unknown keys in an environment section are rejected. The base is compared **workspace-wide** (`base::`); an environment's own addition is compared within that environment (`env::::`). Keying the base per-environment meant two environments with disjoint entrypoint sets never shared a comparison key, so a base resolving to different closures in dev and prod went undetected — the mechanism proved consistency *within* each environment and nothing about the base being shared. `--env` requires its target to be an entrypoint, whether or not the environment contributes any constitution: an environment binds law to a deployment target, and a `source_only` environment with no base would otherwise check a library and report success. An entrypoint is a seed with an entry, its own top-level `main locus` (`spec/semantics.md`, "The entry locus"): a seed whose only `main locus` is an imported library's, or sits inside a `module { }`, is refused as a library is, and `--matrix` does not count it among the entrypoints it requires an environment for. **Roles (GH #1109).** `[environments..roles]` is the deployment's record of who holds which role: `role = ["uid:1000", "gid:20", "user:riley", "group:ops", "*"]`, one key per role the entrypoint declares (`spec/types.md` § "Roles"). A key that is not an identifier, a member spelling outside those five, or an account name outside letters, digits, `.`, `_`, `-` and `@` (the table travels as one line, so a name may not carry a separator) is a manifest error. `--matrix` proves, per (entrypoint, environment) pair, that every role the entrypoint declares is mapped there (`[]` says explicitly that nobody holds it), and that nothing is mapped that the entrypoint does not declare: an omission is indistinguishable from a mistake, and a misspelt key would otherwise map nobody quietly. Since R4 (GH #1417) the compiler does not bake the section into the program: a served surface's role source is the one its serve site names (`spec/api.md` § Identity sources), and the stdlib's `std::api::StaticRoles` takes its table as a param or, at run time, from `LOTUS_API_ROLES`. `hale check --env `, `hale build --env ` and `hale run --env ` adopt the environment's constitution. Combinations that cannot be honoured are rejected rather than ignored. `--matrix` runs many evaluations, so a per-evaluation artifact flag (`--dump-topology`, the `--check-*` baselines) has no single artifact to emit or gate against; and `--env` / `--workspace` alongside it would select nothing the matrix does not already enumerate. `--env` with `--workspace` is likewise rejected: a workspace sweep includes libraries, and an environment binds law to an entrypoint. A constitution required by the manifest has no source `adopt` line, so a diagnostic about it names the environment and the manifest rather than pointing at a main locus that mentions it nowhere. A constitution's identity does not depend on the import alias through which its seed was reached: the same policy seed imported as `pol` in one entrypoint and `plc` in another is one identity. `--matrix` checks every declared (entrypoint, environment) pair. It does not short-circuit; the exit status reflects every pair. An entrypoint in no environment is an error, and a seed that fails to PARSE is an unknown entrypoint rather than a non-entrypoint — otherwise a syntax error would erase a seed from coverage. Within one environment, all entrypoints must resolve each constitution to the same closure digest. A listed seed the check refuses (one with no entry: a library, or a seed whose only `main locus` is imported or inside a `module { }`) is reported by that refusal alone, and its pair fails. It has no entrypoint for the environment's roles to be mapped to, so no role coverage is reported for it, and a constitution it can never deploy under takes no part in the closure-digest comparison. A pair whose seed loads but fails to typecheck keeps both: its role coverage and its constitutions' identities are read from what it declares, whether or not it typechecks. The artifact records three things: per-claim `source` (where this clause came from), and an `evaluation` section carrying the `environment` label plus `roots` and `closure` with their digests (which deployment this run certified, and under what law). All are inside the integrity-covered body — an evaluation context editable after the fact would certify nothing. ### Source maps and model semantics (GH #408 Phase 0) Spans in the artifact are **file-local**, resolved through a `sources` table: ```json "sources": [{"id": 0, "path": "apps/api/main.hl", "digest": "…"}], "provenance": { "decls": { "App": {"source": 0, "span": [36, 39]} } } ``` They were bundle-global byte offsets — an artifact of concatenating the seed's files, meaningful only inside the process that produced them. A consumer composing artifacts from separately compiled applications cannot turn `[1204, 1231]` into a location, so no cross-artifact witness could say where to look. Every path is relative to one root, and no path is absolute. The root is the **workspace** — the nearest ancestor of the target holding a `hale.toml` — when every source is under it, and otherwise the deepest common ancestor of every source: an application in `app/` with its own `hale.toml` importing `../lib` names its files `app/main.hl` and `lib/…`. Paths are canonicalized before being made relative, so a target typed relative to the working directory is named as one typed absolute. Both matter: an absolute path makes the artifact machine-specific, and rooting at the target alone leaves an imported seed — which usually lives outside it — absolute anyway. The artifact must be byte-identical for the same sources regardless of the working directory it was produced from or the place the tree is checked out, because comparing two of them is the point. The source map is the program's one naming of its files: the execution identity a recording carries frames these paths, in the map's order ([runtime.md § Replay](runtime.md)). Each source carries a content digest, so a consumer can tell whether two artifacts were built from the same text, and can catch a stale artifact paired with edited source, without the source being shipped. A span the map cannot place reports `"source": -1` rather than being attributed to the nearest file: an unplaceable location is more useful than a confidently wrong one. **`semantics`** is a version distinct from `schema`. Schema says a row has these fields; it cannot say that "an interface dispatch fans out to every conformer" or "unknown implies violation" were the rules in force when the rows were produced. Two compilers agreeing on the schema and disagreeing on the semantics would compose artifacts into a model neither would certify, with nothing in the document revealing it. A consumer that does not recognise the value must refuse rather than assume equivalence. ## Fleet composition (GH #408 Phase 1) A **fleet** is a named deployed system of application *instances* — not "every main in a repository". `hale fleet check|dump ` composes **artifacts**, never source. That distinction is the design. A source-merged "super-main" would be unsound in both directions: an unbound topic is in-process by default, so merging two binaries makes matching publishers and subscribers look locally connected when no deployed route joins them; deploy-time routes that exist only in configuration would not appear at all; and calls, which cannot cross a process boundary, would become ordinary reachability. So **matching wire identities establish compatibility; only an explicit route creates a fleet edge.** A plan names exact, finite instances and routes. Autoscaling ranges and wildcard discovery are elaborator inputs, not sealed-plan contents: a bounded range is not one truth value for a cardinality claim. ```json { "schema": "1.1", "name": "prod", "instances": [{"id": "oms-0", "artifact": "artifacts/oms.json", "labels": ["oms"]}], "routes": [{"id": "request", "transport": "unix", "publishers": [{"instance": "oms-0", "topic": "t::OrderRequest"}], "subscribers": [{"instance": "gw-0", "topic": "t::OrderRequest"}]}] } ``` Composition: 1. **Validate every component.** Integrity before meaning: the whole-body `artifact_digest` is checked first, because `shape_hash` covers the model half only and cannot vouch for the `topics` rows the join reads. Then the `semantics` version, then the component's own `verdict` — local law is a precondition of fleet admission, and an artifact that fails it is not admissible. 2. **Namespace under the instance id.** `oms-0::Oms::on_intent`. An application type is not a deployed instance, and cardinality, witnesses and multiple instances of one artifact all need that. 3. **Retain interiors.** Calls stay strictly within one instance; there is no cross-process call to invent. 4. **Join on the wire identity** — `(subject, payload_hash)`, never the local topic name, which can mean different shapes in different applications. Endpoints that disagree are a plan that cannot be formed. A topic with no declared `subject:` is not portable and is rejected on a route. 5. **Insert route edges explicitly**, so a cross-process hop keeps its boundary rather than collapsing into a direct call. 6. **Propagate unknowns.** Uncertainty in a component stays uncertainty in the fleet: it may add paths, never delete one. `fleet_shape_hash` covers the model half — instance identities and cardinalities, routes and their wire identities, component shape hashes, the composed relations. Provenance and unknowns stay outside it, mirroring the application artifact's split: moving source must not change the identity of a deployment, but a changed transport or a second instance must. Unknown keys in a plan are rejected, for the reason they are rejected in the environment manifest: a misspelled field and an omitted one look identical to a verifier. ### The workspace's deployments ```toml [fleets] production = "ops/fleet/prod.plan.json" staging = "ops/fleet/staging.plan.json" ``` `hale fleet check` with no plan checks every declared deployment. A repository usually has more than one, and checking whichever one somebody remembered to name is the same partial-coverage problem `--matrix` solves for entrypoints. Every fleet runs even when an earlier one fails, and the exit status is the worst of them. `--in

` checks the fleets another workspace declares (a candidate's worktree); `--if-declared` makes a workspace that declares none a success that says so, for a step that runs on every workspace. **Schema 1.2 (GH #566 F5): seeds and nodes.** An instance may name a `seed` — a directory relative to the plan — instead of an `artifact`; composition then cuts the artifact from the seed first (`hale check --dump-topology`, into `.hale/fleet//.topology.json` beside the plan), so the plan can describe the workspace's own services and a change is checked as the fleet it would deploy. A seed that does not check is an instance with no admissible artifact; an instance with neither is a plan that cannot be formed. An instance may also name the `node` that expresses it (`hale node `, `spec/dna.md`); composition ignores it. `[dna] fleet = ""` in `hale.toml` names which declared fleet the DNA expresses. `[fleets]` and `[environments]` are **separate axes** and a workspace may declare both. An environment binds law to an ENTRYPOINT at the application tier; a fleet is an ARRANGEMENT of deployed instances. `production` in one need not mean `production` in the other, and collapsing them would force every entrypoint's law to be a function of some deployment it may not even appear in. There is deliberately no coverage check over plans — unlike entrypoints, which are discoverable seeds, a plan is an arbitrary file path, so "every plan in the repository is declared" is not a question that can be asked without guessing. ### Fleet claims (GH #408 Phase 2) Claims over the composed model, carried in the plan as normalized rows rather than source grammar — the plan is an IR, so a generator can produce one without Hale syntax committing to a deployment format. ```json "groups": { "strategies": {"labels": ["strategy"]}, "oms": {"instances": ["oms-0"]} }, "claims": [ {"name": "orders_pass_oms", "forbid_reaches": {"from": "strategies", "to": "gateways", "avoiding": "oms"}}, {"name": "one_order_authority", "count_publisher_instances": {"subject": "svc.order.request", "eq": 1}}, {"name": "gw_receives_orders", "require_subscribes": {"group": "gateways", "subject": "svc.order.request"}} ] ``` A fleet group quantifies over **instances**, by id or by label; its vertices are every vertex of those instances — the same projection an application group makes from a locus to its methods, one altitude up. An unknown name or an empty resolution is an error, never an empty set. - **`forbid_reaches`** walks the composed edge set: interior calls **including the through-stdlib contracted edges** (`calls_via_stdlib`), interior bus hops, and explicit routes. Reading only `calls` loses a path the component's own claims can see — a handler reaching its publisher through `std::http::Router` contributes no direct edge at all. `avoiding` masks a group's vertices out, which makes it the interposition form — any surviving path is a bypass, and `avoiding` may not name an endpoint of its own claim, since masking one deletes the domain being quantified over. An instance in **both** the source and target groups is a zero-length violation: the source already is the forbidden destination. - **`only_edges`** grants by wire **subject**, not by transport address. There are no cross-process calls to grant. - **`require_subscribes` / `require_publishes`** are structural deployment statements: some instance in the group exposes the endpoint **and the plan connects it**. Both halves are load-bearing — checking only that the endpoint exists lets a plan where nothing publishes a subject satisfy "the ledger receives fills", which is the one thing that claim is for. "Ledger listens for fills" is provable; "every fill is durably booked" is not implied by it. - **`count_publisher_instances` / `count_subscriber_instances`** count instance-qualified **endpoints**, which is a different sort from the application tier's declaration count — hence the different spelling. Two components can each be individually legal while the deployment has two publishers. A count must name at least one of `eq`, `min`, `max`: with none of them a claim compares nothing and holds against every fleet while reading like law. A claim names **exactly one** verb. Several verbs under one name would be judged one at a time and recorded under that name as though the whole sentence held, so the shape is refused: split it, and each half gets its own name and verdict. Fleet composition decodes each admitted component ONCE into a typed interior (GH #476 Change 7) — the vertex universe, both call relations unioned, the publish/subscribe rows, the topics join surface, the component's own unknowns, decl provenance, and the hashed endpoint identity — and composes from those typed rows exclusively; no modeled fact is recovered from generic JSON after admission. Route identity is ONE grain (round 2): a plan endpoint names a local topic, so the role check AND the edge builder both read the typed rows carrying that topic identity — a literal end on the same wire is a different endpoint and satisfies neither — while fleet claims that deliberately quantify over wire subjects keep the wire-grain accessor. The component's POSITIVE completeness account is honored WITH THE POLARITY OF THE LAW (rounds 5–6). `forbid_reaches` / `only_edges` certify an absence, so incomplete knowledge in an involved component prevents the proof: `holds` becomes `uncertified`, naming the instances and their withdrawn capability flags, scoped by the unreachable-unknown rule. The endpoint and count forms handle completeness INSIDE their evaluators: a known, routed `require_*` witness is a positive fact no incomplete set can erase, and a route-backed structural failure is definite (the plan's route table is complete). Counts follow the canonical monotone rule over a [known, known+hidden] interval — known endpoint rows are the lower bound; an uncounted component that withdraws the RELEVANT completeness (publisher counts consult publish + cardinality, subscriber counts subscribe + cardinality) raises only the upper bound; `min` holds once the lower bound reaches it, `max`/`eq` definitely violate once the lower bound exceeds them, and the undecided interval is `uncertified` — with the eq/min/max conjunction judged over that interval. The claim row is serialized AFTER this final verdict. Track A is capped at the current schema exactly: the canonical layout table is versioned by it. And the declared identity is VERIFIED: both Track A and fleet recompute `shape_hash` from the raw model half before any decoding (`verify_shape_hash`) — a consistent hashed+unhashed endpoint edit under a stale shape_hash refuses, so the identity that replay and composition compare is always the identity of the bytes present. The typed decode is STRICT and fallible (round 3): a malformed semantic row — a call endpoint that is not a string, a non-string unknown reason — is REFUSED, never filtered, because a silently dropped edge or hole reason is precisely the omission the shared traversal must never see; only genuinely optional facts (provenance, unplaceable decl sources, a bus-free program's absent endpoint section) may be absent. The top-level LAYOUT is canonical and closed (round 5): order is not JSON pedantry here — it DEFINES the verified hash ranges (`shape_hash` covers the bytes between its entry and `sources`; `artifact_digest` covers everything before itself), so known keys must appear in the emitter's canonical sequence, unknown top-level keys are refused, and `artifact_digest` must be the FINAL entry — a model-half section cannot be moved outside the identity's coverage, and no tail entry can escape the document digest. And every consumed document must carry ONE unambiguous value per key, judged the way a parser judges it (round 4): duplicate object keys are rejected before parsing on their DECODED names (`"shape\u005fhash"` and `"shape_hash"` are one key), and the identity fields are located STRUCTURALLY — one raw walk (`scan_top_level`) yields the top-level entries, and both `verify_shape_hash` and `verify_artifact_digest` read exactly the top-level field the parsed document consumes, so a nested decoy in some other section is data, never the verified value. Route admission validates **roles**, not just topic identity. A publisher endpoint must publish the subject and a subscriber endpoint must subscribe it, as its own artifact records — declaring a topic is not using it, and any component importing a topic module declares every topic in it. Without this a plan could name a producer that does not exist and satisfy a law about the consumer side with nothing feeding it. A plan that misdescribes its components is an invalid model, refused before any claim is evaluated rather than reported as a law failure. **Uncertainty propagates.** A component's `unknowns` are part of the composed model, not a footnote beside it. A prohibition whose source can reach a vertex with an incomplete outgoing edge set answers `uncertified` — that vertex's missing edges could lead to the target, so the absence is not proved. `uncertified` fails like `violated`; the distinction is recorded because the repair differs (resolve the unknown edge, versus fix the program). Two rules keep it usable: a concrete counterexample wins over a hole, and only a hole the claim's source can actually reach counts, so an unrelated unknown elsewhere in the deployment does not poison every law. `uninhabited_interface_call` is not such a hole — in a closed world an interface with no conformers has no values, so the site is dead rather than unknown. Endpoints resolve through each component's `topics` table by wire subject, never by local name. A violation renders a **cross-artifact witness**: the instance- qualified vertices, the route carrying **each** hop, and the source file each vertex lives in — which is what Phase 0's source maps exist to make renderable. ``` fleet claim `orders_pass_oms` violated — witness: prober-0::Probe::submit [rogue/main.hl] -(route `bypass`)-> gw-0::Gateway::on_order [gw/main.hl] ``` ### Signed components & attestation (GH #408 Phase 7) A composition proves a world against artifacts it can read; a signature proves those artifacts are the ones a key-holder meant. It certifies **provenance and integrity, never behavior** — the artifact never claims a message will arrive, and a signature never claims the code is good. Out of scope, deliberately: a compromised builder, a malicious compiler, runtime memory tampering. The scheme is **ES256** (ECDSA P-256 over SHA-256), because the system already speaks it: it is `std::crypto`'s signature suite, OpenSSL-backed in the runtime, PEM keys, raw `r‖s` 64-byte signatures. One algorithm end to end means a Hale program — a supervisor, a deploy gate — verifies the same sidecar with the language's own stdlib. Signatures cover the artifact's **exact bytes**. That is sound because artifacts are byte-reproducible (schema 1.8), and it is necessary because the in-band `artifact_digest` is FNV-1a — an integrity tripwire, not a trust anchor. Nothing signs a digest. The sidecar is `.sig`, one line, `es256:<128 hex>`; an unknown prefix is a refusal, not a skip. ```sh hale fleet keygen ops # ops.pem (0600) + ops.pub.pem hale fleet sign app.topo.json --key ops.pem hale fleet check prod.plan.json --trust ops.pub.pem ``` **Trust is strict when declared.** Passing `--trust` (repeatable), or declaring keys in the manifest, makes an unsigned or unverifiable component a composition **error**: ```toml [fleet_trust] keys = ["keys/ops.pub.pem"] # SPKI PEM, manifest-relative ``` There is no `require = true` knob, for the reason `no_base` exists in `[claims]`: a trust set that quietly admits unsigned artifacts is law that looks bound and binds nothing. An absent section means signatures are not checked — the pre-Phase-7 meaning of a composition, unchanged. Signature verification runs **before** the integrity digest: provenance before integrity before meaning, each check covering the bytes the next one reads. The all-fleets form (`hale fleet check` with no plan) takes trust from the manifest only; `--trust` there is an error, so one flag cannot quietly rebind every declared deployment. The fleet artifact records what admitted each component — unhashed provenance, like the rest of the `components` section: ```json {"id": "app-0", "artifact": "artifacts/app.json", "sha256": "59ad65d4…", "signed_by": "a88ca61a96ffa055"} ``` `sha256` is the admitted bytes; `signed_by` is the key's identity (first 8 bytes of SHA-256 over the SPKI DER) or `null` when trust was not declared — a fact, not an omission, so a reader can tell "unsigned admission" from "verified under this key". **Attestation** answers the remaining question: are the executables this plan deploys the ones the operator hashed? Plan schema 1.1 adds two optional rows per instance, and `hale fleet attest` is all-or-nothing over them: ```json {"id": "app-0", "artifact": "artifacts/app.json", "binary": "bin/app", "binary_sha256": "de55ee70…"} ``` A missing row is a refusal, not a skip — a partial attestation would report coverage it does not have. `binary_sha256` is cryptographic where `artifact_digest` deliberately is not: this hash is the thing an operator asserts across a trust boundary. Attestation checks bytes at rest at deploy time; whether a *running* process is still that binary is runtime territory (sent/delivered observation, 7b), and the artifact never claims otherwise. ## Secrets — confine, classify, claim (GH #436) Hale's answer to secrets is not an analysis. It is the ownership primitive doing its job, plus one classified operation, plus law. **1. `@sealed locus L` — confinement.** A sealed locus's `params` are readable only from inside its own methods. Others may still CALL it; they may not read its state: ```hale @sealed locus Signer { params { key: Bytes; } @effects(is: { secret_use }) fn sign(m: Bytes) -> Signature { … } } ``` This exists because loci are otherwise **not** field-encapsulated — `self.child.key` typechecks from anywhere holding the locus, so without sealing "the key never leaves the locus that owns it" is a property you check rather than one that is true. The annotation is opt-in and breaks no existing program. There is currently **no** claim form for requiring an annotation, so a constitution cannot yet demand `@sealed` across a group — a `require sealed(all G)` form is the obvious follow-up, and the same gap applies to `@supervised`. Only `params` are confined. Capacity slots and methods are untouched — sealing confines state, it does not make a locus uncallable, which is the entire point. **`@sealed` and `contract { expose … }` cannot be combined.** They are contradictory claims about the same boundary and sealing wins, so an `expose` on a sealed locus reads as a permission it cannot grant — the contract consistency check passes, a matching `consume` binds, and every use of the field is then rejected. The pair is a check error. `expose` cannot serve as the sealed allowlist without redefining it: it is the coordinator/coordinatee surface, so honouring it would grant reads to an `accept`ing parent while still denying them to a parent holding the same child as a param — one field, public to one kind of holder and not the other. Param **initialization** is deliberately not restricted. A parent writing `Signer { key: … }` already holds the value it passes, so sealing the initializer would cost ordinary configuration and buy nothing. Real secret material should be loaded inside `birth` from a vault, environment, or file rather than passed in. A constitution can then demand confinement rather than trusting that every author remembered: ```hale constitution SecretBaseline { vault_confined: require sealed(all vaults); no_plugin_secrets: forbid reaches(plugins, effects(secret_use)); } ``` **2. One classified operation.** The privileged method carries a user effect class, so every path that can touch the secret is visible on the call graph — `frontier::infer_effects` propagates it transitively with no further annotation. **3. Law.** The domain states who may reach it and how often, using claim forms that already exist: `secret_use` is a **compiler built-in** — the stdlib owns the mechanism, the compiler owns the class identity, the application states the law. Declaring `effect secret_use;` is an error: user classes intern per-`Program`, so a stdlib-declared class had no identity an application's claims could name, and the law over `std::secret` was silently unenforceable and order-dependent. ```hale claims { no_plugin_secrets: forbid reaches(plugins, effects(secret_use)); one_op_per_request: bound secret_use <= 1 on paths from handlers; } ``` A violation names the crossing call: ```text claim `no_plugin_secrets` violated: `plugins` reaches `effects(secret_use)` — witness: `PluginHost::sneaky` -> `Signer::sign` ``` **`std::secret` closes the initialization gap.** Sealing protects the read side; a parent writing `Signer { key: … }` still holds what it passes. The stdlib loci therefore take the **name of a source**, never the bytes: ```hale locus Gateway { params { s: std::secret::Signer = std::secret::Signer { env_var: "SIGNING_KEY" }; } fn go(m: Bytes) -> Bytes { return self.s.sign(m); } } ``` `self.s.key` from `Gateway` is a compile error. The key is read during `birth`, so it exists only inside a sealed locus from the moment it enters the program and there is no construction site at which the caller held it. `std::secret::Credential` is the same discipline for a token or password, with a `fingerprint()` that is safe to log. Sealing keys off the receiver's resolved type. Since GH #470 every qualified path naming a Hale-source stdlib declaration resolves to the mangled name that source declares — the whole surface, not only sealed loci — so field-existence, method arity, and interface-satisfaction checking apply to stdlib-typed values exactly as to user types (the old `Ty::Unknown` tolerance was fail-open: a wrong-arity method coerced to a stdlib interface unchecked and corrupted memory at the fat-pointer call). Rust-implemented builtin handles keep the permissive typing; their path-call names are validated by the stdlib surface registry. **The recommended shape**, a pattern rather than an enforced contract: an ordinary function prepares a request from public data and returns a closed plan; the sealed locus interprets the plan and performs the one privileged step. The planner never receives the secret, a handle, or any secret capability. Prefer a closed operation enum over a callback — a finite vocabulary is reviewable, a function parameter is a programmable oracle. Worked end to end in `crates/hale-codegen/tests/fixtures/examples/secrets-sealed-handler.hl`. **In the artifact.** `sealed` is a hashed model row (schema 1.9), so a locus gaining or losing `@sealed` moves `shape_hash` and a `--check-topology` gate sees it. Confinement is a structural property rather than only a claim input: a seal changing with no topology diff would be exactly the invisible security change the artifact exists to surface. `require sealed` replays from that row. `require attributed` does not: it turns on DIRECT effect sites, and the artifact exports inferred per-fn effect sets rather than the direct/transitive distinction, so that form is **compiler-certified** — the artifact carries its verdict and not the facts to recompute it. ### What this guarantees, and what it does not > The secret lives in a locus that owns it, the domain cannot obtain > it, the only operations on it are classified, and the domain's > claims constrain who may reach those operations and how often. This is **not** information flow and **not** noninterference. Two residual assumptions, both deliberate: 1. **The sealed locus's own body is trusted.** Sealing stops others reading the field; it does not stop the owner returning it. Keep that locus small enough to review. 2. **Primitive classifications are compiler-asserted facts.** That `crypto::hmac` behaves as claimed is a declaration in the stdlib frontier, not a proof — the same trust every effect claim rests on. Deliberately out of scope, each a separate and stronger theorem: derivation tracking (nothing derived from the key influences a public output), control dependence (a constant-time compare still lets the *verdict* be published), resource uniqueness (nonce counters, DRBG state), and zeroization. ## Structural & design rules | Check | Catches | Severity | Enforced by | |---|---|---|---| | **CQRS / no-locus-return** | a locus `fn` member whose return type (or `fallible(T)` payload) names a user-declared locus type — returning a managed entity from a method is a Law-of-Demeter / CQRS / Dependency-Inversion violation that also leaks via payload-arena routing | error | `check_no_locus_return` | | **Stdlib error-type shadow** | a user-declared `type IoError` / `ParseError` / `CryptoError` / `IndexError` / `KeyError` / `EmptyError` whose shape doesn't match the stdlib's, when that error type is reached by a fallible stdlib call: the error a call fails with is its stdlib surface row's (`std::time::parse_iso8601` reaches `ParseError`, `std::compress::gzip` `IoError`), or, for a path that renames to a Hale-source stdlib fn, that fn's `fallible(E)` | error | `check_stdlib_error_shadowing` (resolve) | | **Codec purity** | a bus codec whose `encode` / `decode` method isn't pure (codecs may be dispatched off-thread): it writes a `self` field, publishes, violates a closure, prints, or calls a stdlib function whose row in the stdlib surface table carries `syscall`, `block` or `publish`, or a fn that does any of these | error | `check_main_and_bindings`, reading the purity column of the effect rows, whose stdlib leaves are derived from the surface table | | **`ring_layout` contract** | a foreign-ring layout declaration that's internally ill-formed — unknown scalar/`len_prefix` repr, missing `framing` (or `byte_records` without a `len_prefix` / `buffer_size`, or `slots` without `slot_size` / `slot_count`), no cursor / a cursor without an `at`, unknown cursor ordering or unit, a missing `magic` / `data_at`, or a `shm_ring(..., layout: N)` whose `N` doesn't resolve to a declared `ring_layout` | error | `check_ring_layout` + `check_main_and_bindings` | | **`ring_layout` geometry** | a *cross-field* inconsistency that would let a record header land out of bounds or silently corrupt the reader: a header scalar or the cursor overrunning `data_at`, two fields overlapping, a non-power-of-two `align`, a `pad_sentinel` too wide for the `len_prefix`, a `len_prefix` width `> align`, a non-8-aligned `atomic_u64` cursor, or (producer side) a `buffer_size:` that isn't a multiple of `align` | error | `check_ring_layout` + `check_main_and_bindings` | | **Foreign-ring payload shape** | a `layout:`-bound topic whose payload is neither flat-shapeable (typed mode — read by direct cast, needs a fixed byte layout) nor `BytesView` (raw-frame mode — a bounded view per record, for heterogeneous rings); e.g. a struct with `String` / `Bytes` / variable-size fields. Enforced regardless of `where zero_copy` | error | `check_main_and_bindings` | | **A value-returning violator is fallible** | a value-returning fn or locus method that may violate (a `violate` statement in its own body, or a call to a value-returning fn that may violate and is not `fallible`; a call to a method returning nothing does not count) and is not declared `fallible(ClosureViolation)`: "\`P.take\` returns a value and may violate (\`violate exhausted\`): declare it \`fallible(ClosureViolation)\`; its callers then say what happens when the locus fails", the parenthesis "through \`helper\`" for a call, the notes at the call and the `violate`. Lifecycle bodies, bus handlers and `fn main` are exempt (`spec/semantics.md` § "A value-returning method that may violate is fallible") | error | `violate_fallible_laws` | | **One error type for a violator** | a fn that may violate declared `fallible(E)` with `E` other than `ClosureViolation`: "\`P.take\` may violate (\`violate exhausted\`) and is declared \`fallible(E)\`: a fn that may violate is \`fallible(ClosureViolation)\`, and a fn has one error type" | error | `violate_fallible_laws` | | **Violation in a method returning nothing** | a fn returning nothing that may violate and is not `fallible`, with the same exemptions: "\`P.poke\` may violate (\`violate exhausted\`): declare it \`fallible(ClosureViolation)\`; its callers then say what happens when the locus fails. This becomes a law in a later release" | warning | `violate_fallible_laws` | | **Violation in a perspective-served method** | a locus method serving a perspective fn that may violate and is not `fallible`, exempt from the two rules above until a `fallible` perspective call lowers: "\`RouterV1.route\` serves \`Router.route\` and may violate (\`violate c\`); a perspective call cannot carry the failure yet, so the method keeps today's behaviour (the caller of a violated perspective call must not read the value)" (`spec/semantics.md` § "A value-returning method that may violate is fallible", the stated gap) | warning | `violate_fallible_laws` | | **A perspective fn's fallibility** | a method serving a perspective fn with a different fallibility, either way: "\`RouterV1.route\` is \`fallible(ClosureViolation)\` but \`Router.route\` is not: a method serving a perspective matches its fallibility" | error | `check_serves_conformance` | | **Cell slot-of-origin** | releasing a `Cell` into a different `(locus, slot)` than it was acquired from | error | codegen | | **Recovery event alphabet** | a name in a closure's `persists_through(...)` or `resets_on(...)` that is not a recovery event: the alphabet is closed, `restart`, `restart_in_place` and `quarantine`; the message names it at the name, and suggests the event a misspelling one edit away means | error | `outside_the_alphabet` (closure events) | | **Persisting through dissolve** | `dissolve` in a closure's `persists_through(...)`: an accumulator does not outlive its locus's dissolve, so the clause can mean nothing | error | `persists_through_dissolve` (closure events) | | **Contradicting recovery clauses** | a recovery event one closure names in both `persists_through(...)` and `resets_on(...)`: the two contradict each other. Reported at the `resets_on` name, with the `persists_through` name as its witness | error | `in_both_clauses` (closure events) | | **Unreached recovery event** | in a closed world (the program has an entry), a recovery event a closure of the program's own seed names that no handler and no recovery statement applies to its locus, read from the handler rows and from the recovery statements outside the handlers as the checker types them, each on the locus its receiver's type names, a generic body's per specialization (a spent `restart(c) for N` bound is `quarantine`). The witness is each handler and statement that names the locus, with the events it applies, or the locus when none does. Not judged for an imported locus, nor for an event some recovery applies to a child no row names (a generic supervisor's handler's type parameter, or a receiver of a type parameter in a generic body no specialization binds) | warning | `unreached_events` (closure events) | | **Persistence with no accumulator** | `persists_through(...)` on a closure whose assertion has no `sum`, `count` or `mean`: there is nothing to keep. Reported at the clause, with the assertion as its witness | warning | `nothing_to_keep` (closure events) | | **Sealed confinement** | a read or a write of a `@sealed` locus's `params` field from outside that locus's own members (`self.signer.key` in its parent), naming the methods to call instead; see § "Secrets — confine, classify, claim". Judged over the param-access rows the checker records for every access through a locus-typed receiver, sealed or not, which the `--sealable` survey reads too. A parameter's default is judged where it is evaluated, at each call that leaves it, with that caller as the reader, and refused once, at the default. A receiver typed as a generic locus's specialization (`b: Box`) reaches the generic locus's `params`, and is judged against its `@sealed`. A generic body is judged in each of its specializations, so an access through a value of a parameter type (`self.inner.key`, `inner: T`) is refused where it is written when a specialization binds that type to a sealed locus, once however many do, through a chain of fields of any depth (`self.h.s.key`, `h: Holder`) | error | `outside_access` (sealed access) | | **Module-nested entry** | a seed whose only `main locus` sits inside a `module { }`: it is not the entry (`spec/semantics.md` § "The entry locus"), and nothing else in the seed is, so the program would build with a `main locus` that never runs. Reported once, at that locus's name, read from the entry row; every other rule still judges the declaration | error | `module_nested_main_is_not_the_entry` (lowering laws) | | **Cross-pool spawn as a value** | a locus literal used as a value (let-bound, an argument, a field, a sub-expression) where the ownership graph's bubble plan posts it to an owner on another thread: a cross-pool spawn is fire-and-forget (`spec/semantics.md` § "accept bubbling"), so it may only be a bare statement. Judged at every place lowering builds the literal, a default's expansions included, from the ownership graph's sites and expansions and the typed bodies' omitted arguments | error | `cross_pool_spawn_used_as_a_value` (lowering laws) | | **Self-containing locus** | a locus whose params defaults construct one of its own kind by value, by a literal or through a fresh-factory call, so its construction has no floor (`spec/types.md` § "A locus may not contain itself by value"). Reported at the param that closes the cycle, naming the ring | error | `self_containing_locus` (lowering laws) | | **Role declared once** | a `role` declared twice (`spec/types.md` § "Roles"): a role is one name the deployment maps, not a merge. Reported at the second declaration, with the first as its witness | error | `declared_twice` (roles) | | **Declared role** | a role an `includes` names that no `role` declares (a row's `requires` is the surface laws') | error | `undeclared` (roles) | | **Acyclic role includes** | a role reachable from its own `includes` chain: composition is grant-only and union-only, so a cycle says nothing | error | `role_cycle` (roles) | | **Pool starvation** | two or more `run()` bodies that statically never return sharing one cooperative pool of the deployed root (the main locus's own `run()` counts on pool `main`): the pool runs each `run()` to completion in birth order, so the later ones never start (`spec/semantics.md` rules 7 and 8). Read from the placement table's rows and the flow rows' "never returns" column; not reported on a pool where the dead-receiver error fired | warning | `check_pool_starvation` | | **Birth-order trap** | a params field of the deployed root whose `run()` runs inline on the main thread and statically never returns, with params declared after it, which are then never born. Reported once, at the first such field, listing the fields it starves; read from the placement table's rows and the flow rows | warning | `check_birth_order` | | **Dropped scope-local delivery** | a bus subscriber — or a locus that keeps one for its whole life: one its params defaults build, transitively, or its bodies build and `accept` or store in a field — built and dissolved inside one call (a `let` or a bare statement, neither stored in a field nor returned, with no `accept` owning it) whose enclosing scope runs on a classic cooperative pool (not `where async_io`; a free fn's domains are its callers'): its subscription registers with that pool, whose cells are handler-atomic, and the call holds the pool's worker for the subscriber's whole life, so every delivery to it is dropped at its dissolve (`spec/runtime.md` § "Subscriptions follow the tower"). Reported at the literal, naming the locus, the call and the pool, and saying to place the pool `where async_io` or keep the subscriber beyond the call; a literal in a bus handler is type-check rule 20's, and a scope whose domains are unknown proves nothing. Read from the allocation summary's rows, the ownership graph and the placement table's dynamic sites | warning | `check_scope_local_subscriber_on_classic_pool` | | **Hot-path allocation** | an allocation per loop iteration or per bus message in the program's own fns: a locus instantiated, a factory's result bound, an allocating receive in a loop; under `@hot` also `snapshot()` / `finish()` and a whole-struct `self. =` replace. Read from the allocation summary's rows; an error under `@hot`, otherwise a warning that `@unbounded` or `@bounded_by()` silences | warning / error | `check_hot_path_alloc` | | **Unit declared once** | a `unit` declared twice (`spec/units.md` § "Declarations"): a unit is one node of the catalogue. Reported at the second declaration's name, with the first as its witness; a unit of a name the stdlib's time catalogue declares (`ms`, U4) is that unit declared again, the message naming the catalogue | error | `declared_twice` (units) | | **Declared unit** | an equation's target, a denomination or an origin that names no declared unit, at the name, suggesting the nearest declared one, a program's own before the stdlib's; a retired time unit (`m`) says what it is now instead (minutes are `min`) | error | `undeclared_units` (units) | | **Unit cycles multiply to one** | a cycle of unit equations whose product is not one, at the equation the closure found inconsistent, stating what it claims and what the other equations imply; the witness is the other path, each step at its declaration | error | `inconsistent_cycles` (units) | | **One quantity per component** | a second quantity declared with `quantity` and no policy over one component of the catalogue: the component's other types are denominations of its one quantity. Reported at the second's name, with the first as its witness; and a refinement of a quantity denominated in a unit outside its component | error | `second_quantity` (units) | | **Quantity counts an Int** | a quantity over anything but `Int`, a quantity with no `in`, and `Int in D` with no `quantity`, each saying what to write | error | `quantity_shape` (units) | | **Point over a quantity** | a `point` over anything but a quantity, an `origin:` on anything but a point, and a point's denomination or origin in a unit outside its quantity's component | error | `point_shape` (units) | | **Round and range clauses** | a `round:` naming none of `floor`, `ceil`, `trunc`, `half_even`, `half_up`, or on an identity or a range; a `range:` bound that is no integer literal, a range holding no value, and a refinement's range outside its parent's (with the parent's range as its witness) | error | `clauses` (units) | | **Identity and range bases** | an identity that is not `distinct Int` or has a denomination; a range type over a primitive other than `Int` or with a denomination, and a refinement of `Int` with no `range:` | error | `identity_and_range_bases` (units) | | **Refinement of a scalar** | a scalar declaration over a name nothing declares; a refinement (no kind word) of a struct, an enum, an alias of one, a locus or another non-scalar; a refinement chain that comes back to itself | error | `refinement_bases` (units) | | **Unit named like a literal suffix** | a `unit` named `d`, the Decimal literal's suffix (`3d` is the Decimal `3`), or `e` / `E` and a digit, a Float's exponent (`3e5`): the lexer reads either before a unit, so no literal could write the unit. Reported at the name | error | `literal_suffix_names` (units) | | **Named like a builtin type** | a quantity, a point, an identity, a range or a `unit` of a program named like a builtin type: a primitive (`Bytes`, `Int`, `String`, …; a type position reads it before any declaration, so the declaration could never be named) or a builtin error type (`IoError`, `RangeError`, …). Reported at the name: "type \`Bytes\`: \`Bytes\` is the builtin buffer type; a quantity cannot take a builtin type's name" | error | `builtin_type_names` (units) | CQRS is GitHub issue #18 item 6; its three sanctioned remedies (parent-child + contract, bus mediator, delegation) are named in the diagnostic. See `spec/semantics.md § Locus method dispatch`. ## Default-on & opt-in analyses One GitHub issue #18 analysis runs **by default**: item 4 (bus-graph property checks — *errors*, fail the build). The rest, including item 1 (memory-bound), are **opt-in** (behind a flag) or deferred. Only item 4 is a build gate; don't assume the others in a build: - **Memory-bound proofs (item 1)** — **opt-in**, two ways. The proof is opt-in by design: "bounded per epoch" only means something for a long-lived process (a daemon, a bus handler, a persistent locus), so a script that allocates and exits owes it nothing and pays nothing by default — the same descent-curve stance as the `@locality` cache-tier budgets (annotation/flag-gated, never automatic). The two opt-in surfaces: - **`@bounded locus L { … }`** — the in-source opt-in. A locus annotated `@bounded` is checked on every `hale check` (no flag), and a `@unbounded fn`/`@unbounded run { … }` inside it is the greppable carve-out that silences one body's sites. This is the descent marker: the locus that took on long-lived state asks for the proof on itself. *(Currently advisory warnings; the intended end state is a hard **error** contract once the precision refinements — store-latest vs. append, `@form(cap)` composition — drive in-scope false positives to zero.)* - **`@bounded_by()`** (GH #1490) stands wherever `@unbounded` stands — a free fn, a locus method, a lifecycle hook — and silences exactly what it silences: the survey's sites in that body and the hot-path lint's warnings for it (one rule: both put the fn in the summary's silenced set). The two say different things. **`@unbounded` means the growth is not yet understood**; **`@bounded_by()` states the input that bounds it** — real growth, bounded by something outside the code: an in-memory mirror of the record (`@bounded_by(record)`), a queue of open requests (`@bounded_by(open_requests)`). The argument is one identifier or dotted name (`@bounded_by(self.fan)`), kept as written; it is documentation and is never resolved. A missing or empty argument, or more than one, is a parse error. A fn carries at most one of the two: `@bounded_by` beside `@unbounded` is refused at the `@bounded_by`, saying to keep it when the bound is known and `@unbounded` while it is not; a second `@bounded_by` is a duplicate (§ Decorator stacks), and on a hook both are refused where it parses. It meets `@unbounded`'s contradictions under its own spelling: with `@hot`, with an effect assertion that forbids `alloc`, and with `@budget(alloc_per_call = 0)`. `--dump-alloc-summary` shows the bound beside the fn (`bounded_by(record)`), and `--report-unbounded-use` reports it. - **The whole-program advisory survey — DEFAULT-ON since 2026-07-02** (the M3 stage-5 flip, after a full-corpus audit triaged all 402 warnings: every true positive preserved, every residual false positive in a documented accepted class — see notes/unbounded-alloc-audit-2026-07-02.md). Flags every site regardless of `@bounded` (a `@unbounded` or `@bounded_by` fn is still suppressed, and so is a site with **no author position**: one whose span lies in source a desugar generated — at or beyond `API_SYNTH_BASE` (the window of source the serve expansion generates), or in a declaration the snapshot's origin rows mark synthesized whose offset no source file owns, a `json_gen` parser or api codec owning none — and no other site, whoever's code calls it; the check's warnings and the editor's `hale/allocSummary` decide with the same rule); run-to-exit programs (a `main` with no `run` loop and no bus handler of the program's own) warn nothing — a script owes the proof nothing. A `run` loop or bus handler counts only on a locus the program can start (GH #1490): every locus its own seed declares, and every imported or stdlib locus a path in a fn its `main` reaches (or in a started locus's declaration) names — a literal, a param field of its type, a binding's adapter. An imported seed's handlers run in the processes that start them, so a program that only calls a seed's fns runs to exit however many handlers the seed declares (the host's verb program over `dna/host` and `dna/core`). A call through a function value the walk cannot resolve leaves the set unknown, and every own `run` loop and handler counts, as before. The survey judges the program's own sites over the whole program: calls into imported seeds and into the stdlib's Hale-source bodies are followed, so a site reached unboundedly through them (a stdlib loop the program starts that dispatches to its handler through an interface) is reported; a stdlib loop the program never starts invokes nothing. Warnings print but never fail the build. **`--no-warn-unbounded-alloc`** is the opt-out; `--warn-unbounded-alloc` is accepted-and-ignored (the former opt-in spelling). **`--report-unbounded-use`** says which `@unbounded` and `@bounded_by` decorators silence anything (GH #1490). It prints one line on stdout per decorated fn or lifecycle hook of the program, its imported seeds' included, and of the stdlib source it reaches: the appended api runtime's, and the analysis copy's fns and hooks the program reaches: `unbounded-use:`. `` is relative to the working directory (a stdlib decorator's is `crates/hale-stdlib/hl/`, as the repository spells it, whatever the working directory), `` is the decorator's (a hook takes no other decorator, so its declaration opens with it), and `` is spelled as the program spells it. `` counts the fn's sites this survey would report were the decorator removed: final verdict unbounded accumulation, the program's own, at an author position — the same filter as the warnings — and 0 in a run-to-exit program. `` counts the hot-path allocation warnings the lint drops for the fn (0 for a `@hot` fn, whose findings are errors either way). `` is `unbounded`, or `bounded_by()` with the name as written. The carve-out drops a fn's own sites and findings and nothing else — no verdict reads it — so each count is exactly what removing that one decorator would add. Each count is the analysis's, before the check drops advisories in files the target does not own: a seed's decorator is counted in a consumer's context, where its fn may be invoked unboundedly though the consumer's check would not print the warning. A stdlib decorator is counted the same way, its fn's sites and findings judged in this program as though the stdlib's rows were the program's own, with no author-position filter (the stdlib's source is real, if not the bundle's): the advisory reports the program's own sites alone, so removing a stdlib decorator changes no warning, and the count says what it would surface were the stdlib's sites reported. Lines are sorted by path and line; the diagnostics and the exit code are the check's. `scripts/unbounded-unused.sh` aggregates the report over every DNA program, the in-tree corpus and `tests/hale`'s tests, and lists the decorators of either kind, in `dna/` and in the stdlib, that silence nothing in any program that reaches them. `--dump-alloc-summary` prints the raw per-fn summary of the program's own fns and loci, judged the same way: one row per declaration (a fn, a method, an authored hook, a mode), keyed by its site and printed under its name, so two declarations sharing a name (a program the check refuses as a duplicate top-level name) are two rows. A per-method allocation summary + call-graph escape/loop dataflow — with **escape-awareness** (a non-escaping local in a per-message handler is reclaimed at the per-delivery method-scratch destroy, so it isn't flagged), call-result escape tagging, and **loop-ranking** (a `while v < N` const counter is proven bounded) — flags a value allocated in a per-message handler / unbounded loop that escapes and **accumulates until the locus dissolves**. A whole-value replace (`self.f = Struct{…}`) genuinely leaks (the arena bump-allocates a fresh value each time); the fix is **in-place mutation** (`self.f.x = v` / `self.a[i] = v`), a capacity-bounded `@form` (`ring_buffer` / `lru_cache` / a `capacity` slot), the bus (reclaims per dispatch), or a per-iteration child locus. It also flags an **insert into a growing collection** — `v.push(x)` / `m.set(x)` where the receiver's declared type is a `@form(vec)` / `@form(hashmap)` locus — in an unbounded context; the backing buffer grows with population and frees only at dissolve. A `@form(ring_buffer)` / `@form(lru_cache)` is cap-bounded and excluded. (Detection reads *declared* receiver types — params, typed `let`s, locus param fields — not inferred ones.) Zero corpus false positives. Untyped-receiver collection inserts remain deferred. See `notes/memory-bound-proofs.md`. **A String `+` is a site however it is spelled** (GH #1490). The survey records a concatenation where an operand is a String by what the program declares, never by guess: a string literal; a binding whose annotation, initializer (`let out = piece(0)`, `let s = to_string(n)`), parameter type or `for` iterable (`for s in names`, `names: [String; 4]`) makes it one; a field declared `String` (a locus param, a struct field, or a locus param written without a type whose default is a string literal), on `self` or on a receiver the walk types (`r.text`, `self.r.text`); a call of a fn or a locus method whose declared return is `String`, or of a builtin whose signature returns one (`to_string`); an element of a String array; and a concatenation, an `or`, an `if` or a block whose value is one of these. A binding is scoped as written (an inner `let x = 5` or a match binding shadows a String `x`), and what it cannot type stays unrecorded, so `i + 1` is never a concatenation. So `out = out + piece(i)` and `out = wrap(out) + sep` are the accumulator `out = out + "x"` is, judged by the same rules (the returned-accumulator rule below included), and so is `self.buf = self.buf + chunk`. Before, an operand counted only as a literal or a name declared `String`, and a declared `String` never matched (the type is a primitive), so a chain with no literal in it was no site at all: an under-report, now closed. `@budget` and the hot-path lint read the same sites. **The reclaim boundary.** Each site carries the boundary at which its memory is reclaimed (the dump's `reclaim@…` column): a bus payload at its dispatch (`reclaim@bus-dispatch`); a non-escaping (`local`) allocation in a **scratch-local** free fn at that fn's return (`reclaim@fn-return`); everything else when its locus dissolves (`reclaim@locus-dissolve`), a method's or handler's local at its per-call scratch destroy aside, as above. Scratch-local is the class lowering gives a subregion of its own, freed at return after the return value is copied out: `String` and scalar params and return, no struct or locus literal, no method call, no publish, no `self`, calls only within the class; the check reads the classification lowering reads, over the same declarations, so the two never disagree. The boundary is judged **relative to the loop analyzed**: a fn's return falls inside each iteration of a loop that calls the fn and outside every iteration of a loop in the fn's own body, so a function return is not an iteration's reclamation. A scratch-local fn called once per iteration does not accumulate its temporaries across the loop, and the temporaries of its own loop are bounded by one call, as a method's are. **`local` is not scratch**: a local allocation in any other free fn lands in its caller's arena, so when that fn runs once per iteration of an unbounded loop in a long-lived frame (`main`, `run`, or a free fn they call) it accumulates across the loop and is reported (it "lands in its caller's arena"). A value that escapes the fn — its return value, a store to `self`, a bus payload — keeps its own boundary; a recursive scratch-local fn keeps the locus boundary (its activations' subregions are alive at once, to an unbounded depth); a locus instantiation is not a value in its caller's arena (the instance's arena is its own). `--dump-alloc-summary` tags a scratch-local fn `[scratch-local]`, or `[scratch-local, recursive]`. **A returned accumulator is one value per call** (GH #1490). A value a fn builds in an unbounded loop of its own body and returns — the `let mut out = ""; while … { out = out + x; } return out;` shape, or a `found = Row { … }` the loop replaces — is not judged as growing with the loop when the fn's activation has a boundary: a method's or handler's per-call scratch, a scratch-local fn's subregion, or, for a free fn that lands in its caller's arena, a caller that has one (the fn is reached from no `main` / `run` body, nor from a free fn of theirs, without a scratch on the way). Each iteration's value supersedes the last, and the superseded ones die at that boundary with the call's other temporaries, so the site counts once per invocation; whether the value returned accumulates is then the caller's question, as for any returned value — reported when a direct caller is a `main` / `run` body or a free fn reached from one (its result lands there each call: "in a fn invoked unboundedly"), not when the caller is a method or handler, whose scratch frees it. The in-loop verdict stays where there is no boundary — the fn's frame is such a long-lived frame's arena, so every iteration's value stays until the locus dissolves — and inside a literal `while true`, which never reaches it. RSS-validated: an accumulator of 20 iterations called 10k times adds nothing over the runtime floor consumed in a method (4.6 MB), and ~96 MB consumed directly in `run`'s loop (`alloc_model_rss.rs::a_returned_loop_accumulator_reclaims_with_its_activation`). **A flow's `run()` is one activation when nothing in it repeats without a bound.** A flow child's arena is reclaimed when its `run()` returns (`spec/semantics.md` § "`release(c)` and flow children"), so a flow `run()` is not a long-lived frame when no frame that allocates into its arena — the `run()` itself and the free fns it reaches that land in their caller's arena — holds an unbounded loop (any loop but a const-bounded `for`-range or `while` counter). What it and those fns allocate is then the activation's, as a method's scratch is; a flow whose `run()` loops, itself or through such a fn (a connection serving many messages), stays long-lived, and what the loop allocates accumulates for the connection's life. A method or handler it calls stops the walk: its own scratch takes what its loops allocate. **A locus started unboundedly often asks unboundedly often.** A fn invoked unboundedly that starts a locus (a literal of it in its body) starts it once per invocation, and each start runs the locus's `birth` and `run` once. Each start is an instance of its own, so the hooks' own sites — their frame, their `self` — are not judged as growing; what the hooks call on another locus is shared by every start, and those callees are invoked unboundedly. A TCP consumer's bus handler handing each connection to a child that asks the program's HTTP handler is the case: the handler's `handle` is invoked once per connection. - **Hot-path allocation contract — `@budget(alloc_per_call = N)`** (2026-07-16). The dual of `@unbounded`: where `@unbounded` acknowledges intentional unbounded allocation, `@budget` declares an *opt-in per-call ceiling* and the compiler **enforces it as a hard error**. On a `fn` (free or method), `@budget(alloc_per_call = N)` asserts the fn performs at most `N` arena allocations per call. The check reuses the item-1 allocation summary + call graph: it counts the arena-allocating literals / `@form` inserts it can see, **transitively through resolved (bundle-local) callees**, plus the known-allocating `recv` family (`recv` / `recv_bytes` / `recv_with_source` — the same set the hot-path lint flags); a loop-nested allocation, or a call to an allocating fn inside a loop, or recursion, is **unbounded per call**. `N = 0` is the zero-alloc certificate — the strongest form, for a per-datagram handler or decode helper the runtime calls on the hot path with a guarantee it touches no arena. Opaque calls other than the `recv` family are outside what the budget sees (the same boundary the escape analysis draws); pair the contract with `recv_into` + a reused `BytesBuilder`. fn-only; stacks with the other contract decorators (see § Decorator stacks below, which is also where `@budget(alloc_per_call = 0)` against `@unbounded` is settled). A violation reports the measured count and points at every offending allocation with the fast-path fix. - **Effect assertions — `@effects(...)` and its `@no_*` sugar** (GH #265, 2026-07-29). `@budget`'s discipline generalized from allocation *count* to effect *classes*: an opt-in contract at a root the author cares about, inferred everywhere else, enforced as a hard error. **One surface, one engine, one classified frontier** — not a family of independent flags. The general form is `@effects(...)`: ```hale @effects(none: {syscall, block}) fn decode(b: Bytes) -> Msg { ... } @effects(none: {time}) fn backoff(n: Int) -> Int { ... } @effects(publish: {OrderFill}) fn route(o: Order) { ... } ``` `none: {…}` forbids effect classes; `publish: {…}` declares the allowed publish set (exact, because the topic set is closed). The classes are `syscall`, `block`, `time`, `entropy`, `env`, `ffi`, `publish`, `spawn`, `recursion`. The `@no_*` family is **documented sugar**, desugared at parse time so the checker has exactly one shape to interpret (a flag can never drift from the general form): | sugar | means | |---|---| | `@no_syscall` | `@effects(none: {syscall})` | | `@no_block` | `@effects(none: {block})` | | `@no_ffi` | `@effects(none: {ffi})` | | `@no_publish` | `@effects(none: {publish})` | | `@no_spawn` | `@effects(none: {spawn})` | | `@no_recursion` | `@effects(none: {recursion})` | | `@deterministic` | `@effects(none: {time, entropy, env})` | All stack with each other and with `@hot` / `@budget(...)` / `@unbounded`, in any order (§ Decorator stacks), so the full hot-path certificate is one line: `@no_block @no_syscall @deterministic @no_recursion @hot @budget(alloc_per_call = 0)`. **Where each class's truth comes from.** `syscall` / `block` / `time` / `entropy` / `env` are queries against the **fully classified stdlib registry** — all 327 surface entries carry an `EffectSet` in `hale-types::stdlib_surface`, with zero unclassified residue (pinned by a test). The classification distinguishes *reading* an effect source from operating on a *supplied value*: `time_from_unix(n)` is deterministic while `monotonic_ns()` is not; `http::parse_request` is pure while `http::get` is blocking I/O. **A classification is a property of the call, never of a placement** (GH #791). `block` means "this waits", and the registry row says so unconditionally: `std::time::sleep` carries `block` on every pool, because on a classic pool it really does hold that pool's OS thread. It has to be unconditional — a locus TYPE is placed per *instance* (F.31), so the same method can run on an async_io pool for one field and a classic pool for another, and a free fn has no placement at all; a fn-grained certificate that depended on placement would have no single answer. So `@no_block` refuses a handler that sleeps even where that sleep parks, and the `.hale.effects` manifest reports `block` there. Where the worker *is* held is a question about a placement, and the one check that knows a placement — the placement-implied advisory below — is the one place that answers it. **Incompleteness fails closed, in both of its forms.** An entry present but *unclassified* is treated as may-do-anything and violates every assertion. So is a `std::` path with **no registry row at all** — the two used to be asymmetric, and that asymmetry was a soundness hole: an unclassified row failed closed while a whole unregistered namespace read as pure, silently certifying calls into it. "Absent" and "unknown" are the same claim, and neither can be certified. The **language builtins that write to a stream** (`println`, `print`, `eprintln`, `eprint`) are syscall-class. They are not `std::` paths, so they once sat outside the frontier entirely and a `@no_syscall` fn could print freely — while the diagnostic for `std::io::fs::*` described the syscall class as covering "stdio". Writing to a stream is a `write(2)`; it can block, and a hot-path certificate that permits it is not certifying what it claims. `ffi` matches bundle-local `@ffi` declarations. `publish` and `spawn` are **syntactic** — `Topic <- v` and `Child { … }` are effects the language expresses directly, recorded as effect *sites* on the summary rather than as call edges. `recursion` is a graph property. **Reachability follows handles, not just paths.** A call made on a value — `reader.slurp()`, `resolver.get(…)` — is a real edge, and the analysis resolves the receiver's declared type to walk into the method body. This includes the part of the standard library that is **written in Hale** (`hale-stdlib`): those bodies are fed to the callgraph, so their effects are *inferred from the implementation* rather than declared in a table that can drift. Witness paths through a stdlib locus are rendered in the public spelling (`std::cli::Resolver::get`), never the internal mangled name. This matters more than it sounds: the locus-with-methods shape is the idiomatic way to do I/O in Hale — the same shape a violation diagnostic recommends as the fix. Moving an effect behind a locus the asserting fn still calls does not make it unreachable, and the checker must not confuse the two. **Diagnostics carry the witness path** — the call chain from the asserting root to the offending leaf, which `@budget`'s fixpoint structurally could not produce: ``` effect assertion violated: `on_tick` must not reach `block`, but reaches on_tick -> helper -> nap [std::time::sleep — a blocking operation …]. ``` plus a second diagnostic at the leaf itself. **A leaf inside the stdlib is a note, never a line of your source** (2026-09-20, GH #856). The analyzer walks into the Hale-source stdlib, so the offending leaf is often a stdlib body — and that body parses in a coordinate space of its own, starting at offset 0, which is also where your first file starts. The leaf therefore carries no position in your seed, and no channel invents one: `hale check` prints it as ``` note: the `alloc` effect happens here (in the standard library, io_tcp.hl:118:18) ``` under the finding, `--json` emits it with `"file":""`, `"line":0`, `"col":0` and the stdlib location in its `message`, and the language server does not publish it as a document diagnostic at all — there is no range in the open file to give it. The witness path in the finding's own message is what names the route. Before this, the leaf's offset was tested against the seed's file windows like any other: past the seed's end it landed nowhere, and in a seed large enough to contain the number it was reported at that line of that file — a squiggle on code with nothing wrong with it. **Boundaries:** opaque callees outside the classified frontier are not seen (the same soundness boundary the escape analysis and `@budget` draw); `@ffi` labels are trusted, not verified; a computed publish subject cannot be proven in-set and is reported. `@no_panic` remains a separate track — disposition coverage + index-op selection, not leaf reachability. - **Quantitative budgets — `@budget( = N)`** (GH #265 step 5, 2026-07-29). `@budget` counts more than allocations now; dimensions compose in one clause, comma-separated: - `stack_bytes` — worst-case stack depth as a **DAG longest path** over estimated frame sizes. Acyclicity is the precondition, which is why this pairs with `@no_recursion`: a cycle reports *unbounded*. **What the estimate rests on** (#326, examined 2026-08-03). Frames are estimated from declared shapes: 32 bytes of call overhead, 8 per parameter, 8 per local — except an array local, charged its declared extent (`N × width(elem)`). That unit is close to right *because of Hale's memory model, not by luck* — structs and string/bytes buffers are arena-allocated, so a local is a pointer and almost nothing but scalars is ever on the stack. The same estimator in C would be wrong by orders of magnitude. The premise is pinned by tests (`hale-codegen/tests/stack_budget_premises.rs`) precisely because it is load-bearing: if a shape ever became stack-allocated, the estimate would silently under-count by the size of that shape. That is not hypothetical — it happened once. GH #767 moved a non-escaping `[c; N]` literal onto the frame (see `spec/memory.md`), which is why an array is now charged its extent rather than a pointer's 8 bytes. The charge is unconditional: an array that still takes the arena path is over-charged, which is the safe direction for a bound whose contract is "the real frame is no larger than this". Inlining, the other obvious worry, cuts the safe way: the model charges `CALL_OVERHEAD` per level of call depth and inlining removes levels, so it strictly reduces the term the model spends most of its budget on. **What the estimate does NOT cover.** Storage the optimizer introduces is invisible to any source-level model — register spills above all. We build `-O3` with `target-cpu=native`, so on an AVX-512 host a spilled vector register is **64 bytes** against a model whose unit is 8, and a vectorized loop can consume hundreds of bytes with no source local to explain it. Inlining amplifies this by merging live ranges, which is what produces spills — the risk is merged *pressure*, not merged locals. So: the bound is a **structural** estimate over declared shapes, not a machine-level guarantee and not WCET. It is sound against everything the frontend can see and silent about everything the backend adds. Treat it as a bound on *program shape* — call depth and declared locals — and not as a promise about bytes of hardware stack. Settling the latter needs post-codegen measurement (LLVM's `.stack_sizes` section), which is not wired up; `hale check`'s diagnostic already says "estimated from declared shapes", and this entry now matches that honesty rather than exceeding it. - `block_points` — how many blocking operations one call may reach (`0` is `@no_block`; `1` is "may wait once, on its own socket"). - `publish` — publishes per call. `@budget(publish = 1)` **is** the exactly-once-reply contract the issue sketched as `@replies`, falling out as a count rather than a bespoke analysis. - `fanout` — transitive subscriber **deliveries** one call causes, read off the bus graph. This is the amplification/backpressure property no per-fn count reveals: a handler publishing to a 200-subscriber subject amplifies 200×. Counted over RUNTIME REGISTRATIONS, per message, and transitively (GH #476 Change 5h). One `subscribe` declaration on a locus arranged with `replicas = 3` is three deliveries; a publish reaching a relay that republishes is charged its onward deliveries too, multiplied by the number of handler executions that reached it; and because one message carries one KEY, the recipients of a keyed publish are the ones that key selects — `where key == replica` costs one delivery, not one per replica. The maximum is taken over the key values the publish site can produce, and over the conformers of one interface dispatch, each scenario costed whole before any comparison. An unknowable count — a computed subject, a subscriber population the arrangement does not enumerate, a subject bound to a transport, an unknown key filter with a live registration — is *unbounded*, never a guess. A contributor inside a loop saturates to unbounded, matching `alloc_per_call`'s per-call semantics — except where the contribution is exactly ZERO, since repeating a publish that reaches no registration still reaches none. - **Phase-indexed effects — `@phase_effects(...)` on a locus** (GH #265 step 6, 2026-07-29). The lifecycle model expresses what a function-level effect system cannot: `@phase_effects(birth: {alloc}, run: {})` **is** the DO-178 "no dynamic memory after initialization" discipline, stated directly rather than assembled from two unrelated flags. Each phase names the classes it may perform (`alloc`, plus the `@effects` classes, **including declared user classes** — #392 closed the contract over the live class universe like `only:`, with the same atomic-only complement; the hardcoded built-in list was the documented deficiency); a phase omitted is unconstrained, a phase with `{}` forbids everything. Phases resolve to lifecycle hooks (`birth`, `run`, `drain`, `dissolve`, `accept`, `release`) or to any member fn / handler by name. - **`@no_panic` — disposition coverage** (GH #265, 2026-07-29). Deliberately *not* an effect class: this is a syntactic property of a body, not a query over the classified frontier. A fn asserting `@no_panic` must have no reachable trap — no explicit `violate`, no fallible expression dispositioned `or raise` (which propagates rather than handles), no trapping index form. `or discard`, a substitute value, and `or handler(err)` all satisfy it. - **The conformance loop — checking the checker** (GH #265 step 7, 2026-07-29). Static classification is a *claim*; the running binary is the oracle. `crates/hale-codegen/tests/ effects_conformance.rs` compiles programs carrying assertions, runs them, and samples the runtime's own counters (`std::diag::syscall_count` / `heap_alloc_count`) around the certified call: a fn certified `@no_syscall` that performs a syscall, or `@budget(alloc_per_call = 0)` that allocates, is **a caught soundness bug in the analysis itself** — the one class of defect that "the checker says what I expect" testing cannot find. A negative control asserts the oracle detects the effect when it genuinely happens, so the conformance checks can never pass vacuously. Same philosophy as GenMC-in-CI, applied to effects. - **The `.hale.effects` manifest + CI gate** (GH #265 step 7). The manifest is a **behavioural fingerprint**: every fn's declared contracts *and* its INFERRED effect set (`does={…}`), stable-sorted for diffs. `hale check --dump-effects-manifest` writes it; `--check-effects-manifest ` diffs against a committed baseline and **fails the build** when the program's effects change, printing which fn gained or lost what. A program whose check has an error has no manifest: either flag prints nothing on stdout and `check failed: no effects manifest` on stderr, nothing is diffed, and the exit code is the check's; warnings alone do not block it. That catches the case annotations cannot: a handler that quietly starts doing filesystem I/O shows up as `+ Api::emit … does={syscall,publish,alloc}` in review even though no annotation changed. Regenerate deliberately with the dump flag when the change is intended. Rows recurse through `module` declarations (GH #296 review: a module-contained fn absent from the rows was invisible to both this gate and `hale replay`'s safety admission); an inline-module fn the callgraph summarizer cannot yet resolve renders `does={unclassified}` — fail-closed, and scoped to modules so non-module manifests are unchanged. The inferred sets are the snapshot's effect rows, the walk `hale replay`'s live-effects gate reads too: a call into an imported seed resolves through the import renames (the names codegen resolves), so a fn's `does={…}` includes what it does through the seeds it imports. Before F.40 phase 3 the manifest's walk stopped at the seed boundary and under-reported those effects; an imported fn's own rows stay out of an app's manifest. - **Corpus-wide conformance** (GH #265 step 7). Beyond the per-test runtime oracle, a sweep over the whole in-tree `.hl` corpus asserts three properties everywhere real code lives: no reachable stdlib call lands on an **unclassified** registry row (an unclassified leaf silently weakens every assertion over it, so frontier completeness must not rot as the corpus grows); inference is **deterministic** (the same program inferred twice yields identical sets — otherwise manifests diff spuriously); and every corpus program carrying an assertion **satisfies** it. - **The `.hale.effects` manifest** (GH #265 step 7). `effect_manifest` / `render_effect_manifest` emit the whole program's declared contracts in a stable, sorted line format alongside `.hale.topo`. Declaration-only at v1 (inferring a full effect set per fn is the effect-rows-on-function-types slippery slope the issue defers); what it buys today is that an effect **regression** — a handler that quietly lost a contract — shows up as a one-line diff in review, the way an API break shows in a `.d.ts` diff. - **Cross-actor causality — `@effects(causes: {…})`** (GH #265, 2026-07-29). The call graph stops at a publish; the **bus graph continues**. Because Hale's message graph is declared over a closed topic set, "this handler, by publishing `orders`, can transitively cause a filesystem write in the audit subscriber" is a checkable property — the compiler walks publish sites → subject → each subscriber's inferred effect set. The diagnostic names the causal path (`Api::handle -> subject Orders -> Audit::on_order`). Only effects reached THROUGH the bus are reported; direct effects are the `none:` form's job. Actor systems without a declared message graph structurally cannot offer this. Judged over the canonical model since GH #476 Change 5f, which makes three things true that the earlier walk got wrong. A handler whose effects are not fully classified leaves the law **`uncertified` WITH its reason**, rather than reporting every class it never proved — and uncertified is reported, not silent, so a subject the walk cannot even start from (a body outside the analyzable universe) now says so. A class the walk DID prove survives whatever else is unknown, so a law a known effect already violates reads as violated. And delivery joins on WIRE IDENTITY: a literal `"t" <- …` send is judged exactly like the declared spelling it lowers to, an outbound binding makes the closure uncertified rather than silently ending, and a keyed publish that cannot meet a subscription's predicate is not a reach at all. - **Backward causality — `@effects(depends: {…})`** (#330, 2026-07-31). The dual of `causes:`. `causes:` walks the bus graph forward; nothing walked it backward, so an independence claim between two parts of a bus graph was unenforceable — a dependence routed through one republishing intermediary is invisible in every declaration on the depending locus, whose `bus {}` block names only the innocent subject it directly subscribes to. `depends:` is a COMPLETE declaration of the subjects that may transitively reach any of the locus's handlers, and the diagnostic names the path (`subject SumLookup -> Launderer -> subject Recalled -> StatedCarry`). **Locus-level**: dependence enters through subscriptions, which are declared per-locus, so a fn-level `depends:` is a parse error rather than a silent no-op. Opt-in on measured grounds — over a real application (428 topics, 114 loci) transitivity adds nothing beyond the `bus {}` block for 87% of loci, so a mandatory form would be redundant far more often than informative. The closure is over the **bus graph**: influence travelling outside it (see shared state below) is not part of it. Judged over the canonical model since Change 5g. The backward walk is SUBSCRIPTION-grained — it carries the actual `subscribe` rows, so an unrelated wildcard or a disjoint key filter on the same wire cannot manufacture an upstream — and it climbs reverse CALLS, so a publish inside a free helper belongs to every locus that can reach that helper. A stdlib interior that publishes (`std::log::Logger.info` computes `log.` and sends from inside its own body) is a publisher like any other. A declaration may name the topic or its wire subject; they address one endpoint. An operand naming nothing is `invalid` rather than a violation report about the subjects it failed to cover. And where the model cannot complete the walk — an inbound `listen` route, a computed publisher, a caller reached through a call it cannot follow — the law is `uncertified` with that reason, provided the residue is REACHABLE: an unfollowable call in a function nothing executes withdraws nothing. - **User-declared effect classes — `effect NAME;` and `@effects(is: {…})`** (#345, 2026-08-01). A program may name its own effect classes and have them propagated by the same engine, with the same witness paths: ``` effect money; @effects(is: {money}) fn charge(cents: Int) -> Int { … } @effects(none: {money}) fn price(n: Int) -> Decimal { … } // violates if it reaches charge ``` Grounded exactly like a built-in. The objection to user effects is that they have no frontier — that `@no_money` could only mean "no fn somebody remembered to annotate". But effects worth checking are about interaction with the outside, and that IS the frontier: money moves when the processor is called, the ledger row is written, the settlement is published. So `is:` adds rows to the classification that already exists rather than introducing a second kind. **The compiler owns propagation; the program owns classification** — the same split the stdlib registry has, with a different owner. Classes are interned as indices and occupy the free bits above the ten built-ins — `EffectSet` is a u64, so 54 are available. Overflow is an **error at the declaration**, not a saturating no-op: a class with no bit unions as PURE, so `none: {…}` on it would certify a fn that reaches a declared source. The analysis fails closed everywhere else and must here too. Classes cross seed boundaries. Each seed interns its own names from zero, so the merge unions the tables and rewrites each seed's indices before concatenating items — without that, two seeds' class 0 share a bit and a `none:` on one is checked against the other. - **An indirect call fails closed** (#353, 2026-08-03). A call through a function-typed parameter reaches the call graph as an unresolved callee, indistinguishable from a call to an unknown free fn — which contributed nothing. So `@no_syscall` on a fn whose body is `return f(v);` typechecked while the program performed the syscall, and `@budget(alloc_per_call = 0)` leaked identically. Every certificate the language offers ran through one hole, function pointers being the first genuinely open-world construct in the language. An indirect call is now treated as **may do anything**: unclassified for the effect classes, unbounded for the quantitative dimensions. Deliberately conservative rather than exact — Hale is whole-program and closed-world, so the target set IS enumerable and exact resolution is possible, but a certificate wrong in the safe direction beats one wrong in the other, so the conservative form lands first and precision becomes an improvement rather than a correctness fix. - **An indirect call reaches the program's function values** (F.40 phase 3, E5). An indirect call is a call through a function-typed parameter, through a local the analysis does not follow to one function (a binding chosen by a branch, a reassigned binding, one a loop reassigns, a binding read from a field or a call's result), or through a computed callee (`pick()(x)`). A function value only arises from a function's name read as a value — there is no lambda, and the FFI refuses function-pointer types — so the closed world enumerates what such a call can reach: the functions some expression of the whole program (its imported seeds and the standard library included) reads as a value — an argument, a `let`, a struct field, a params default, a return — whose parameter count is the call's and whose declared signature can be the callee's declared type (a function-typed parameter's, or every declared field of the name a local was bound from; a type alias or a generic matches anything). Each is judged as the call of that function, and a certificate names it: `@no_syscall` on a fn whose body is `let f = if c { pid } else { pure }; return f();` is violated through `pid`. The standard library's router is this case: `add_fn` reads its handler as a value, so a dispatch reaches the registered handlers of the route's type, and a clean handler keeps an exact certificate. A call no function value of the program can be — none of its type is ever read as a value, or the program reads a locus method as a value, which the analysis does not follow (and the build does not lower) — stays the **may do anything** call above. Until this, a call through a local the analysis did not follow was a call to nothing, and a certificate over one was false (a classified correction). - **Closed effect contracts — `@effects(only: {…})`** (#354, 2026-08-03). The dual of `none:`. `none:` forbids a listed set and permits everything else, which makes it **rot**: expressing "this handler only allocates" requires enumerating every other class, and adding a class to the language silently widens every such contract — 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 live class universe (the ten built-ins plus every declared user class). Nothing is written down that can go stale, so a class declared after the contract was written is outside it automatically. Rendered separately from `none=` in the manifest — a reader must be able to tell a closed contract from an open one, because they age differently. - **Composed effect classes — `effect NAME = { A, B };`** (#354, 2026-08-03). A class may be DEFINED as the union of others. A composed class owns **no bit**; its mask is its members', which yields both useful directions with no additional analysis: forbidding `io` tests against `syscall|block` and catches either, and a fn that reaches a syscall carries `io`. This also repairs a fail-open in `@deterministic`, which desugars to a hardcoded `none: {time, entropy, env}` and therefore could not see a user-declared class: `effect wallclock = { time };` puts the time bit in the class's mask, so the existing contract catches it with no new mechanism — and it stays opt-in, since an atomic class like `money` is correctly *not* swept into determinism. A definition cycle resolves to no effect at all, so every contract naming it would hold vacuously; cycles are rejected. - **Synchronized access is blocking and non-deterministic** (#340/#341, 2026-08-01). A `sync`-bearing form takes a lock, so a call reaching one contributes `block`, and a read through one defeats `@deterministic` — another pool can change the value between two calls with identical arguments. Attributed because **placement is not static**: once placement can be swapped at runtime, whether a mutex ever contends is undecidable at compile time, so a certificate reading "never blocks, we are single-pool today" would be invalidated by a later swap. A form with no `sync` discipline takes no lock and stays certifiable. - **Supervision coverage — `@supervised`** (GH #265, 2026-07-29). A locus marked `@supervised` asserts that every locus in its subtree has a failure policy in scope — an `on_failure` on itself or an ancestor. A locus with children and no policy above it is reported by name: a failure there has nowhere to go. The declared ownership tree makes this a tree walk; it is the static supervision-coverage property the actor world has wanted for decades. - **`@secret` params — a LINT, not a certificate**. A parameter declared `@secret name: T` reaching a bus publish or a log / file sink in the same fn body is reported as a **warning**. It is not a proof. "Must not reach a sink" is a whole-world property; this is a local walker over one body that follows no calls and tracks no aliases, and the fragment it walks is narrower still — `then` branches but not `else`, no `match`, no `let`, no assignment. Anything outside that fragment is absent from the result rather than surfacing as `uncertified`. The default traversal stays narrow deliberately: widening it fails programs that compile today, and a lint that grows teeth in a point release is a userspace break even when every new finding is a real bug. **`hale check --strict-secret`** runs the widened walk: every branch (including `else`, `else if`, and both block and EXPRESSION `match` arms), alias propagation through `let` and tuple destructuring, and `uncertified` for anything it cannot follow — an unfollowed call, a field store, a return. It is loud, which is the honest signal that one body's reasoning is not a containment proof. The expression walk is **exhaustive by construction**: it has no catch-all arm, so adding an `Expr` variant fails the build rather than opening a laundering route. Branch taint is shared rather than merged per-branch — imprecise in the over-tainting direction. **Composite rendering does not open a route** (GH #469). Making structs, tuples and arrays printable widens exactly the surface this lint guards, so the interaction is stated rather than left to be rediscovered: the walk keys on the *mention* of a tainted name and recurses through calls and binary operators, and an f-string desugars into a concatenation of `to_string` calls over the very expressions the author wrote. So `println(f"{creds}")` is flagged on the same grounds as `println(creds.token)` — the argument mentions `creds` either way. Whole-value rendering is not a new hole; it is the same hole at a coarser grain. The exclusions in the printable set (see `spec/semantics.md` § "Rendering values as text") carry the rest: a **locus never renders**, so there is no printable path to the `params` of a `@sealed` locus, which is the containment this section's guarantee half depends on. What is genuinely new and genuinely unguarded: a secret held in an ordinary `type` field with no `@secret` marker anywhere renders when its record does. That was already true of `println(creds.token)` — there is no taint to key on either way — so it is a limit of the marker, not a regression. For a guarantee rather than a lint, see § Secrets below. - **Inferred effect sets + symbolic cost** (GH #265, 2026-07-29). `frontier::infer_effects` computes the transitive effect set of ANY fn — no declaration required — which is what feeds the causality check and lets the `.hale.effects` manifest report inferred sets alongside declared contracts. (Effect rows on function *types* remain the deferred slippery slope; a manifest is a report, not a type.) `cost_expression` renders a structural cost — `O(n^k)` in nesting depth with a step estimate — explicitly **not WCET**: the first-filter triage shape, meaningful only for a fn already proven bounded. - **Placement-implied contracts — the assertion you don't write** (GH #265, 2026-07-29). A locus placed `cooperative(pool = X) where async_io` shares that pool's single worker; a blocking operation reachable from one of its handlers holds the worker and stalls every other locus on the pool. Since the placement already declares the intent, the compiler enforces it **with no annotation at all**: an unannotated handler on an async_io pool that reaches a blocking call gets a warning naming the chain and both fixes (move the work to its own pool, or assert `@no_block` to have it enforced as an error). Advisory rather than hard, because a lone locus owning its pool may block deliberately; writing an explicit assertion suppresses the advisory (the author is engaged, and the enforced error replaces it). This is the class of bug that shipped as a downstream latency mystery — a sleeping handler holding an engine pool — now visible at compile time. **The pools are the deployed root's** (F.40 phase 3, the entry's consumers). The async_io pools read are those of the `main locus` the build deploys, the entry row's root, the entry (`spec/semantics.md` § "The entry locus"), or a seed's refused module-nested `main`, which the check still judges; another `main locus` (rule 1's error, the entry being the last) and an imported library's are not deployed, and their `placement { }` entries imply nothing. **The leaf set is `block` minus what parks** (GH #791, 2026-09-20). Waiting stalls co-scheduled loci only if it holds the worker, and on an `async_io` pool some waits do not: the runtime swaps the coro out and the drain loop continues. Those leaves — `std::time::sleep` (the timer-only park), `io::tcp::{accept_one, recv_into, recv_stamped_into, __accept_one, __recv, __recv_bytes}`, `io::udp::{recv, __recv, recv_with_source, recv_into}` and `io::tls::{recv_into, recv_stamped_into}` — are exempt from THIS advisory (`stdlib_surface::ASYNC_IO_PARKING`, enumerated from the park lowering, each entry naming its runtime primitive). Everything else still warns, including the calls that open a connection (`tcp::connect`, `tls::connect`, `tls::upgrade`, and so `std::http::*`), file and stdin reads, `tls::recv_bytes` (a plain `SSL_read`; only the `recv_into` family got the park) and `std::process::{run, wait, read_stdout, read_stderr}`. The exemption is per-LEAF: a handler that both sleeps and blocks is still reported, with the witness naming the blocking leaf. Without this, the fix for the bug above (the timer-only sleep park) turned its own reproducer into a correct program that the advisory still flagged — and the fix it suggested, `@no_block`, is a compile error on that program. - **Hot-path allocation lint — default-on advisory** (2026-07-16). Two loop-scoped anti-patterns get a **warning** (never a build failure), so the allocation-free shape is the path of least resistance rather than expert folklore: (1) a **locus** (its own arena / heap buffer) or a `std::bytes::BytesBuilder` instantiated inside a loop — hoist it to a reused field; (2) an **allocating `recv`** (the `recv` family) in a loop — use `recv_into` with a reused `BytesBuilder`. Both accumulate in the method scratch until the enclosing method returns, and a `run()` read loop never returns. Loop-scoped keeps the signal clean (per-iteration is the unambiguous case); a plain value struct/type literal is not flagged (only loci and heap-bearing builders), and a per-invocation instantiation outside a loop reclaims at method exit. This is the conservative default advisory; `@budget` is the strict opt-in contract built on the same intent. Gap D extensions (2026-07-17): (3) a locus / `BytesBuilder` instantiated **anywhere in a bus handler** (not just a loop) — a handler runs per message, so a per-call instance is the ~4.5 KB/frame class; hoist it to a reused field. (4) **`accept` without `release` on a daemon-shaped locus** — declaring `release(c: C)` marks `C` a flow child (reclaimed when its `run()` completes); without it every accepted child is RESIDENT until the parent dissolves, so a parent whose `run()` loops forever (literal `while true` — the deliberately narrow daemon signal) grows O(accepted children). Run-to-exit accept examples stay silent. **The lint reaches inside `module { … }`** (GH #764, 2026-09-20). A module is a NAMESPACE, not an analysis boundary: the resolver registers a module's declarations under their bare names, so a fn or a locus method one brace deeper is an ordinary member of the bundle. Its bodies are walked, and its findings carry the same severity, as if it had been written at the top level — including the `@hot` promotion to a hard error. (Before #764 the lint stopped at the top level, so wrapping a program in a module silenced it.) **The lint is a law over the allocation summary's rows** (F.40 phase 3). It walks no body of its own: each finding is a row's site or call where it is written, read with the row's context (a bus handler, `@hot`, `@unbounded`), and the allocating `recv` family is the one list `@budget` reads. So it sees what the summary sees — a locus instantiated in a loop inside a publish's value or a bare `{ … }` block is a finding, and so is one inside an index expression's subscript, which the summary walks as it walks any operand. - **`@hot` — hot-path certification** (Gap D, 2026-07-17). The layered escalation between the default advisory and `@budget`'s counted ceiling: `@hot fn` certifies "this is a 10k/s-class path" and (a) **promotes the hot-path lint's findings inside that fn to hard errors** (prefixed `@hot:`), and (b) enables two stricter, perf-only hints that would nag as defaults: `.snapshot()` / `.finish()` in a loop or handler (each call copies the builder's full contents — prefer the zero-copy `.view()` / `.text_view()`), and a whole-struct replace of a direct self-field (post-Gap-A the replaced String clones retire, so this is no longer a leak — but each store still pays a clone + retire per heap field where in-place scalar mutation is allocation-free). fn-only; stacks with a following `@budget(...)`: `@hot @budget(alloc_per_call = 0) fn send(...)`. - **Anchor-retirement verdict flip** (Gap D, 2026-07-17). The item-1 survey's model learned what Gap A's runtime now does: a whole-field `self. = Struct { ... }` replace of a struct whose fields are all scalar / `String` reclaims at the enclosing method's activation boundary (the struct bytes memcpy in place; replaced String clones retire and recycle — RSS-validated flat over 1M replaces), so such a site invoked unboundedly is no longer reported. The conservative verdict stays for: structs with `Bytes` / nested compound / array fields (those leaves don't retire yet), stores directly inside a `run()`-loop (no activation boundary — pending retires never flush), and scratchless owners. **A `String` field replaced whole is store-latest** (GH #1490, step 4). A `self. = ` store retires the value it replaces (`lotus_str_assign_in_place`), and the field's next value reuses the block whatever its length: once a locus replaces a String field, the field's values take blocks from size classes of their own (16-byte steps to 128 bytes, then four classes per power of two, to 16 MiB), each block its class's whole capacity, recorded in the arena's capacity table so the retire knows the block's real size whatever value it last held; the activation-boundary flush files the block on its class's list, and the next value of that class takes it. A field's memory is bounded by its largest value (times the replaces one activation leaves pending), however its length varies. A struct replaced whole (the flip above) clones its top-level `String` fields into the same field blocks, so its "flat" holds at any lengths too. The survey models the store as it models that struct replace: a concatenation that is the whole new value of a `String` field of `self`, proven to carry nothing of that field (a replace, not an append), is `retired_store`, so invoked unboundedly from a frame with an activation boundary it is not reported; directly in a `run()` loop or a scratchless frame, and as an append, it is. Independence is proven by provenance, never assumed: the walk tracks which fields of `self` every binding may carry — what it was bound with and every write into it (whole, or through a field or an element), and, for a binding a loop writes, what every iteration writes into it (replayed to a fixpoint, since a later iteration's write reaches an earlier read) — and a value carries what its operands carry: a read of `self.` carries `f`, a free fn's or a builtin's call what its arguments carry (it reaches `self` only through them), a method of another receiver its receiver's and arguments', an `if` whose arms are values one of its arms'. A method of `self`, `self` itself, a call through a local, a `match`, an arm or block with statements in it, and any form the walk does not follow may carry every field. A parameter carries what its callers pass, over every call the survey sees, to a fixpoint (a value passed down a chain of calls keeps what it carries): from a call on `self`, what the argument carries (the same instance); from a call on any other receiver, every field when the argument carries anything of the caller's `self` (the receiver may be the same instance); a call the survey cannot resolve passing such an argument makes every method of that name, or (through a function value) every free fn, carry every field. A parameter a call leaves takes its default, which Hale evaluates in the caller's scope, so the call passes what the default reads there (`= old` with the caller's `let old = self.log` carries `log`); a call resolved only after the walk (an interface's conformer, a function value) leaves its defaults unknown, and they carry every field. A struct or locus literal's left fields likewise take their defaults where the literal is written. A parameter no call passes — a bus handler's message, a hook's argument — carries nothing, except that a locus publishing what it carries of its own `self` may be its own subscriber: every handler of that locus receives what the payload carries. Which subscription a publish reaches is not judged at all — not by subject (wildcards: `t.**` takes `t.next`; a transport's own pattern dialect; topics declared over subjects; computed subjects) and not by type (a transparent alias, `type Message = Tick`, names the same type). A locus none of whose publishes carries anything of its own fields keeps its handlers' messages fresh, and a message from another locus is taken as fresh. So `let previous = self.log; self.log = previous + "x"`, `self.log = self.previous() + "x"`, `self.log = wrap(self.log) + "x"` and `fn append(p: String) { self.log = p + "x"; }` called as `self.append(self.log)` are appends and reported, as `self.log = self.log + "x"` is; a value built from a handler's message, other fields, `to_string`, a free fn of those, or a parameter every caller passes such a value is a replace. The same rule judges a concatenation that is a whole field of a struct replaced whole, against the struct's field of `self`. Measured (`alloc_model_rss.rs`): 1M replaces in a method at lengths 6–55 stay at the 4.7 MB floor of near-constant lengths (before: 35.7 MB), a one-`String`-field struct at lengths 6–55 the same (before: 36 MB), values climbing to ~1 KB over 200k replaces the same; a bus handler, 500k messages at lengths 6–55, 5.1 MB (before: 20.9 MB). A field's first values, before its locus's first replace, and a value above 16 MiB take exact-size blocks through the general freelist and are retired at their length, as before. - **Resource-budget tracking (item 5)** — fully shipped, opt-in. A static **count** of pinned threads / cooperative pools / bus subjects / fd-acquisition sites (fd-opening calls *and* held-fd `Listener` / `Stream` instantiations) via `hale check --dump-resource-budget`; a **CI ceiling gate** `--check-resource-budget ` (fails the build when a count exceeds a declared ceiling); and **fd-leak detection** `--warn-resource-leak` (an fd-acquiring call whose result is stored resident in an unbounded context). See `notes/resource-budgets.md`. The fd-opening calls are `std::io::file::open`, `std::io::tcp`'s `connect`, `connect_wait`, `listen_socket` and `accept_one` (and the last two's `__` primitives), and `std::io::unix`'s `connect`, `connect_wait` and `listen_socket`; the tcp `connect_wait` was missing until F.40 phase 4, S5, so a program that calls it counts one more site per call. The budget counts the resource, not the declaration that asks for it, and reads the threads and pools from the placement table (F.40 phase 3, P1): - **OS threads** (`pinned_threads`) are the threads placement spawns, partitioned by the scope that creates each. A pinned anchor of the deployed root (a root field placed `pinned`, one per replica, so `replicas = K` is K) counts once per live occurrence of the construction that builds it: its count in one construction times that construction's bound, summed over the root's constructions. The alternatives of one choice take their maximum (one construction takes one), and replica rows are counted once, never multiplied by K again. A construction with no static bound (a root built in a loop, by a fn called in a loop, or by a recursive fn) makes the count **uncertain**, with the reason; an uncertain count is within no declared ceiling, and the gate fails with that reason. An adapter of the root's `bindings { }` is one thread, counted once whatever the root's bound. A nested instance runs on its anchor's thread and adds none. Only the deployed root's rows count: an imported `main` or a module-nested one, which lowering does not deploy, costs nothing. - **Cooperative pools** (`cooperative_pools`) are worker pools: one per named pool however many instances it holds, and an affinity is a property of its pool, never a thread. `main` is the program's own thread and never a pool, whether or not a program spells `pool = main`; the dump shows it on a line of its own. - **Not counted:** a transport binding's reader thread and the serve thread a stdlib transport's birth spawns are not placement facts. The dump names them on a line of its own, with the root's transport bindings counted, and never folds them into the thread total, which is no bound on all of a process's threads. The ceiling file is TOML; every key is optional (an absent key leaves that resource unconstrained, an unknown key is an error): ```toml pinned_threads = 4 cooperative_pools = 2 bus_subjects = 16 fd_open_sites = 8 ``` - **Closure-assertion lifting (item 3)** — scoped, deliberately parked. The tractable case (constant assertions) is already handled: typecheck rejects any closure whose assertion observes no runtime-varying value (pure literals *or* const arithmetic), so there are no constant closures to lift. The only liftable closures are ones provable from producer arithmetic (symbolic execution) — low-leverage for a niche feature, not built. Closures still verify their (runtime-observing) invariants at *runtime*. See `notes/closure-lifting.md`. The item-1 whole-program survey and the hot-path lint are advisory (warnings). The one build-failing *allocation* gate is opt-in: `@budget(alloc_per_call = N)` on a fn — you ask for the ceiling, and a violation is a hard error. fd and thread bounds remain advisory / CI-gated (item 5), not automatic build failures. Item 2 (race-completeness for substrate primitives) is a *substrate* quality bar, not a user-facing check: it model-checks the runtime's own concurrent primitives under all C11 interleavings with GenMC, run as a standing CI gate (the `genmc` job). Every substrate primitive with a cross-thread synchronization surface is now modeled: the lockfree hashmap's enter/drain/grow protocol, the pinned-locus mailbox monitor, the cooperative-pool bus queue's conditional lock, and the arena subregion-slot freelist lock. (The per-thread chunk pool needs no model — it is `__thread`, with no cross-thread access.) See `verification/`. ## Decorator stacks (GH #723) A fn's decorators are a LIST, not a slot. The **identity** decorators — `@ffi(...)`, `@export` — say what the declaration is, and each is exclusive with the other. The **contract** decorators — `@unbounded`, `@bounded_by()`, `@hot`, `@budget(...)`, and the effect assertions (`@effects(...)` and its `@no_*` sugar) — say what it promises, and *all of them stack, in any order*: ```hale @unbounded @no_syscall fn absorb(log: Lines, l: String) { … } @no_syscall @unbounded fn absorb(log: Lines, l: String) { … } // same thing @no_block @deterministic @unbounded @budget(alloc_per_call = 4) fn label(n: Int) -> String { … } ``` Order carries no meaning: a stack is a set of contracts over one fn, each checked by its own analysis. `@unbounded` suppressing the allocation advisory and `@no_syscall` proving no reachable syscall are independent claims, and a fn that accumulates deliberately while promising it touches nothing outside its arena needs to state both. (Before #723 each decorator parsed the `fn` itself, so a second one was a *syntax* error — which forced the workaround of wrapping `@unbounded` methods in a free `@no_syscall` fn purely to carry the second contract.) A **lifecycle hook** is the exception: `@unbounded run { … }` or `@bounded_by() run { … }`, once, and nothing else (a repeat or the pair is a parse error there, since a hook keeps no decorator list). A hook is one-shot, so a per-call ceiling has no calls to divide by, and it is not a fn a caller asserts over. Put the other contracts on a `fn` the hook calls. What a stack cannot do is contradict itself. These shapes are **errors at check time, reported at the decorator** (not parse errors — the declaration is well-formed, its meaning is not): - **The same contract decorator twice.** The second is either redundant or silently overrides the first, and either way the author wrote something they did not mean. The general `@effects(...)` is exempt: it is a set of clauses (`none:` / `publish:` / `is:` / …) and two of them compose, where a bare flag can only repeat itself. (A repeated *dimension* inside one `@budget(...)` is rejected in the same spirit.) - **`@unbounded` against a contract that forbids allocation.** Every conflicting pair is this one pair in three spellings: `@hot` (which promotes the very advisory `@unbounded` silences to a hard error), an assertion that forbids the `alloc` class (`@effects(none: {alloc})`, or an `@effects(only: {…})` that leaves `alloc` out — a closed set forbids what it omits), and `@budget(alloc_per_call = 0)`, the zero-allocation certificate. Each says there is no allocation to acknowledge; `@unbounded` says there is one and it is deliberate. A decorator that contradicts its neighbour cannot be enforced, and both together describe no program. `@bounded_by()` silences what `@unbounded` silences, so it meets the same three, reported under its own spelling. - **`@bounded_by()` beside `@unbounded`** (GH #1490). Both silence the same warnings and they say opposite things about the growth: the one names the input that bounds it, the other says it is not yet understood. Reported at the `@bounded_by`: keep it when the bound is known, `@unbounded` while it is not. A per-call ceiling **above** zero is not a contradiction: `@budget(alloc_per_call = 2) @unbounded` reads "at most two allocations per call, and their aggregate growth is intended" — a cache insert per call is exactly that shape. The two decorators measure different things (per call vs. in aggregate), which is why only the zero case collides. ================================================================================ ## Testing pipeline ## source: spec/testing.md ================================================================================ # Testing pipeline The testing pipeline is part of the language toolchain, not an add-on. `hale test` ships in the same binary as `hale build`. Test infrastructure exists from day 1 because the language's discipline (closure tests, k_max bounds, projection-class invariants, multi-perspective stability commit-rules) needs testing infrastructure to be enforced. `hale test` (Layer 1 + Layer 2), `hale bench` (Layer 3 single-language), `hale fmt`, and `hale doc` all ship in the CLI today (`lex` / `parse` / `check` / `run` / `build` / `test` / `bench` / `verify` / `fmt` / `doc` / `fetch` / `lsp` / `mcp`). Only `hale bench -compare` remains design-only below. `hale verify` runs `check`'s exact analysis surface but GATES: any finding — advisory or error — exits 1, making it the CI discipline gate (where `check` stays the fast advisory oracle: warnings print, only errors fail). ## Three layers of correctness Hale testing distinguishes three layers, each with its own tooling: ### Layer 1 — Language correctness *Does this program parse, typecheck, and have the meaning the language spec says it should?* - **Parser tests.** Given source, the parser produces the expected AST (or rejects with the expected error). Stored as `.hl` files paired with `.expected.json` (or similar) AST dumps. Driven by the grammar in `spec/grammar.ebnf`. - **Typechecker tests.** Given a program, the typechecker accepts or rejects with the expected diagnostic. Same shape as parser tests. - **Operational-semantics tests.** Given a program and inputs, the program produces the expected output. Driven by the operational semantics document (yet to be written). These run as part of `hale test` and as part of compiler CI. A compiler regression should be caught here. ### Layer 2 — Mathematical / framework correctness *Does the framework's discipline hold for this program?* The language's job is not just "compile the source" — it's "refuse to compile a program that violates framework discipline." The framework's commitments need test infrastructure: - **k_max bound verification.** For every locus, the compiler computes `k_max = B / [(1 − phi) * c + phi * sigma]` and checks that no `accept` call site can exceed it. Tests assert the compiler rejects over-budget call sites. - **Closure-test existence.** For every `closure name { ... }` block, the compiler verifies the cycle exists (both sides of `~~` reference defined values within the same scope). Tests assert the compiler rejects cycles that don't close. - **Projection-class invariants.** A locus declared `projection rich` cannot be instantiated with N > rich's bound; etc. Tests assert mismatches are rejected. - **Multi-perspective stability commit-rules.** A `perspective` with `stable_when |perspectives| >= 3` cannot be serialized with fewer perspectives validated. Tests assert violations fail at runtime. - **Substrate-derivation discipline.** When a value carries anchor metadata, anchor-self-consistent uses are flagged. Tests assert anchor-self-consistency triggers a warning or error per declared policy. - **Vertical-only flow.** Lateral references between sibling loci are compile-time rejected. Tests assert the compiler rejects sibling-to-sibling access. These tests are written in Hale itself. The standard library provides `assert(...)`, `assert_rejects(...)`, `assert_closure(...)` and similar primitives. Test programs are valid Hale programs; the framework discipline applies to them too. ### Layer 3 — Performance *Does this program meet its declared performance envelope? And how does it compare to equivalent implementations in other languages?* #### Single-language benchmarks A benchmark is a function annotated with `bench` (TBD: grammar extension, or stdlib function with a magic name like Go's `Benchmark*`). The runner invokes it for a measured number of iterations; reports time-per-op, allocations-per-op, memory high-water. ``` fn bench_hello() { Hello { }; } ``` Output is JSON-serializable for CI consumption. Baselines are checkpointed in version control; regressions produce a diff the developer must explicitly accept. #### Comparative benchmarks (Hale vs. other languages) These are **internal development tools**, not published results. Their purpose is to give the team visibility into the language's performance shape as it evolves — to catch regressions, validate that framework-discipline overhead is in the expected range, and spot when a design choice is costing us order-of-magnitude throughput. A benchmark file declares its equivalent in another language as a sibling: ``` // bench_message_passing_test.hl // // @external_equivalents: // - lang: go // path: ./equivalents/message_passing.go // - lang: rust // path: ./equivalents/message_passing.rs ``` The runner builds and runs each; reports a comparison table. The author writes the equivalent however they think is fair for the comparison they want — there is no "fairness review," because nothing is being published. The numbers are useful to us; they don't need to be defensible to outsiders. Useful comparative-perf categories for internal use: - **Coordination-overhead.** Many-message-passing scenarios vs. Erlang / Go. - **Region-allocation throughput.** Allocation / deallocation rate vs. GC'd languages. - **Closure-test overhead.** Same program with closure tests on / off — measures the cost of the framework discipline. - **Mode-projection.** Same kernel computed three ways (bulk / harmonic / resolution) vs. a hand-written per-N-implementation in another language. Comparative results are not gatekept; any branch can produce them and stash them in `bench-results/` (gitignored). A regression in hale-vs-X ratio is a developer signal, not a CI gate. #### Performance regressions in CI Every benchmark has a stored baseline (numerical envelope, not a fixed value — a tolerance band). The runner asserts current runtime is within band. Bands tighten over time as the compiler improves; widening a band is an explicit, reviewed action. ## Test file layout ``` project/ ├── src/ │ └── *.hl // production source └── tests/ ├── unit/ │ └── *_test.hl // unit tests, by module ├── integration/ │ └── *_test.hl // multi-locus integration tests ├── bench/ │ └── *_bench.hl // benchmarks └── equivalents/ // external-language equivalents for ├── go/ // comparative benchmarks ├── rust/ └── erlang/ ``` Or, alternatively, Go-style: `*_test.hl` lives next to the source it tests. Both layouts are supported; the runner finds tests by suffix (`_test.hl`) regardless of location. ## Toolchain commands | Command | Purpose | |---|---| | `hale build` | Compile source → executable / library | | `hale check` | Static checks: parse, typecheck, framework discipline | | `hale test` | Run all `*_test.hl` files in the project | | `hale test -run pattern` | Run matching tests only | | | (`hale test` applies the same `hale.toml [ffi]` csrc/link pickup as `hale build`, so tests importing FFI-bearing libs link — 2026-07-18) | | `hale bench` | Run all `*_bench.hl` files (see below) | | `hale bench -compare` *(planned)* | Build and run external equivalents alongside | | `hale verify` | Layer-2 discipline gate: `check`'s full analysis, ANY finding fails (no execution) | | `hale fmt` | Canonical formatter (Go-style: zero config; see below) | | `hale doc` | API reference from `///` doc comments (Markdown / `--json`; see below) | | `hale describe ` | A served exposure's description (GH #1417, `spec/api.md` § The description): members, streams, schemas, the caller and the roles it holds; an endpoint is a socket path, `http://host:port` (the caller named by `--token`) or `ws://host:port` (a hub), and the document is the bytes the exposure serves; from a program it is `hale check --api` (the inventory, or one exposure for `--exposure NAME --caller P`) | | `hale call [json]` | Read the description, call one member naming its digest, print the response; a handler error or a refusal on stderr with exit 1, `--receipt` for the answer as the exposure wrote it | | `hale watch ` | Subscribe to a stream of a hub; one JSON line per frame (`subscribed`, `event` with its `seq`) | | `hale admin [--port n]` | A local page over the description, every action one request to the endpoint | | `hale mcp --app ` | A served exposure as an MCP server: the members the caller may call as tools; an `mcp://host:port` endpoint (an `mcp::Rpc` listener) is forwarded | `hale test` runs Layer 1 + Layer 2 today; `hale bench` runs Layer 3's single-language half. `hale test` builds every test binary into one **private per-run scratch directory** — a fresh directory under the temp directory, mode 0700, created so that a name someone else already holds is refused, not adopted — and removes it, with the objects codegen wrote beside each binary, when the run ends on any path: all pass, a failure, a test that does not compile. Nothing is written next to the sources. `hale run`, `hale replay` and `hale bench` build the same way, into a directory of their own. A binary that is to be kept is `hale build -o ` (spec/projects.md). This replaces predictable names in the shared temp directory (`hale_test___`, `hale_run_`, `hale_replay_`, `hale_bench_`); a tool that looked for one of those paths while a test ran no longer finds it. `hale test` compiles and runs its files **in parallel**: up to N at once, N being `-j N` / `--jobs N` if given, else `HALE_TEST_JOBS`, else the number of available cores; `-j 1` runs them one after another. The job count changes the wall time and nothing else a reader sees. The report is printed once every file is done, one `ok` / `FAIL` line per file in sorted path order with each failure's detail beneath its own line, then the summary; `--json` rows come in the same order; the exit status is the same. A test's stdout and stderr are captured with its own run, so one test's output never lands under another's line. The runner changes no process-wide state: every test binary starts in the cwd and with the environment `hale test` was started with — plus `HALE_BIN`, the path of the `hale` running the tests, when the caller did not set it, so a test that builds a program of its own (a child it signals, a tool it drives) builds it with the same toolchain (2026-09-24, GH #1039) — and a vault of its own. Each test file runs with `HALE_VAULT_DIR` set to a fresh directory, `/hale-test-vaults-/-` (mode 700, under a root the runner refuses unless this user owns it), removed when the file ends, and `HALE_VAULT_ADDR` unset. What a test draws or reads through `std::secret` never reaches the developer's vault, and no test reads a secret the developer holds (2026-09-28; before it, a DNA fixture run by hand drew a throwaway organization's entries into `~/.cache/hale/vault`). A test that needs a second vault sets `HALE_VAULT_DIR` on a child it starts; a child that is itself `hale test` gives each of its files one of its own again. `HALE_TEST_KEEP_VAULT=1` keeps every file's vault and prints where, to look at what a test provisioned; a run killed before it removed its vaults has them swept by the next. A test that shares a fixed resource with another test — a port, a path, a record — is a test that races under the default; a fixture takes its port and scratch directory per run (2026-09-22, GH #1009; before it the files ran one after another). `check` and `verify` resolve the whole import graph, and a parse failure **anywhere** in it — the target's own files, a library it imports, a library that library imports — fails both, at the offending file's own line and column, with the same exit status and `--json` row any other error gets. That is the contract a gate rests on: `check` never reports success on a tree `build` would refuse (2026-09-19, GH #765; before it, an imported seed that failed to parse left its declarations silently absent and both commands answered clean). The `--json` half of that contract reached the target's OWN files on the same day (GH #777): the parse path predated the JSON reporting path and printed text to stderr, so a syntactic failure in the seed being checked exited non-zero with an empty stream — a gate saw a failure with nothing explaining it. Every parse diagnostic is now a record like any other. An input that could not be READ answers the same way. A target that is not there, a `.hl` file of the seed that will not open, a file of the import graph that will not open: each is one record, `"kind":"io error"`, naming the path with the OS error as its message and no position — `"line":0,"col":0`, because a file that never opened has no text to be positioned in (2026-09-20, GH #806; before it these printed a sentence on stderr and left `--json` empty, so an environment failure and a crash were the same thing to a gate). The text rendering is unchanged, and so is every command without a machine-readable channel. The ENTRY file of a build is in that set too. A `*_test.hl` that will not open is a `hale test --json` row with `"status":"fail"` whose `message` is the `could not read : ` sentence, not an empty string (2026-09-20, GH #903; that one site still printed at the failure and handed its caller nothing, so the row reporting it carried no reason and the explanation went to stderr). The **position** is not command-scoped. Every command that resolves imports — `build`, `run`, `test`, `bench`, `replay`, as well as `check` and `verify` — reports a diagnostic from an imported file as `path:line:col: kind: message` with the offending source line and a caret, at that file's OWN line and column. Each file of an import graph is parsed at its own virtual base so the merged spans stay globally unique; un-shifting by that base is part of reporting, not a property of one command's reporting path (2026-09-20, GH #775; before it, the commands with no `--json` channel rendered the bundle offset against the file's own text, so the file name and the message were right and the line and column were not). Nor is the **path**. A diagnostic names its file by the file's canonical path — absolute, symlinks resolved, with no `.` or `..` component — in the text rendering, in a `note:` secondary location, and in the `file` field of a `--json` record (the positionless `io error` record above included), from every command, for the target's own files and for every file reached through an `import` alike. That is one string per file rather than one per channel, which is what a consumer joining the two needs: `check` used to print the canonical path an imported file was recorded under while `build`, `run` and `test` printed it as the resolver reached it (`/abs/app/../lib/second.hl`), and the target's own files were named exactly as the command line spelled them, `..` and all (2026-09-20, GH #822). `hale test --json`'s rows follow the same rule for the same reason. A row says which test RAN rather than where an error is, but a tool joining those rows to `check --json` records on `file` needs one string per file, and the row used to echo the command line — so `hale test ../app/x_test.hl` and a `check` of the same seed named that file differently (2026-09-20, GH #867). The human-readable `ok ` / `FAIL ` lines keep the spelling the command line used: they are read beside the command that produced them. Nor is the **kind** of failure. A refusal raised by CODEGEN rather than by the front end — a construct the checker accepts and the backend does not support, a missing toolchain component the program needs — is a located diagnostic like any other whenever it carries a span: `path:line:col: codegen error: message`, with the offending source line and a caret, from `build`, `run`, `test`, `bench` and `replay` alike, byte for byte the same line from each. A codegen refusal with no span to point at prints `codegen error: message` and nothing else, from all of them. These commands have no machine-readable channel, so this is a text contract only; `check` never reaches codegen and is unaffected (2026-09-20, GH #848; before it only `build` used the span, and the rest printed the error's Rust debug form — `UnsupportedAt("…", Span { start: Pos(55), end: Pos(60) })` — so `hale run` could refuse a program without naming a line to open). `bench` names the bench file itself: it compiles the file with the synthesized driver appended, read in place of the file, with no temporary copy (2026-09-30, F.40 phase 2.2b; before it `bench` compiled a copy it wrote beside the file and deleted, and re-labelled the copy's positions). Nor, finally, is the **rule set**. The checks that need the whole program — the F.18 bare-callee rule and the bare-identifier and bare-type-name rules that follow it (`spec/types.md` § *Calls to bare names*) — are on for every command that HAS the whole program: `check` and `verify` on a seed or a file, `build`, `run`, `test`, `replay` and `bench`, which compile exactly what they bundle, and `hale lsp`, which loads the whole seed as `hale check ` does, imports followed, and typechecks it only once every member has read and parsed. A call to a name nothing declares is therefore `path:line:col: type error: call to X: no free fn, generic fn or fn-pointer binding with that name is in scope` from all of them (2026-09-20, GH #911 B1 / #846; the callee half was off on the build path, so the front end said that and the backend answered `codegen error: unsupported in codegen v0: call to X: …` — no file, no line, no caret, and a did-you-mean over compiler-internal symbols. Two answers to one question, and the useful one was the one the build did not give). `hale check ` is given what `hale build ` is given — the file and its own imports, no sibling — so it answers as the build does (2026-10-11; it kept a permissive reading for a sibling's name until then, and accepted the file its build refused). Every command that compiles checks what it compiles the same way, from one load: the seed and its imports, shaped by one sequence (the `--env` constitutions, the desugars), with one set of form rows (each `@form`'s `sync` discipline, sync inference's included), and checked with the build's rules, and a check that reports an error is the command's failure before anything is lowered (2026-09-30, F.40 phase 2.2b). Before it each command had its own copy of that pipeline, and they differed: `hale bench` typechecked nothing, so its only answers came from codegen; `run `, `test` and `replay` ran no sync inference before their check; and `run ` bound no `--env`. ## `hale bench` — the Layer-3 runner `hale bench [file | dir]` discovers `*_bench.hl` files (dir walk, `vendor/` and dot-dirs skipped); every **zero-param free fn named `bench_*`** is a benchmark. The runner appends a synthesized driver `main` (a bench file must not define its own), checks and compiles the result at the release profile with the same `hale.toml [ffi]` pickup as build/test, and runs it; a check error is the file's failure, and a warning is not printed. The driver self-calibrates Go-style: batch sizes grow ×10 until one batch takes ≥100 ms, then the final batch reports **ns/op** and **allocs/op** (`std::diag::heap_alloc_count` deltas; shown as `-` in sanitizer builds where the counting shim is absent). `-run ` filters by bench name; `--json` emits one record per bench (`file`/`name`/`iters`/`ns_per_op`/`allocs_per_op`) for CI. Benchmarks may print their own output — non-report lines pass through. Still planned from the original design: stored baselines with tolerance bands as a CI gate, and `-compare` external-language equivalents. The `fn bench_*` magic-name convention (Go's `Benchmark*` shape) is the resolved answer to the "grammar extension or magic name?" question above — no grammar change. ## `hale doc` — the API-reference generator Zero config. `hale doc [file | dir]` renders a seed's API reference from `///` doc comments (the convention in spec/tokens.md): every public top-level declaration — fns, loci (with params and their documented methods), types, topics, interfaces, consts — with its signature and doc text. Markdown to stdout by default; `-o ` writes it; `--json` emits one record per declaration (`file`/`kind`/`name`/`signature`/`doc`/ `members`) for tooling and agents. `__`-prefixed names and `main` are internal and skipped; a file that doesn't parse is reported and skipped with exit 1. Doc text is recovered positionally (the lines directly above the declaration, stepping over decorator lines), so the lexer and AST are untouched. `hale doc --stdlib` renders the `std::` surface instead: the rename table supplies public paths, the bundled stdlib source supplies decl shapes + `///` docs (mangled param types demangled; internal-typed params hidden), and the typecheck signature table supplies the C-primitive-backed free fns that have no `.hl` decl. The spec/stdlib.md tables remain the canonical CONTRACT; the generated reference is the browsable companion, and stdlib declarations grow `///` docs namespace-by-namespace (metrics, log, and BytesBuilder are done). ## `hale fmt` — the canonical formatter Zero config, Go-style: there are no options that change the output. `hale fmt [paths]` formats `.hl` files in place (no path = the current directory tree; `vendor/` and dot-directories are skipped); `--check` lists files that would change and exits 1 (the CI gate); `--diff` previews without writing; `--stdin` filters stdin→stdout for editor integration. What canonical form means (a token-stream formatter — the author's line-break structure is PRESERVED, gofmt-style; there is no max-line-length enforcement): - **Indentation** — 4 spaces per bracket depth. A closing bracket returns to its opener's line indent; brackets opened together on one line indent their contents once. Bracket-less continuation lines (a leading `&&`/`.`, a trailing binary operator on the previous line) get one extra level. - **Spacing** — canonical pair rules: binary operators spaced, unary `-`/`!` tight to their operand (a `-`/`+` is unary where no operand ends before it; a reserved word right after `.` is a field name and ends one, so `err.closure + " "` stays binary — GH #1064), `.`/`::`/`..` tight, nothing inside `(` `)` `[` `]`, literal braces spaced (`Rec { key: 1 }`, `{ }`), `:` tight-left (except the spaced `locus X : serves P` conformance colon, per this spec's own examples), generic angles tight (`Holder`), lifecycle parens tight (`run()`). - **Blank lines** — collapsed to at most one; none at file start; exactly one trailing newline. Intra-line alignment padding (`let x = 1;`) collapses to single spaces. - **Comments** — preserved verbatim in position: own-line comments indent with the code, trailing comments sit one space after it. Safety: the formatter re-lexes its own output and refuses to write unless the semantic token stream is byte-identical to the input's — a formatter bug can mangle whitespace, never what the compiler sees. Files that don't lex are reported and left untouched. Formatting is idempotent; the corpus test (`hale-syntax/tests/fmt_corpus.rs`) holds every fixture example and stdlib source to both properties. ## Test assertion library Provided by `std::test`. Not a separate testing framework; the language's stdlib includes test primitives. ### v0.1 (sealed m87, m88) Three primitives, all written purely in Hale: ```hale fn main() { std::test::assert(2 + 2 == 4, "trivial arithmetic"); std::test::assert_eq_int(answer(), 42, "answer"); std::test::assert_eq_str(greet("world"), "hello, world", "greeting"); } ``` The test-runner contract is exit-code based: - **Pass** = exit 0 with **no stdout**. A test program that runs to completion silently has passed. Stderr is not inspected: a test may write progress or diagnostics with `eprintln` and still pass. - **Fail** = non-zero exit code with `ASSERTION FAILED: ` (and, for `assert_eq_*`, `expected: X / actual: Y`) on stdout. The first failure short-circuits. A `.hl` test program is just an ordinary Hale binary. #### What runs after a failed assertion (GH #717) A failing assertion prints its diagnostic, **records** the failure and returns. It does not terminate the process from inside the assertion. What happens next is fixed: 1. **Nothing else in the test body runs.** Control leaves `fn main` at the failing assertion's call site. Every later `std::test::assert*` is also a no-op against the recorded failure, so there is never a second `ASSERTION FAILED` line and never a second diagnostic. 2. **`fn main`'s ordinary teardown runs**, exactly as it does on a normal return: cooperative-pool workers are joined, the bus queue is drained, and every locus `let`-bound in `main` before the failing assertion dissolves — reverse declaration order, child cascade, `dissolve()` bodies and all (`spec/memory.md` § Lifetime rules, § Drain cascade). A locus born *after* the failing assertion never existed and is skipped. 3. **The process then exits non-zero** (code 1), so the exit-code contract above is unchanged. That is the whole mechanism. It is **not** exception unwinding: a failed assertion is not a value, not catchable, and does not propagate through arbitrary frames. Two consequences follow, and they are the contract, not accidents: - A test that must release something — a subprocess it spawned, scratch state it created, a socket, a lock file — releases it in the `dissolve()` of a locus the test `let`-binds in `main`. That is the supported cleanup route, and it cleans only what the test owns. There is no runner-owned cleanup hook, and none is needed. - An assertion that fails *outside* `main`'s own frame — inside a free `fn`, a locus method, an `on_failure` body — has no main teardown to return through and still terminates the process immediately with code 1. Put the assertions that guard owned resources in `main`. **Process kill is a separate case.** `SIGKILL` (and a hard crash) cannot be intercepted by anything: no `dissolve()` runs, no atexit handler runs, and a child or scratch directory the program owned outlives it. That is a property of the platform, not of the assertion path — a test whose resources must survive a `kill -9` of the runner needs an external reaper (a process group, a cgroup, a supervising harness), and Hale does not provide one. A failed assertion and a `return` from `main` run the dissolve cascade; a runtime panic runs atexit-registered cleanup but not the cascade; `SIGKILL` runs nothing at all. ### The compiler's own Hale-language suite `tests/hale/` holds `*_test.hl` programs that test the language in the language, run by `hale test` and wired into the workspace suite (`crates/hale-cli/tests/hale_native_suite.rs`). Run them directly with `hale test tests/hale`. A behaviour test belongs there rather than in a Rust integration test when it is "run a program, check what it computed". Two things change in the move: - **The expectation stops being transcribed.** The Rust form compiles a program that *prints*, then substring-matches the output from another language. The Hale form asserts next to the code — no second copy to drift. It is also stricter: `assert_eq_int(n, 42)` rejects what `stdout.contains("a=42")` accepts, because the latter also passes on `a=421`. - **The program gets typechecked.** The harness build the Rust codegen tests call (`build_source` in `crates/hale-codegen/tests/support/build.rs`) loads and lowers but never gates lowering on the checker, so those programs are compiled and executed without ever being checked. What stays in Rust is anything asserting on *compiler output* rather than program behaviour: diagnostics, IR shape, leak counts, observation records. ### What landed vs what's still aspirational | Surface | Status | |---|---| | `std::test::assert(cond, msg)` | sealed m87 | | `std::test::assert_eq_int(actual, expected, msg)` | sealed m87 | | `std::test::assert_eq_str(actual, expected, msg)` | sealed m87 | | `assert_neq` / `assert_neq_int` / `assert_neq_str` | not shipped | | `assert_rejects(...)` (compile-time errors) | not shipped — needs compiler-level surface | | `assert_closure(name, tolerance)` | not shipped — needs closure-test introspection | | `mock_locus(...)` | not shipped | | `bench_iter(n, f)` | not shipped | | `hale test` CLI runner | shipped — discovery→compile→run→report driver over `*_test.hl` (`-run`, `-j`, `--json`), files in parallel | ## Determinism **Single-pool execution is deterministic.** A program whose loci all run on the main cooperative scheduler — no `placement` pinning, no additional pools — produces the same publishes and the same deliveries in the same order on every run, given the same inputs. This is a guarantee, not an observation: one pool is one consumer thread by construction (`spec/runtime.md` § Scheduler — the same invariant dispatch devirtualization rests on), handlers run to completion, and the main-thread bus queue drains FIFO, so there is no scheduling freedom for an order to vary within. "Given the same inputs" is load-bearing: time, entropy, environment, FFI returns, and socket reads are inputs, and a program that consumes them may compute differently even though its scheduling cannot reorder. Whether a body reaches those inputs is a compile-time question — `@deterministic` (`spec/verification.md` § Effect assertions) asserts it, and `frontier::infer_effects` answers it without any annotation. Pinned: `crates/hale-codegen/tests/replay_determinism.rs` compares the complete ordered `BUS_PUBLISH`/`BUS_DELIVER` record sequence of a multi-locus cascade across repeated runs. A divergence there is a runtime bug, never a flaky test. Multi-pool programs get no ordering guarantee across pools today: each pool's single consumer thread has a well-defined per-consumer delivery order, but the interleaving between pools is OS scheduling. Recording that per-consumer order (and replaying it) is the record/replay track — GH #296. ## Property-based testing Reserved as a future extension. The language's strong type-and-discipline surface makes property-based testing particularly natural — you can declare properties that should hold for all inputs and let the runner generate counter-examples. Not in the v0.1 stdlib. ## Continuous integration The toolchain emits machine-readable output: - `hale test --json` produces JSON test results (per-test pass/fail, per-test timing, error messages). - `hale bench --json` produces JSON benchmark output (per-bench time, allocations, comparative table if `-compare` given). - `hale check --json` produces JSON diagnostics. CI consumes the JSON; standard reporters (JUnit XML, GitHub Actions annotations, etc.) are downstream conversions. ## What writing this surfaces (for resolution) 1. **`bench` annotation: keyword, attribute, or naming convention?** Go uses `BenchmarkName`. Rust uses `#[bench]`. Hale has neither attributes nor magic-name conventions yet. Decision pending; probably an attribute (`@bench fn ...`) added to the grammar in v0.2. 2. **Determinism.** ~~Does the runtime need deterministic scheduling for benchmark consistency?~~ Resolved for the single-pool case: it already has it, by construction — see § Determinism above. Deterministic *re-execution* of multi-pool programs is the record/replay track (GH #296); forcing deterministic scheduling in production is an explicit non-goal there. 3. **External-language toolchain access.** `hale bench -compare` needs `go`, `rustc`, `erlc`, etc. on PATH. Documenting this clearly is dev-experience work. ================================================================================ ## Design rationale ## source: spec/design-rationale.md ================================================================================ # Design rationale For each major syntactic construct in the Hale grammar, this document records: 1. **What the construct does.** 2. **What the framework commits the design to.** (The closed-graph evidence that constrains the design.) 3. **What the syntax commits to.** (The specific surface choices and what they imply.) 4. **What was considered and rejected.** The goal is that a future reader understands not just what the grammar parses but *why* it parses that and not something else. --- ## Foundational axiom: types are for shapes, loci are for flow Hale commits to a clean two-primitive split at the declaration level: - **`type`** — a static record. Pure shape. Fields, names, layout. Returnable by value, equal by value, no projection modes, no contracts, no birth/run/dissolve. - **`locus`** — dynamic flow. Lifecycle (birth → accept → run → drain → dissolve), contracts (expose / consume), bus participation (publish / subscribe), projection (resolution / harmonic / bulk views over the same instance). If a thing has lifecycle, it is a locus. If it is pure data, it is a type. There is no third category at v0; the split is clean. **Recursive principle.** Loci are the fundamental building block at every layer of an Hale program: an app is a locus; a library namespace is a locus (empty `params { }`, only methods — the namespace-locus pattern); a long-running service is a locus; a goroutine-equivalent is a locus; a bus subscriber is a locus; an HTTP-handler is a locus; a cache / pool / pipeline / queue is a locus. **Inside any locus, behavior is itself a locus tower one layer down.** The recursion bottoms at primitive operations (arithmetic, single field reads, primitive calls). Everything above the floor is loci nested in loci. This axiom underlies most of the per-construct rationale that follows: every section answers some shape of *"why is this piece of locus syntax in the language?"* The answer, in every case, is that flow needs lifecycle / contracts / projection / recovery, and locus is the syntactic surface those four attach to. Type declarations need none of that — they are pure shape — so they have a separate, much simpler surface (see also `spec/types.md`). Full design note: `notes/hale-types-vs-loci.md`. --- ## Design discipline: substrate invariance Hale is written largely by agents and read largely by humans, which makes "who is at the keyboard?" an available argument in every design discussion. It is not a valid one, and this section exists so that it has a standing answer rather than being relitigated each time. **The rule.** A language decision is justified by what it does for *reasoning*, never by who is expected to do the reasoning. "An agent will not mind the ceremony" and "a human needs something more familiar" are both out of order. **Why it holds.** The founding claim — that systems outgrow working memory, so the language should hold the architecture — names no substrate. Locality, one-construct-one-meaning, and a diagnostic that reports the actual witness path all reduce what must be held in mind to be correct. Working memory is bounded for every reasoner; the bound differs, the shape of the constraint does not. Where a real difference between agents and humans does exist, it is in **acquisition cost** — typing volume, memorising unfamiliar syntax, discovering an API without completion. Those are tooling concerns (`hale fmt`, the LSP, `hale doc`), and no language-level question turns on them. Capture semantics, sequence ownership and effect propagation are not decided by keystroke count. So the differential lives outside the region where design decisions are made, and ignoring it costs nothing. **Why the rule is load-bearing rather than merely tidy.** Designing toward either substrate fails in a specific way. Assume agents absorb friction and the result is a language nobody can audit — which forfeits the entire point of making architecture checkable. Assume humans need familiarity and the surface converges on whatever is already popular, losing the model. Refusing to reason about the substrate forces every feature to justify itself on reasoning grounds, which is the only justification that survives contact with the next maintainer anyway. **The evidence is in the defect record.** Every substantive correctness bug found in the effect system has been a reasoning-support failure that misleads any reader equally: - a manifest rendering every user effect class as ``, so two distinct classes produced identical baseline lines and a real change could diff to nothing; - `@no_syscall` and `@budget` passing through an indirect call, so a certificate read as true while the effect occurred; - a corpus gate scanning a directory that had moved, so a guarantee misled by continuing to exist. None of those are worse for one kind of reader. A false certificate is false to whoever relies on it. **Corollary.** The same rule disposes of the inverse argument. Ergonomic work — better diagnostics, completion, fewer gratuitous surfaces — is not a concession to human users to be weighed against rigour. It is reasoning support, and it earns its place on the same grounds as everything else here. --- ## 0. Surface language: Go-shaped **Commits to.** Familiar syntax for engineers; braces for blocks; semicolons as statement terminators; `let` for binding; `fn` for functions. **Why.** The first authors of programs in this language are agents and humans collaborating, often already fluent in Go. Surface familiarity reduces cognitive cost and lets the genuinely-novel parts (lifecycle, contract, projection class, mode, closure) carry the unfamiliarity. **Considered and rejected.** - *Lisp-shaped (S-expressions).* Maximally machine-friendly but high friction for human review. Agent-first does not require agent-only. - *ML-shaped (let/in, type inference everywhere).* Type inference is a weak match for the explicitness the framework wants, and ML's syntax is unfamiliar to the team. - *Indentation-significant (Python).* The off-side rule interacts badly with multi-line block expressions and closure assertions; explicit braces are simpler. --- ## 1. ASCII-only source, names instead of Greek **Commits to.** `phi`, `sigma`, `B`, `c`, `k_max`, `sum`, `prod`, `approx` / `~~`. No Unicode operators in source. Renderer can produce Greek for human display; the source is ASCII. **Why.** The ancient texts' named-concept registry already commits to: source uses names, renderer produces symbols. Hale inherits this. Agent-first authorship benefits from no symbol-input friction. Tooling is simpler. **Considered and rejected.** - *Allow Unicode operators (`Σ`, `≈`, `φ`).* Reject because: source becomes editor-dependent; tooling becomes harder; no semantic advantage; the renderer pipeline already exists. --- ## 2. Locus declaration ``` locus Fitter : tier 4, projection chunked { params { ... } contract { ... } bus { ... } birth(...) { ... } accept(child: Strategy) { ... } run() { ... } drain() { ... } dissolve() { ... } on_failure(c: Strategy, err: Error) { ... } mode bulk(...) -> ... { ... } mode harmonic(...) -> ... { ... } mode resolution(...) -> ... { ... } closure pnl_attribution { ... } } ``` **Commits to.** A locus is the unit of declaration. Annotations (tier, projection class) are optional; default tier is inferred from nesting; default projection class is `chunked` if the locus declares `accept` and the compiler cannot statically determine N. Lifecycle members, mode declarations, failure handlers, and closure tests all live as members of the locus body, not as separate top-level declarations. This keeps the framework's "every locus is its own substrate-cell" commitment syntactic: everything about a locus is in its block. **Why.** Every locus needs a stable identity for the compiler to reason about. The locus *is* the unit of memory region, of lifecycle, of closure-test scoping, of contract — making it the syntactic block keeps these aligned. Tier and projection-class annotations are optional because the framework's own discipline permits inference (multi-perspective stability doesn't require hand-declaration; learned values are commit-after-N=3). **Considered and rejected.** - *Locus as a struct with attached methods.* Reject because the framework's lifecycle states are not equivalent to methods — they're state-machine transitions the parent invokes via policy. They need their own keyword surface so the compiler can enforce ordering and the runtime can dispatch. - *Tier and projection class as separate top-level decorators (`@tier(4) locus L`).* Reject; decorators imply meta-level manipulation we don't want. Annotations go inline with the declaration, with `:` as the annotation separator. - *Implicit lifecycle (no keywords; just a `loop()` function).* Reject; explicit lifecycle states are how parent-policy-driven recovery becomes language-native. Without `birth`/`drain`/ `dissolve` as distinct constructs, recovery primitives have no hooks. --- ## 3. `params` block (also: locus state) ``` params { B: int = 1_000_000; c: int = 1000; sigma: int = 10; phi: float = 1.0; capital_usd: decimal = 1_000_000.00d; running_sum: int = 0; inferred_param: int : inferred; } ``` **Commits to.** Each param has a name, a type, and either a value (compile-time-evaluable expression) or `: inferred`. The compiler treats hand-declared values as priors and `inferred` values as to-be-determined (statically by the compiler if possible, otherwise at runtime via the lotus runtime's perspective-stability machinery). **`params` is also the locus's state.** Following Ruby's `@foo` pattern, Hale collapses the params-vs-state distinction. The declared params are simultaneously: 1. *Birth-time defaults*: overridable at instantiation (`Aggregator { running_sum: 100 }`). 2. *Runtime mutable state*: accessible and reassignable via `self.foo` throughout the locus's lifetime, from any lifecycle method, mode block, closure, or member function. There is no separate `state { ... }` block. A locus's state is its params; its params are its state. **Why.** Multi-perspective stability is the framework's commit discipline. Hand-declared values are perspectives the author provides; `inferred` is "no perspective yet, system finds one." Collapsing params and state means the same surface is both the declared-perspective surface and the running-state surface; no artificial barrier between them. Aligned with how Erlang processes hold state (one mutable bundle per process) and how Ruby instance variables work (`@foo` is both a parameter and an instance variable). **Considered and rejected.** - *Separate `state { ... }` block from `params { ... }`.* Reject; introduces an artificial distinction between "declared at birth" and "mutable at runtime" that the framework doesn't make. The framework's substrate-cell view treats locus state as one thing. - *Make every param a literal (immutable).* Reject; loses the Ruby-style ergonomics; a long-running locus needs mutable state and forcing a separate state mechanism is ceremony. - *Use option types instead of `inferred`.* Reject; an `Option` param can be present-or-absent at runtime, but `inferred` is a compile-time / runtime determination promise. Different semantics, different syntax. --- ## 4. `contract` block ``` contract { expose position_size: decimal; expose pnl: decimal; consume book: Resolution; consume volume: Bulk; } contract: inferred ; contract { expose explicit_field: int; expose inferred ; consume inferred ; } ``` **Commits to.** `expose` declares fields visible to coordinators above; `consume` declares typed dependencies on coordinatees below. Either may be `inferred`. A contract may be entirely explicit, entirely inferred, or mixed. **Why.** This is the contract-graded visibility commitment from the design conversation. The contract is what mediates access between L's region and C's sub-region — physical layout is hierarchical, logical access is contract-mediated. Making this syntactic keeps the visibility rule auditable: a reader knows exactly what a locus exposes and what it depends on without having to read the whole body. `inferred` lets the compiler synthesize the contract from the locus body (compile-time inference) or learn it from runtime observation (NN-style inference). The framework's discipline guards against bad inference: a learned contract must satisfy closure tests and respect substrate-derivation anchoring. **Considered and rejected.** - *Contract derived only from the body, no syntactic block.* Reject; explicit contracts are an audit surface. Even if the compiler can infer them, declaring them tracks the author's intent and lets later changes to the body be checked against the original commitment. - *Single keyword (no expose/consume distinction).* Reject; vertical-only flow needs the up/down direction marked. A symmetric contract obscures the asymmetry of the design. --- ## 5. `bus` block ``` bus { subscribe "fitter.observation" as on_observation of type Observation; subscribe "fitter.kernel.updates" as on_kernel of type KernelUpdate; publish "fitter.drift" of type DriftReport; } ``` **Commits to.** External typed message bus is a first-class declarative surface. The grammar names subscriptions and publications without committing to a specific bus implementation (NATS, Unix sockets, shared memory, UDP multicast). The runtime binds the bus block to the actual transport at link / startup time. **Why.** The running example needs UDP multicast input. Future programs will need NATS, Kafka, or other transports. Declaring the bus interface in source means the language can typecheck the messages flowing in/out without committing to a specific runtime. This also enables the perspective-shipping contract between fitter and applier binaries — both compile from the same Hale source, both have type-level agreement. **Considered and rejected.** - *Functions as message handlers, no `bus` block.* Reject; without a syntactic bus surface, the compiler can't typecheck the outbound subjects (which are runtime-resolved string paths). The block makes them static. - *Bus declarations at top level instead of inside locus.* Reject; the bus interface is a property of a specific locus (its inbound flow filter), not the whole program. --- ## 6. Lifecycle blocks ``` birth() { ... } accept(child: Strategy) { ... } run() { ... } drain() { ... } dissolve() { ... } ``` **Commits to.** Five named lifecycle states, declared as parameterized blocks within a locus. `birth` is invoked once at locus instantiation; `accept` is invoked on each coordinatee attachment; `run` is the steady-state loop; `drain` halts new work but lets in-flight finish; `dissolve` frees the region. **Why.** Failure-recovery is parent-policy-driven, and recovery primitives (`restart`, `quarantine`, etc.) need named states to operate over. The compiler enforces the state machine. Missing transitions get compiler-supplied defaults (e.g., default `dissolve` frees the region; default `drain` waits for inflight messages). **Considered and rejected.** - *Implicit lifecycle (just `init`, `loop`).* Reject; recovery needs distinct named transitions. - *Lifecycle as trait/interface (`impl Lifecycle for L`).* Reject; we don't have traits in v0, and the lifecycle is so central to the language that making it a stdlib trait would bury it. Keywords keep it visible. --- ## 7. `mode` declarations ``` mode bulk(input: [Book]) -> VolumeProfile { ... } mode harmonic(input: [Book]) -> StrategyProfile { ... } mode resolution(input: [Book]) -> SingleDecision { ... } ``` **Commits to.** Three modes (bulk / harmonic / resolution) are language-native; user defines any subset; compiler emits one implementation per declared mode. **Why.** Modes are a substrate primitive from the ancient texts — the commitment that one kernel has three projections. Making them syntactic means the compiler can: - Generate optimized code per mode (vectorization for bulk, per-class projection for harmonic, lazy / tail-only access for resolution). - Verify mode-projection invariants (e.g., bulk + harmonic reconstruction equals identity within tolerance — a closure test in itself). - Allow callers to request a specific mode by name. **Considered and rejected.** - *Modes as functions (`fn bulk(...)`).* Reject; modes are tied to a specific kernel/locus, and the locus block needs to know about them for type-system reasons (the kernel's signature is shared across modes). - *More than three modes.* Reject; the framework explicitly commits to three. Other shapes are not modes. - *Fewer than three required.* The grammar allows zero, one, two, or three to be declared. The framework permits a locus that doesn't operate in resolution mode (e.g., a pure aggregator has only bulk). --- ## 8. `on_failure` handler ``` on_failure(c: Strategy, err: Error) { match err { Error::Timeout(_) -> restart_in_place(c); Error::Corruption(_) -> quarantine(c) for 30s; Error::Capital(_) -> bubble(err); _ -> dissolve(c); } } ``` **Commits to.** A locus declares one `on_failure` handler that the runtime invokes when a coordinatee fails. The handler receives the failed coordinatee and the error, and chooses among recovery primitives. **Why.** The framework's failure-traversal commitment is vertical-only (failures flow up to the parent), and the parent makes the policy decision. Locus-attached `on_failure` is the syntactic home for that policy. Per-coordinatee-class overrides live in the contract (`consume` member with a per-class `on_failure`); not yet in v0 grammar but reserved. **Considered and rejected.** - *Multiple `on_failure` handlers (per error type).* Reject for v0; one handler with `match` is sufficient and avoids handler- selection ambiguity. - *Implicit failure handling (default `dissolve`).* The compiler *does* default to `dissolve(c)` if `on_failure` is omitted, but the keyword is mandatory whenever any non-default policy is wanted. The default is conservative (simplest cleanup). --- ## 9. `closure` blocks ``` closure pnl_attribution { sum(intent.pnl) ~~ sum(book.realized_pnl) within 0.05d; epoch tick; persists_through(restart_in_place, quarantine); resets_on(restart); } ``` **Commits to.** A closure test is a structural audit declared at a locus. The first non-clause in the body is the assertion: two expressions and a tolerance. Subsequent clauses control epoch boundaries and recovery interaction. **Why.** Cyclic-closure is a substrate primitive from the ancient texts. Making it syntactic enables: - Compile-time verification that the cycle exists (both sides of the `~~` reference defined values; the runtime accumulates both within the same scope). - Runtime band-checking with named epochs. - Recovery-event-aware accumulation (epoch resets / persists). The `~~` operator is reserved for closure assertions only (per precedence.md); using it elsewhere is a parse error. **Cycle-existence rule.** A closure assertion must observe at least one runtime-varying value. An assertion whose left and right are both pure literals (no identifiers, no `self`, no calls) is a compile error: the result is fixed at compile time and the closure can't audit anything. This is the first narrowing of the cycle-existence rule; the deeper version (left and right reach a common producer through some causal chain) requires the typechecker to track param-to-param dataflow and lands in a later iteration. Field references inside closure assertions resolve through the strict locus surface (params + methods + `self.children` + `self.k_max`); a typoed field name like `self.greting` is a compile error rather than a silent `Ty::Unknown` slip. **Considered and rejected.** - *Closure tests as library calls (`assert_closure(...)`).* Reject; without language support the compiler can't verify cycle existence statically, and recovery-event handling becomes a runtime convention rather than a language feature. - *Inline closure assertions instead of named blocks.* Reject; named tests are auditable (the audit log references closure names) and reusable across loci that share a structural cycle. --- ## 10. `perspective` declarations ``` perspective Kernel { params { scale_row: [decimal; 8]; sigma_factor: decimal; regime_id: int; } stable_when { // Held to be stable when ≥3 perspectives have validated // and the closure tests at the producing locus pass. return num_validated >= 3 && closure_status == ok; } serialize_as KernelV1; } ``` **Commits to.** In the transport-driven hot-load model (the aspirational path — see `semantics.md` § "Perspective hot-load"; the *shipped* perspective is the in-process contract + slot), a perspective is a serializable parameter bundle within a shared compiled-in schema. Both producer (fitter) and consumer (applier) compile from the same Hale source, so the type *is* the contract; the bus carries only parameter values. **Why.** This is the fitter/applier split: one process fits parameters from observations; another applies them at high frequency. The serialization format isn't a separate concern — it's the perspective type. Compile-time type agreement between binaries means no protocol-versioning handshake; the schema version is the source-code version they both compile from. `stable_when` is a function-level boolean expression that the runtime evaluates to decide whether a perspective is ready to ship. This puts multi-perspective-stability into the source. **Considered and rejected.** - *Perspectives as tagged structs.* Reject; the `stable_when` function and the `serialize_as` annotation are intrinsic to the perspective concept. A tagged struct loses these. - *Run-time-only perspective negotiation (versioning protocol).* Reject; the perspective is the contract, the source is the schema, no negotiation needed. --- ## 11. Region-based memory, no GC (No grammar surface — this is semantic. Documented for posterity.) **Commits to.** Each locus has a private memory region. C's region is a sub-region of L's region. Allocation within a region is locus-scoped; dissolution frees the region wholesale. No garbage collector; no borrow checker. **Why.** The framework's recursion property gives the hierarchy for free. Hale's locus-lifecycle methods give the deterministic free-points. The contract block gives the access discipline. Together they give region-based memory management without the inference problems that have historically made region-based MM hard (Tofte-Talpin region inference is hard; here, the hierarchy is explicit in the source). The projection class is a **perspective-resolution commitment** — a declaration of what observation granularity the locus serves to perspectives one tower up. Storage strategy is downstream of that commitment, not the commitment itself: - **`rich`** — fine-grained. Perspectives address individual children by name; each child carries its own state worth observing in detail. Storage consequence: per-locus arena per child, low churn, freed on dissolution. - **`chunked`** — mid-grained. Perspectives operate over chunks or ranges; the parent commits to "I'll serve observations at chunk resolution." Storage consequence: per-locus arena with per-coordinatee sub-regions, freed on each coordinatee dissolution. The sub-region shape is what makes chunk-level observation cheap. - **`recognition`** — aggregate. Perspectives operate over the population, not individuals — "represent as a curve," "represent as a histogram," "represent as a count." At this resolution, individual child types stop mattering; the perspective consumes the population's structural shape. Storage consequence: pre-allocated fixed pool (or shared slab); cell stride derived from the accept-method type union; no dynamic allocation in steady state. The compiler picks the allocator based on the locus's declared resolution. The resolution choice is what's load-bearing; the allocator is its implementation. **Considered and rejected.** - *Garbage collection.* Reject; latency-sensitive systems can't afford GC pauses, and Hale's locus structure obviates the need. - *Rust-style ownership/borrow checker.* Reject; Hale's hierarchical regions provide the ownership structure implicitly. Ownership tracking is unnecessary. - *Reference counting.* Reject; same as GC but worse latency characteristics. Region dissolution is wholesale and deterministic. --- ## 12. Sum / product reductions in the grammar `sum(expr)`, `prod(expr)` are language-native primary expressions rather than stdlib functions. **Why.** Closure assertions reference `sum` constantly. Putting the reduction operators in the grammar means: - They have well-defined precedence (higher than any binary op). - The compiler knows they're aggregations and can reason about them in closure verification. - Capacity computations (Σ over coordinatees) are syntactically unambiguous. Other reductions (`min`, `max`, `count`) are stdlib. `sum` and `prod` are special because they appear in framework-primitive expressions. --- ## 13. Time and duration as language-native types ``` let t: time = `2026-05-08T12:00:00Z`; let d: duration = 5s; let timeout: duration = 100ms + 50us; ``` **Why.** Trading and any closure-test-with-band system needs time and duration as first-class. Making them lexical avoids the "is `5s` a string or a duration" ambiguity and prevents unit-confusion bugs. --- ## 14. `decimal` as a primitive type **Why.** Floating-point arithmetic is wrong for money and any other fixed-precision domain. Hale makes `decimal` a primitive distinct from `float`, with semantics matching the `shopspring/decimal` Go library. Decimal literals use the `d` suffix (`1.50d`). --- ## 15. Generics and projection-class generics `Rich`, `Chunked`, `Recognition` are not stdlib types; they're language-native generic constructors. The compiler recognizes them and selects the appropriate allocator / implementation based on which projection-class wrapper a value carries. **Commits to.** A user can write code parametric in projection class: ``` fn process(input: P) -> P { ... } ``` The compiler picks the body specialization based on `P`'s concrete instantiation. **Why.** This is the "same source, different generated allocator" commitment. Without language-level projection-class generics, the user would have to write three nearly-identical functions for `Rich`, `Chunked`, `Recognition`. With them, one function compiles to three. --- ## A. Locus instantiation and handles (Added in v0.1.1, after the hello-world example surfaced this.) A locus is instantiated using struct-literal syntax: ``` let h = Hello { greeting: "hi" }; Hello { }; // unbound; locus dissolves at statement end ``` The compiler distinguishes locus instantiation from struct construction by what `Hello` is declared as. The semantic difference is significant: - A struct literal allocates the value and assigns its fields. - A locus instantiation allocates a *region* inside the enclosing locus's region, invokes `birth()` synchronously, and returns a typed handle. `birth()` runs to completion before the instantiation expression returns. If `birth()` panics, the runtime emits a failure event that the parent's `on_failure` handles (or defaults to process exit at the runtime root). When the handle is bound (`let h = ...`), the locus lives until `h` goes out of scope (at which point default `drain` and `dissolve` are invoked). When the handle is **unbound** (`Hello { };` as a statement- expression), the rule depends on whether the locus has any **ongoing-work surface** beyond birth: - **Ephemeral.** Only `birth` + `params` (or just `params`). The locus dissolves at the enclosing statement boundary. Hello-world's `Hello { };` is the canonical case. - **Long-lived.** Has `run`, *or* has bus subscriptions, *or* has mode declarations callable from outside, *or* otherwise exposes a surface that can be invoked post-birth. The locus becomes an *anonymous child of the enclosing scope*; its work proceeds until the enclosing scope dissolves it (typically via SIGINT-triggered drain cascade). Examples: 01's `Ticker { n: 3 };` (run), 05's `Echo { };` (bus subscriptions). The rule generalizes: a locus is long-lived iff it can do something *after* birth completes. If birth is all there is, it dissolves at the statement that birthed it. This means every function scope is itself an implicit locus (see §D below). Anonymous children of a scope dissolve before the scope returns — same rule as bound handles, just without a name. Multiple bindings of a handle are not yet specified. v0 punts; expected: handles are move-only (Rust-shaped), so `let h2 = h;` transfers ownership and `h` is no longer usable. Reference counting is rejected (no GC, no ARC). ## B. The `self` keyword (Added in v0.1.1.) Inside a lifecycle block (`birth`, `accept`, `run`, `drain`, `dissolve`), a mode block (`mode bulk`, etc.), or a closure block, the keyword `self` refers to the enclosing locus. `self.greeting` accesses the `greeting` param; `self.position` accesses a contract-exposed field; etc. Outside these contexts (in free `fn` bodies, in `const` decls, in top-level expressions), `self` is a parse error. Considered and rejected: - *Implicit access to params by name.* `greeting` instead of `self.greeting` from inside a lifecycle. Reject; risks collision with locals; agent-first prefers explicit. - *`this` instead of `self`.* Reject; `self` is more aligned with Rust / Python and avoids the C++/Java baggage. ## D. Function scope as implicit locus (and lifecycle methods are not) (Added in v0.1.2 from 01-locus-with-run; refined in v0.1.3 from 02-parent-child.) **Free `fn` functions have implicit loci.** Every `fn main()`, `fn helper()`, etc. has an **implicit locus** at its scope. Locally-bound handles and anonymous children of the function body are children of this implicit locus. The function returns when: - The function body's last statement completes, AND - All children of the function's implicit locus have dissolved. For `fn main() { Ticker { ... }; }`, the implicit `main` locus has one anonymous child (the ticker). `main` cannot return until the ticker's `run()` has completed (or been drained). This makes "main returns when its work is done" the natural semantics without requiring explicit `wait()` or `join()` calls. **Lifecycle methods do not have their own implicit locus.** `birth`, `accept`, `run`, `drain`, `dissolve`, `on_failure` are not regular functions — they run *as the locus*. Children instantiated inside a lifecycle method attach to the enclosing locus, not to a fresh implicit scope. ``` locus Coordinator { accept(g: Greeter) { println(g.greeting); // reads child's exposed state } run() { // Greeter { ... } here: child of Coordinator, // NOT of run()'s scope. accept() will be invoked. Greeter { greeting: "hi" }; } } ``` This distinction matters because the framework's "locus is the unit of region" commitment means lifecycle methods can't have their own region — they ARE the locus. The implicit-function-locus model also underwrites SIGINT handling: SIGINT triggers `drain()` on the runtime root locus (which contains main); the drain cascades to main's implicit locus, which cascades to its children. See F.4 (drain cascade). ## C. Default lifecycle methods (Added in v0.1.1.) When a locus omits a lifecycle keyword, the compiler supplies a default: - `birth()` default: no-op. - `accept(c)` default: register the coordinatee in the locus's registry; no policy. - `run()` default: empty steady-state — wait for messages or signals, dispatch to handlers as declared. - `drain()` default: stop accepting new work; wait for in-flight to complete. - `dissolve()` default: free the locus's region wholesale. - `on_failure(c, err)` default: `bubble(err)`. The runtime root's default `on_failure` is process exit with stack trace. A locus with only `params` and `birth` (like the hello-world program) is fully valid; the compiler fills in the rest. ## E. `mut` keyword and immutable-by-default bindings (Added in v0.1.2.) Bindings are **immutable by default**. `let x = 0;` produces an immutable binding; reassignment `x = 1;` is a compile-time error. `let mut x = 0;` produces a mutable binding; reassignment is permitted. This matches Rust. Considered and rejected: - *Mutable by default (Go).* Simpler surface; loses the discipline of marking mutation. Lotus's framework alignment prefers explicit-mutation marking. - *No mutation; recursion only.* Pure but awkward; while-loops with counters are natural and the `mut` annotation is cheap. - *Allow rebinding via shadowing.* Confusing; doesn't help with loops (inner `let i = i + 1` shadows in inner scope; outer loop never advances). Mutability is a per-binding property, not a per-type property. A `let mut x: int` is mutable; the `int` type itself is not "mutable" or "immutable." This avoids the type-level mutability machinery seen in some languages. --- ## The locked design commitments have moved The **F-series** — the locked design commitments (what each construct commits the design to, what was considered and rejected, and which are superseded or still sketches) — is an append-only decision log, now in its own file: [`decisions.md`](./decisions.md). This file keeps the *current* conceptual rationale; `decisions.md` is the decision/history layer. ================================================================================ ## pond — real-world gotchas & conventions ## source: pond/CLAUDE.md ================================================================================ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What this repo is `pond` is the Hale language's "non-std std lib" — opinionated, domain-shaped contrib libraries that apps vendor inline. It sits between `runtime/stdlib/` (always-loaded substrate) and one-off app code. Each lib is its own seed (one directory of `.hl` files, per Hale's F.19 per-directory model); consumers vendor the whole pond repo and import only the libs they need: ```hale import "vendor/pond/sqlite" as db; import "vendor/pond/agent/llm" as llm; ``` There is no monorepo-level build. Each lib type-checks independently with `hale check ` (libs have no `fn main()`, so `hale build` on a bare lib dir errors — that's expected). Each example builds with `hale build /examples//`. The `hale` CLI binary is produced from the upstream `hale-lang/hale` compiler repo and assumed on PATH; keep it fresh — a stale CLI silently uses old lowering (the CLI warns; rebuild with `cargo build -p hale-cli --release`). ## Authoritative documents (read these before editing) - **`README.md`** — catalog of every lib by tier, with suggested aliases. - **`CONTRACTS.md`** — locked public API surface for every lib. *This is binding.* If you implement a lib, your code must match the surface here. If a constraint forces a deviation, log it in the lib's section of `FRICTION.log` and reflect it in the dated status note at the top of CONTRACTS.md (currently `## 2026-08-03 status note`). - **`FRICTION.log`** (repo root) — the single consolidated friction log, one section per lib (consolidated 2026-06-12; the per-lib `FRICTION.md` files are gone). Records deviations from the contract, blocking gaps, upstream bug repros, and proposed stdlib unblocks. Closed entries stay in place as the historical record. Cross-reference as `FRICTION.log § pond/` plus the entry title. - **`COMPILER-BUGS.md`** (repo root) — the handoff file for the compiler team: *only* live, reproduced compiler defects, each with a self-contained repro, an expected result, an observed result, and the compiler commit it was verified against. Nothing else belongs in it — no design notes, no triage, no workaround logs (those stay in `FRICTION.log`). Re-verify every entry against the pinned CLI when re-baselining, and delete entries as they close. - **`/README.md`** — the as-built surface for that lib (may differ from CONTRACTS.md while deviations are open) and, where relevant, a "When this unblocks" recipe describing the cleanup pass to perform when an upstream gap closes. ## Hard rules from the broader Hale workspace These come from the upstream compiler repo's AGENTS.md and apply here: - **Don't edit `crates/` in the compiler repo.** If a primitive is missing, work within the existing surface or log it as friction — never reach into compiler territory to add it. Note: `@ffi("c")` is a *library-author* surface that never touches `crates/` — binding a system C library (sqlite, etc.) is in-bounds for pond. The former "sqlite chain is BLOCKED on a stdlib primitive" premise was refuted upstream (WS4, 2026-06-11): `pond/sqlite` is now a real `@ffi` driver and no pond lib is architecturally blocked today. - **No `panic` / `assert`.** Every failure routes through `fallible(E)` (value channel) or closure violation (structural channel). Bridge value→structural with the `closure NAME { captures: ...; epoch inline; } / violate NAME;` pattern from `spec/styleguide.md § 7`. Since 2026-06-12, `violate` is accepted inside lifecycle bodies (the old codegen rejection lifted). - **Two-channel rule** (`spec/semantics.md § "Where each channel lives"`, narrowed in v0.8.1 / open-question #24). User-declared `fn` member fns on a locus CAN declare `fallible(E)` (value + heap-bearing payloads, full `or raise` / `or ` / `or handler(err)` / `or discard` disposition surface). What stays rejected: **substrate-facing surfaces** — lifecycle methods (`birth` / `run` / `accept` / `drain` / `dissolve` / `on_failure`), mode methods (`bulk` / `harmonic` / `resolution`), closure assertions, and bus-subscribed handlers (rejection fires at the subscribe site). The repo-wide migration to fallible member fns is DONE (2026-06-08 pass, completed 2026-06-12); `logfmt`'s sinks remain non-fallible by design (they satisfy the non-fallible `std::text::Sink` interface). ## Compiler-shape rules and gotchas These shape non-obvious code in this repo. The first is a permanent language rule; the rest are current-compiler behaviors with the live workaround. **Baseline: hale 0.16.0 @ `37914e5` (2026-08-12)** — re-probed that day, everything below still holds and pond needs no source change against it (30/30 seeds check clean, 49/49 tests, 38/38 examples build and run): - **Methods may not return locus values (m90 / #18.6 CQRS rejection — permanent).** Factories that return loci are **free fns** (`mat::zeros(...)`, `metrics::counter(reg, ...)`, `nn::forward(...)`). This is the *inverse* of the old G3/G4 note ("free fns can't return LocusRef") — that gotcha is retired. - **G34 is CLOSED (upstream WS3.4, 2026-06-11).** Pond libs can import other pond libs — including `_util/*` from tier libs — and instantiate their types/loci by qualified literal (`util::KvPack { }` in expression position works). The **re-export barrier still holds by design**: an end app must import a lib itself to name that lib's types. Existing local-copy duplication may be collapsed opportunistically, but it isn't mandatory — `tui` evaluated importing `term` and declined (per-cell hot-path cost + consumer vendoring weight beat ~25 lines of frozen ANSI fragments; see `FRICTION.log § pond/tui`). - **Bus topic decls: sibling-file topics now resolve under `check` too.** Upstream WS3.3 (2026-06-11) fixed cross-file topic resolution — `bus { publish T; }` / `T <- v;` resolve a `topic T` from a sibling file under `hale build` and `hale run`. The check-vs-build divergence is **CLOSED at hale `3c05dad`** (2026-08-11): `hale check` now merges a multi-file no-import seed before checking, so a topic declared in one file and subscribed from a sibling resolves under `check` exactly as it already did under `build` (verified with a three-file probe). Co-locating a `topic` decl with its publisher is therefore no longer required — the existing co-location is inertia, not a constraint. When ≥2 files publish the same topic, use the literal-subject form (`publish "wire.subject" of type T;` + `"wire.subject" <- v;`) — `agent/llm` does this across its two client files, and its wire subjects are a compatibility contract (do not rename). - **`or fail E { ... }` payloads may now reference `err`** — **FIXED at hale `3c05dad`** (2026-08-11); `return src(x) or fail DstError { kind: err.kind };` compiles and runs. The wrap-helper idiom (`fn wrap_x(e: SrcError) -> T fallible(DstError)`) still works and stays where it carries real translation logic, but is no longer forced for a field copy. Also lifted earlier (v0.12.0): reading an error payload's fields in a plain `or { ... }` block works — `parse_int(s) or { println(err.kind); -1 }` compiles and runs (caveat: stdlib `ParseError.kind` carries `"parse_int"`, not the spec-documented `"not_int"`/`"empty"`/`"overflow"` — verify before branching on stdlib kind strings). Since hale `400ac68`, `or` handlers may themselves be `fallible(E2)` with implicit propagation — write `or wrap_x(err)`, no trailing `or raise` (the old `or (wrap_x(err) or raise)` spelling still works but is redundant). Caveat, NARROWED at v0.16.0: a **stdlib path-call** now works directly as a handler (`boom() or std::str::index_of("ab", "b")` compiles and runs — verified 2026-08-11), so only `@form`-synthesized methods still need the explicit nested spelling. - **`-> ()` on a non-fallible locus method** — **FIXED at hale `3c05dad`** (2026-08-11); `-> ()` normalizes to "no return type" and the old `tuple type must have at least 2 elements; got 0` reject is gone. Omitting the return type is still the house style; both spellings now compile. - **`or ` LocusRef→Interface coercion now works for `@form`-synthesized methods too** — **FIXED at hale `3c05dad`** (2026-08-11); `vec_of_iface.get(i) or Concrete { }` gets the same fat-pointer coercion as a plain fallible call. The hoisted let-ascription in `agent/tools/registry.hl` (`let noop: Tool = __NoopTool { }; ... entries.get(i) or noop`) is now optional — it still reads fine, so it is left in place rather than churned. - **Unbounded-allocation warnings are DEFAULT-ON** (upstream M3 stage 5). Every `hale check` on a bare lib surveys the whole seed and keeps ALL warnings (run-to-exit example binaries warn nothing). They're advisory — never fail the check — but this repo treats them as a triage queue: each warning is either fixed or recorded as triaged (true-by-design / false-positive-shaped) in the lib's `FRICTION.log` section. The analysis got materially smarter in v0.11.12 (const-folded loop ceilings, eager per-iteration children, `vec.set` retire, fail-exits-the-loop) — the 2026-08-03 re-baseline cleared embeddings/crypto/logfmt/migrations/ml-neural to 0 with no source change. The 2026-08-11 triage pass took the repo from **78 sites to 18**. 57 were acknowledged with `@unbounded` on 26 enclosing fns, each carrying a one-line `// @unbounded: ` justification directly above it — the justification lives next to the code rather than in a log, so it is reviewed with the code. Every one was verified domain-bounded before annotating (tui's decoders emit one `Event` per input sequence; `ml/neural`'s sweeps are over LAYERS, not samples; `math/matrix`'s fills size the fn's own fresh return value; `jobs`' payloads sit on failure-EXIT paths; `pq`/`tui`'s loops are bounded by pool size and terminal size). **All 18 remaining are one blocked class, not un-triaged.** Every one is `hot-path allocation` — `ml/neural/model.hl` (15), `pq/pool.hl` (2), `http/client/wire.hl` (1) — and every one already carries a correct `@unbounded`. They stay visible only because that lint class ignores the annotation its own message tells you to use (`COMPILER-BUGS.md` § 2). They go silent the moment it lands; **no pond change is pending.** **`http/client` and `metrics` were UNFROZEN for this** (2026-08-11). They were vendored copies of stdlib-promoted surfaces marked frozen, which meant they could never be made clean and permanently blocked adopting `hale verify` (any-finding-fails) as a CI gate. They are now *maintained, not frozen*: the surface stays closed and behaviour still tracks the stdlib, but hygiene changes — annotations, comments, triage — are allowed so they satisfy the same gates as every other lib. Behavioural divergence from the stdlib is still a bug in the copy. Mechanics: `@unbounded` on an enclosing fn/hook is the sanctioned acknowledgment for an intentionally- or domain-bounded shape, and the path to adopting `hale verify` (fails on ANY finding) as a CI gate — not adopted here yet. Since v0.12+, advisories from IMPORTED seeds are reported only when that seed is the check target. The opt-out flag `--no-warn-unbounded-alloc` exists; don't use it in the verification gate. - **Green `hale check` does not imply codegen-clean.** Bare libs never reach codegen, so codegen-only breaks (all of the above) hide behind a passing check. The gap NARROWED at v0.12/v0.13 — check now resolves imports (cross-seed analysis), compares types at call boundaries, and rejects unknown `std::` namespaces — but fallibility-disposition enforcement is still codegen-only (an unhandled fallible `Stream.send` checked green for 19 days in agent/llm, 2026-07/08). The two divergences found in the 2026-08-11 pass — the block-tail return (`fn f() -> Int { let d = 1; d }` typechecking `ok` then failing codegen) and the sibling-file topic — are both **FIXED at hale `3c05dad`**, but the class is not closed. Always build and run at least one example after touching a lib, and run `hale test /`. - **Intra-pond imports are always relative** (`import "../db"`, `import "../../../sqlite"` from examples). The consumer-style `vendor/pond/…` spelling belongs to end apps only — since v0.12's import-resolving `hale check` it fails the per-lib check gate (this bit pq, the one lib that used it; fixed 2026-08-03). - **Upstream memory-safety bugs — the whole family is CLOSED (re-probed 2026-08-11 at hale `3c05dad`).** All three previously-open bugs verified fixed: the method-frame heap corruption (closed at v0.13.0), the **zero-reads** manifestation (hale#381 — differential probe now gives an identical `loss=0.16499` for both allocation orders, 5/5, versus the old `0.158267` zero-input signature), and the migrations **factory-after-a-caught-failure** segfault (hale#375 — reconstructed from the real seeds, 5/5 clean). The ordering disciplines those bugs forced are therefore no longer load-bearing; retiring them is a pending cleanup, not a live constraint. **The v0.16.0 free-fn locus-rebinding regression is FIXED at hale `3c05dad`** (2026-08-11). It briefly made `ml/neural`'s `forward()` return an empty Matrix (params intact, every `get` failing) and killed two tests; the library was deliberately left uncontorted and is now green again with **no source change**. Bisected to hale `cd75511` (GH #402) and fixed the same day. One residue survives — see `COMPILER-BUGS.md` § 1: a **`let`-bound locus passed as an argument to a free fn that returns it**, where the caller returns that result, is still reclaimed early (`let x = make(); return passthru(x);` hands back an emptied locus, while `return passthru(make());` is fine). Silent, exit 0. No pond lib hits it today; don't write that shape until it closes. **The retirement pass is DONE (2026-08-11).** The workarounds those bugs forced are gone: `ml/neural`'s allocation-ordering discipline (tests + xor-trainer demo — the train_step test now builds each model *before* its inputs, the order the discipline forbade), and migrations' inline-set duplication (seven copies collapsed into a `std_set()` factory). `Trainer.fit`'s preallocate + `extract_row_into` was KEPT deliberately — it is the allocation-free hot loop on its own merits, and its comment now argues that rather than citing a fixed segfault. Nothing in this repo is now shaped by a memory bug. ## Repo structure (high-level) - **`_util/*`** — Tier 0 internals; single-file namespace-lotus utilities operating on primitives only. Five today: `intfloat`, `decimal_float`, `duration_int`, `kvpack`, `rowbuf`. Importable from anywhere since WS3.4 (the old G34 restriction is gone). - **`http/`, `crypto/`, `subprocess/`, `math/`, `term/`** — Tier 0 infrastructure. (`http/client` PROMOTED to the stdlib `std::http` client surface in hale ≥ v0.11.4 — the vendored copy is kept for older pins and is *maintained, not frozen*; pond's `Request`/`Response` became stdlib `ClientRequest`/`ClientResponse`.) `term/` is pure Hale over the `std::term` primitives (hale #108-#110). - **`sqlite/`, `router/`, `sessions/`, `jobs/`, `migrations/`** — Tier 1 Rails-shape web stack. (`router/` PROMOTED to `std::http::Router` in hale ≥ v0.11.4 — the vendored copy is kept for older pins; new code uses the stdlib surface directly.) **All real since 2026-06-12**: `sqlite/` is a pure-`@ffi` driver over the system `libsqlite3` (needs `libsqlite3-dev` at build time); `jobs/` and `migrations/` run on it (`migrations/` via the `sqlite::Driver` adapter satisfying `db::DbDriver` — promoted from migrations into `sqlite/` 2026-07-06, so `migrations/` itself is pure Hale over `pond/db`). - **`logfmt/`, `metrics/`, `supervisor/`, `tracing/`** — Tier 2 observability + supervision. (`logfmt/` FileSink+ConsoleSink PROMOTED to `std::log` and `metrics/` PROMOTED wholesale to `std::metrics` in hale ≥ v0.11.8 — vendored copies kept for older pins, `metrics/` *maintained, not frozen*; stdlib metrics Registry owns its storage and takes histogram bounds as a space-separated String, not a math Matrix. logfmt's OtlpSink stays in pond.) The OTLP exports (logfmt `OtlpSink`, tracing `export_otlp`) POST via the stdlib client (`std::http::post`) since 2026-08-04 — consumers no longer need to vendor `pond/http` for them. - **`db/`, `pq/`** — backend-neutral `DbDriver` interface + Postgres pgwire driver (the Go `database/sql` split). - **`agent/{llm,tools,conversation,sandbox,embeddings}/`, `ml/neural/`** — Tier 5 AI / agent orchestration. - **`websocket/`** — Tier 3 realtime: RFC 6455 client + server-side upgrade, with liveness deadlines (recv-timeout ping/pong) since 2026-06-12. - **`tui/`** — Tier 8 DevX: Elm-shaped full-screen TUI runtime (App/Program, typed input events, cell-grid diff renderer, widgets) + real apps as examples (logview, metricsdash, procpanel). Self-contained seed *by choice* (import of `term` evaluated and declined — see FRICTION.log). - **`heron/`** — MOVED to [hale-lang/tree-sitter-hale](https://github.com/hale-lang/tree-sitter-hale) (2026-07-19; full history). pond is Hale seeds only again; the grammar's corpus-sync CI lives with the grammar. FFI libs: `sqlite/` (`glue.c` + `hale.toml [ffi]`). Everything else is pure Hale. Note the `[ffi]` auto-pickup only scans *direct* imports — an end app using `jobs`/`migrations` over sqlite must also `import` sqlite itself (consistent with the vendoring rule below). Backlog tiers (6, 7, 8 — game/sim, data formats, devx; plus the rest of tier 3 realtime messaging beyond `websocket/`) are listed in `README.md` but not yet built. ## Design rules to enforce when adding/editing libs 1. Each lib is one Hale seed (one directory of `.hl` files; F.19 per-directory model). 2. Each lib ships `README.md`, source files, a section in the root `FRICTION.log`, `tests/*_test.hl` unit tests, and `examples//` with an agent-runnable demo. The tests and the example are part of the deliverable — and they're also the codegen gates (see gotchas above). 3. Public surface is locked in `CONTRACTS.md`. Deviations require both a `FRICTION.log` entry in the lib's section and an update to CONTRACTS.md's status note. 4. **No transitive deps in v1 — at the vendoring level.** Lib-from-lib imports are allowed (`jobs` → `sqlite`, `tracing` → `http/client`), but a consumer app must vendor every lib in the chain explicitly. Don't paper over this. 5. Every lib matches the six-pattern catalog (App locus / Namespace lotus / Service / Spawned child / Shape type / Free fn). Things outside the catalog get logged as friction, not coded around. ## Cross-cutting conventions - **`Bytes` vs `String`** — prefer `Bytes` for binary I/O (HTTP bodies, TCP framing, JSON wire), `String` for human-readable text and stdlib paths. - **Collections: `bounded[T; N]` for fixed-capacity data, tab/newline-separated strings for genuinely unbounded data.** Since hale shipped `bounded[T; N]` (2026-07-02), fixed-capacity collections use it directly (`RouteParams.path_keys/path_vals`, `LlmRequest.messages`, conversation history) — the old TSV idiom for those surfaces is retired. Genuinely unbounded row data (query results: `Rows { csv: String }` with newlines, `Row { data: String }` with tabs) stays on the delimited-string shape — a fixed cap would be a lie there. Avoid invented parametric collections — use the index-API pair or a `Matrix` of values, per stdlib precedent (`list_dir_count` + `list_dir_at`). - **Error payload types are per-lib.** Each lib declares its own `LibError` shape; cross-lib `or` chains compose normally because every payload sits in its own scope. - **Bus subjects via `topic` decls** when the topic is internal to one lib (decl co-located with the publisher — see gotchas); literal-string subjects for ≥2-publisher topics, wildcard subscriptions, or runtime-computed paths. ## Build & verify ```bash # Type-check a single lib (libs have no fn main – check, don't build): hale check path/to/lib/ # Build + run a demo: hale build path/to/lib/examples// ./path/to/lib/examples// # binary lands next to main.hl # sqlite (and examples importing it, incl. jobs/migrations demos) # additionally need sqlite dev files. With libsqlite3-dev installed # the plain build works; on a box with only the runtime .so.0, use # user-local shims: C_INCLUDE_PATH=$HOME/.local/include LIBRARY_PATH=$HOME/.local/lib \ hale build sqlite/examples/kv-demo/ # Unit tests (hale ≥ 400ac68 ships `hale test`): hale test path/to/lib/ # discovers /tests/*_test.hl, compiles + runs each hale test path/to/lib/ -run substr # filter by test-file name hale test . # repo root: runs every lib's tests # @ffi tests (sqlite/jobs/migrations) link under `hale test` too as # of hale > v0.11.8 (the runner runs the same hale.toml [ffi] pickup # as `hale build` — FRICTION § pond/sqlite CLOSED 2026-07-18); the # include/lib shims are still needed when sqlite lives outside the # default search paths: C_INCLUDE_PATH=$HOME/.local/include LIBRARY_PATH=$HOME/.local/lib \ hale test sqlite/ ``` Per `.gitignore`, demo binaries land at `examples//` and `examples//main` and must not be committed (the ignore rule is generic: extensionless files under `examples/` are ignored). Unit tests live at `/tests/*_test.hl` — a separate directory, NOT loose in the lib dir (a test file's `fn main()` would join the seed and break vendoring consumers). Each test file is an ordinary Hale binary: `import ".." as lib;` at the top (one level up from tests/, not the examples' `../..`), `std::test::assert / assert_eq_int / assert_eq_str` in `fn main()`; pass = exit 0 with no stdout. The no-stdout contract means library paths that print by design (Migrator's apply narration, Worker's birth line, tui's flush-to-stdout) are not unit-testable — cover their silent siblings and leave the printing paths to the examples (see the testability notes in FRICTION.log). Because tests build real binaries, `hale test` also exercises codegen — it's a second codegen gate beside the examples, and it caught three real lib bugs on day one (websocket terminal-header drop, agent/llm JSON key shadowing, tracing attr mangling). There is no linter and no CI config in this repo. Verification is per-lib, three gates: the lib must type-check under `hale check` (with its allocation warnings triaged — see gotchas), `hale test /` must pass, and the demo must build and run with the documented behavior. Remember: `hale check` alone does not exercise codegen — the test run and example build/run are the real gates. (`hale fmt --check .` is clean as of 2026-08-12 — the tree was reformatted in one commit, so keep new code fmt-canonical and run `hale fmt` before committing. The reformat is semantically inert but large; `git log --follow` across it needs `-w`.) Useful CLI conveniences at current HEAD: `hale build --dev` (O1 pipeline for fast iteration), `HALE_TIME=1` (per-phase build timing), `hale check --json` (NDJSON diagnostics), `hale fmt` / `hale fmt --check`, `hale doc --stdlib` (generated API reference incl. per-fn effect classes), `hale verify` (check + advisories, any finding exits 1 — the CI-shaped gate; not this repo's gate yet), `hale bench` (`*_bench.hl` discovery, ns/op + allocs/op), `hale lsp`, `hale mcp`. Native builds are host-tuned O3 by default — a binary meant to run on other machines needs `--target-cpu baseline`. At v0.16.0 that spelling still works but now names `x86-64-v3` (AVX2/BMI2/FMA), not a true baseline; `--target` also takes canonical triples and `hale --list-targets` prints the support tiers.